loopable / web /src /settings /settingsApi.ts
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
609fb78 verified
Raw
History Blame Contribute Delete
12.7 kB
// ---------------------------------------------------------------------------
// 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 }),
});
}