package main import ( "crypto/rand" "encoding/hex" "encoding/json" "fmt" "net/http" "strconv" "strings" "inou/lib" ) // Mobile API endpoints for inou app // Base: /api/v1/ // CORS helper for mobile/web API // FIXED(review-2026-02-28): Use origin allowlist instead of wildcard var corsAllowedOrigins = map[string]bool{ "https://inou.com": true, "https://www.inou.com": true, "https://dev.inou.com": true, // staging "http://localhost:1080": true, // dev "http://localhost:3000": true, // dev "capacitor://localhost": true, // iOS app "http://localhost": true, // Android app } func cors(w http.ResponseWriter, r *http.Request) bool { origin := r.Header.Get("Origin") if corsAllowedOrigins[origin] { w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Set("Access-Control-Allow-Credentials", "true") } w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") if r.Method == "OPTIONS" { w.WriteHeader(204) return true } return false } func initMobileAPI(mux *http.ServeMux) { mux.HandleFunc("/api/v1/auth/send", handleAPISend) mux.HandleFunc("/api/v1/auth/verify", handleAPIVerify) mux.HandleFunc("/api/v1/dashboard", handleAPIDashboard) mux.HandleFunc("/api/v1/prompts", handleAPIPrompts) mux.HandleFunc("/api/v1/prompts/respond", handleAPITrackerRespond) } // --- Auth --- type APISendRequest struct { Email string `json:"email"` } type APIVerifyRequest struct { Email string `json:"email"` Code string `json:"code"` } type APIVerifyResponse struct { Token string `json:"token"` NeedsOnboard bool `json:"needs_onboard"` } func handleAPISend(w http.ResponseWriter, r *http.Request) { if cors(w, r) { return } if r.Method != "POST" { jsonError(w, "Method not allowed", 405) return } var req APISendRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonError(w, "Invalid JSON", 400) return } email := strings.ToLower(strings.TrimSpace(req.Email)) if email == "" { jsonError(w, "Email required", 400) return } // Check if minor (existing users only) if id, ok := lib.DossierExists(email); ok { if d, _ := lib.DossierGet("", id); d != nil && d.DateOfBirth != "" && isMinor(d.DateOfBirth) { jsonError(w, "Minors must use guardian account", 403) return } } if _, err := lib.DossierLogin(email, 0); err != nil { jsonError(w, "Failed to send code", 500) return } jsonOK(w, map[string]bool{"success": true}) } func handleAPIVerify(w http.ResponseWriter, r *http.Request) { if cors(w, r) { return } if r.Method != "POST" { jsonError(w, "Method not allowed", 405) return } var req APIVerifyRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonError(w, "Invalid JSON", 400) return } email := strings.ToLower(strings.TrimSpace(req.Email)) codeStr := strings.TrimSpace(req.Code) code, _ := strconv.Atoi(codeStr) id, err := lib.DossierLogin(email, code) if err != nil { jsonError(w, "Invalid or expired code", 401) return } // Generate session token token := generateSessionToken() // TODO: store session token when session management is implemented d, _ := lib.DossierGet("", id) needsOnboard := d == nil || d.Name == "" if !needsOnboard { lib.SendSignal(fmt.Sprintf("Mobile login: %s", d.Name)) } jsonOK(w, APIVerifyResponse{Token: token, NeedsOnboard: needsOnboard}) } // --- Dashboard --- type APIDossierEntry struct { GUID string `json:"guid"` Name string `json:"name"` Relation string `json:"relation"` CanAdd bool `json:"can_add"` } type APIDashboardResponse struct { Name string `json:"name"` Dossiers []APIDossierEntry `json:"dossiers"` } func handleAPIDashboard(w http.ResponseWriter, r *http.Request) { if cors(w, r) { return } d := getAPIAuth(r) if d == nil { jsonError(w, "Unauthorized", 401) return } accessList, _ := lib.AccessListByAccessor(d.DossierID) var dossiers []APIDossierEntry // Add self first dossiers = append(dossiers, APIDossierEntry{ GUID: formatHexID(d.DossierID), Name: "", // Empty means "self" Relation: "self", CanAdd: true, }) // Add others for _, a := range accessList { if a.DossierID == d.DossierID { continue } target, _ := lib.DossierGet("", a.DossierID) // nil ctx - internal operation if target == nil { continue } dossiers = append(dossiers, APIDossierEntry{ GUID: formatHexID(a.DossierID), Name: target.Name, Relation: "other", // Relation removed from RBAC CanAdd: (a.Ops & lib.PermWrite) != 0, }) } jsonOK(w, APIDashboardResponse{ Name: d.Name, Dossiers: dossiers, }) } // --- Prompts (stub) --- func handleAPIPrompts(w http.ResponseWriter, r *http.Request) { if cors(w, r) { return } d := getAPIAuth(r) if d == nil { jsonError(w, "Unauthorized", 401) return } // TODO: Implement per spec jsonOK(w, map[string]interface{}{"trackers": []interface{}{}}) } func handleAPITrackerRespond(w http.ResponseWriter, r *http.Request) { if cors(w, r) { return } d := getAPIAuth(r) if d == nil { jsonError(w, "Unauthorized", 401) return } // TODO: Implement per spec jsonOK(w, map[string]bool{"success": true}) } // --- Helpers --- func getAPIAuth(r *http.Request) *lib.Dossier { auth := r.Header.Get("Authorization") if !strings.HasPrefix(auth, "Bearer ") { fmt.Println("getAPIAuth: no Bearer prefix") return nil } token := strings.TrimPrefix(auth, "Bearer ") if token == "" { fmt.Println("getAPIAuth: empty token") return nil } fmt.Printf("getAPIAuth: looking up token %s...\n", token[:20]) d := lib.DossierGetBySessionToken(token) fmt.Printf("getAPIAuth: result=%v\n", d != nil) return d } func generateSessionToken() string { b := make([]byte, 32) rand.Read(b) return hex.EncodeToString(b) } func jsonOK(w http.ResponseWriter, data interface{}) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(data) } func jsonError(w http.ResponseWriter, msg string, code int) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) json.NewEncoder(w).Encode(map[string]string{"error": msg}) } func relationName(rel int) string { switch rel { case 0: return "self" case 1: return "parent" case 2: return "child" case 3: return "spouse" case 4: return "sibling" case 5: return "caregiver" case 6: return "medical" default: return "other" } }