@bevel-software/platform-core-backend 0.10.0 → 0.11.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 (188) hide show
  1. package/dist/core/create-core-server.d.ts.map +1 -1
  2. package/dist/core/create-core-server.js +12 -1
  3. package/dist/core/create-core-server.js.map +1 -1
  4. package/dist/core/create-core-services.d.ts +22 -0
  5. package/dist/core/create-core-services.d.ts.map +1 -1
  6. package/dist/core/create-core-services.js +34 -1
  7. package/dist/core/create-core-services.js.map +1 -1
  8. package/dist/index.d.ts +2 -0
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +4 -0
  11. package/dist/index.js.map +1 -1
  12. package/dist/modules/access/access-control.interface.d.ts +54 -28
  13. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  14. package/dist/modules/access/access-control.service.d.ts +129 -14
  15. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  16. package/dist/modules/access/access-control.service.js +435 -66
  17. package/dist/modules/access/access-control.service.js.map +1 -1
  18. package/dist/modules/access/access-declarations.d.ts.map +1 -1
  19. package/dist/modules/access/access-declarations.js +5 -3
  20. package/dist/modules/access/access-declarations.js.map +1 -1
  21. package/dist/modules/access/access-mutation.service.d.ts +39 -6
  22. package/dist/modules/access/access-mutation.service.d.ts.map +1 -1
  23. package/dist/modules/access/access-mutation.service.js +78 -18
  24. package/dist/modules/access/access-mutation.service.js.map +1 -1
  25. package/dist/modules/access/access-splice.d.ts +31 -4
  26. package/dist/modules/access/access-splice.d.ts.map +1 -1
  27. package/dist/modules/access/access-splice.js +40 -16
  28. package/dist/modules/access/access-splice.js.map +1 -1
  29. package/dist/modules/access/access.routes.d.ts.map +1 -1
  30. package/dist/modules/access/access.routes.js +204 -82
  31. package/dist/modules/access/access.routes.js.map +1 -1
  32. package/dist/modules/access/admin-locked-commit.d.ts +134 -0
  33. package/dist/modules/access/admin-locked-commit.d.ts.map +1 -0
  34. package/dist/modules/access/admin-locked-commit.js +277 -0
  35. package/dist/modules/access/admin-locked-commit.js.map +1 -0
  36. package/dist/modules/access/admin-route-helpers.d.ts +32 -0
  37. package/dist/modules/access/admin-route-helpers.d.ts.map +1 -0
  38. package/dist/modules/access/admin-route-helpers.js +44 -0
  39. package/dist/modules/access/admin-route-helpers.js.map +1 -0
  40. package/dist/modules/access/capability-registry.d.ts +41 -0
  41. package/dist/modules/access/capability-registry.d.ts.map +1 -0
  42. package/dist/modules/access/capability-registry.js +46 -0
  43. package/dist/modules/access/capability-registry.js.map +1 -0
  44. package/dist/modules/access/directory-sync-bot.d.ts +13 -0
  45. package/dist/modules/access/directory-sync-bot.d.ts.map +1 -0
  46. package/dist/modules/access/directory-sync-bot.js +64 -0
  47. package/dist/modules/access/directory-sync-bot.js.map +1 -0
  48. package/dist/modules/access/group-files.d.ts +83 -0
  49. package/dist/modules/access/group-files.d.ts.map +1 -0
  50. package/dist/modules/access/group-files.js +167 -0
  51. package/dist/modules/access/group-files.js.map +1 -0
  52. package/dist/modules/access/groups-admin.routes.d.ts +19 -0
  53. package/dist/modules/access/groups-admin.routes.d.ts.map +1 -0
  54. package/dist/modules/access/groups-admin.routes.js +98 -0
  55. package/dist/modules/access/groups-admin.routes.js.map +1 -0
  56. package/dist/modules/access/groups-admin.service.d.ts +166 -0
  57. package/dist/modules/access/groups-admin.service.d.ts.map +1 -0
  58. package/dist/modules/access/groups-admin.service.js +442 -0
  59. package/dist/modules/access/groups-admin.service.js.map +1 -0
  60. package/dist/modules/access/groups-edit.d.ts +58 -0
  61. package/dist/modules/access/groups-edit.d.ts.map +1 -0
  62. package/dist/modules/access/groups-edit.js +162 -0
  63. package/dist/modules/access/groups-edit.js.map +1 -0
  64. package/dist/modules/access/reference-scan.d.ts +141 -0
  65. package/dist/modules/access/reference-scan.d.ts.map +1 -0
  66. package/dist/modules/access/reference-scan.js +440 -0
  67. package/dist/modules/access/reference-scan.js.map +1 -0
  68. package/dist/modules/access/roles-admin.service.d.ts +88 -119
  69. package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
  70. package/dist/modules/access/roles-admin.service.js +230 -384
  71. package/dist/modules/access/roles-admin.service.js.map +1 -1
  72. package/dist/modules/access/roles-edit.d.ts +51 -25
  73. package/dist/modules/access/roles-edit.d.ts.map +1 -1
  74. package/dist/modules/access/roles-edit.js +133 -59
  75. package/dist/modules/access/roles-edit.js.map +1 -1
  76. package/dist/modules/access/synced-groups-committer.d.ts +28 -0
  77. package/dist/modules/access/synced-groups-committer.d.ts.map +1 -0
  78. package/dist/modules/access/synced-groups-committer.js +139 -0
  79. package/dist/modules/access/synced-groups-committer.js.map +1 -0
  80. package/dist/modules/access/synced-groups-writer.d.ts +78 -0
  81. package/dist/modules/access/synced-groups-writer.d.ts.map +1 -0
  82. package/dist/modules/access/synced-groups-writer.js +219 -0
  83. package/dist/modules/access/synced-groups-writer.js.map +1 -0
  84. package/dist/modules/database/core-schema.d.ts +17 -0
  85. package/dist/modules/database/core-schema.d.ts.map +1 -1
  86. package/dist/modules/database/core-schema.js +9 -0
  87. package/dist/modules/database/core-schema.js.map +1 -1
  88. package/dist/modules/mcp/mcp-auth.middleware.d.ts +13 -2
  89. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  90. package/dist/modules/mcp/mcp-auth.middleware.js +61 -2
  91. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  92. package/dist/modules/mcp/mcp.routes.d.ts +9 -3
  93. package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
  94. package/dist/modules/mcp/mcp.routes.js +126 -2
  95. package/dist/modules/mcp/mcp.routes.js.map +1 -1
  96. package/dist/modules/mcp/mcp.service.d.ts +14 -0
  97. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  98. package/dist/modules/mcp/mcp.service.js +6 -1
  99. package/dist/modules/mcp/mcp.service.js.map +1 -1
  100. package/dist/modules/workflow/file-lock.service.d.ts +11 -1
  101. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  102. package/dist/modules/workflow/file-lock.service.js +15 -1
  103. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  104. package/dist/modules/workflow/git/git.service.d.ts +34 -11
  105. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  106. package/dist/modules/workflow/git/git.service.js +165 -37
  107. package/dist/modules/workflow/git/git.service.js.map +1 -1
  108. package/dist/modules/workflow/locking-filesystem.d.ts +4 -0
  109. package/dist/modules/workflow/locking-filesystem.d.ts.map +1 -1
  110. package/dist/modules/workflow/locking-filesystem.js +181 -28
  111. package/dist/modules/workflow/locking-filesystem.js.map +1 -1
  112. package/dist/modules/workflow/pending-commits.service.d.ts +10 -0
  113. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  114. package/dist/modules/workflow/pending-commits.service.js +19 -1
  115. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  116. package/dist/modules/workflow/workflow.service.d.ts +17 -2
  117. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  118. package/dist/modules/workflow/workflow.service.js +113 -19
  119. package/dist/modules/workflow/workflow.service.js.map +1 -1
  120. package/kb-template/AGENTS.md +4 -1
  121. package/migrations/0004_file_lock_mode.sql +2 -0
  122. package/migrations/meta/0004_snapshot.json +1564 -0
  123. package/migrations/meta/_journal.json +7 -0
  124. package/package.json +3 -3
  125. package/src/core/create-core-server.ts +13 -0
  126. package/src/core/create-core-services.ts +68 -0
  127. package/src/index.ts +10 -0
  128. package/src/modules/access/__tests__/access-control.service.test.ts +9 -3
  129. package/src/modules/access/__tests__/access-groups.test.ts +427 -0
  130. package/src/modules/access/__tests__/access-mutation.service.test.ts +171 -5
  131. package/src/modules/access/__tests__/access-splice.test.ts +65 -0
  132. package/src/modules/access/__tests__/access.routes.group-grant.test.ts +337 -0
  133. package/src/modules/access/__tests__/access.routes.revoke.test.ts +48 -0
  134. package/src/modules/access/__tests__/admin-locked-commit.test.ts +221 -0
  135. package/src/modules/access/__tests__/admin-route-helpers.test.ts +61 -0
  136. package/src/modules/access/__tests__/directory-sync-bot.test.ts +106 -0
  137. package/src/modules/access/__tests__/grant-sources.test.ts +67 -0
  138. package/src/modules/access/__tests__/groups-admin.service.test.ts +440 -0
  139. package/src/modules/access/__tests__/reference-scan.test.ts +288 -0
  140. package/src/modules/access/__tests__/roles-admin.service.test.ts +161 -83
  141. package/src/modules/access/__tests__/roles-capabilities.test.ts +325 -0
  142. package/src/modules/access/__tests__/roles-edit.test.ts +104 -28
  143. package/src/modules/access/__tests__/roles.routes.test.ts +63 -40
  144. package/src/modules/access/__tests__/synced-groups-committer.test.ts +238 -0
  145. package/src/modules/access/__tests__/synced-groups-writer.test.ts +249 -0
  146. package/src/modules/access/access-control.interface.ts +66 -32
  147. package/src/modules/access/access-control.service.ts +536 -73
  148. package/src/modules/access/access-declarations.ts +5 -2
  149. package/src/modules/access/access-mutation.service.ts +88 -14
  150. package/src/modules/access/access-splice.ts +55 -17
  151. package/src/modules/access/access.routes.ts +227 -93
  152. package/src/modules/access/admin-locked-commit.ts +331 -0
  153. package/src/modules/access/admin-route-helpers.ts +55 -0
  154. package/src/modules/access/capability-registry.ts +74 -0
  155. package/src/modules/access/directory-sync-bot.ts +76 -0
  156. package/src/modules/access/group-files.ts +212 -0
  157. package/src/modules/access/groups-admin.routes.ts +113 -0
  158. package/src/modules/access/groups-admin.service.ts +551 -0
  159. package/src/modules/access/groups-edit.ts +187 -0
  160. package/src/modules/access/reference-scan.ts +513 -0
  161. package/src/modules/access/roles-admin.service.ts +290 -418
  162. package/src/modules/access/roles-edit.ts +134 -61
  163. package/src/modules/access/synced-groups-committer.ts +177 -0
  164. package/src/modules/access/synced-groups-writer.ts +303 -0
  165. package/src/modules/database/core-schema.ts +9 -0
  166. package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +116 -0
  167. package/src/modules/mcp/__tests__/mcp.e2e.test.ts +3 -1
  168. package/src/modules/mcp/__tests__/mcp.routes.delete.test.ts +6 -1
  169. package/src/modules/mcp/__tests__/mcp.routes.local-token.test.ts +301 -0
  170. package/src/modules/mcp/mcp-auth.middleware.ts +61 -1
  171. package/src/modules/mcp/mcp.routes.ts +137 -2
  172. package/src/modules/mcp/mcp.service.ts +6 -1
  173. package/src/modules/workflow/__tests__/locking-filesystem.test.ts +336 -0
  174. package/src/modules/workflow/__tests__/preserve-roles-yaml.test.ts +1 -1
  175. package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +32 -0
  176. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +13 -2
  177. package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +139 -1
  178. package/src/modules/workflow/file-lock.service.ts +15 -0
  179. package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +0 -1
  180. package/src/modules/workflow/git/__tests__/git.service.commitChanges.test.ts +132 -0
  181. package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +0 -1
  182. package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +0 -1
  183. package/src/modules/workflow/git/git.service.ts +174 -35
  184. package/src/modules/workflow/locking-filesystem.ts +188 -26
  185. package/src/modules/workflow/pending-commits.service.ts +27 -1
  186. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +0 -1
  187. package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +0 -1
  188. package/src/modules/workflow/workflow.service.ts +140 -20
@@ -266,3 +266,68 @@ describe('round-trip — every granted entry parses back', () => {
266
266
  );
267
267
  });
268
268
  });
269
+
270
+ describe('token-kind family — exact-token grants, shadow-aware revokes', () => {
271
+ // One name, two spellings, two possible principals: the bare token is the
272
+ // GROUP's whenever a group shadows the name (group-first precedence), the
273
+ // `role/` token is always the ROLE's. Grants must be exact-token idempotent;
274
+ // revokes are alias-tolerant ONLY when unshadowed (the caller passes
275
+ // tokenMatch: 'exact' when a group owns the bare name).
276
+ const SHARED = `---
277
+ write:
278
+ - role/Sales
279
+ - Sales
280
+ - Admin
281
+ ---
282
+ `;
283
+
284
+ it("GRANT is exact-token: a bare GROUP grant goes through even when role/<Name> is present", () => {
285
+ const md = '---\nwrite:\n - role/Sales\n---\n';
286
+ const r = spliceGrant(md, 'write', { kind: 'role', role: 'Sales' });
287
+ expect(r.changed).toBe(true); // NOT swallowed as already-granted
288
+ expect(r.text).toContain(' - role/Sales');
289
+ expect(r.text).toContain(' - Sales');
290
+ });
291
+
292
+ it('GRANT is exact-token: a role/<Name> grant goes through even when the bare token is present', () => {
293
+ const md = '---\nwrite:\n - Sales\n---\n';
294
+ const r = spliceGrant(md, 'write', { kind: 'role', role: 'role/Sales' });
295
+ expect(r.changed).toBe(true);
296
+ expect(r.text).toContain(' - Sales');
297
+ expect(r.text).toContain(' - role/Sales');
298
+ });
299
+
300
+ it('GRANT stays idempotent for the SAME spelling (both spellings)', () => {
301
+ expect(spliceGrant(SHARED, 'write', { kind: 'role', role: 'Sales' }).changed).toBe(false);
302
+ expect(spliceGrant(SHARED, 'write', { kind: 'role', role: 'role/Sales' }).changed).toBe(false);
303
+ });
304
+
305
+ it("REVOKE unshadowed (default 'name'): revoking the role strips BOTH spellings (legacy cleanup)", () => {
306
+ const r = spliceRevoke(SHARED, 'write', { kind: 'role', role: 'role/Sales' });
307
+ expect(r.changed).toBe(true);
308
+ expect(r.text).not.toContain('Sales');
309
+ expect(r.text).toContain(' - Admin');
310
+ });
311
+
312
+ it("REVOKE shadowed ('exact'): revoking the ROLE leaves the group's bare token", () => {
313
+ const r = spliceRevoke(SHARED, 'write', { kind: 'role', role: 'role/Sales' }, { tokenMatch: 'exact' });
314
+ expect(r.changed).toBe(true);
315
+ expect(r.text).not.toContain('role/Sales');
316
+ expect(r.text).toContain(' - Sales'); // the GROUP's grant survives
317
+ expect(r.text).toContain(' - Admin');
318
+ });
319
+
320
+ it("REVOKE shadowed ('exact'): revoking the GROUP (bare) leaves the role's explicit token", () => {
321
+ const r = spliceRevoke(SHARED, 'write', { kind: 'role', role: 'Sales' }, { tokenMatch: 'exact' });
322
+ expect(r.changed).toBe(true);
323
+ expect(r.text).toContain(' - role/Sales'); // the ROLE's grant survives
324
+ const lines = r.text.split('\n');
325
+ expect(lines).not.toContain(' - Sales');
326
+ });
327
+
328
+ it("REVOKE unshadowed bare principal (group since vanished): 'name' matching strips both spellings", () => {
329
+ const r = spliceRevoke(SHARED, 'write', { kind: 'role', role: 'Sales' });
330
+ expect(r.changed).toBe(true);
331
+ expect(r.text).not.toContain('Sales'); // bare token falls back to the role — same principal
332
+ });
333
+ });
@@ -0,0 +1,337 @@
1
+ import type { Server } from 'node:http';
2
+ import type { AddressInfo } from 'node:net';
3
+ import express from 'express';
4
+ import { describe, it, expect, afterEach, vi } from 'vitest';
5
+
6
+ import type { IAccessControl } from '../access-control.interface.js';
7
+ import type { WorkspaceService } from '../../workspace/workspace.service.js';
8
+ import type { AuthService } from '../../auth/auth.service.js';
9
+ import type { WorkflowService } from '../../workflow/workflow.service.js';
10
+ import type { WorkflowEventBus } from '../../workflow/event-bus.js';
11
+ import type { Database } from '../../database/connection.js';
12
+ import { createAccessRoutes } from '../access.routes.js';
13
+
14
+ /**
15
+ * HTTP-level contract tests for GROUP and ROLE principals on the share
16
+ * surface: the grant route's grammar (groups splice as the bare token, roles
17
+ * as the explicit `role/<Name>` token), the dropped collision refusals
18
+ * (precedence resolves them now), the suggest endpoint's `roles`+`groups`
19
+ * lists (with the deprecated `plugins` alias), and revoke accepting the group
20
+ * kind. These pin the ROUTE layer; the RESOLVER's group-first precedence and
21
+ * the real kbPrincipals shape are covered in access-groups.test.ts /
22
+ * access-mutation.service.test.ts against the real service.
23
+ */
24
+
25
+ const USER = { id: 'u-1', email: 'alice@bevel.software', name: 'Alice' };
26
+ const WS = 'alice/feature'; // non-protected feature branch
27
+ const KB = 'knowledge-base';
28
+
29
+ async function makeHarness(opts: {
30
+ files?: Record<string, string>;
31
+ roles?: string[];
32
+ groups?: string[];
33
+ }): Promise<{ server: Server; baseUrl: string; files: Map<string, string> }> {
34
+ const files = new Map<string, string>(Object.entries(opts.files ?? {}));
35
+
36
+ const accessControl = {
37
+ canWrite: vi.fn(async () => true),
38
+ canRead: vi.fn(async () => true),
39
+ canDownload: vi.fn(async () => false),
40
+ canOwner: vi.fn(async () => false),
41
+ grantSources: vi.fn(async () => ({})),
42
+ invalidate: vi.fn(),
43
+ kbPrincipals: vi.fn(async () => ({
44
+ roles: opts.roles ?? [],
45
+ groups: opts.groups ?? [],
46
+ people: [],
47
+ })),
48
+ eligibleWriters: vi.fn(async () => ({ roles: [], users: [] })),
49
+ eligibleReaders: vi.fn(async () => ({ restricted: true, roles: [], users: [] })),
50
+ eligibleOwners: vi.fn(async () => ({ roles: [], users: [] })),
51
+ eligibleDownloaders: vi.fn(async () => ({ roles: [], users: [] })),
52
+ } as unknown as IAccessControl;
53
+
54
+ const workspaceService = {
55
+ getOrCreateForBranch: vi.fn(async () => ({ id: WS, name: WS, kbDirName: KB })),
56
+ readFile: vi.fn(async (_id: string, wsRel: string) => {
57
+ const v = files.get(wsRel);
58
+ if (v === undefined) {
59
+ const err = new Error(`ENOENT ${wsRel}`) as NodeJS.ErrnoException;
60
+ err.code = 'ENOENT';
61
+ throw err;
62
+ }
63
+ return v;
64
+ }),
65
+ writeFile: vi.fn(async (_id: string, wsRel: string, content: string) => {
66
+ files.set(wsRel, content);
67
+ }),
68
+ } as unknown as WorkspaceService;
69
+
70
+ const authService = { getUserById: vi.fn(async () => USER) } as unknown as AuthService;
71
+ const workflowService = {
72
+ getLock: vi.fn(async () => null),
73
+ acquireLock: vi.fn(async () => ({ acquired: true, lock: {} })),
74
+ releaseLock: vi.fn(async () => undefined),
75
+ releaseLockNoCommit: vi.fn(async () => undefined),
76
+ } as unknown as WorkflowService;
77
+ const eventBus = { emit: vi.fn() } as unknown as WorkflowEventBus;
78
+ const db = {} as unknown as Database;
79
+
80
+ const app = express();
81
+ app.use(express.json());
82
+ app.use('/api', (req, _res, next) => {
83
+ (req as unknown as { userId: string }).userId = USER.id;
84
+ next();
85
+ });
86
+ app.use('/api', createAccessRoutes(accessControl, workspaceService, authService, workflowService, eventBus, db, KB));
87
+
88
+ const server = await new Promise<Server>((resolve) => {
89
+ const s = app.listen(0, () => resolve(s));
90
+ });
91
+ const addr = server.address() as AddressInfo;
92
+ return { server, baseUrl: `http://127.0.0.1:${addr.port}`, files };
93
+ }
94
+
95
+ function close(s: Server): Promise<void> {
96
+ return new Promise((resolve, reject) => s.close((e) => (e ? reject(e) : resolve())));
97
+ }
98
+
99
+ describe('group principals on the share surface', () => {
100
+ let h: Awaited<ReturnType<typeof makeHarness>> | null = null;
101
+ afterEach(async () => {
102
+ if (h) await close(h.server);
103
+ h = null;
104
+ });
105
+
106
+ const post = (route: string, body: unknown) =>
107
+ fetch(`${h!.baseUrl}/api/workspace/${encodeURIComponent(WS)}/access/${route}`, {
108
+ method: 'POST',
109
+ headers: { 'Content-Type': 'application/json' },
110
+ body: JSON.stringify(body),
111
+ });
112
+
113
+ it('grants read to a known group — spliced as the bare-name token', async () => {
114
+ h = await makeHarness({ groups: ['GTM Team', 'Product'] });
115
+ const res = await post('grant', {
116
+ path: `${KB}/Sales`,
117
+ kind: 'folder',
118
+ verb: 'read',
119
+ principal: { kind: 'group', group: 'GTM Team' },
120
+ });
121
+ expect(res.status).toBe(200);
122
+ const md = h.files.get(`${KB}/Sales/access.md`)!;
123
+ expect(md).toContain('- GTM Team');
124
+ expect(md).not.toContain('role/'); // a GROUP grant is the bare token
125
+ });
126
+
127
+ it('grants a ROLE as the explicit role/<Name> token', async () => {
128
+ h = await makeHarness({ roles: ['Everyone', 'Sales'] });
129
+ const res = await post('grant', {
130
+ path: `${KB}/Sales`,
131
+ kind: 'folder',
132
+ verb: 'write',
133
+ principal: { kind: 'role', role: 'Sales' },
134
+ });
135
+ expect(res.status).toBe(200);
136
+ expect(h.files.get(`${KB}/Sales/access.md`)).toContain('- role/Sales');
137
+ });
138
+
139
+ it('the built-in everyone grant stays a bare token (no role/ alias exists for it)', async () => {
140
+ h = await makeHarness({ roles: ['Everyone'] });
141
+ const res = await post('grant', {
142
+ path: `${KB}/Sales`,
143
+ kind: 'folder',
144
+ verb: 'read',
145
+ principal: { kind: 'role', role: 'Everyone' },
146
+ });
147
+ expect(res.status).toBe(200);
148
+ const md = h.files.get(`${KB}/Sales/access.md`)!;
149
+ expect(md).toContain('- Everyone');
150
+ expect(md).not.toContain('role/Everyone');
151
+ });
152
+
153
+ it('404s an unknown group with a typed body', async () => {
154
+ h = await makeHarness({ groups: ['GTM Team'] });
155
+ const res = await post('grant', {
156
+ path: `${KB}/Sales`,
157
+ kind: 'folder',
158
+ verb: 'read',
159
+ principal: { kind: 'group', group: 'Ghost Team' },
160
+ });
161
+ expect(res.status).toBe(404);
162
+ expect(await res.json()).toMatchObject({ kind: 'unknown-group', group: 'Ghost Team' });
163
+ });
164
+
165
+ it('404s an unknown role with a typed body', async () => {
166
+ h = await makeHarness({ roles: ['Everyone'] });
167
+ const res = await post('grant', {
168
+ path: `${KB}/Sales`,
169
+ kind: 'folder',
170
+ verb: 'write',
171
+ principal: { kind: 'role', role: 'Ghost Role' },
172
+ });
173
+ expect(res.status).toBe(404);
174
+ expect(await res.json()).toMatchObject({ kind: 'unknown-role', role: 'Ghost Role' });
175
+ });
176
+
177
+ it('a group sharing a role name is grantable — precedence resolves it (no refusal)', async () => {
178
+ h = await makeHarness({ roles: ['Everyone', 'Product'], groups: ['Product'] });
179
+ const asGroup = await post('grant', {
180
+ path: `${KB}/Sales`,
181
+ kind: 'folder',
182
+ verb: 'read',
183
+ principal: { kind: 'group', group: 'Product' },
184
+ });
185
+ expect(asGroup.status).toBe(200);
186
+ expect(h.files.get(`${KB}/Sales/access.md`)).toContain('- Product');
187
+ // The ROLE of the same name grants as the explicit token, side by side.
188
+ const asRole = await post('grant', {
189
+ path: `${KB}/Sales`,
190
+ kind: 'folder',
191
+ verb: 'write',
192
+ principal: { kind: 'role', role: 'Product' },
193
+ });
194
+ expect(asRole.status).toBe(200);
195
+ expect(h.files.get(`${KB}/Sales/access.md`)).toContain('- role/Product');
196
+ });
197
+
198
+ it('suggest lists roles + groups without collision withholding, plus the deprecated plugins alias', async () => {
199
+ h = await makeHarness({ roles: ['Everyone', 'Product'], groups: ['GTM Team', 'Product'] });
200
+ const res = await fetch(
201
+ `${h.baseUrl}/api/workspace/${encodeURIComponent(WS)}/access/suggest?q=`,
202
+ );
203
+ expect(res.status).toBe(200);
204
+ const body = (await res.json()) as { roles: string[]; groups: string[]; plugins: string[] };
205
+ expect(body.groups).toEqual(['GTM Team', 'Product']); // nothing withheld
206
+ expect(body.roles).toEqual(['Everyone', 'Product']);
207
+ // Deprecated alias — keeps the shipped dialog alive for one release.
208
+ expect(body.plugins).toEqual(body.roles);
209
+ });
210
+
211
+ it('revokes with a group principal (name-based, no existence check)', async () => {
212
+ h = await makeHarness({
213
+ groups: ['GTM Team'],
214
+ files: {
215
+ [`${KB}/Sales/access.md`]: '---\nread:\n - Vanished Team\n---\n',
216
+ },
217
+ });
218
+ const res = await post('revoke', {
219
+ path: `${KB}/Sales`,
220
+ kind: 'folder',
221
+ principal: { kind: 'group', group: 'Vanished Team' },
222
+ });
223
+ expect(res.status).toBe(200);
224
+ expect(h.files.get(`${KB}/Sales/access.md`)).not.toContain('Vanished Team');
225
+ });
226
+
227
+ it('revoking a ROLE strips both spellings (legacy bare + role/ token)', async () => {
228
+ h = await makeHarness({
229
+ roles: ['Everyone', 'Sales'],
230
+ files: {
231
+ [`${KB}/Sales/access.md`]: '---\nwrite:\n - Sales\n - role/Sales\n - Admin\n---\n',
232
+ },
233
+ });
234
+ const res = await post('revoke', {
235
+ path: `${KB}/Sales`,
236
+ kind: 'folder',
237
+ principal: { kind: 'role', role: 'Sales' },
238
+ });
239
+ expect(res.status).toBe(200);
240
+ const md = h.files.get(`${KB}/Sales/access.md`)!;
241
+ expect(md).not.toContain('Sales');
242
+ expect(md).toContain('- Admin'); // untouched
243
+ });
244
+ });
245
+
246
+ describe('token-kind family at the route boundary', () => {
247
+ let h: Awaited<ReturnType<typeof makeHarness>> | null = null;
248
+ afterEach(async () => {
249
+ if (h) await close(h.server);
250
+ h = null;
251
+ });
252
+
253
+ const post = (route: string, body: unknown) =>
254
+ fetch(`${h!.baseUrl}/api/workspace/${encodeURIComponent(WS)}/access/${route}`, {
255
+ method: 'POST',
256
+ headers: { 'Content-Type': 'application/json' },
257
+ body: JSON.stringify(body),
258
+ });
259
+
260
+ it("granting 'role/Everyone' normalizes to the bare built-in — never the dead role/everyone token", async () => {
261
+ h = await makeHarness({ roles: ['Everyone'] });
262
+ const res = await post('grant', {
263
+ path: `${KB}/Open`,
264
+ kind: 'folder',
265
+ verb: 'read',
266
+ principal: { kind: 'role', role: 'role/Everyone' },
267
+ });
268
+ expect(res.status).toBe(200);
269
+ const md = h.files.get(`${KB}/Open/access.md`)!;
270
+ expect(md).toContain('- Everyone');
271
+ expect(md).not.toContain('role/');
272
+ });
273
+
274
+ it("granting 'role/Everyone' a non-read verb is refused like the bare spelling", async () => {
275
+ h = await makeHarness({ roles: ['Everyone'] });
276
+ const res = await post('grant', {
277
+ path: `${KB}/Open`,
278
+ kind: 'folder',
279
+ verb: 'write',
280
+ principal: { kind: 'role', role: 'role/Everyone' },
281
+ });
282
+ expect(res.status).toBe(400);
283
+ });
284
+
285
+ it("revoking 'role/Everyone' strips a bare everyone grant (symmetric normalization)", async () => {
286
+ h = await makeHarness({
287
+ roles: ['Everyone'],
288
+ files: { [`${KB}/Open/access.md`]: '---\nread:\n - everyone\n - Admin\n---\n' },
289
+ });
290
+ const res = await post('revoke', {
291
+ path: `${KB}/Open`,
292
+ kind: 'folder',
293
+ principal: { kind: 'role', role: 'role/Everyone' },
294
+ });
295
+ expect(res.status).toBe(200);
296
+ const md = h.files.get(`${KB}/Open/access.md`)!;
297
+ expect(md).not.toContain('everyone');
298
+ expect(md).toContain('- Admin');
299
+ });
300
+
301
+ it('granting a GROUP that shadows an already-granted role/<Name> is NOT swallowed as idempotent', async () => {
302
+ h = await makeHarness({
303
+ roles: ['Everyone', 'Product'],
304
+ groups: ['Product'],
305
+ files: { [`${KB}/Sales/access.md`]: '---\nread:\n - role/Product\n---\n' },
306
+ });
307
+ const res = await post('grant', {
308
+ path: `${KB}/Sales`,
309
+ kind: 'folder',
310
+ verb: 'read',
311
+ principal: { kind: 'group', group: 'Product' },
312
+ });
313
+ expect(res.status).toBe(200);
314
+ const md = h.files.get(`${KB}/Sales/access.md`)!;
315
+ expect(md).toContain('- role/Product');
316
+ expect(md.split('\n')).toContain(' - Product'); // the group's bare token landed
317
+ });
318
+
319
+ it('revoking the ROLE while a same-named GROUP is granted leaves the bare token (kbPrincipals-driven shadowing)', async () => {
320
+ h = await makeHarness({
321
+ roles: ['Everyone', 'Product'],
322
+ groups: ['Product'],
323
+ files: {
324
+ [`${KB}/Sales/access.md`]: '---\nwrite:\n - Product\n - role/Product\n---\n',
325
+ },
326
+ });
327
+ const res = await post('revoke', {
328
+ path: `${KB}/Sales`,
329
+ kind: 'folder',
330
+ principal: { kind: 'role', role: 'Product' },
331
+ });
332
+ expect(res.status).toBe(200);
333
+ const md = h.files.get(`${KB}/Sales/access.md`)!;
334
+ expect(md).not.toContain('role/Product');
335
+ expect(md.split('\n')).toContain(' - Product'); // the GROUP's grant survives
336
+ });
337
+ });
@@ -76,6 +76,10 @@ async function makeHarness(opts: {
76
76
  canWrite,
77
77
  grantSources,
78
78
  invalidate: vi.fn(),
79
+ // The mutation service consults the merged principal index to decide
80
+ // exact-vs-name token matching on revoke (group shadowing). No groups in
81
+ // these fixtures → unshadowed (name-level) matching, the legacy behavior.
82
+ kbPrincipals: vi.fn(async () => ({ roles: [], groups: [], people: [] })),
79
83
  // resolvedView() fans out to these after a successful mutation; harmless stubs.
80
84
  // canRead/canDownload/canOwner are ALSO consumed by denyHere's assert.
81
85
  canRead: vi.fn(async (_w: string, email: string) => effEmail(email, true)),
@@ -275,6 +279,50 @@ describe('POST /access/revoke — route classification + modes', () => {
275
279
  expect(h.stubs.files.get(`${KB}/Sales/access.md`)).toContain('Admin'); // untouched
276
280
  });
277
281
 
282
+ it('a GROUP revoke matches its exact bare token only — a VANISHED group never strips role/<Name>', async () => {
283
+ // The file grants BOTH spellings of one name: the bare token (the
284
+ // group's grant) and role/Product (the role's). The group has since
285
+ // vanished from the active source (kbPrincipals reports no groups), so
286
+ // the mutation layer's shadow probe reads "unshadowed" — name-level
287
+ // matching would strip BOTH spellings, silently revoking the ROLE. The
288
+ // route pins exact-token matching for group principals instead.
289
+ h = await makeHarness({
290
+ files: {
291
+ [`${KB}/Sales/access.md`]: '---\nwrite:\n - Product\n - role/Product\n---\n',
292
+ },
293
+ grantSources: () => ({}),
294
+ });
295
+ const res = await revoke({
296
+ path: `${KB}/Sales`,
297
+ kind: 'folder',
298
+ principal: { kind: 'group', group: 'Product' },
299
+ });
300
+ expect(res.status).toBe(200);
301
+ const text = h.stubs.files.get(`${KB}/Sales/access.md`)!;
302
+ // The group's bare grant is gone; the role's explicit grant survives.
303
+ expect(text).toContain('- role/Product');
304
+ expect(text.split('\n')).not.toContain(' - Product');
305
+ });
306
+
307
+ it('a ROLE revoke still sweeps legacy bare spellings when nothing shadows the name', async () => {
308
+ // Unshadowed role revokes keep the historical cleanup: both `Product`
309
+ // and `role/Product` are the ROLE's spellings, and both go.
310
+ h = await makeHarness({
311
+ files: {
312
+ [`${KB}/Sales/access.md`]: '---\nwrite:\n - Product\n - role/Product\n---\n',
313
+ },
314
+ grantSources: () => ({}),
315
+ });
316
+ const res = await revoke({
317
+ path: `${KB}/Sales`,
318
+ kind: 'folder',
319
+ principal: { kind: 'role', role: 'Product' },
320
+ });
321
+ expect(res.status).toBe(200);
322
+ const text = h.stubs.files.get(`${KB}/Sales/access.md`)!;
323
+ expect(text).not.toContain('Product');
324
+ });
325
+
278
326
  it('refuses 403 when the caller cannot write the target access config', async () => {
279
327
  h = await makeHarness({
280
328
  files: { [`${KB}/Sales/access.md`]: '---\nwrite:\n - Alice <alice@bevel.software>\n---\n' },