| // --------------------------------------------------------------------------- | |
| // 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<string, unknown>; | |
| 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 <img>. | |
| 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<unknown> { | |
| 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<LoginResult> { | |
| 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<SessionUser | null> { | |
| 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<void> { | |
| try { | |
| await fetch(`${API_V1}/auth/logout`, { method: "POST", credentials: CREDS }); | |
| } catch { | |
| // deliberate: see above | |
| } | |
| } | |