@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bevel-software/platform-core-backend",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "Open-source core backend of the Bevel platform: git-backed knowledge workspace, workflow (branches/change requests/locks/SSE), skills, tools, secrets vault, access control and the remote MCP surface.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -55,8 +55,8 @@
55
55
  "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
56
56
  "yaml": "^2.9.0",
57
57
  "zod": "^3.24.0",
58
- "@bevel-software/platform-shared": "0.27.0",
59
- "@bevel-software/platform-mcp-core": "0.27.0"
58
+ "@bevel-software/platform-mcp-core": "0.28.0",
59
+ "@bevel-software/platform-shared": "0.28.0"
60
60
  },
61
61
  "devDependencies": {
62
62
  "@types/adm-zip": "^0.5.8",
@@ -0,0 +1,48 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import pg from 'pg';
3
+
4
+ /**
5
+ * The one fixture for tests that cross a real Postgres. They run where
6
+ * `TEST_DATABASE_URL` names a server this role may create databases on (CI
7
+ * carries one as a service container) and are skipped elsewhere
8
+ * (`describe.skipIf(!PG_ADMIN_URL)`). Each test works in a scratch database
9
+ * of its own, made by {@link scratchDatabaseUrl} and dropped by
10
+ * {@link dropScratchDatabases}; the database the URL names is only connected
11
+ * to, never written.
12
+ */
13
+ export const PG_ADMIN_URL = process.env.TEST_DATABASE_URL;
14
+
15
+ /** Long enough for migrations, and for dropping every scratch database one after another. */
16
+ export const PG_TEST_TIMEOUT = 120_000;
17
+
18
+ const created: string[] = [];
19
+
20
+ /** Run `fn` on a connection to the admin URL, closed afterwards. */
21
+ export async function withPgAdmin<T>(fn: (admin: pg.Client) => Promise<T>): Promise<T> {
22
+ const admin = new pg.Client({ connectionString: PG_ADMIN_URL });
23
+ await admin.connect();
24
+ try {
25
+ return await fn(admin);
26
+ } finally {
27
+ await admin.end();
28
+ }
29
+ }
30
+
31
+ /** A fresh database for this test, named with `prefix`; its URL. */
32
+ export async function scratchDatabaseUrl(prefix: string): Promise<string> {
33
+ const name = `${prefix}_${randomBytes(6).toString('hex')}`;
34
+ await withPgAdmin((admin) => admin.query(`create database ${name}`));
35
+ created.push(name);
36
+ const url = new URL(PG_ADMIN_URL!);
37
+ url.pathname = `/${name}`;
38
+ return url.toString();
39
+ }
40
+
41
+ /** Drop every scratch database this file made; for `afterAll`, with {@link PG_TEST_TIMEOUT}. */
42
+ export async function dropScratchDatabases(): Promise<void> {
43
+ const names = created.splice(0);
44
+ if (names.length === 0) return;
45
+ await withPgAdmin(async (admin) => {
46
+ for (const name of names) await admin.query(`drop database if exists ${name} with (force)`);
47
+ });
48
+ }
@@ -671,6 +671,7 @@ export async function createCoreServer(
671
671
  core.accessControl,
672
672
  core.workspaceService,
673
673
  core.authService,
674
+ core.authService,
674
675
  core.workflowService,
675
676
  core.eventBus,
676
677
  core.db,
@@ -759,6 +760,7 @@ export async function createCoreServer(
759
760
  core.authService,
760
761
  core.adminAccess,
761
762
  core.accountErasureService,
763
+ core.accountHooks,
762
764
  core.userAccessRemovalService,
763
765
  ));
764
766
  // Connection keys across the deployment (list per account, revoke any) —
@@ -65,6 +65,7 @@ import type { Request } from 'express';
65
65
  import { DocExtractService } from '../modules/workspace/file-readers/doc-extract.service.js';
66
66
  import { UuidSessionSink, type ISessionSink } from '../modules/workspace/session-sink.js';
67
67
  import { AuthService } from '../modules/auth/auth.service.js';
68
+ import { AccountHooks, type IAccountHooks } from '../modules/auth/account-hooks.js';
68
69
  import { AccountErasureService } from '../modules/auth/account-erasure.service.js';
69
70
  import { OidcAuthProvider, oidcSettingsFrom } from '../modules/auth/oidc-auth-provider.js';
70
71
  import { createAuthMiddleware, readAuthCookie } from '../modules/auth/auth.middleware.js';
@@ -302,6 +303,12 @@ export interface CoreServices {
302
303
  githubFacade: GitHubFacade;
303
304
  githubFacadeCredentials: GitHubFacadeCredentialsService;
304
305
  authService: AuthService;
306
+ /**
307
+ * The seam for "an account was invited": a deployment registers here how
308
+ * the person is told (the cloud's mail, say), and the invite's answer says
309
+ * whether somebody did. Core registers nothing.
310
+ */
311
+ accountHooks: IAccountHooks;
305
312
  authMiddleware: ReturnType<typeof createAuthMiddleware>;
306
313
  accountErasureService: AccountErasureService;
307
314
  gitService: GitService;
@@ -815,6 +822,9 @@ export async function createCoreServices(
815
822
  ports.accountAdmission,
816
823
  );
817
824
  const authMiddleware = createAuthMiddleware(authService);
825
+ // Who tells an invited person: the composition root of a distribution
826
+ // registers its listener here, after this returns (see `AccountHooks`).
827
+ const accountHooks = new AccountHooks();
818
828
 
819
829
  // Shared mutex so git and diff operations on the same workspace serialize
820
830
  // against each other — a backup-reseed races a concurrent commit otherwise.
@@ -1608,6 +1618,7 @@ export async function createCoreServices(
1608
1618
  githubFacade,
1609
1619
  githubFacadeCredentials,
1610
1620
  authService,
1621
+ accountHooks,
1611
1622
  authMiddleware,
1612
1623
  accountErasureService,
1613
1624
  gitService,
@@ -280,6 +280,7 @@ describe('Admin write floor at the repository root', () => {
280
280
  service(),
281
281
  { ...ws, getOrCreateForBranch: async () => ({ id: WS, kbDirName: KB }) } as unknown as WorkspaceService,
282
282
  { getUserById: async (id: string) => users[id as 'u-admin'] ?? null } as unknown as AuthService,
283
+ { whileSwitchedOn: async (_email: string, write: () => Promise<void>) => write() },
283
284
  {
284
285
  getLock: async () => null,
285
286
  acquireLock: async () => ({ acquired: true, lock: {} }),
@@ -0,0 +1,203 @@
1
+ import type { Server } from 'node:http';
2
+ import type { AddressInfo } from 'node:net';
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import express from 'express';
7
+ import { describe, it, expect, afterEach, vi } from 'vitest';
8
+
9
+ import { AccessControlService } from '../access-control.service.js';
10
+ import { NodeFs } from '../../kb-fs/node-fs.js';
11
+ import type { WorkspaceService } from '../../workspace/workspace.service.js';
12
+ import type { AuthService } from '../../auth/auth.service.js';
13
+ import type { WorkflowService } from '../../workflow/workflow.service.js';
14
+ import type { WorkflowEventBus } from '../../workflow/event-bus.js';
15
+ import { createAccessRoutes } from '../access.routes.js';
16
+ import { usersDbDouble } from './users-db-double.js';
17
+ import { testKbContext } from '../../../__tests__/kb-context.js';
18
+
19
+ /**
20
+ * `GET /api/access/admins`: who the admins are, for anyone signed in. It
21
+ * answers the Admin role's members — through a group too — and the deployment
22
+ * admins, each once, sorted by name, with names from the accounts table; and
23
+ * nothing else about roles. The roster stays admin-only.
24
+ */
25
+
26
+ const KB = 'knowledge-base';
27
+ const MEMBER = { id: 'u-lena', email: 'lena@acme.com', name: 'Lena Park' };
28
+
29
+ const ROLES = `roles:
30
+ Admin:
31
+ - sam.ortiz@acme.com
32
+ - group:Ops
33
+ Sales:
34
+ - felix@acme.com
35
+ `;
36
+
37
+ const GROUPS = `groups:
38
+ Ops:
39
+ - dana@acme.com
40
+ - sam.ortiz@acme.com
41
+ Product:
42
+ - priya@acme.com
43
+ `;
44
+
45
+ const tmpDirs: string[] = [];
46
+
47
+ async function makeHarness(
48
+ opts: {
49
+ roles?: string;
50
+ groups?: string;
51
+ signedIn?: boolean;
52
+ deploymentAdmins?: string[];
53
+ accounts?: { email: string; name?: string; deactivatedAt?: Date | null }[];
54
+ } = {},
55
+ ): Promise<{ server: Server; baseUrl: string }> {
56
+ const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-admins-route-'));
57
+ tmpDirs.push(workspaceDir);
58
+ const repoDir = path.join(workspaceDir, KB);
59
+ await fs.mkdir(repoDir, { recursive: true });
60
+ await fs.writeFile(path.join(repoDir, 'roles.yaml'), opts.roles ?? ROLES, 'utf-8');
61
+ await fs.writeFile(path.join(repoDir, 'groups.yaml'), opts.groups ?? GROUPS, 'utf-8');
62
+
63
+ const workspaceService = {
64
+ getOrCreateForBranch: vi.fn(async () => ({})),
65
+ getWorkspacePath: vi.fn(async () => workspaceDir),
66
+ readFile: vi.fn(async (_id: string, wsRel: string) => fs.readFile(path.join(workspaceDir, wsRel), 'utf-8')),
67
+ } as unknown as WorkspaceService;
68
+ // The real resolver: the admins read is its own membership answer. Nobody
69
+ // signed in here is an Admin, so nobody may read the roster either.
70
+ const deploymentAdmins = opts.deploymentAdmins ?? ['Owner@Acme.com'];
71
+ const accessControl = new AccessControlService(workspaceService, KB, new NodeFs(), deploymentAdmins);
72
+ const authService = { getUserById: vi.fn(async () => MEMBER) } as unknown as AuthService;
73
+ const db = usersDbDouble(
74
+ opts.accounts ?? [
75
+ { email: 'dana@acme.com', name: 'Dana Admin' },
76
+ { email: 'sam.ortiz@acme.com', name: 'Sam Ortiz' },
77
+ { email: 'felix@acme.com', name: 'Felix Sales' },
78
+ { email: 'priya@acme.com', name: 'Priya Product' },
79
+ MEMBER,
80
+ ],
81
+ );
82
+
83
+ const app = express();
84
+ app.use(express.json());
85
+ app.use('/api', (req, _res, next) => {
86
+ if (opts.signedIn !== false) (req as unknown as { userId: string }).userId = MEMBER.id;
87
+ next();
88
+ });
89
+ app.use(
90
+ '/api',
91
+ createAccessRoutes(
92
+ accessControl,
93
+ workspaceService,
94
+ authService,
95
+ authService,
96
+ {} as unknown as WorkflowService,
97
+ { emit: vi.fn() } as unknown as WorkflowEventBus,
98
+ db,
99
+ testKbContext({ kbDirName: KB }),
100
+ deploymentAdmins,
101
+ ),
102
+ );
103
+ const server = await new Promise<Server>((resolve) => {
104
+ const s = app.listen(0, () => resolve(s));
105
+ });
106
+ return { server, baseUrl: `http://127.0.0.1:${(server.address() as AddressInfo).port}` };
107
+ }
108
+
109
+ function close(s: Server): Promise<void> {
110
+ return new Promise((resolve, reject) => s.close((e) => (e ? reject(e) : resolve())));
111
+ }
112
+
113
+ describe('GET /api/access/admins', () => {
114
+ let server: Server | null = null;
115
+ afterEach(async () => {
116
+ if (server) await close(server);
117
+ server = null;
118
+ await Promise.all(tmpDirs.splice(0).map((d) => fs.rm(d, { recursive: true, force: true })));
119
+ });
120
+
121
+ it('tells a Member every admin — direct, through a group, and the deployment admin — once each, sorted by name', async () => {
122
+ const h = await makeHarness();
123
+ server = h.server;
124
+ const res = await fetch(`${h.baseUrl}/api/access/admins`);
125
+ expect(res.status).toBe(200);
126
+ expect(await res.json()).toEqual({
127
+ admins: [
128
+ { name: 'Dana Admin', email: 'dana@acme.com' },
129
+ // No account yet: named by address.
130
+ { name: 'owner@acme.com', email: 'owner@acme.com' },
131
+ { name: 'Sam Ortiz', email: 'sam.ortiz@acme.com' },
132
+ ],
133
+ });
134
+ });
135
+
136
+ it('reveals nothing else: no other role, group or member, and no other field', async () => {
137
+ const h = await makeHarness();
138
+ server = h.server;
139
+ const text = await (await fetch(`${h.baseUrl}/api/access/admins`)).text();
140
+ for (const leak of ['felix', 'priya', 'Sales', 'Product', 'Ops', 'group:', 'lena']) {
141
+ expect(text).not.toContain(leak);
142
+ }
143
+ const body = JSON.parse(text) as { admins: Record<string, unknown>[] };
144
+ expect(Object.keys(body)).toEqual(['admins']);
145
+ for (const admin of body.admins) expect(Object.keys(admin).sort()).toEqual(['email', 'name']);
146
+ });
147
+
148
+ it('answers only the deployment admins when roles.yaml does not parse, as the resolver does', async () => {
149
+ const h = await makeHarness({ roles: 'roles:\n Admin: not-a-list\n' });
150
+ server = h.server;
151
+ const body = (await (await fetch(`${h.baseUrl}/api/access/admins`)).json()) as { admins: unknown[] };
152
+ expect(body.admins).toEqual([{ name: 'owner@acme.com', email: 'owner@acme.com' }]);
153
+ });
154
+
155
+ it('answers only the deployment admins when the resolver rejects roles.yaml the editing model would read', async () => {
156
+ // A role named with the reserved plugin prefix: the resolver refuses the
157
+ // whole file, so nobody in it is an Admin — and nobody is listed as one.
158
+ const h = await makeHarness({
159
+ roles: 'roles:\n Admin:\n - sam.ortiz@acme.com\n - group:Ops\n plugin/x:\n - felix@acme.com\n',
160
+ });
161
+ server = h.server;
162
+ const body = (await (await fetch(`${h.baseUrl}/api/access/admins`)).json()) as { admins: unknown[] };
163
+ expect(body.admins).toEqual([{ name: 'owner@acme.com', email: 'owner@acme.com' }]);
164
+ });
165
+
166
+ it('does not count the members of a GROUP named Admin, as the resolver does not', async () => {
167
+ const h = await makeHarness({ groups: 'groups:\n Admin:\n - priya@acme.com\n' });
168
+ server = h.server;
169
+ const text = await (await fetch(`${h.baseUrl}/api/access/admins`)).text();
170
+ expect(text).not.toContain('priya');
171
+ expect(text).toContain('sam.ortiz@acme.com');
172
+ });
173
+
174
+ it('leaves out a role member whose account is switched off, but not a switched-off deployment admin', async () => {
175
+ const off = new Date('2026-01-01T00:00:00Z');
176
+ const h = await makeHarness({
177
+ accounts: [
178
+ { email: 'dana@acme.com', name: 'Dana Admin', deactivatedAt: off },
179
+ { email: 'sam.ortiz@acme.com', name: 'Sam Ortiz' },
180
+ { email: 'owner@acme.com', name: 'Olive Owner', deactivatedAt: off },
181
+ MEMBER,
182
+ ],
183
+ });
184
+ server = h.server;
185
+ const body = (await (await fetch(`${h.baseUrl}/api/access/admins`)).json()) as { admins: unknown[] };
186
+ expect(body.admins).toEqual([
187
+ { name: 'Olive Owner', email: 'owner@acme.com' },
188
+ { name: 'Sam Ortiz', email: 'sam.ortiz@acme.com' },
189
+ ]);
190
+ });
191
+
192
+ it('refuses a caller who is not signed in', async () => {
193
+ const h = await makeHarness({ signedIn: false });
194
+ server = h.server;
195
+ expect((await fetch(`${h.baseUrl}/api/access/admins`)).status).toBe(401);
196
+ });
197
+
198
+ it('leaves the roles roster admin-only', async () => {
199
+ const h = await makeHarness();
200
+ server = h.server;
201
+ expect((await fetch(`${h.baseUrl}/api/access/roles`)).status).toBe(403);
202
+ });
203
+ });
@@ -28,11 +28,14 @@ interface Harness {
28
28
  baseUrl: string;
29
29
  canWriteBatch: ReturnType<typeof vi.fn>;
30
30
  canOwnerBatch: ReturnType<typeof vi.fn>;
31
+ canReadBatch: ReturnType<typeof vi.fn>;
31
32
  }
32
33
 
33
- // Alice WRITES both skills but is named in the `owner:` grant of one.
34
+ // Alice WRITES both skills but is named in the `owner:` grant of one, and
35
+ // READS a folder she may not write.
34
36
  const WRITABLE = new Set(['Skills/a/SKILL.md', 'Skills/b/SKILL.md']);
35
37
  const OWNED = new Set(['Skills/a/SKILL.md']);
38
+ const READABLE = new Set(['Skills/a/SKILL.md', 'Skills/b/SKILL.md', 'KnowledgeBase/Sales']);
36
39
 
37
40
  const verdicts = (set: Set<string>) =>
38
41
  vi.fn(async (_w: string, _e: string, paths: string[]) => new Map(paths.map((p) => [p, set.has(p)])));
@@ -40,7 +43,8 @@ const verdicts = (set: Set<string>) =>
40
43
  async function makeHarness(): Promise<Harness> {
41
44
  const canWriteBatch = verdicts(WRITABLE);
42
45
  const canOwnerBatch = verdicts(OWNED);
43
- const accessControl = { canWriteBatch, canOwnerBatch } as unknown as IAccessControl;
46
+ const canReadBatch = verdicts(READABLE);
47
+ const accessControl = { canWriteBatch, canOwnerBatch, canReadBatch } as unknown as IAccessControl;
44
48
 
45
49
  const workspaceService = {} as unknown as WorkspaceService;
46
50
  const authService = { getUserById: vi.fn(async () => USER) } as unknown as AuthService;
@@ -57,6 +61,7 @@ async function makeHarness(): Promise<Harness> {
57
61
  accessControl,
58
62
  workspaceService,
59
63
  authService,
64
+ authService,
60
65
  {} as unknown as WorkflowService,
61
66
  { emit: vi.fn() } as unknown as WorkflowEventBus,
62
67
  usersDbDouble(),
@@ -68,7 +73,7 @@ async function makeHarness(): Promise<Harness> {
68
73
  const s = app.listen(0, () => resolve(s));
69
74
  });
70
75
  const addr = server.address() as AddressInfo;
71
- return { server, baseUrl: `http://127.0.0.1:${addr.port}`, canWriteBatch, canOwnerBatch };
76
+ return { server, baseUrl: `http://127.0.0.1:${addr.port}`, canWriteBatch, canOwnerBatch, canReadBatch };
72
77
  }
73
78
 
74
79
  function close(s: Server): Promise<void> {
@@ -119,16 +124,31 @@ describe('POST /access/batch', () => {
119
124
  expect(h.canWriteBatch).not.toHaveBeenCalled();
120
125
  });
121
126
 
127
+ it('answers READ for `verb: read` — a folder the caller may read but not write gets true', async () => {
128
+ h = await makeHarness();
129
+
130
+ const paths = [...PATHS, 'KnowledgeBase/Sales'];
131
+ const res = await post({ paths, verb: 'read' });
132
+ expect(res.status).toBe(200);
133
+ expect(await res.json()).toEqual({
134
+ results: { 'Skills/a/SKILL.md': true, 'Skills/b/SKILL.md': true, 'KnowledgeBase/Sales': true },
135
+ });
136
+ expect(h.canReadBatch).toHaveBeenCalledWith(WS, USER.email, paths);
137
+ expect(h.canWriteBatch).not.toHaveBeenCalled();
138
+ expect(h.canOwnerBatch).not.toHaveBeenCalled();
139
+ });
140
+
122
141
  it('400s a verb it does not dispatch, resolving nothing', async () => {
123
142
  h = await makeHarness();
124
143
 
125
- for (const verb of ['read', 'admin', 42, null]) {
144
+ for (const verb of ['download', 'admin', 42, null]) {
126
145
  const res = await post({ paths: PATHS, verb });
127
146
  expect(res.status).toBe(400);
128
- expect(await res.json()).toEqual({ error: 'verb must be "write" or "owner"' });
147
+ expect(await res.json()).toEqual({ error: 'verb must be "write", "owner" or "read"' });
129
148
  }
130
149
  expect(h.canWriteBatch).not.toHaveBeenCalled();
131
150
  expect(h.canOwnerBatch).not.toHaveBeenCalled();
151
+ expect(h.canReadBatch).not.toHaveBeenCalled();
132
152
  });
133
153
 
134
154
  it('keeps the paths validation ahead of the verb', async () => {
@@ -156,6 +156,7 @@ describe('access mutations on a file that cannot carry frontmatter', () => {
156
156
  accessControl,
157
157
  workspaceService,
158
158
  { getUserById: vi.fn(async () => USER) } as unknown as AuthService,
159
+ { whileSwitchedOn: async (_email: string, write: () => Promise<void>) => write() },
159
160
  workflowService,
160
161
  { emit: vi.fn() } as unknown as WorkflowEventBus,
161
162
  usersDbDouble(),
@@ -88,7 +88,7 @@ async function makeHarness(opts: {
88
88
  (req as unknown as { userId: string }).userId = USER.id;
89
89
  next();
90
90
  });
91
- app.use('/api', createAccessRoutes(accessControl, workspaceService, authService, workflowService, eventBus, db, testKbContext({ kbDirName: KB })));
91
+ app.use('/api', createAccessRoutes(accessControl, workspaceService, authService, authService, workflowService, eventBus, db, testKbContext({ kbDirName: KB })));
92
92
 
93
93
  const server = await new Promise<Server>((resolve) => {
94
94
  const s = app.listen(0, () => resolve(s));
@@ -98,7 +98,7 @@ async function makeHarness(opts: {
98
98
  });
99
99
  app.use(
100
100
  '/api',
101
- createAccessRoutes(accessControl, workspaceService, authService, workflowService, eventBus, db, testKbContext({ kbDirName: KB })),
101
+ createAccessRoutes(accessControl, workspaceService, authService, authService, workflowService, eventBus, db, testKbContext({ kbDirName: KB })),
102
102
  );
103
103
 
104
104
  const server = await new Promise<Server>((resolve) => {
@@ -134,6 +134,7 @@ describe('access routes on a proposal branch (real git)', () => {
134
134
  access,
135
135
  workspaceService,
136
136
  authService,
137
+ authService,
137
138
  workflowService,
138
139
  { emit: () => {} } as unknown as WorkflowEventBus,
139
140
  usersDbDouble(),
@@ -64,6 +64,7 @@ async function makeHarness(opts: { canRead?: boolean } = {}): Promise<Harness> {
64
64
  accessControl,
65
65
  {} as unknown as WorkspaceService,
66
66
  { getUserById: vi.fn(async () => USER) } as unknown as AuthService,
67
+ { whileSwitchedOn: async (_email: string, write: () => Promise<void>) => write() },
67
68
  {} as unknown as WorkflowService,
68
69
  { emit: vi.fn() } as unknown as WorkflowEventBus,
69
70
  {} as unknown as Database,
@@ -134,7 +134,7 @@ async function makeHarness(opts: {
134
134
  (req as unknown as { userId: string }).userId = USER.id;
135
135
  next();
136
136
  });
137
- app.use('/api', createAccessRoutes(accessControl, workspaceService, authService, workflowService, eventBus, db, testKbContext({ kbDirName: KB })));
137
+ app.use('/api', createAccessRoutes(accessControl, workspaceService, authService, authService, workflowService, eventBus, db, testKbContext({ kbDirName: KB })));
138
138
 
139
139
  const server = await new Promise<Server>((resolve) => {
140
140
  const s = app.listen(0, () => resolve(s));
@@ -105,6 +105,7 @@ async function makeHarness(opts: {
105
105
  accessControl,
106
106
  workspaceService,
107
107
  authService,
108
+ authService,
108
109
  workflowService,
109
110
  { emit: vi.fn() } as unknown as WorkflowEventBus,
110
111
  opts.db ?? usersDbDouble(opts.accounts ?? []),
@@ -146,7 +146,7 @@ describe('SECURITY: Admin-role removal takes effect on the next request', () =>
146
146
  (req as unknown as { userEmail?: string }).userEmail = req.header('x-test-email') ?? undefined;
147
147
  next();
148
148
  });
149
- app.use('/api', createAccountRoutes(authService, adminAccess, erasure));
149
+ app.use('/api', createAccountRoutes(authService, adminAccess, erasure, { runAccountInvited: async () => [] }));
150
150
  server = await new Promise<Server>((resolve) => {
151
151
  const s = app.listen(0, () => resolve(s));
152
152
  });
@@ -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
  });