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

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 (99) 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 +14 -2
  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 +83 -42
  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/kb-template/access.md +2 -1
  56. package/package.json +3 -3
  57. package/src/__tests__/pg-scratch.ts +48 -0
  58. package/src/core/create-core-server.ts +2 -0
  59. package/src/core/create-core-services.ts +11 -0
  60. package/src/modules/access/__tests__/access-control.admin-root-floor.test.ts +24 -3
  61. package/src/modules/access/__tests__/access-control.download-read.test.ts +3 -1
  62. package/src/modules/access/__tests__/access-control.service.test.ts +77 -16
  63. package/src/modules/access/__tests__/access.routes.admins.test.ts +203 -0
  64. package/src/modules/access/__tests__/access.routes.batch.test.ts +25 -5
  65. package/src/modules/access/__tests__/access.routes.folder-governs.test.ts +1 -0
  66. package/src/modules/access/__tests__/access.routes.group-grant.test.ts +1 -1
  67. package/src/modules/access/__tests__/access.routes.overrides.test.ts +1 -1
  68. package/src/modules/access/__tests__/access.routes.proposal-branch.test.ts +1 -0
  69. package/src/modules/access/__tests__/access.routes.prospective.test.ts +1 -0
  70. package/src/modules/access/__tests__/access.routes.revoke.test.ts +1 -1
  71. package/src/modules/access/__tests__/access.routes.unknown-email.test.ts +1 -0
  72. package/src/modules/access/__tests__/change-read-gate.test.ts +36 -5
  73. package/src/modules/access/__tests__/roles-admin.security.test.ts +1 -1
  74. package/src/modules/access/__tests__/roles-admin.service.test.ts +15 -2
  75. package/src/modules/access/__tests__/roles.routes.test.ts +132 -7
  76. package/src/modules/access/__tests__/user-access-removal.service.test.ts +2 -2
  77. package/src/modules/access/__tests__/users-db-double.ts +12 -3
  78. package/src/modules/access/access-control.interface.ts +15 -2
  79. package/src/modules/access/access-control.service.ts +93 -49
  80. package/src/modules/access/access.routes.ts +67 -6
  81. package/src/modules/access/roles-admin.service.ts +51 -11
  82. package/src/modules/access/user-access-removal.service.ts +1 -1
  83. package/src/modules/auth/__tests__/account-deactivation.test.ts +145 -16
  84. package/src/modules/auth/__tests__/account-hooks.test.ts +57 -0
  85. package/src/modules/auth/__tests__/account-switch-lock.pg.test.ts +78 -0
  86. package/src/modules/auth/__tests__/account-switch-lock.test.ts +43 -0
  87. package/src/modules/auth/__tests__/account.routes.test.ts +382 -6
  88. package/src/modules/auth/__tests__/auth.service.created.pg.test.ts +69 -0
  89. package/src/modules/auth/__tests__/auth.service.test.ts +159 -1
  90. package/src/modules/auth/account-hooks.ts +69 -0
  91. package/src/modules/auth/account-switch-guard.ts +14 -0
  92. package/src/modules/auth/account-switch-lock.ts +58 -0
  93. package/src/modules/auth/account.routes.ts +112 -10
  94. package/src/modules/auth/auth.service.ts +268 -52
  95. package/src/modules/auth/password-hash.ts +5 -2
  96. package/src/modules/database/__tests__/pg-error.test.ts +21 -0
  97. package/src/modules/database/__tests__/pii-backfill.pg.test.ts +6 -27
  98. package/src/modules/database/pg-error.ts +15 -0
  99. package/src/modules/workflow/workflow.service.ts +2 -1
@@ -637,6 +637,18 @@ describe('Admin fixed members — the deployment admin', () => {
637
637
  expect(roster.find((r) => r.canonical === 'sales')!.fixedMembers).toEqual([]);
638
638
  });
639
639
 
640
+ it('sends the note with the roster, and the refusal leads with that same note', async () => {
641
+ // The page prints `fixedMembersNote` as sent, so this is the one copy of
642
+ // the wording: the note and the API refusal cannot drift apart.
643
+ const svc = serviceWith([OWNER]);
644
+ const roster = await svc.getRoster();
645
+ const note = roster.find((r) => r.canonical === 'admin')!.fixedMembersNote;
646
+ expect(note).toBe('Owner; cannot be removed here');
647
+ expect(roster.find((r) => r.canonical === 'sales')!.fixedMembersNote).toBeNull();
648
+ const refusal = await svc.addMember(ADMIN, 'admin', OWNER).catch((e: Error) => e);
649
+ expect((refusal as Error).message.startsWith(`${note}. `)).toBe(true);
650
+ });
651
+
640
652
  it('canonicalises the configured address and drops a blank one', async () => {
641
653
  // The composition root passes `[config.adminEmail]` unconditionally, so an
642
654
  // unset ADMIN_EMAIL arrives as [''] — that must not become a member "".
@@ -665,7 +677,7 @@ describe('Admin fixed members — the deployment admin', () => {
665
677
  status: 422,
666
678
  payload: { kind: 'fixed-deployment-admin' },
667
679
  message: expect.stringContaining(
668
- 'Deployment admin, set in the server configuration; cannot be removed here',
680
+ 'Owner; cannot be removed here',
669
681
  ),
670
682
  });
671
683
  // Nothing written: the file is byte-identical.
@@ -685,7 +697,7 @@ describe('Admin fixed members — the deployment admin', () => {
685
697
  status: 422,
686
698
  payload: { kind: 'fixed-deployment-admin' },
687
699
  message: expect.stringContaining(
688
- 'Deployment admin, set in the server configuration; cannot be removed here',
700
+ 'Owner; cannot be removed here',
689
701
  ),
690
702
  });
691
703
  expect(await fs.readFile(path.join(repo, 'roles.yaml'), 'utf-8')).toContain(OWNER);
@@ -700,6 +712,7 @@ describe('Admin fixed members — the deployment admin', () => {
700
712
  it('with no ADMIN_EMAIL configured, no role has a fixed member', async () => {
701
713
  const roster = await serviceWith([]).getRoster();
702
714
  expect(roster.every((r) => r.fixedMembers.length === 0)).toBe(true);
715
+ expect(roster.every((r) => r.fixedMembersNote === null)).toBe(true);
703
716
  // ...and ordinary Admin membership stays editable.
704
717
  await expect(serviceWith([]).addMember(ADMIN, 'admin', OWNER)).resolves.toBeDefined();
705
718
  });
@@ -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
  }
@@ -149,8 +149,11 @@ export type AccessDecisionSource = {
149
149
  * `via` names the tier that matched and `principal` the display name of the
150
150
  * group, role or plugin principal it matched on (the caller's own email for
151
151
  * `person`, `everyone` for the built-in). `admin-floor` is Admin's write at
152
- * the repository root, which no rule there can take away; its source is the
153
- * root's rules, or null when the root has none.
152
+ * the repository root, and the read that write implies, which no rule there
153
+ * can take away; its source is the scope the floor decided at — the root's
154
+ * rules (`folder`), or the own `frontmatter` of a file directly in the root,
155
+ * whose entries are part of the root and cannot take the floor away either —
156
+ * or null when the walk found no scope at all.
154
157
  */
155
158
  export interface AccessDecision {
156
159
  allowed: boolean;
@@ -678,6 +681,16 @@ export interface IAccessControl {
678
681
  */
679
682
  holdsAdminRootWrite(workspaceId: string, userEmail: string): Promise<boolean>;
680
683
 
684
+ /**
685
+ * Every address the resolver treats as an Admin in the working-tree model,
686
+ * each once: the Admin role's members — through a group too — and the
687
+ * deployment owners. The same membership answer {@link holdsAdminRootWrite}
688
+ * gives one address at a time, enumerated. When the model cannot be loaded
689
+ * (a `roles.yaml` that does not parse makes nobody an Admin), the
690
+ * deployment owners alone.
691
+ */
692
+ adminEmails(workspaceId: string): Promise<string[]>;
693
+
681
694
  /**
682
695
  * Whether `userEmail` may put a misplaced platform file back at
683
696
  * `destinationRelativePath` — the ONE write that is allowed to land on a
@@ -130,15 +130,16 @@ interface AccessModel {
130
130
  * Canonical emails that count as Admin whatever `roles.yaml` says — the
131
131
  * deployment owner (`ADMIN_EMAIL`). Empty when none is configured.
132
132
  *
133
- * This exists for the two hardcoded `write` rescues below and the root write
134
- * floor, and only those. It is NOT a general grant: it never enters scope
135
- * resolution, so it gives no read, no download, and no write beyond
136
- * `roles.yaml`, `access.md`, and what the root floor reaches (see
137
- * `hasPermissionResolved`). The deployment owner is the person who can already change
138
- * `ADMIN_EMAIL` itself, so admitting them to the rescue path concedes
139
- * nothing they could not already take — while withholding it turns a
140
- * `roles.yaml` that has lost its last Admin into a knowledge base nobody
141
- * can repair through the app.
133
+ * An owner IS an Admin, for every verb: resolution gives them the Admin
134
+ * role's own keys (`principalKeysOf`), so they read, download, write and
135
+ * own exactly where a member of the role does and are excluded exactly
136
+ * where the role is, and they hold the two hardcoded `write` rescues and
137
+ * the root floor as the role does. The owner is the person who can already
138
+ * change `ADMIN_EMAIL` itself, so none of this concedes anything they could
139
+ * not already take — while withholding it turns a `roles.yaml` that has
140
+ * lost its last Admin into a knowledge base nobody can repair through the
141
+ * app, and an owner the file does not name into someone the write floor
142
+ * admits everywhere and the read gate admits nowhere.
142
143
  */
143
144
  deploymentOwners: ReadonlySet<string>;
144
145
  }
@@ -478,19 +479,38 @@ function resolveAtPath(
478
479
  /**
479
480
  * Every principal key a caller holds: the tokens their email is a member of,
480
481
  * plus the PUBLIC keys every caller holds regardless (a plugin principal
481
- * whose plugin grants `everyone`). Undefined when there is nothing at all,
482
+ * whose plugin grants `everyone`), plus — for a deployment owner — the Admin
483
+ * role's own keys, whether or not `roles.yaml` names them: the owner IS an
484
+ * Admin (see `AccessModel.deploymentOwners`), so every rule that binds the
485
+ * role binds them, for every verb. Undefined when there is nothing at all,
482
486
  * matching the plain `byEmail.get` the tier-2 check used to read.
483
487
  */
484
488
  function principalKeysOf(model: AccessModel, email: string): Set<string> | undefined {
485
489
  const own = model.roles.byEmail.get(email);
486
490
  const pub = model.roles.publicKeys;
487
- if (!pub?.size) return own;
488
- return new Set([...(own ?? []), ...pub]);
491
+ const asOwner = model.deploymentOwners.has(email) && !own?.has(ADMIN_ROLE_KEY);
492
+ if (!pub?.size && !asOwner) return own;
493
+ return new Set([
494
+ ...(own ?? []),
495
+ ...(pub ?? []),
496
+ ...(asOwner ? principalKeysOfToken(model, ADMIN_ROLE_KEY) : []),
497
+ ]);
489
498
  }
490
499
 
491
500
  /** The explicit key of the Admin ROLE — never the bare token, which a same-named group may own. */
492
501
  const ADMIN_ROLE_KEY = `${ROLE_TOKEN_PREFIX}${ADMIN_CANONICAL}`;
493
502
 
503
+ /**
504
+ * The verbs the Admin floor at the repository root confers: write, and the
505
+ * read that write implies (`VERB_REQUIRES`). An admin who may change
506
+ * everything under the root may see it; otherwise the read-before-write gate
507
+ * refuses them the very change the floor grants. Download is not implied by
508
+ * write and stays with the access tree.
509
+ */
510
+ function holdsAdminFloor(verb: Verb): boolean {
511
+ return verb === 'write' || verb === 'read';
512
+ }
513
+
494
514
  /**
495
515
  * Add to `keys` what holding them confers on top: every plugin principal
496
516
  * expanded from one of the keys, and the public keys every caller holds.
@@ -540,9 +560,10 @@ const NO_KEYS: ReadonlySet<string> = new Set();
540
560
  /**
541
561
  * The tier-2 half of `hasPermissionResolved` on its own: a verdict for a set
542
562
  * of principal KEYS with no person behind them — no direct-email tier, no
543
- * admin rescue, no machine-owned rule — only the Admin write floor, when the
544
- * keys include the Admin role. Closest scope first; within a scope a
545
- * grant to any key wins over a denial to another, exactly as for a person.
563
+ * admin rescue, no machine-owned rule — only the Admin floor (write, and the
564
+ * read it implies), when the keys include the Admin role. Closest scope
565
+ * first; within a scope a grant to any key wins over a denial to another,
566
+ * exactly as for a person.
546
567
  */
547
568
  function hasPermissionForKeys(
548
569
  model: AccessModel,
@@ -551,7 +572,7 @@ function hasPermissionForKeys(
551
572
  relativePath: string,
552
573
  fileOwn?: OwnEntries | null,
553
574
  ): boolean {
554
- const adminFloor = verb === 'write' && keys.has(ADMIN_ROLE_KEY);
575
+ const adminFloor = holdsAdminFloor(verb) && keys.has(ADMIN_ROLE_KEY);
555
576
  for (const scope of resolveScopes(model, verb, relativePath, fileOwn)) {
556
577
  if (adminFloor && isAdminFloorScope(scope, relativePath)) return true;
557
578
  let grant = false;
@@ -620,17 +641,20 @@ function isAdminEmail(model: AccessModel, email: string): boolean {
620
641
  * owner is whoever can already set `ADMIN_EMAIL`, so this concedes no
621
642
  * authority they did not have; it only gives it a door.
622
643
  *
623
- * One floor for `write`, applied during the walk: an admin (same definition)
624
- * always holds `write` at the repository root. When the walk reaches the root
625
- * `access.md` (or the own frontmatter of a file directly in the root) — or runs
626
- * out of scopes without a verdict — an admin is granted,
627
- * whatever the root file says, so no root rule can lock the deployment out of
628
- * its own tree. A NEARER scope still decides first: a subfolder that denies
629
- * Admin (or denies `everyone` without naming Admin) excludes admins there like
630
- * anyone else. The root file's own entries are not rewritten — the floor is a
631
- * verdict, not a grant line — so `grantSources` stays file-backed.
644
+ * One floor for `write` and the `read` it implies (`holdsAdminFloor`), applied
645
+ * during the walk: an admin (same definition) always holds both at the
646
+ * repository root. When the walk reaches the root `access.md` (or the own
647
+ * frontmatter of a file directly in the root) — or runs out of scopes without
648
+ * a verdict — an admin is granted, whatever the root file says, so no root
649
+ * rule can lock the deployment out of its own tree, and nobody who may change
650
+ * everything under the root is refused the sight of it. A NEARER scope still
651
+ * decides first: a subfolder that denies Admin (or denies `everyone` without
652
+ * naming Admin) excludes admins there like anyone else. The root file's own
653
+ * entries are not rewritten — the floor is a verdict, not a grant line — so
654
+ * `grantSources` stays file-backed.
632
655
  *
633
- * No special-cases for `read`/`download` — they fall through to scope resolution.
656
+ * No special-case for `download` (or `owner`) — they fall through to scope
657
+ * resolution; write implies read, not download.
634
658
  */
635
659
  function hasPermissionResolved(
636
660
  model: AccessModel,
@@ -676,14 +700,12 @@ function decidePermissionResolved(
676
700
  }
677
701
  }
678
702
 
679
- const adminFloor = verb === 'write' && isAdminEmail(model, email);
680
- // For write, the deployment owner IS Admin — the floor admits them, so the
681
- // Admin rules along the way bind them too: a subfolder denying Admin excludes
682
- // the owner exactly as it excludes the role's members.
683
- let userRoles = principalKeysOf(model, email);
684
- if (adminFloor && !userRoles?.has(ADMIN_ROLE_KEY)) {
685
- userRoles = new Set([...(userRoles ?? []), ...principalKeysOfToken(model, ADMIN_ROLE_KEY)]);
686
- }
703
+ const adminFloor = holdsAdminFloor(verb) && isAdminEmail(model, email);
704
+ // The deployment owner IS Admin: `principalKeysOf` gives them the role's
705
+ // keys, so the Admin rules along the way bind them too — a subfolder
706
+ // denying Admin excludes the owner exactly as it excludes the role's
707
+ // members, and one granting Admin admits them.
708
+ const userRoles = principalKeysOf(model, email);
687
709
  const scopes = resolveScopes(model, verb, relativePath, fileOwn);
688
710
 
689
711
  for (const scope of scopes) {
@@ -1090,12 +1112,11 @@ function canEveryoneReadResolved(
1090
1112
  // Every *named* principal must also still read. A collapsed deny may have
1091
1113
  // been shadowed by a closer-scope `everyone` grant, so re-resolve each
1092
1114
  // candidate through the closeness-first gate rather than trusting the
1093
- // collapsed deny state. (Role members live in roles.yaml; inline-named users
1094
- // come from `byEmail`.)
1095
- const candidates = new Set<string>();
1096
- for (const email of model.roles.byEmail.keys()) candidates.add(email);
1097
- for (const email of byEmail.keys()) candidates.add(email);
1098
- for (const email of candidates) {
1115
+ // collapsed deny state. The candidates are the same set the eligible-email
1116
+ // lists enumerate — roles.yaml members, inline-named users, and the
1117
+ // deployment owner, whom a `deny Admin` carves out like any member of the
1118
+ // role whether or not roles.yaml names them.
1119
+ for (const email of namedCandidateEmails(model, byEmail)) {
1099
1120
  if (!hasPermissionResolved(model, 'read', email, relativePath, fileOwn)) return false;
1100
1121
  }
1101
1122
  return true;
@@ -1106,11 +1127,14 @@ function canEveryoneReadResolved(
1106
1127
  *
1107
1128
  * Read is default-deny: a path is readable only when resolution grants the
1108
1129
  * caller `read` directly, via one of their roles, via the built-in `everyone`
1109
- * role, or via `owner:` (owners implicitly read).
1130
+ * role, or via `write:`/`owner:` (both imply read).
1110
1131
  *
1111
- * No admin rescue (mirrors `download`): admins read a node only if an
1112
- * `access.md` lists them — directly, via a role, through `everyone`, or as an
1113
- * owner.
1132
+ * No admin rescue on root files (that is write's), but the Admin floor
1133
+ * holds: an admin — a member of the role or the deployment owner — reads
1134
+ * wherever no nearer scope excludes Admin, whether or not the root names it,
1135
+ * because the floor gives them write there and write implies read. A
1136
+ * subfolder that denies Admin, or everyone, takes the read away as it does
1137
+ * for any principal.
1114
1138
  */
1115
1139
  function canReadResolved(
1116
1140
  model: AccessModel,
@@ -1183,9 +1207,10 @@ function eligibleHoldersResolved(
1183
1207
  const record = model.roles.byCanonical.get(canonical);
1184
1208
  addPrincipal(record ? record.displayName : canonical, record?.kind ?? 'role');
1185
1209
  }
1186
- // The Admin write floor (see `hasPermissionResolved`): Admin holds write here
1187
- // whenever no nearer scope excludes it, whether or not any file names it.
1188
- if (verb === 'write' && model.roles.byCanonical.has(ADMIN_ROLE_KEY)) {
1210
+ // The Admin floor (see `hasPermissionResolved`): Admin holds write, and the
1211
+ // read it implies, here whenever no nearer scope excludes it, whether or not
1212
+ // any file names it.
1213
+ if (holdsAdminFloor(verb) && model.roles.byCanonical.has(ADMIN_ROLE_KEY)) {
1189
1214
  const adminRole = model.roles.byCanonical.get(ADMIN_ROLE_KEY)!;
1190
1215
  if (allowedFor(principalKeysOfToken(model, ADMIN_ROLE_KEY))) addPrincipal(adminRole.displayName, 'role');
1191
1216
  }
@@ -1232,8 +1257,8 @@ function eligibleHoldersResolved(
1232
1257
 
1233
1258
  /**
1234
1259
  * Every individual the model names: `roles.yaml` members, emails named inline
1235
- * at this path's scope, and the deployment owner (Admin for the write floor
1236
- * even when `roles.yaml` does not list them).
1260
+ * at this path's scope, and the deployment owner (bound by every rule that
1261
+ * names Admin even when `roles.yaml` does not list them).
1237
1262
  */
1238
1263
  function namedCandidateEmails(model: AccessModel, byEmail: CollapsedScope['byEmail']): Set<string> {
1239
1264
  const candidates = new Set<string>(model.deploymentOwners);
@@ -2164,6 +2189,25 @@ export class AccessControlService implements IAccessControl {
2164
2189
  return isAdminEmail(model, canonicalEmail(userEmail));
2165
2190
  }
2166
2191
 
2192
+ async adminEmails(workspaceId: string): Promise<string[]> {
2193
+ let model: AccessModel;
2194
+ try {
2195
+ model = await this.loadModel(workspaceId);
2196
+ } catch (err) {
2197
+ // As canRestorePlatformFile: with no model to ask, the deployment owners
2198
+ // are the only admins left. A `roles.yaml` that does not parse is that
2199
+ // by design; anything else (a clone being read, the disk) is said, so a
2200
+ // list that names fewer admins than there are is diagnosable.
2201
+ if (!(err instanceof AccessConfigError)) {
2202
+ log.warn('the access model could not be loaded; the admins list names the deployment owners only:', { err });
2203
+ }
2204
+ return [...this.deploymentOwners];
2205
+ }
2206
+ const candidates = new Set<string>(model.deploymentOwners);
2207
+ for (const email of model.roles.byEmail.keys()) candidates.add(email);
2208
+ return [...candidates].filter((email) => isAdminEmail(model, email));
2209
+ }
2210
+
2167
2211
  async canRestorePlatformFile(
2168
2212
  workspaceId: string,
2169
2213
  userEmail: string,