// --------------------------------------------------------------------------- // shell/session.ts — the CLIENT half of X2's auth contract (EXIT wave 1, X5). // // WHAT THIS REPLACES. The shell's door was HTTP Basic on a shared APP_PASSWORD // (`api/main.py`'s BasicAuth middleware): no per-user identity, no BU isolation, // no `may_open` gate — which is why SHELL.md says this frame must not be // reachable by a real user until EXIT-3 lands. This module is the per-user door: // the SAME `core/users` accounts the Streamlit `gate()` verifies, reached over // `POST /api/v1/auth/login` and carried by the X3 signed cookie. // // ⚠ THE COOKIE IS HttpOnly (X3), SO THIS CODE CANNOT SEE IT — and that is the // point. There is no client-side "am I logged in?" bit to cache, mirror into // localStorage, or trust: the only way to know is to ASK the server, which is // also the only party that can tell whether the user's `epoch` was bumped out // from under an outstanding cookie. So `me()` runs on every boot, and its // answer is never persisted. A cached session flag would keep painting the // frame for a revoked user, which is the whole reason X3 has an epoch. // // WHY THIS FILE HOLDS NO import.meta. It is compiled to CommonJS and run under // node by `verify_login.py`; `import.meta` is a hard error there. Anything // environment-shaped (the current app's base URL) is a PARAMETER, never a // module-level read — see nav.ts for the same rule. // --------------------------------------------------------------------------- // The prefix and the credentials word live in `apiContract.ts` — ONE copy, read // by this module, by nav.ts and by the grid's apiBridge. `verify_login.py` // mutates them there to prove the gate can see them. import { API_V1, CREDENTIALS as CREDS, checkTenant } from "../apiContract"; export { API_V1 }; /** * The session's user, per X2's login/me payload. Deliberately narrow: the shell * needs identity for the account row and nothing else. **Scope is NOT decided * here** — the row pool is BU-scoped server-side (EXIT-3b) and the nav is * `may_open`-filtered server-side (X6), so `bus`/`modules` are for DISPLAY, and * a client that re-derived permission from them would be the permission system * trusting its own subject (the `Field.createdBy` precedent in types.ts). */ export interface SessionUser { username: string; name: string; role: string; /** BU labels this user may view — `core.users.allowed_bus_labels` semantics. */ bus: string[]; /** Granted registry keys, or the literal `'all'` — `core/users`' own encoding. */ modules: string[] | "all"; /** Wave 14 C-AVATAR — the user's OWN profile photo as a data URL, or absent. Everyone * else's photos ride `workspace.userAvatars`, keyed by display name. */ avatar?: string | null; } export function isAdmin(user: SessionUser | null): boolean { return user?.role === "admin"; } /** * Normalise the server's user object. * * DEFENSIVE ON THE DISPLAY FIELDS, STRICT ON IDENTITY. A missing `name` falls * back to the username (a person with no display name still gets a door); a * missing `username` returns null, because an authenticated session with no * subject is not a session. The distinction matters: hard-failing on a cosmetic * field would turn a harmless server change into every user locked out, while * inventing a subject would be worse than the 401 it papers over. */ export function parseUser(raw: unknown): SessionUser | null { if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; const r = raw as Record; const username = typeof r.username === "string" ? r.username.trim() : ""; if (!username) return null; const name = typeof r.name === "string" && r.name.trim() !== "" ? r.name.trim() : username; const role = typeof r.role === "string" ? r.role : ""; const bus = Array.isArray(r.bus) ? r.bus.filter((b): b is string => typeof b === "string") : []; const modules = r.modules === "all" ? "all" : Array.isArray(r.modules) ? r.modules.filter((m): m is string => typeof m === "string") : []; // Defensive like the display fields: only a data: image URL is carried; anything // else renders the initials disc rather than a broken . const avatar = typeof r.avatar === "string" && r.avatar.startsWith("data:image/") ? r.avatar : null; return { username, name, role, bus, modules, avatar }; } /** The `{user: {...}}` envelope both `/auth/login` and `/auth/me` return. */ export function parseUserEnvelope(body: unknown): SessionUser | null { if (!body || typeof body !== "object") return null; return parseUser((body as { user?: unknown }).user); } // --- error copy ------------------------------------------------------------ /** Shown when the server gives us nothing better on a rejected credential. Same * words as the Streamlit `gate()` — one product, one wrong-password sentence. */ export const BAD_CREDENTIALS = "Incorrect username or password."; export const SERVER_UNAVAILABLE = "Sign-in is unavailable right now. Try again in a moment."; export const NETWORK_UNAVAILABLE = "Cannot reach the server. Check your connection and try again."; /** * X2's error envelope is `{"error": {"code", "message"}}`. Which message the * user sees is a policy question, so the rule is explicit rather than "show * whatever came back": * * - **4xx → prefer the SERVER's message.** A 4xx message is policy the client * cannot know — "too many attempts, try again in 30 seconds" is the server's * backoff talking (X5: *the server owns backoff*), and replacing it with our * generic line would hide the only actionable thing on the screen. * - **5xx → OUR generic line.** A 500's message describes the server's * internals to someone who is, by definition, not authenticated yet. * - **status 0 → a transport failure**, which never reached a server at all. * * `status` 0 is our own encoding for "fetch threw" — there is no HTTP 0. */ export function errorMessage(status: number, body: unknown): string { if (status === 0) return NETWORK_UNAVAILABLE; if (status >= 500) return SERVER_UNAVAILABLE; const err = (body as { error?: { message?: unknown } } | null)?.error; const msg = typeof err?.message === "string" ? err.message.trim() : ""; if (msg) return msg; return status === 401 ? BAD_CREDENTIALS : SERVER_UNAVAILABLE; } // --- the three calls ------------------------------------------------------- export type LoginResult = | { ok: true; user: SessionUser } | { ok: false; message: string }; async function readJson(res: Response): Promise { try { return await res.json(); } catch { return null; } } /** * X2 `POST /api/v1/auth/login`. NO client-side backoff, deliberately: X5 gives * the delay to the server, because a delay a browser imposes on itself is a * delay an attacker's script simply does not import. * * `tenant` is omitted rather than defaulted — X2 types it optional with the * server's own default, and a client that pins `'royal-imports'` into every * login is a client that has to be re-shipped for tenant #2. */ export async function login(username: string, password: string): Promise { let res: Response; try { res = await fetch(`${API_V1}/auth/login`, { method: "POST", credentials: CREDS, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }), }); } catch { return { ok: false, message: errorMessage(0, null) }; } const body = await readJson(res); if (!res.ok) return { ok: false, message: errorMessage(res.status, body) }; const user = parseUserEnvelope(body); // A 200 whose body we cannot read as a user is NOT a session. Treating it as // one would paint the frame for a subject nobody can name. if (!user) return { ok: false, message: SERVER_UNAVAILABLE }; return { ok: true, user }; } /** * X2 `GET /api/v1/auth/me`. FAIL-CLOSED: anything that is not a 200 carrying a * readable user — 401, 404 (the route not built yet), a proxy's HTML error * page, a dropped connection — reads as NO SESSION. There is no state in which * a failure to confirm the session is treated as having one. */ export async function me(): Promise { try { const res = await fetch(`${API_V1}/auth/me`, { credentials: CREDS }); // ⭐ THE BOOT SIDE OF THE TENANT GUARD. `me()` runs on every boot, so this is what ARMS // the comparator with the tenant this frame belongs to — every later call then only has to // notice a change. It also catches the swap directly on a re-check. // ⚠ Deliberately BEFORE the `!res.ok` return: a repointed tab still gets a 200 here, and // the guard must see the header on exactly the request that establishes identity. if (checkTenant(res)) return null; if (!res.ok) return null; return parseUserEnvelope(await readJson(res)); } catch { return null; } } /** * X2 `POST /api/v1/auth/logout` → 204, cookie cleared. * * Returns nothing and swallows failures ON PURPOSE: the caller drops to the * login screen either way. A logout button that reports failure and leaves the * user sitting in the app is worse than one that always closes the door on this * browser — and the cookie is the server's to invalidate, so the honest local * action is "stop showing the frame". */ export async function logout(): Promise { try { await fetch(`${API_V1}/auth/logout`, { method: "POST", credentials: CREDS }); } catch { // deliberate: see above } }