@bevel-software/platform-core-backend 0.27.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/dist/core/create-core-server.d.ts.map +1 -1
  2. package/dist/core/create-core-server.js +2 -2
  3. package/dist/core/create-core-server.js.map +1 -1
  4. package/dist/core/create-core-services.d.ts +7 -0
  5. package/dist/core/create-core-services.d.ts.map +1 -1
  6. package/dist/core/create-core-services.js +5 -0
  7. package/dist/core/create-core-services.js.map +1 -1
  8. package/dist/modules/access/access-control.interface.d.ts +9 -0
  9. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  10. package/dist/modules/access/access-control.service.d.ts +1 -0
  11. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  12. package/dist/modules/access/access-control.service.js +20 -0
  13. package/dist/modules/access/access-control.service.js.map +1 -1
  14. package/dist/modules/access/access.routes.d.ts +4 -1
  15. package/dist/modules/access/access.routes.d.ts.map +1 -1
  16. package/dist/modules/access/access.routes.js +66 -7
  17. package/dist/modules/access/access.routes.js.map +1 -1
  18. package/dist/modules/access/roles-admin.service.d.ts +35 -6
  19. package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
  20. package/dist/modules/access/roles-admin.service.js +36 -9
  21. package/dist/modules/access/roles-admin.service.js.map +1 -1
  22. package/dist/modules/access/user-access-removal.service.js +1 -1
  23. package/dist/modules/access/user-access-removal.service.js.map +1 -1
  24. package/dist/modules/auth/account-hooks.d.ts +46 -0
  25. package/dist/modules/auth/account-hooks.d.ts.map +1 -0
  26. package/dist/modules/auth/account-hooks.js +25 -0
  27. package/dist/modules/auth/account-hooks.js.map +1 -0
  28. package/dist/modules/auth/account-switch-guard.d.ts +15 -0
  29. package/dist/modules/auth/account-switch-guard.d.ts.map +1 -0
  30. package/dist/modules/auth/account-switch-guard.js +2 -0
  31. package/dist/modules/auth/account-switch-guard.js.map +1 -0
  32. package/dist/modules/auth/account-switch-lock.d.ts +34 -0
  33. package/dist/modules/auth/account-switch-lock.d.ts.map +1 -0
  34. package/dist/modules/auth/account-switch-lock.js +48 -0
  35. package/dist/modules/auth/account-switch-lock.js.map +1 -0
  36. package/dist/modules/auth/account.routes.d.ts +4 -1
  37. package/dist/modules/auth/account.routes.d.ts.map +1 -1
  38. package/dist/modules/auth/account.routes.js +100 -10
  39. package/dist/modules/auth/account.routes.js.map +1 -1
  40. package/dist/modules/auth/auth.service.d.ts +101 -6
  41. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  42. package/dist/modules/auth/auth.service.js +246 -51
  43. package/dist/modules/auth/auth.service.js.map +1 -1
  44. package/dist/modules/auth/password-hash.d.ts +5 -2
  45. package/dist/modules/auth/password-hash.d.ts.map +1 -1
  46. package/dist/modules/auth/password-hash.js +5 -2
  47. package/dist/modules/auth/password-hash.js.map +1 -1
  48. package/dist/modules/database/pg-error.d.ts +9 -0
  49. package/dist/modules/database/pg-error.d.ts.map +1 -0
  50. package/dist/modules/database/pg-error.js +17 -0
  51. package/dist/modules/database/pg-error.js.map +1 -0
  52. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  53. package/dist/modules/workflow/workflow.service.js +2 -1
  54. package/dist/modules/workflow/workflow.service.js.map +1 -1
  55. package/package.json +3 -3
  56. package/src/__tests__/pg-scratch.ts +48 -0
  57. package/src/core/create-core-server.ts +2 -0
  58. package/src/core/create-core-services.ts +11 -0
  59. package/src/modules/access/__tests__/access-control.admin-root-floor.test.ts +1 -0
  60. package/src/modules/access/__tests__/access.routes.admins.test.ts +203 -0
  61. package/src/modules/access/__tests__/access.routes.batch.test.ts +25 -5
  62. package/src/modules/access/__tests__/access.routes.folder-governs.test.ts +1 -0
  63. package/src/modules/access/__tests__/access.routes.group-grant.test.ts +1 -1
  64. package/src/modules/access/__tests__/access.routes.overrides.test.ts +1 -1
  65. package/src/modules/access/__tests__/access.routes.proposal-branch.test.ts +1 -0
  66. package/src/modules/access/__tests__/access.routes.prospective.test.ts +1 -0
  67. package/src/modules/access/__tests__/access.routes.revoke.test.ts +1 -1
  68. package/src/modules/access/__tests__/access.routes.unknown-email.test.ts +1 -0
  69. package/src/modules/access/__tests__/roles-admin.security.test.ts +1 -1
  70. package/src/modules/access/__tests__/roles-admin.service.test.ts +15 -2
  71. package/src/modules/access/__tests__/roles.routes.test.ts +132 -7
  72. package/src/modules/access/__tests__/user-access-removal.service.test.ts +2 -2
  73. package/src/modules/access/__tests__/users-db-double.ts +12 -3
  74. package/src/modules/access/access-control.interface.ts +10 -0
  75. package/src/modules/access/access-control.service.ts +19 -0
  76. package/src/modules/access/access.routes.ts +67 -6
  77. package/src/modules/access/roles-admin.service.ts +51 -11
  78. package/src/modules/access/user-access-removal.service.ts +1 -1
  79. package/src/modules/auth/__tests__/account-deactivation.test.ts +145 -16
  80. package/src/modules/auth/__tests__/account-hooks.test.ts +57 -0
  81. package/src/modules/auth/__tests__/account-switch-lock.pg.test.ts +78 -0
  82. package/src/modules/auth/__tests__/account-switch-lock.test.ts +43 -0
  83. package/src/modules/auth/__tests__/account.routes.test.ts +382 -6
  84. package/src/modules/auth/__tests__/auth.service.created.pg.test.ts +69 -0
  85. package/src/modules/auth/__tests__/auth.service.test.ts +159 -1
  86. package/src/modules/auth/account-hooks.ts +69 -0
  87. package/src/modules/auth/account-switch-guard.ts +14 -0
  88. package/src/modules/auth/account-switch-lock.ts +58 -0
  89. package/src/modules/auth/account.routes.ts +112 -10
  90. package/src/modules/auth/auth.service.ts +268 -52
  91. package/src/modules/auth/password-hash.ts +5 -2
  92. package/src/modules/database/__tests__/pg-error.test.ts +21 -0
  93. package/src/modules/database/__tests__/pii-backfill.pg.test.ts +6 -27
  94. package/src/modules/database/pg-error.ts +15 -0
  95. package/src/modules/workflow/workflow.service.ts +2 -1
@@ -4,12 +4,14 @@ import fs from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import os from 'node:os';
6
6
  import express from 'express';
7
+ import { PgDialect } from 'drizzle-orm/pg-core';
8
+ import type { SQL } from 'drizzle-orm';
7
9
  import { describe, it, expect, afterEach, vi } from 'vitest';
8
10
  import { NodeFs } from '../../kb-fs/node-fs.js';
9
11
 
10
12
  import type { IAccessControl } from '../access-control.interface.js';
11
13
  import type { WorkspaceService } from '../../workspace/workspace.service.js';
12
- import type { AuthService } from '../../auth/auth.service.js';
14
+ import { AuthService } from '../../auth/auth.service.js';
13
15
  import type { WorkflowService } from '../../workflow/workflow.service.js';
14
16
  import type { WorkflowEventBus } from '../../workflow/event-bus.js';
15
17
  import type { FileTreeEntry } from '@bevel-software/platform-shared';
@@ -40,7 +42,9 @@ const ROLES = `roles:
40
42
  /** Temp workspace dirs to clean up after each test. */
41
43
  const tmpDirs: string[] = [];
42
44
 
43
- async function makeHarness(opts: { isAdmin?: boolean } = {}): Promise<{ server: Server; baseUrl: string }> {
45
+ async function makeHarness(
46
+ opts: { isAdmin?: boolean; switchOffOnRolesLock?: string } = {},
47
+ ): Promise<{ server: Server; baseUrl: string; txLog: string[] }> {
44
48
  const isAdmin = opts.isAdmin ?? true;
45
49
 
46
50
  // Back the harness with a REAL temp workspace dir: the service's single-file
@@ -103,8 +107,6 @@ async function makeHarness(opts: { isAdmin?: boolean } = {}): Promise<{ server:
103
107
  }),
104
108
  } as unknown as WorkspaceService;
105
109
 
106
- const authService = { getUserById: vi.fn(async () => ADMIN) } as unknown as AuthService;
107
-
108
110
  // Lock holder tracked so the service's getLock pre-check passes; the single-
109
111
  // file write itself lands on disk via LockingFilesystem → LocalFilesystem.
110
112
  // Strict per-path locks — a live row is contended even for the same user,
@@ -119,6 +121,8 @@ async function makeHarness(opts: { isAdmin?: boolean } = {}): Promise<{ server:
119
121
  acquireLock: vi.fn(async (_w: string, _b: string, p: string, user: { id: string; name: string }) => {
120
122
  const h = locks.get(p);
121
123
  if (h) return { acquired: false, lock: lockRow(h) };
124
+ // An account switched off while the promotion waited for the lock.
125
+ if (p.endsWith('roles.yaml')) rolesLocked = true;
122
126
  locks.set(p, user);
123
127
  return { acquired: true, lock: lockRow(user) };
124
128
  }),
@@ -130,7 +134,47 @@ async function makeHarness(opts: { isAdmin?: boolean } = {}): Promise<{ server:
130
134
  } as unknown as WorkflowService;
131
135
 
132
136
  const eventBus = { emit: vi.fn() } as unknown as WorkflowEventBus;
133
- const db = usersDbDouble();
137
+ const accounts = [
138
+ { email: 'off@example.com', deactivatedAt: new Date('2026-09-01T00:00:00Z') },
139
+ 'on@example.com',
140
+ ];
141
+ const before = usersDbDouble(accounts);
142
+ const after = usersDbDouble(
143
+ accounts.map((a) =>
144
+ a === opts.switchOffOnRolesLock ? { email: a, deactivatedAt: new Date('2026-10-10T00:00:00Z') } : a,
145
+ ),
146
+ );
147
+ let rolesLocked = false;
148
+ const current = () => (rolesLocked ? after : before);
149
+ // What happened inside each users transaction, in order: the lock taken
150
+ // with `execute` (rendered), and what roles.yaml held when it ended.
151
+ const txLog: string[] = [];
152
+ const rolesOnDisk = () => fs.readFile(path.join(repoDir, 'roles.yaml'), 'utf-8');
153
+ const db = {
154
+ select: () => current().select(),
155
+ transaction: async <T>(cb: (tx: unknown) => Promise<T>): Promise<T> => {
156
+ txLog.push('begin');
157
+ const tx = {
158
+ execute: async (query: SQL) => {
159
+ const { sql, params } = new PgDialect().sqlToQuery(query);
160
+ txLog.push(`${sql} ${JSON.stringify(params)}`);
161
+ return [];
162
+ },
163
+ select: () => current().select(),
164
+ };
165
+ try {
166
+ return await cb(tx);
167
+ } finally {
168
+ const onDisk = await rolesOnDisk();
169
+ txLog.push(`end: ${['on@example.com', 'new@example.com'].filter((e) => onDisk.includes(e)).join(', ') || 'none'}`);
170
+ }
171
+ },
172
+ } as unknown as typeof before;
173
+
174
+ // The real auth service over the same double, so the promotion's switch
175
+ // lock and read run as they do in production (`whileSwitchedOn`).
176
+ const authService = new AuthService(db, { jwtSecret: 'test', adminEmail: '', adminPassword: '', allowedEmailDomains: [] });
177
+ vi.spyOn(authService, 'getUserById').mockImplementation(async () => ADMIN);
134
178
 
135
179
  const app = express();
136
180
  app.use(express.json());
@@ -138,13 +182,13 @@ async function makeHarness(opts: { isAdmin?: boolean } = {}): Promise<{ server:
138
182
  (req as unknown as { userId: string }).userId = ADMIN.id;
139
183
  next();
140
184
  });
141
- app.use('/api', createAccessRoutes(accessControl, workspaceService, authService, workflowService, eventBus, db, testKbContext({ kbDirName: KB })));
185
+ app.use('/api', createAccessRoutes(accessControl, workspaceService, authService, authService, workflowService, eventBus, db, testKbContext({ kbDirName: KB })));
142
186
 
143
187
  const server = await new Promise<Server>((resolve) => {
144
188
  const s = app.listen(0, () => resolve(s));
145
189
  });
146
190
  const addr = server.address() as AddressInfo;
147
- return { server, baseUrl: `http://127.0.0.1:${addr.port}` };
191
+ return { server, baseUrl: `http://127.0.0.1:${addr.port}`, txLog };
148
192
  }
149
193
 
150
194
  function close(s: Server): Promise<void> {
@@ -258,4 +302,85 @@ describe('/api/access/roles routes', () => {
258
302
  const body = (await add.json()) as { roles: { canonical: string; members: string[] }[] };
259
303
  expect(body.roles.find((r) => r.canonical === 'sales')!.members).toContain('mkt@example.com');
260
304
  });
305
+
306
+ // The invite's promotion (`ifActive`) reads whether the account is switched
307
+ // off inside the roles lock, not from the list the dialog read before the
308
+ // send nor before the lock was waited for.
309
+ async function addAdmin(baseUrl: string, body: unknown) {
310
+ return fetch(`${baseUrl}/api/access/roles/admin/members`, {
311
+ method: 'POST',
312
+ headers: { 'Content-Type': 'application/json' },
313
+ body: JSON.stringify(body),
314
+ });
315
+ }
316
+
317
+ it('ifActive refuses a switched-off account (409 deactivated) and writes nothing', async () => {
318
+ const h = await makeHarness();
319
+ server = h.server;
320
+ const res = await addAdmin(h.baseUrl, { email: 'Off@Example.com', ifActive: true });
321
+ expect(res.status).toBe(409);
322
+ expect(await res.json()).toEqual({ error: 'This account is switched off', kind: 'deactivated' });
323
+ const roster = (await (await fetch(`${h.baseUrl}/api/access/roles`)).json()) as {
324
+ roles: { canonical: string; members: string[] }[];
325
+ };
326
+ expect(roster.roles.find((r) => r.canonical === 'admin')!.members).not.toContain('off@example.com');
327
+ });
328
+
329
+ it('ifActive promotes a switched-on account, and one with no account yet', async () => {
330
+ const h = await makeHarness();
331
+ server = h.server;
332
+ expect((await addAdmin(h.baseUrl, { email: 'on@example.com', ifActive: true })).status).toBe(200);
333
+ const res = await addAdmin(h.baseUrl, { email: 'new@example.com', ifActive: true });
334
+ expect(res.status).toBe(200);
335
+ const body = (await res.json()) as { roles: { canonical: string; members: string[] }[] };
336
+ expect(body.roles.find((r) => r.canonical === 'admin')!.members).toEqual(
337
+ expect.arrayContaining(['on@example.com', 'new@example.com']),
338
+ );
339
+ });
340
+
341
+ it('ifActive reads the account once the roles lock is held: one switched off while it waited is refused', async () => {
342
+ const h = await makeHarness({ switchOffOnRolesLock: 'on@example.com' });
343
+ server = h.server;
344
+ const res = await addAdmin(h.baseUrl, { email: 'on@example.com', ifActive: true });
345
+ expect(res.status).toBe(409);
346
+ expect(await res.json()).toEqual({ error: 'This account is switched off', kind: 'deactivated' });
347
+ const roster = (await (await fetch(`${h.baseUrl}/api/access/roles`)).json()) as {
348
+ roles: { canonical: string; members: string[] }[];
349
+ };
350
+ expect(roster.roles.find((r) => r.canonical === 'admin')!.members).not.toContain('on@example.com');
351
+ });
352
+
353
+ // Every switch-off (and an account created switched off) takes the same
354
+ // lock first; see account-deactivation.test for that side.
355
+ it.each([
356
+ ['an existing account', 'On@Example.com', 'on@example.com'],
357
+ ['an address with no account yet', 'new@example.com', 'new@example.com'],
358
+ ])(
359
+ 'ifActive holds the address switch lock until roles.yaml is written, for %s',
360
+ async (_label, email, canonical) => {
361
+ const h = await makeHarness();
362
+ server = h.server;
363
+ expect((await addAdmin(h.baseUrl, { email, ifActive: true })).status).toBe(200);
364
+ expect(h.txLog).toEqual([
365
+ 'begin',
366
+ `select set_config('lock_timeout', $1, true) ["15000"]`,
367
+ `select pg_advisory_xact_lock(hashtext($1)) ["account-switch:${canonical}"]`,
368
+ `end: ${canonical}`,
369
+ ]);
370
+ },
371
+ );
372
+
373
+ it('without ifActive, adding a switched-off account works as before', async () => {
374
+ const h = await makeHarness();
375
+ server = h.server;
376
+ expect((await addAdmin(h.baseUrl, { email: 'off@example.com' })).status).toBe(200);
377
+ });
378
+
379
+ it('refuses a non-boolean ifActive (400)', async () => {
380
+ const h = await makeHarness();
381
+ server = h.server;
382
+ for (const ifActive of ['true', 1, null]) {
383
+ expect((await addAdmin(h.baseUrl, { email: 'on@example.com', ifActive })).status).toBe(400);
384
+ }
385
+ });
261
386
  });
@@ -329,11 +329,11 @@ describe('UserAccessRemovalService', () => {
329
329
  }
330
330
  });
331
331
 
332
- it('refuses the deployment owner and the last Admin, writing nothing', async () => {
332
+ it('refuses the owner and the last Admin, writing nothing', async () => {
333
333
  const { service, workflow } = build(stubWorkflow(), ['Lee@x.io']);
334
334
  const report = await service.report(LEE);
335
335
  expect(report.removable).toBe(false);
336
- expect(report.blockedReason).toMatch(/deployment owner/);
336
+ expect(report.blockedReason).toBe('The owner stays in roles and access rules.');
337
337
  await expect(service.remove(ADMIN, LEE, 'deleted-1')).rejects.toMatchObject({ status: 409 });
338
338
 
339
339
  const other = build(stubWorkflow(), []);
@@ -48,12 +48,13 @@ function boundValues(node: unknown, depth = 0): string[] {
48
48
  const canonical = (email: string) => email.trim().toLowerCase();
49
49
 
50
50
  export function usersDbDouble(
51
- accounts: readonly (string | { email: string; name?: string })[] = [],
51
+ accounts: readonly (string | { email: string; name?: string; deactivatedAt?: Date | null })[] = [],
52
52
  ): Database {
53
53
  const rows = accounts.map((a) => {
54
54
  const email = typeof a === 'string' ? a : a.email;
55
55
  const name = typeof a === 'string' ? undefined : a.name;
56
- return { id: `u-${email}`, email, name: name ?? email.split('@')[0] };
56
+ const deactivatedAt = typeof a === 'string' ? null : (a.deactivatedAt ?? null);
57
+ return { id: `u-${email}`, email, name: name ?? email.split('@')[0], deactivatedAt };
57
58
  });
58
59
  const from = () => {
59
60
  // Thenable so a bare `await db.select().from(users)` resolves, with
@@ -68,5 +69,13 @@ export function usersDbDouble(
68
69
  };
69
70
  return query;
70
71
  };
71
- return { select: () => ({ from }) } as unknown as Database;
72
+ const db = {
73
+ select: () => ({ from }),
74
+ // One connection, no isolation to model: the callback runs on the same
75
+ // double.
76
+ transaction: async <T>(cb: (tx: unknown) => Promise<T>) => cb(db),
77
+ // Locks taken with `execute` (advisory locks) answer nothing.
78
+ execute: async () => [],
79
+ };
80
+ return db as unknown as Database;
72
81
  }
@@ -678,6 +678,16 @@ export interface IAccessControl {
678
678
  */
679
679
  holdsAdminRootWrite(workspaceId: string, userEmail: string): Promise<boolean>;
680
680
 
681
+ /**
682
+ * Every address the resolver treats as an Admin in the working-tree model,
683
+ * each once: the Admin role's members — through a group too — and the
684
+ * deployment owners. The same membership answer {@link holdsAdminRootWrite}
685
+ * gives one address at a time, enumerated. When the model cannot be loaded
686
+ * (a `roles.yaml` that does not parse makes nobody an Admin), the
687
+ * deployment owners alone.
688
+ */
689
+ adminEmails(workspaceId: string): Promise<string[]>;
690
+
681
691
  /**
682
692
  * Whether `userEmail` may put a misplaced platform file back at
683
693
  * `destinationRelativePath` — the ONE write that is allowed to land on a
@@ -2164,6 +2164,25 @@ export class AccessControlService implements IAccessControl {
2164
2164
  return isAdminEmail(model, canonicalEmail(userEmail));
2165
2165
  }
2166
2166
 
2167
+ async adminEmails(workspaceId: string): Promise<string[]> {
2168
+ let model: AccessModel;
2169
+ try {
2170
+ model = await this.loadModel(workspaceId);
2171
+ } catch (err) {
2172
+ // As canRestorePlatformFile: with no model to ask, the deployment owners
2173
+ // are the only admins left. A `roles.yaml` that does not parse is that
2174
+ // by design; anything else (a clone being read, the disk) is said, so a
2175
+ // list that names fewer admins than there are is diagnosable.
2176
+ if (!(err instanceof AccessConfigError)) {
2177
+ log.warn('the access model could not be loaded; the admins list names the deployment owners only:', { err });
2178
+ }
2179
+ return [...this.deploymentOwners];
2180
+ }
2181
+ const candidates = new Set<string>(model.deploymentOwners);
2182
+ for (const email of model.roles.byEmail.keys()) candidates.add(email);
2183
+ return [...candidates].filter((email) => isAdminEmail(model, email));
2184
+ }
2185
+
2167
2186
  async canRestorePlatformFile(
2168
2187
  workspaceId: string,
2169
2188
  userEmail: string,
@@ -21,6 +21,7 @@ import type { WorkspaceService } from '../workspace/workspace.service.js';
21
21
  import type { KbContext } from '../../shared/kb-context.js';
22
22
  import { branchForWorkspaceId } from '../../shared/workspace-id.js';
23
23
  import type { AuthService } from '../auth/auth.service.js';
24
+ import type { IAccountSwitchGuard } from '../auth/account-switch-guard.js';
24
25
  import type { WorkflowService } from '../workflow/workflow.service.js';
25
26
  import type { WorkflowEventBus } from '../workflow/event-bus.js';
26
27
  import { WorkflowDomainError } from '../../shared/domain-errors.js';
@@ -51,6 +52,7 @@ import { RolesAdminService } from './roles-admin.service.js';
51
52
  import type { Principal } from '../access-model/access-splice.js';
52
53
  import type { Database } from '../database/connection.js';
53
54
  import { users } from '../database/schema.js';
55
+ import { canonicalEmail } from '../../shared/email-identity.js';
54
56
  import '../auth/auth.middleware.js';
55
57
 
56
58
  /** Verbs the share UI may grant. Verbs are independent — `download` is grantable on its own. */
@@ -85,6 +87,8 @@ export function createAccessRoutes(
85
87
  accessControl: IAccessControl,
86
88
  workspaceService: WorkspaceService,
87
89
  authService: AuthService,
90
+ /** Auth's switch-off coordination, for the invite's promotion (`ifActive`). */
91
+ switchGuard: IAccountSwitchGuard,
88
92
  workflowService: WorkflowService,
89
93
  eventBus: WorkflowEventBus,
90
94
  db: Database,
@@ -431,12 +435,15 @@ export function createAccessRoutes(
431
435
 
432
436
  /**
433
437
  * POST /api/workspace/:id/access/batch
434
- * Body: `{ paths: string[], verb?: 'write' | 'owner' }`. Returns
438
+ * Body: `{ paths: string[], verb?: 'write' | 'owner' | 'read' }`. Returns
435
439
  * `{ results: { [path]: boolean } }`.
436
440
  *
437
441
  * `verb` defaults to `write`, the question every existing caller asks.
438
442
  * `owner` answers from the `owner:` lists alone — no admin rescue, and a
439
- * writer is not an owner — which is what an Owner pill has to mean.
443
+ * writer is not an owner — which is what an Owner pill has to mean. `read`
444
+ * is the verdict the single lookup's `canRead` gives, for many paths at
445
+ * once: what New page asks on a draft branch, where a page may go wherever
446
+ * its author may read, of every Knowledge folder in one request.
440
447
  */
441
448
  router.post('/workspace/:id/access/batch', async (req, res) => {
442
449
  const user = await requireUser(req, res);
@@ -451,13 +458,18 @@ export function createAccessRoutes(
451
458
  res.status(400).json({ error: 'paths cannot exceed 500 entries per request' });
452
459
  return;
453
460
  }
454
- if (verb !== 'write' && verb !== 'owner') {
455
- res.status(400).json({ error: 'verb must be "write" or "owner"' });
461
+ if (verb !== 'write' && verb !== 'owner' && verb !== 'read') {
462
+ res.status(400).json({ error: 'verb must be "write", "owner" or "read"' });
456
463
  return;
457
464
  }
458
465
 
459
466
  try {
460
- const batch = verb === 'owner' ? accessControl.canOwnerBatch : accessControl.canWriteBatch;
467
+ const batch =
468
+ verb === 'owner'
469
+ ? accessControl.canOwnerBatch
470
+ : verb === 'read'
471
+ ? accessControl.canReadBatch
472
+ : accessControl.canWriteBatch;
461
473
  const result = await batch.call(
462
474
  accessControl,
463
475
  req.params.id,
@@ -1228,6 +1240,39 @@ export function createAccessRoutes(
1228
1240
  }
1229
1241
  });
1230
1242
 
1243
+ // Who the admins are, for anyone signed in: the top bar tells someone who
1244
+ // is not an admin to ask one, and names them. Names and emails ONLY — the
1245
+ // roster above (every role, its members, groups and references) stays
1246
+ // admin-only. Names come from the accounts table; an admin with no account
1247
+ // yet (a deployment admin before first sign-in, say) is named by address.
1248
+ // A role member whose account is switched off cannot sign in to invite
1249
+ // anyone, so is left out; a deployment admin is not (it signs in with the
1250
+ // environment's password regardless).
1251
+ router.get('/access/admins', async (req, res) => {
1252
+ const user = await requireUser(req, res);
1253
+ if (!user) return;
1254
+ try {
1255
+ const emails = await rolesAdmin.getAdminEmails();
1256
+ const rows =
1257
+ emails.length === 0
1258
+ ? []
1259
+ : await db
1260
+ .select({ email: users.email, name: users.name, deactivatedAt: users.deactivatedAt })
1261
+ .from(users)
1262
+ .where(inArray(users.emailBidx, emails));
1263
+ const names = new Map(rows.map((r) => [canonicalEmail(r.email), r.name?.trim() ?? '']));
1264
+ const off = new Set(rows.filter((r) => r.deactivatedAt).map((r) => canonicalEmail(r.email)));
1265
+ const admins = emails
1266
+ .filter((email) => !off.has(email) || rolesAdmin.isFixedAdmin(email))
1267
+ .map((email) => ({ name: names.get(email) || email, email }))
1268
+ .sort((a, b) => a.name.localeCompare(b.name) || a.email.localeCompare(b.email));
1269
+ res.json({ admins });
1270
+ } catch (err) {
1271
+ const { status, body } = toHttpError(err);
1272
+ res.status(status).json(body);
1273
+ }
1274
+ });
1275
+
1231
1276
  // NOTE: POST /access/roles (create), PATCH /access/roles/:canonical
1232
1277
  // (rename), and DELETE /access/roles/:canonical are GONE — roles are
1233
1278
  // app-defined capabilities, not user-editable objects.
@@ -1239,7 +1284,23 @@ export function createAccessRoutes(
1239
1284
  await assertRolesAdmin(user.email);
1240
1285
  const canonical = canonicalRoleName(req.params.canonical);
1241
1286
  const email = requireNonEmptyString((req.body ?? {}).email, 'email');
1242
- res.json({ roles: await rolesAdmin.addMember(user, canonical, email) });
1287
+ // `ifActive`: the invite's promotion, which leaves a switched-off
1288
+ // account unchanged. Inside the roles file's lock, auth reads the
1289
+ // account under the address's switch lock and keeps that transaction
1290
+ // open until roles.yaml is written and committed
1291
+ // (`IAccountSwitchGuard.whileSwitchedOn`). So a switch-off either committed
1292
+ // before the read (the promotion is refused) or waits until the
1293
+ // promotion is written (it lands after, as for any Admin switched off
1294
+ // later: the place in roles stays, the credentials stop being honoured).
1295
+ const ifActive = (req.body ?? {}).ifActive;
1296
+ if (ifActive !== undefined && typeof ifActive !== 'boolean') {
1297
+ res.status(400).json({ error: 'ifActive must be a boolean' });
1298
+ return;
1299
+ }
1300
+ const whileSwitchedOn = (write: () => Promise<void>): Promise<void> => switchGuard.whileSwitchedOn(email, write);
1301
+ res.json({
1302
+ roles: await rolesAdmin.addMember(user, canonical, email, ifActive ? { aroundWrite: whileSwitchedOn } : undefined),
1303
+ });
1243
1304
  } catch (err) {
1244
1305
  const { status, body } = toHttpError(err);
1245
1306
  res.status(status).json(body);
@@ -103,6 +103,13 @@ export interface RoleRosterEntry {
103
103
  * email once, as fixed.
104
104
  */
105
105
  fixedMembers: string[];
106
+ /**
107
+ * The note the page prints beside {@link fixedMembers}, and the sentence
108
+ * both Admin-membership refusals lead with. Sent with the roster so the
109
+ * page keeps no copy of the wording. Null when the role has no fixed
110
+ * members.
111
+ */
112
+ fixedMembersNote: string | null;
106
113
  /** Canonical names of groups this role is assigned to (`group:` members). */
107
114
  groups: string[];
108
115
  isAdmin: boolean;
@@ -165,11 +172,12 @@ export class RolesAdminService {
165
172
  private readonly fixedAdminEmails: readonly string[];
166
173
  /**
167
174
  * The one explanation both Admin-role refusals carry, verbatim: the note the
168
- * page prints beside a fixed member. Kept as a named constant so the refusal
169
- * an API caller reads and the note a UI reader sees cannot drift apart.
175
+ * page prints beside a fixed member, which it reads from the roster's
176
+ * `fixedMembersNote`. One constant, so the refusal an API caller reads and
177
+ * the note a UI reader sees cannot drift apart.
170
178
  */
171
179
  private static readonly FIXED_ADMIN_NOTE =
172
- 'Deployment admin, set in the server configuration; cannot be removed here';
180
+ 'Owner; cannot be removed here';
173
181
 
174
182
  constructor(
175
183
  private readonly workspaceService: IWorkspaceService,
@@ -227,9 +235,10 @@ export class RolesAdminService {
227
235
  /**
228
236
  * Is `email` a deployment admin — an address the server configuration makes
229
237
  * an Admin, ahead of `roles.yaml`? Drives both the fixed member rows and
230
- * the add/remove refusals on the Admin role.
238
+ * the add/remove refusals on the Admin role, and keeps a switched-off
239
+ * deployment admin among the admins people are told to ask.
231
240
  */
232
- private isFixedAdmin(email: string): boolean {
241
+ isFixedAdmin(email: string): boolean {
233
242
  return this.fixedAdminEmails.includes(canonicalEmail(email));
234
243
  }
235
244
 
@@ -326,6 +335,8 @@ export class RolesAdminService {
326
335
  // Only Admin has fixed members: `deploymentOwners` is an Admin rescue
327
336
  // in the resolver, never a general grant, so no other role gains one.
328
337
  fixedMembers: isAdmin ? [...this.fixedAdminEmails] : [],
338
+ fixedMembersNote:
339
+ isAdmin && this.fixedAdminEmails.length > 0 ? RolesAdminService.FIXED_ADMIN_NOTE : null,
329
340
  groups: role.members
330
341
  .filter(isGroupRefMember)
331
342
  .map((m) => canonicalRoleName(m.slice(GROUP_REF_PREFIX.length))),
@@ -342,6 +353,18 @@ export class RolesAdminService {
342
353
  return out;
343
354
  }
344
355
 
356
+ /**
357
+ * The addresses the app treats as an Admin, each once — the resolver's own
358
+ * membership answer ({@link IAccessControl.adminEmails}), so who this names
359
+ * and who the admin gates admit cannot drift apart. Nothing else about
360
+ * roles: this backs a read any signed-in person may make (who to ask for an
361
+ * invite), unlike the roster.
362
+ */
363
+ async getAdminEmails(): Promise<string[]> {
364
+ const workspaceId = await this.ensureWorkspace();
365
+ return this.accessControl.adminEmails(workspaceId);
366
+ }
367
+
345
368
  /**
346
369
  * Whether the default-branch roles.yaml parses. Drives the "roles file
347
370
  * corrupted" banner. Auth-only (never admin-gated): a corrupted file resolves
@@ -446,10 +469,24 @@ export class RolesAdminService {
446
469
  * admits it ahead of `roles.yaml` — so the write would add a row that means
447
470
  * nothing, next to the fixed row the page already shows. Other roles are
448
471
  * unaffected: a deployment admin is an ordinary member of "Sales".
472
+ *
473
+ * `aroundWrite`, when given, runs once the roles file's lock is held and
474
+ * is handed the edit itself (read, check, write, commit) to run. What it
475
+ * does around that call brackets the whole write: the invite holds the
476
+ * address's account-switch lock (a transaction-scoped advisory lock keyed
477
+ * on the canonical email, see `AuthService.whileSwitchedOn`) across it, so
478
+ * a switch-off cannot commit in between, whether or not the account exists
479
+ * yet (see the members route). It may throw instead of calling `write`;
480
+ * nothing is written then.
449
481
  */
450
- async addMember(actor: AuthUser, canonical: string, email: string): Promise<RoleRosterEntry[]> {
482
+ async addMember(
483
+ actor: AuthUser,
484
+ canonical: string,
485
+ email: string,
486
+ opts?: { aroundWrite?: (write: () => Promise<void>) => Promise<void> },
487
+ ): Promise<RoleRosterEntry[]> {
451
488
  this.assertNotFixedAdmin(canonical, email);
452
- await this.runEdit(actor, (text) => editAddMember(text, canonical, email));
489
+ await this.runEdit(actor, (text) => editAddMember(text, canonical, email), opts?.aroundWrite);
453
490
  return this.getRoster();
454
491
  }
455
492
 
@@ -682,17 +719,20 @@ export class RolesAdminService {
682
719
  * `pre` produces the candidate (and may throw RolesAdminError for invariant
683
720
  * violations before any write). Skips on a no-op. Every candidate passes
684
721
  * the resolver's own parser (assertLoadable) plus the pre-disk validator
685
- * before a byte lands.
722
+ * before a byte lands. `aroundWrite` wraps the locked edit (see
723
+ * {@link addMember}).
686
724
  */
687
725
  private async runEdit(
688
726
  actor: AuthUser,
689
727
  pre: (currentText: string) => EditResult,
728
+ aroundWrite?: (write: () => Promise<void>) => Promise<void>,
690
729
  ): Promise<void> {
691
730
  const workspaceId = await this.ensureWorkspace();
692
731
  await this.assertRolesUnlocked(workspaceId, actor);
693
- return this.locked.withFileLocks(workspaceId, actor, [ROLES_YAML], () =>
694
- this.runEditLocked(workspaceId, actor, pre),
695
- );
732
+ return this.locked.withFileLocks(workspaceId, actor, [ROLES_YAML], () => {
733
+ const write = () => this.runEditLocked(workspaceId, actor, pre);
734
+ return aroundWrite ? aroundWrite(write) : write();
735
+ });
696
736
  }
697
737
 
698
738
  private async runEditLocked(
@@ -492,7 +492,7 @@ export class UserAccessRemovalService {
492
492
  /** Why `email` may not be removed from access files, or null when it may. */
493
493
  private blockedReason(rolesText: string | undefined, email: string): string | null {
494
494
  if (this.deploymentOwners.some((o) => canonicalEmail(o) === email)) {
495
- return 'This is the deployment owner (ADMIN_EMAIL); their access cannot be removed here.';
495
+ return 'The owner stays in roles and access rules.';
496
496
  }
497
497
  const admin = rolesText
498
498
  ? (() => {