@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
@@ -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,
@@ -133,11 +133,31 @@ describe('Admin write floor at the repository root', () => {
133
133
  expect(await service().canWrite(WS, ADMIN, 'README.md')).toBe(true);
134
134
  });
135
135
 
136
- it('is write only: read and download still answer to the access tree', async () => {
137
- await write(repo, 'access.md', '---\n---\nwrite:\n - deny Admin\n');
136
+ it('is write and the read it implies: download still answers to the access tree', async () => {
137
+ await write(repo, 'access.md', '---\n---\nwrite:\n - deny Admin\nread:\n - deny Admin\n');
138
138
  const svc = service();
139
- expect(await svc.canDownload(WS, ADMIN, 'README.md')).toBe(false);
139
+ for (const who of [ADMIN, OWNER]) {
140
+ expect(await svc.canRead(WS, who, 'README.md')).toBe(true);
141
+ expect(await svc.canRead(WS, who, 'Knowledge/Foo.md')).toBe(true);
142
+ expect(await svc.canDownload(WS, who, 'README.md')).toBe(false);
143
+ }
140
144
  expect(await svc.canWrite(WS, ENGINEER, 'README.md')).toBe(false);
145
+ expect(await svc.canRead(WS, ENGINEER, 'README.md')).toBe(false);
146
+ // Who may read, for the banner: the floor's holders, and nobody else.
147
+ expect((await svc.eligibleReaders(WS, 'Knowledge/Foo.md')).roles).toEqual(['Admin']);
148
+ });
149
+
150
+ it('the deployment owner, absent from roles.yaml, reads where the Admin role reads and not where it is excluded', async () => {
151
+ await write(repo, 'access.md', '---\n---\nread:\n - Engineer\n');
152
+ await write(repo, 'HR/access.md', '---\n---\nread:\n - Engineer\n - deny Admin\n');
153
+ await write(repo, 'Ops/access.md', '---\n---\nread:\n - deny everyone\n - Admin\n');
154
+ const svc = service();
155
+ for (const who of [ADMIN, OWNER]) {
156
+ expect(await svc.canRead(WS, who, 'Knowledge/Foo.md')).toBe(true);
157
+ expect(await svc.canRead(WS, who, 'HR/Salaries.md')).toBe(false);
158
+ expect(await svc.canRead(WS, who, 'Ops/Runbook.md')).toBe(true);
159
+ }
160
+ expect(await svc.canRead(WS, ENGINEER, 'Ops/Runbook.md')).toBe(false);
141
161
  });
142
162
 
143
163
  describe('a subfolder that excludes Admin', () => {
@@ -280,6 +300,7 @@ describe('Admin write floor at the repository root', () => {
280
300
  service(),
281
301
  { ...ws, getOrCreateForBranch: async () => ({ id: WS, kbDirName: KB }) } as unknown as WorkspaceService,
282
302
  { getUserById: async (id: string) => users[id as 'u-admin'] ?? null } as unknown as AuthService,
303
+ { whileSwitchedOn: async (_email: string, write: () => Promise<void>) => write() },
283
304
  {
284
305
  getLock: async () => null,
285
306
  acquireLock: async () => ({ acquired: true, lock: {} }),
@@ -102,7 +102,9 @@ describe('download implies read', () => {
102
102
  'access.md': '---\n---\ndownload:\n - Product Manager\n',
103
103
  });
104
104
  expect(await svc.canRead(workspaceId, 'pm@x.io', 'Knowledge/Deal.md')).toBe(true);
105
- expect(await svc.canRead(workspaceId, 'admin@x.io', 'Knowledge/Deal.md')).toBe(false);
105
+ // Nobody else: the fold adds no grant that was not written. (An admin
106
+ // reads here too, through the root floor, not through this fold.)
107
+ expect(await svc.canRead(workspaceId, 'mallory@x.io', 'Knowledge/Deal.md')).toBe(false);
106
108
  });
107
109
 
108
110
  it('for a GROUP grant', async () => {
@@ -943,16 +943,24 @@ describe('AccessControlService', () => {
943
943
  expect(await svc.canRead(workspaceId, 'sara@example.com', 'Knowledge/Foo.md')).toBe(true);
944
944
  });
945
945
 
946
- it('no admin rescue for read — admins read a restricted node only if listed', async () => {
946
+ it('the root floor gives admins read (write implies it) whatever the root says; a nearer scope can still exclude them', async () => {
947
947
  const { workspaceDir, repo } = await seedWorkspace(root, workspaceId);
948
948
  await writeFile(repo, 'roles.yaml', ROLES_YAML);
949
- // Write/read restricted to Product Manager — Admin is neither listed nor a
950
- // writer here, so there's no rescue path to read (unlike write on access.md).
949
+ // Write/read restricted to Product Manager — Admin is not named at the
950
+ // root, but the floor gives Admin write there and write implies read, so
951
+ // an admin who could create anything under the root can also see it.
951
952
  await writeFile(repo, 'access.md', '---\nwrite:\n - Product Manager\nread:\n - Product Manager\n---\n');
953
+ // A subfolder is an ordinary scope: it takes Admin's read away as it would anyone's.
954
+ await writeFile(repo, 'Knowledge/Board/access.md', '---\nread:\n - deny Admin\n---\n');
952
955
 
953
956
  const svc = new AccessControlService(stubWorkspaceService(workspaceId, workspaceDir), PROCESS_MAP_DIR, new NodeFs());
954
- // razvan is Admin but not a Product Manager — restricted read denies him.
955
- expect(await svc.canRead(workspaceId, 'razvan@bevel.software', 'Knowledge/Foo.md')).toBe(false);
957
+ // razvan is Admin but not a Product Manager.
958
+ expect(await svc.canRead(workspaceId, 'razvan@bevel.software', 'Knowledge/Foo.md')).toBe(true);
959
+ expect(await svc.canRead(workspaceId, 'razvan@bevel.software', 'Knowledge/Board/Minutes.md')).toBe(false);
960
+ // The floor is write and read, not download.
961
+ expect(await svc.canDownload(workspaceId, 'razvan@bevel.software', 'Knowledge/Foo.md')).toBe(false);
962
+ // Nobody else gains anything from it.
963
+ expect(await svc.canRead(workspaceId, 'ali@bevel.software', 'Knowledge/Foo.md')).toBe(false);
956
964
  });
957
965
 
958
966
  it("a node's own read: frontmatter grants just that file", async () => {
@@ -1002,17 +1010,24 @@ describe('AccessControlService', () => {
1002
1010
  });
1003
1011
 
1004
1012
  describe('eligibleReaders', () => {
1005
- it('reports restricted=true with no readers for a default-denied node', async () => {
1013
+ it('reports restricted=true, with Admin as the one reader, for a default-denied node', async () => {
1006
1014
  const { workspaceDir, repo } = await seedWorkspace(root, workspaceId);
1007
1015
  await writeFile(repo, 'roles.yaml', ROLES_YAML);
1008
1016
  // The only line in the tree is a DENIAL — a denial grants nobody, and
1009
1017
  // no verb that folds into read grants anyone either, so the node is
1010
- // default-denied with no readers to name.
1018
+ // default-denied. Admin still reads it through the root floor, and the
1019
+ // list says so: the banner must name who can actually open it.
1011
1020
  await writeFile(repo, 'access.md', '---\nread:\n - deny everyone\n---\n');
1012
1021
 
1013
1022
  const svc = new AccessControlService(stubWorkspaceService(workspaceId, workspaceDir), PROCESS_MAP_DIR, new NodeFs());
1014
1023
  const e = await svc.eligibleReaders(workspaceId, 'Knowledge/Foo.md');
1015
- expect(e).toEqual({ restricted: true, principals: [], roles: [], users: [], publicVia: [] });
1024
+ expect(e).toEqual({
1025
+ restricted: true,
1026
+ principals: [{ name: 'Admin', kind: 'role' }],
1027
+ roles: ['Admin'],
1028
+ users: [],
1029
+ publicVia: [],
1030
+ });
1016
1031
  });
1017
1032
 
1018
1033
  it('reports restricted=false when read: everyone applies', async () => {
@@ -1047,10 +1062,15 @@ describe('AccessControlService', () => {
1047
1062
  // node really is readable by everyone — not restricted.
1048
1063
  expect(await svc.canRead(workspaceId, 'felix@example.com', 'Knowledge/Open/Foo.md')).toBe(true);
1049
1064
  const e = await svc.eligibleReaders(workspaceId, 'Knowledge/Open/Foo.md');
1065
+ // Admin is listed beside everyone, as it is for write: the floor holds
1066
+ // wherever no nearer scope excludes it.
1050
1067
  expect(e).toEqual({
1051
1068
  restricted: false,
1052
- principals: [{ name: 'everyone', kind: 'role' }],
1053
- roles: ['everyone'],
1069
+ principals: [
1070
+ { name: 'Admin', kind: 'role' },
1071
+ { name: 'everyone', kind: 'role' },
1072
+ ],
1073
+ roles: ['Admin', 'everyone'],
1054
1074
  users: [],
1055
1075
  publicVia: [],
1056
1076
  });
@@ -1133,19 +1153,60 @@ describe('AccessControlService', () => {
1133
1153
  });
1134
1154
 
1135
1155
  /**
1136
- * Beyond the two rescue files, the owner gets exactly what the Admin role
1137
- * gets: the root write floor, and nothing a nearer rule takes away. Read
1138
- * and download still answer to the access tree.
1156
+ * Beyond the two rescue files, the owner IS the Admin role: the root floor
1157
+ * (write, and the read it implies), every grant and denial that names
1158
+ * Admin along the way, and nothing more. An owner the file does not name
1159
+ * used to hold write everywhere and read nowhere, so the read-before-write
1160
+ * gate refused them the very file the floor let them create.
1139
1161
  */
1140
- it('gets write only through the root floor, never past a subfolder that excludes Admin', async () => {
1162
+ it('is bound by the Admin rules for every verb, as a member of the role would be', async () => {
1141
1163
  const ws = await seeded();
1142
1164
  const { repo } = await seedWorkspace(root, workspaceId);
1143
1165
  await writeFile(repo, 'HR/access.md', '---\nwrite:\n - deny Admin\n---\n');
1166
+ await writeFile(repo, 'Board/access.md', '---\nread:\n - deny Admin\n---\n');
1167
+ await writeFile(repo, 'Vault/access.md', '---\ndownload:\n - Admin\n---\n');
1144
1168
  const svc = new AccessControlService(ws, PROCESS_MAP_DIR, new NodeFs(), [OWNER]);
1169
+ // The floor: write and read under the root, not download.
1145
1170
  expect(await svc.canWrite(workspaceId, OWNER, 'Knowledge/Foo.md')).toBe(true);
1146
- expect(await svc.canWrite(workspaceId, OWNER, 'HR/Salaries.md')).toBe(false);
1147
- expect(await svc.canRead(workspaceId, OWNER, 'Knowledge/Foo.md')).toBe(false);
1171
+ expect(await svc.canRead(workspaceId, OWNER, 'Knowledge/Foo.md')).toBe(true);
1148
1172
  expect(await svc.canDownload(workspaceId, OWNER, 'Knowledge/Foo.md')).toBe(false);
1173
+ // A nearer rule naming Admin binds the owner too, each verb on its own.
1174
+ expect(await svc.canWrite(workspaceId, OWNER, 'HR/Salaries.md')).toBe(false);
1175
+ expect(await svc.canRead(workspaceId, OWNER, 'HR/Salaries.md')).toBe(true);
1176
+ expect(await svc.canRead(workspaceId, OWNER, 'Board/Minutes.md')).toBe(false);
1177
+ expect(await svc.canWrite(workspaceId, OWNER, 'Board/Minutes.md')).toBe(false);
1178
+ // A grant to Admin is a grant to the owner — the role's keys are theirs.
1179
+ expect(await svc.canDownload(workspaceId, OWNER, 'Vault/Keys.md')).toBe(true);
1180
+ // The explorer's batch asks the same question and gets the same answer.
1181
+ const batch = await svc.canReadBatch(workspaceId, OWNER, ['Knowledge/Foo.md', 'Board/Minutes.md']);
1182
+ expect([...batch.entries()]).toEqual([
1183
+ ['Knowledge/Foo.md', true],
1184
+ ['Board/Minutes.md', false],
1185
+ ]);
1186
+ });
1187
+
1188
+ it('is counted when the share dialog asks whether everyone can read: a public folder that denies Admin is restricted for them', async () => {
1189
+ const ws = await seeded();
1190
+ const { repo } = await seedWorkspace(root, workspaceId);
1191
+ // Everyone reads, Admin is carved out, and the role's one member is let
1192
+ // back in by name (a direct entry outranks the role's denial). The only
1193
+ // person the denial still reaches is the owner, whom roles.yaml does not
1194
+ // name — so a check that enumerates roles.yaml alone sees nobody denied.
1195
+ await writeFile(
1196
+ repo,
1197
+ 'Open/access.md',
1198
+ '---\nread:\n - everyone\n - deny Admin\n - Someone Else <someone-else@example.com>\n---\n',
1199
+ );
1200
+ await writeFile(repo, 'Public/access.md', '---\nread:\n - everyone\n---\n');
1201
+ const svc = new AccessControlService(ws, PROCESS_MAP_DIR, new NodeFs(), [OWNER]);
1202
+ expect(await svc.canRead(workspaceId, 'someone-else@example.com', 'Open/Notes.md')).toBe(true);
1203
+ // The denial reaches the owner, so the folder is not readable by
1204
+ // everyone and the dialog must not say it is.
1205
+ expect(await svc.canRead(workspaceId, OWNER, 'Open/Notes.md')).toBe(false);
1206
+ expect((await svc.eligibleReaders(workspaceId, 'Open/Notes.md')).restricted).toBe(true);
1207
+ // Without the carve-out the same owner reads, and the folder is public.
1208
+ expect(await svc.canRead(workspaceId, OWNER, 'Public/Notes.md')).toBe(true);
1209
+ expect((await svc.eligibleReaders(workspaceId, 'Public/Notes.md')).restricted).toBe(false);
1149
1210
  });
1150
1211
 
1151
1212
  it('is matched case-insensitively, like every other email here', async () => {
@@ -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 ?? []),
@@ -185,13 +185,44 @@ describe('ChangeReadGate', () => {
185
185
 
186
186
  it('the rescues name FILES: a folder target at the root is judged as a folder, even for an admin', async () => {
187
187
  // The KB clone's own folder, asked about as an extraction destination:
188
- // the root scope, which here grants nobody — an admin included.
189
- expect(await judge(ADMIN, KB, 'dir')).toEqual({ allowed: false, unreadable: '' });
190
- expect(await judge(ADMIN, `${KB}/KnowledgeBase`, 'dir')).toEqual({ allowed: false, unreadable: 'KnowledgeBase' });
191
- // With a root that lets the admin read (write folds into read), the same folders open.
188
+ // the root scope. It grants nobody here, so a non-admin is refused; an
189
+ // admin reads it through the root floor (write implies read), which is
190
+ // the access tree's own answer, not a rescue.
191
+ expect(await judge(ALICE, KB, 'dir')).toEqual({ allowed: false, unreadable: '' });
192
+ expect(await judge(ALICE, `${KB}/KnowledgeBase`, 'dir')).toEqual({ allowed: false, unreadable: 'KnowledgeBase' });
193
+ expect(await judge(ADMIN, KB, 'dir')).toEqual({ allowed: true, via: 'readable' });
194
+ expect(await judge(ADMIN, `${KB}/KnowledgeBase`, 'dir')).toEqual({ allowed: true, via: 'readable' });
195
+ });
196
+
197
+ it('an admin may add a loose file directly in the knowledge folder, with or without a root access.md', async () => {
198
+ // The reported case: an admin could make a folder under the knowledge
199
+ // root (the new-folder exception) and write inside it, but not a page
200
+ // directly in the root — the floor gave them write there and nothing
201
+ // gave them read. Write implies read, so the floor gives both.
202
+ expect(await judge(ADMIN, `${KB}/KnowledgeBase/notes.md`)).toEqual({ allowed: true, via: 'readable' });
192
203
  await write(repo, 'access.md', `---\nowner:\n - Admin\n---\nwrite:\n - Admin\n`);
193
204
  access.invalidate(WS);
194
- expect(await judge(ADMIN, KB, 'dir')).toEqual({ allowed: true, via: 'readable' });
205
+ expect(await judge(ADMIN, `${KB}/KnowledgeBase/notes.md`)).toEqual({ allowed: true, via: 'readable' });
206
+ });
207
+
208
+ it('the deployment owner is an admin for this gate too, whether or not roles.yaml names them', async () => {
209
+ const OWNER = 'owner@bevel.software';
210
+ const ws = stubWorkspaceService(path.join(root, WS));
211
+ const owned = new ChangeReadGate(
212
+ ws,
213
+ new AccessControlService(ws, KB, new NodeFs(), [OWNER]),
214
+ testKbContext({ kbDirName: KB }),
215
+ new NodeFs(),
216
+ );
217
+ await write(repo, 'KnowledgeBase/Board/access.md', '---\nread:\n - deny Admin\n---\n');
218
+ expect(await owned.judge(WS, OWNER, `${KB}/KnowledgeBase/notes.md`, 'file')).toEqual({ allowed: true, via: 'readable' });
219
+ // Excluded exactly where the Admin role is.
220
+ expect(await owned.judge(WS, OWNER, `${KB}/KnowledgeBase/Board/memo.md`, 'file')).toEqual({
221
+ allowed: false,
222
+ unreadable: 'KnowledgeBase/Board',
223
+ });
224
+ // Not an owner on the gate's own service: no floor, no read.
225
+ expect(await judge(OWNER, `${KB}/KnowledgeBase/notes.md`)).toEqual({ allowed: false, unreadable: 'KnowledgeBase' });
195
226
  });
196
227
 
197
228
  it('a roles.yaml that is there but unusable is not an open door', async () => {
@@ -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
  });