// --------------------------------------------------------------------------- // settings / settingsApi.ts — EXIT wave 2 (W2-8, contract Y4). // // `GET /api/v1/settings` and the four `/api/v1/admin/users` routes. // // ⛔ THE CLIENT HIDES; THE SERVER FORBIDS. Every admin control in this module is // also gated server-side, fail-closed, and Y4 has the gate PROVE it by having a // viewer try. Nothing here may be read as the enforcement — a hidden button is // a courtesy to the user, never a permission check ([[aios-permissioning]]). // // ⚠ TWO FAIL-CLOSED WRITE RULES FROM S1's Y4 AMENDMENT ARE MIRRORED IN THE UI, // so the user learns them from the form rather than from a 400: // 1. **`POST` on an existing username is 409** — `PATCH` is the update path. // The reason is a real defect, not style: `create_user` overwrites the // record with a fresh one, dropping `epoch` back to absent(0) and thereby // REVIVING every cookie minted before the last password rotation. // 2. **`modules: []` is REFUSED on write (400).** The READ semantic stays // "[] means unrestricted" (Y4 says do not fix it this wave) — which means // an admin unticking every box to lock an account down would grant it // EVERYTHING. These routes are the first UI-reachable writer of `modules` // in the product's history, so the footgun simply never gets built. // --------------------------------------------------------------------------- import { API_V1, CREDENTIALS } from "../apiContract"; export interface AdminUser { username: string; name: string; role: string; /** `'all'` or the granted team ids. */ bus: number[] | "all"; /** `'all'` or the granted registry keys. ⚠ `[]` READS as unrestricted. */ modules: string[] | "all"; active: boolean; epoch?: number; /** * ⭢ REQUESTED OF S1, ADDITIVE, NOT YET SERVED (wave 15). One sentence per * account for the list's Access column — "All 2 modules, 1 restricted" — * computed from `perms` server-side, because the alternative is one * `/perms` round trip per row to fill one cell. * * Absent is handled, not assumed: the column falls back to the legacy * `modules` grant, which is what governs an un-migrated record anyway. The * shape is `permsModel.accessSummary`'s output, so both ends say the same * sentence rather than two dialects of it. */ accessSummary?: string; } export interface SettingsPayload { user: { username: string; name: string; role: string }; scope: { bus: string[] | "all"; modules: string[] | "all"; /** C-PERM — the caller's OWN effective wall, read-only. Present since wave * 15. Read here so "Your access" can state what NARROWS this account, not * merely what it may open: a pane saying "All modules" while a permanent * filter halves somebody's book is how a user comes to believe the numbers * are wrong. Never enforcement — hidden fields are stripped from every wire * regardless, so a client ignoring this is narrowed anyway, never widened. */ perms?: Record; }; tenant?: Record; /** * Wave 19 (R3 / contract C2) — is this account the LOOPABLE PLATFORM ADMIN? * * ⛔ NOT `admin`, AND THE DISTINCTION IS THE WHOLE RULING. `admin` is * tenant-scoped: every tenant has them, and R3 says in as many words that a * tenant-scoped `is_admin` does NOT qualify for this. This flag comes from a * separate fail-closed server predicate (record flag AND `tenant == "loopable"`, * a double lock) and is true for exactly one account. * * ⚠ CHROME, NEVER THE WALL. It decides whether a rail entry is drawn. Every * route behind that entry 403s a non-platform-admin on its own, so a client * that forged this flag would reach a pane whose every fetch is refused. * Optional on purpose: a server that predates the field, or one that omits it, * reads as `false` — fail-closed by absence, like every other flag here. */ platformAdmin?: boolean; } export type ApiResult = | { ok: true; data: T } | { ok: false; status: number; message: string }; async function call( path: string, init?: RequestInit & { body?: string } ): Promise> { try { const res = await fetch(`${API_V1}${path}`, { credentials: CREDENTIALS, headers: init?.body ? { "Content-Type": "application/json" } : undefined, ...init, }); if (res.status === 204) return { ok: true, data: undefined as T }; let body: unknown = null; try { body = await res.json(); } catch { /* 204s and empty bodies are normal; a parse failure is not fatal here */ } if (!res.ok) { const err = (body as { error?: { message?: string } } | null)?.error; // A 5xx message is never surfaced verbatim — a server stack trace behind // a login is still a leak. const message = res.status >= 500 ? "Something went wrong on our side. Try again in a moment." : err?.message || (res.status === 403 ? "Administrators only." : res.status === 409 ? "That username already exists. Edit the existing account instead." : "That change could not be saved."); return { ok: false, status: res.status, message }; } return { ok: true, data: body as T }; } catch { return { ok: false, status: 0, message: "The server could not be reached." }; } } export function getSettings(): Promise> { return call("/settings"); } export function listUsers(): Promise> { return call<{ users: AdminUser[] }>("/admin/users"); } /** ⚠ NO `bus` (wave 15, R1). The field still exists on the RECORD — `AdminUser` * keeps reading it through the strangler period — but nothing in this client * writes it any more, so the create payload does not name it. The route * defaults an absent `bus` to `"all"`, which is what this form always sent. */ export interface NewUser { username: string; name: string; role: string; modules: string[] | "all"; password: string; } export function createUser(u: NewUser): Promise> { return call("/admin/users", { method: "POST", body: JSON.stringify(u) }); } /** ⚠ `bus` is NOT patchable from this client any more (R1) — a form writes what * it edits, and once the migration converts `bus` into a permanent filter and * clears the field, an echoed value would put it back. */ export type UserPatch = Partial>; export function patchUser(username: string, patch: UserPatch): Promise> { return call(`/admin/users/${encodeURIComponent(username)}`, { method: "PATCH", body: JSON.stringify(patch), }); } /** ⚠ This BUMPS THE EPOCH, which revokes every outstanding session for that * account. That is the point of the route and the UI says so out loud — an * admin resetting a password should know the person is being signed out. */ export function setPassword(username: string, password: string): Promise> { return call(`/admin/users/${encodeURIComponent(username)}/password`, { method: "POST", body: JSON.stringify({ password }), }); } // --- wave 15, C-PERM: the per-module permission record ---------------------- // // ⛔ ADMIN-ONLY AND FAIL-CLOSED AT THE SERVER. These two calls are exactly as // forbidden to a member as the rest of `/admin/*`; the editor that consumes them // is hidden from non-admins as a courtesy and refused as a rule. // // The body is passed through UNPARSED on purpose — `permsModel.parsePermsPayload` // owns the shape, so the parse is one testable function rather than a validation // that half-lives in a fetch wrapper. export function getUserPerms(username: string): Promise> { return call(`/admin/users/${encodeURIComponent(username)}/perms`); } /** * WHOLE-RECORD REPLACE. Every module the server declared is present in `perms`, * including untouched ones: a key omitted from a replace is a DELETION. * `permsModel.toPutBody` is the only sanctioned way to build this body. */ export function putUserPerms( username: string, body: { perms: Record } ): Promise> { return call(`/admin/users/${encodeURIComponent(username)}/perms`, { method: "PUT", body: JSON.stringify(body), }); } /** Wave 14 C-AVATAR — own-profile photo. The server returns the refreshed `{user}` envelope; * the caller parses it with `session.parseUserEnvelope` so the shell chip updates live. */ export function setAvatar(dataUrl: string): Promise> { return call<{ user: unknown }>("/auth/me/avatar", { method: "POST", body: JSON.stringify({ dataUrl }), }); } export function clearAvatar(): Promise> { return call<{ user: unknown }>("/auth/me/avatar", { method: "DELETE" }); } // ── Wave 18 (C7): Keychains + Connectors ──────────────────────────────────────────────────── export interface KeyEntry { id: string; label: string; type: string; preview: string; created: string; createdBy: string; /** * ⭐ WAVE 32 · R4 / contract C1 — `"business" | "personal"`, read as a STRING (the wave-9 law). * A business-wide connection applies to every user in the tenant and only an admin can create * one; a personal one is visible to its owner alone. Absent on an older server ⇒ business, * which is what every entry stored before this wave actually is. */ scope?: string; /** Who owns a PERSONAL connection. Empty for a business-wide one — it has no single owner. */ owner?: string; } export interface ConnectorRow { key: string; label: string; type: string; source: "env" | "keychain"; preview?: string; paused: boolean; /** R4's scope, the same vocabulary as `KeyEntry.scope`. */ scope?: string; owner?: string; } export interface UnsyncedInfo { known: boolean; count: number | null; rows: Array<{ pid: number | string; fields: number; hint: string }>; shown?: number; note?: string; } export function listKeychain(): Promise< ApiResult<{ entries: KeyEntry[]; locked: boolean; /** R4's vocabulary, served rather than hard-coded here — contract C1 declares it server-side * in `routes_keychain.py`, and a client copy is the drift a parity gate exists to stop. */ scopes?: string[]; /** May THIS account create a business-wide connection (R4: administrators only)? */ canBusiness?: boolean; /** Types that are always business-wide — the whole workspace reads its databases through * them, so "personal" would be a label rather than a boundary. */ tenantWideTypes?: string[]; }> > { return call("/admin/keychain"); } export function addKeychainEntry( label: string, type: string, fields: Record, scope: string ): Promise> { return call("/admin/keychain", { method: "POST", body: JSON.stringify({ label, type, fields, scope }), }); } /** R4's scope door — the ONLY editable part of a stored key. A secret is never re-openable, so * "change the key" means delete and re-add; "change who it is for" is this. */ export function setKeychainScope( id: string, scope: string ): Promise> { return call(`/admin/keychain/${encodeURIComponent(id)}`, { method: "PUT", body: JSON.stringify({ scope }), }); } export function deleteKeychainEntry(id: string): Promise> { return call(`/admin/keychain/${encodeURIComponent(id)}`, { method: "DELETE" }); } export function testKeychainEntry( id: string ): Promise> { return call(`/admin/keychain/${encodeURIComponent(id)}/test`, { method: "POST" }); } export function getConnectors(): Promise< ApiResult<{ connectors: ConnectorRow[]; locked: boolean; pausedNote: string; unsynced?: UnsyncedInfo; scopes?: string[]; canBusiness?: boolean; }> > { return call("/admin/connectors"); } export function pauseConnector( key: string, paused: boolean ): Promise> { return call(`/admin/connectors/${encodeURIComponent(key)}/pause`, { method: "POST", body: JSON.stringify({ paused }), }); }