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 }),
  });
}