| // --------------------------------------------------------------------------- | |
| // 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<string, unknown>; | |
| }; | |
| tenant?: Record<string, unknown>; | |
| /** | |
| * 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<T> = | |
| | { ok: true; data: T } | |
| | { ok: false; status: number; message: string }; | |
| async function call<T>( | |
| path: string, | |
| init?: RequestInit & { body?: string } | |
| ): Promise<ApiResult<T>> { | |
| 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<ApiResult<SettingsPayload>> { | |
| return call<SettingsPayload>("/settings"); | |
| } | |
| export function listUsers(): Promise<ApiResult<{ users: AdminUser[] }>> { | |
| 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<ApiResult<AdminUser>> { | |
| return call<AdminUser>("/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<Pick<AdminUser, "name" | "role" | "modules" | "active">>; | |
| export function patchUser(username: string, patch: UserPatch): Promise<ApiResult<AdminUser>> { | |
| return call<AdminUser>(`/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<ApiResult<void>> { | |
| return call<void>(`/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<ApiResult<unknown>> { | |
| return call<unknown>(`/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<string, unknown> } | |
| ): Promise<ApiResult<unknown>> { | |
| return call<unknown>(`/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<ApiResult<{ user: unknown }>> { | |
| return call<{ user: unknown }>("/auth/me/avatar", { | |
| method: "POST", | |
| body: JSON.stringify({ dataUrl }), | |
| }); | |
| } | |
| export function clearAvatar(): Promise<ApiResult<{ user: unknown }>> { | |
| 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<string, string>, | |
| scope: string | |
| ): Promise<ApiResult<{ entry: KeyEntry }>> { | |
| 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<ApiResult<{ entry: KeyEntry }>> { | |
| return call(`/admin/keychain/${encodeURIComponent(id)}`, { | |
| method: "PUT", | |
| body: JSON.stringify({ scope }), | |
| }); | |
| } | |
| export function deleteKeychainEntry(id: string): Promise<ApiResult<{ ok: boolean }>> { | |
| return call(`/admin/keychain/${encodeURIComponent(id)}`, { method: "DELETE" }); | |
| } | |
| export function testKeychainEntry( | |
| id: string | |
| ): Promise<ApiResult<{ ok: boolean; message: string }>> { | |
| 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<ApiResult<{ key: string; paused: boolean }>> { | |
| return call(`/admin/connectors/${encodeURIComponent(key)}/pause`, { | |
| method: "POST", | |
| body: JSON.stringify({ paused }), | |
| }); | |
| } | |