File size: 12,677 Bytes
092334a da5297e 092334a da5297e 092334a ea7b176 092334a da5297e 092334a da5297e 092334a 016c754 da5297e 016c754 4748aae 609fb78 4748aae 609fb78 4748aae 609fb78 4748aae 609fb78 4748aae 609fb78 4748aae 609fb78 4748aae | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 | // ---------------------------------------------------------------------------
// 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 }),
});
}
|