forked from Goutam/lynkeduppro-crm
140 lines
6.0 KiB
TypeScript
140 lines
6.0 KiB
TypeScript
/**
|
|
* 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<GallerySession> {
|
|
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<string, unknown> | null;
|
|
principalId = pickPrincipalId(data);
|
|
} catch {
|
|
/* ignore — see above */
|
|
}
|
|
|
|
return principalId ? { ok: true, principalId } : { ok: true };
|
|
}
|
|
|
|
function pickPrincipalId(data: Record<string, unknown> | 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<string, unknown>;
|
|
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"}`;
|
|
}
|