/** * Session gate for the Smart Gallery AI routes. * * --------------------------------------------------------------------------- * TRUST MODEL * --------------------------------------------------------------------------- * These routes proxy a paid, rate-limited GPU backend (RunPod) using a secret * held only on this server. An unauthenticated route is therefore not merely an * information-disclosure problem — it is a billable-resource problem: anyone who * can reach the URL can spend the operator's GPU budget, and can use the CRM as * an open relay for arbitrary image/audio processing. * * The upstream SDK demo's routes (apps/web/src/app/api/ai/*) are COMPLETELY * unauthenticated. This module is the fix; every ported route must call it * before doing any work. * * WHO IS TRUSTED * We do not verify a JWT here and we do not hold any signing key. The single * source of truth for "is this caller signed in" is the Shell BFF * (`${BFF_ORIGIN}/api/session/context`), which owns the HttpOnly session cookie. * We forward the caller's raw `cookie` header to it and treat a 200 as proof of * a session. Consequences of that choice, stated explicitly: * * - The BFF is trusted absolutely. If it is compromised or misconfigured to * answer 200 for anonymous callers, these routes are open. BFF_ORIGIN must * therefore only ever point at an origin the operator controls. * - We forward the cookie header verbatim and nothing else. No Authorization * header, no bearer token, and never the RunPod key. * - NOTHING IS CACHED. A cached "yes" would keep a revoked/expired session * alive for the cache lifetime, so every AI request costs one BFF round * trip. That is deliberate: correctness over latency for a spend gate. * - A network failure reaching the BFF returns 503 (fail CLOSED), never 200. * If we cannot prove a session, we do not spend GPU budget. * * DEMO MODE * When the Shell is not configured (`NEXT_PUBLIC_SUPABASE_URL` unset) the CRM * runs on its mock portal and there is no session to check, so we allow the * request and log ONCE at startup-of-first-use. This is the same gate * `isShellConfigured()` uses for mock-vs-real auth elsewhere in the app. * IMPORTANT: never deploy to a public origin with the Shell unconfigured AND a * real RUNPOD_API_KEY present — that combination is an open, billable endpoint. * * WHAT THIS IS NOT * This is authentication only, not authorization. It answers "is there a valid * session", not "may this principal use the gallery". Per-resource policy for * gallery data lives in be-crm behind the `crm.gallery` resource; if these AI * routes ever need the same, check it there rather than re-deriving it here. */ export type GallerySession = | { ok: true; principalId?: string } | { ok: false; status: number; error: string }; /** Mirrors src/lib/appshell.ts — kept local so this stays server-only. */ function isShellConfigured(): boolean { return Boolean(process.env.NEXT_PUBLIC_SUPABASE_URL); } let demoModeWarned = false; /** * Resolve the caller's session. Returns `{ ok: true }` (optionally with the * principal id, used to key rate limits) or a ready-to-return failure with the * status and message the route should emit. */ export async function requireGallerySession(req: Request): Promise { if (!isShellConfigured()) { if (!demoModeWarned) { demoModeWarned = true; console.warn( "[gallery-ai] Shell is not configured (NEXT_PUBLIC_SUPABASE_URL unset) — " + "AI routes are UNAUTHENTICATED in demo mode. Do not expose this deployment publicly " + "while RUNPOD_API_KEY is set.", ); } return { ok: true }; } const cookie = req.headers.get("cookie"); if (!cookie) return { ok: false, status: 401, error: "Not signed in" }; const origin = (process.env.BFF_ORIGIN ?? "http://localhost:4000").replace(/\/$/, ""); let res: Response; try { res = await fetch(`${origin}/api/session/context`, { headers: { cookie, accept: "application/json" }, // Never cache an auth decision — see the trust-model note above. cache: "no-store", signal: AbortSignal.timeout(5000), }); } catch { // Fail closed: we could not prove a session, so we do not spend GPU budget. return { ok: false, status: 503, error: "Session service unavailable" }; } if (res.status === 401) return { ok: false, status: 401, error: "Not signed in" }; if (!res.ok) return { ok: false, status: 503, error: "Session service unavailable" }; // The principal id is best-effort: it only sharpens the rate-limit key, so a // shape we don't recognize degrades to IP-keyed limiting rather than failing. let principalId: string | undefined; try { const data = (await res.json()) as Record | null; principalId = pickPrincipalId(data); } catch { /* ignore — see above */ } return principalId ? { ok: true, principalId } : { ok: true }; } function pickPrincipalId(data: Record | null): string | undefined { if (!data) return undefined; const direct = data.userId ?? data.principalId ?? data.sub ?? data.id; if (typeof direct === "string" && direct) return direct; const user = data.user; if (user && typeof user === "object") { const u = user as Record; const nested = u.id ?? u.userId ?? u.sub; if (typeof nested === "string" && nested) return nested; } return undefined; } /** * Rate-limit key for a request: the authenticated principal when known, * otherwise the first hop of `x-forwarded-for`. * * NOTE the first hop is client-controlled unless a trusted proxy overwrites the * header. It is good enough to throttle honest clients and casual abuse; it is * NOT a security boundary. The session gate above is the security boundary. */ export function rateLimitKey(req: Request, principalId?: string): string { if (principalId) return `u:${principalId}`; const xff = req.headers.get("x-forwarded-for") ?? ""; const first = xff.split(",")[0]?.trim(); return `ip:${first || "unknown"}`; }