loopable / web /src /shell /session.ts
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
bf8519f verified
Raw
History Blame Contribute Delete
9.67 kB
// ---------------------------------------------------------------------------
// 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
}
}