@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
@@ -0,0 +1,325 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+
6
+ import type { WorkspaceService } from '../../workspace/workspace.service.js';
7
+ import type { WorkflowService } from '../../workflow/workflow.service.js';
8
+ import type { AuthUser, FileTreeEntry } from '@bevel-software/platform-shared';
9
+ import { DEFAULT_BRANCH } from '@bevel-software/platform-shared';
10
+ import { AccessControlService } from '../access-control.service.js';
11
+ import { RolesAdminService } from '../roles-admin.service.js';
12
+
13
+ const KB = 'knowledge-base';
14
+ const ADMIN: AuthUser = { id: 'u-admin', email: 'admin@x.io', name: 'Admin' } as AuthUser;
15
+
16
+ const ROLES = `roles:
17
+ Admin:
18
+ - admin@x.io
19
+ Plugin Creator:
20
+ - jane@x.io
21
+ - group:engineering
22
+ Product:
23
+ - felix@x.io
24
+ - sara@x.io
25
+ `;
26
+
27
+ async function write(repo: string, rel: string, contents: string): Promise<void> {
28
+ const abs = path.join(repo, rel);
29
+ await fs.mkdir(path.dirname(abs), { recursive: true });
30
+ await fs.writeFile(abs, contents);
31
+ }
32
+
33
+ function stubWorkspace(workspaceDir: string): WorkspaceService {
34
+ const resolve = (wsRel: string) => path.join(workspaceDir, wsRel);
35
+ const buildTree = async (absDir: string): Promise<FileTreeEntry> => {
36
+ const rel = path.relative(workspaceDir, absDir).replace(/\\/g, '/');
37
+ const children: FileTreeEntry[] = [];
38
+ for (const e of await fs.readdir(absDir, { withFileTypes: true })) {
39
+ if (e.name === '.git') continue;
40
+ const childAbs = path.join(absDir, e.name);
41
+ if (e.isDirectory()) children.push(await buildTree(childAbs));
42
+ else if (e.isFile()) {
43
+ children.push({
44
+ name: e.name,
45
+ relativePath: path.relative(workspaceDir, childAbs).replace(/\\/g, '/'),
46
+ type: 'file',
47
+ });
48
+ }
49
+ }
50
+ return { name: path.basename(absDir), relativePath: rel || '.', type: 'directory', children };
51
+ };
52
+ return {
53
+ getWorkspacePath: async () => workspaceDir,
54
+ getOrCreateForBranch: async () => ({}) as unknown,
55
+ listFiles: async () => buildTree(workspaceDir),
56
+ readFile: async (_id: string, wsRel: string) => fs.readFile(resolve(wsRel), 'utf-8'),
57
+ writeFile: async (_id: string, wsRel: string, content: string) => {
58
+ await fs.mkdir(path.dirname(resolve(wsRel)), { recursive: true });
59
+ await fs.writeFile(resolve(wsRel), content);
60
+ },
61
+ deleteFile: async (_id: string, wsRel: string) => {
62
+ await fs.rm(resolve(wsRel));
63
+ },
64
+ } as unknown as WorkspaceService;
65
+ }
66
+
67
+ function stubWorkflow() {
68
+ const commits: { summary: string }[] = [];
69
+ // Strict per-path locks — a live row is contended even for the same user,
70
+ // exactly like FileLockService (nesting bugs must fail here too).
71
+ const locks = new Map<string, AuthUser>();
72
+ const lockRow = (h: AuthUser) => ({ holderUserId: h.id, holderName: h.name });
73
+ const acquireCalls: { path: string; coordination: boolean }[] = [];
74
+ const svc = {
75
+ getLock: async (_w: string, _b: string, p: string) => {
76
+ const h = locks.get(p);
77
+ return h ? lockRow(h) : null;
78
+ },
79
+ acquireLock: async (
80
+ _w: string,
81
+ _b: string,
82
+ p: string,
83
+ user: AuthUser,
84
+ opts?: { coordination?: boolean },
85
+ ) => {
86
+ acquireCalls.push({ path: p, coordination: !!opts?.coordination });
87
+ // Mirror the production lock-time write gate on the machine-owned file:
88
+ // synced-groups.yaml refuses every non-bot WRITE-intent acquire on the
89
+ // (always protected) default branch. Only a coordination acquire — a
90
+ // pure mutex claiming no write authority — may hold it for a human.
91
+ if (p === `${KB}/synced-groups.yaml` && !opts?.coordination) {
92
+ throw new Error(`Access denied: no write permission on "${p}" (machine-owned)`);
93
+ }
94
+ const h = locks.get(p);
95
+ if (h) return { acquired: false, lock: lockRow(h) };
96
+ locks.set(p, user);
97
+ return { acquired: true, lock: lockRow(user) };
98
+ },
99
+ releaseLock: async (_w: string, _b: string, p: string) => {
100
+ locks.delete(p);
101
+ },
102
+ releaseLockNoCommit: async (_w: string, _b: string, p: string) => {
103
+ locks.delete(p);
104
+ },
105
+ commitChanges: async (_ws: string, _user: AuthUser, summary: string) => {
106
+ commits.push({ summary });
107
+ return {} as unknown;
108
+ },
109
+ } as unknown as WorkflowService;
110
+ return { svc, commits, acquireCalls };
111
+ }
112
+
113
+ describe('RolesAdminService — capabilities, group assignment, conversion', () => {
114
+ let root: string;
115
+ let repo: string;
116
+ let service: RolesAdminService;
117
+ let commits: { summary: string }[];
118
+
119
+ beforeEach(async () => {
120
+ root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-roles-cap-'));
121
+ repo = path.join(root, KB);
122
+ await write(repo, 'roles.yaml', ROLES);
123
+ // Assignable groups must exist in the ACTIVE source — assignGroup
124
+ // validates against it (a ref to a missing group is a dead grant).
125
+ await write(repo, 'groups.yaml', 'groups:\n GTM Team:\n - sara@x.io\n');
126
+ await write(repo, 'access.md', '---\nwrite:\n - Admin\nread:\n - Product\n---\n');
127
+ const workspace = stubWorkspace(root);
128
+ const workflow = stubWorkflow();
129
+ commits = workflow.commits;
130
+ service = new RolesAdminService(
131
+ workspace,
132
+ workflow.svc,
133
+ new AccessControlService(workspace as never, KB),
134
+ KB,
135
+ () => DEFAULT_BRANCH,
136
+ );
137
+ });
138
+
139
+ afterEach(async () => {
140
+ await fs.rm(root, { recursive: true, force: true });
141
+ });
142
+
143
+ const rolesYaml = () => fs.readFile(path.join(repo, 'roles.yaml'), 'utf-8');
144
+ const groupsYaml = () => fs.readFile(path.join(repo, 'groups.yaml'), 'utf-8');
145
+
146
+ it('roster: capability metadata, legacy flag, and members/groups split', async () => {
147
+ const roster = await service.getRoster();
148
+ const admin = roster.find((r) => r.canonical === 'admin')!;
149
+ expect(admin.capability).toMatchObject({ groupAssignable: true });
150
+ expect(admin.capability?.description).toContain('configuration');
151
+
152
+ const creator = roster.find((r) => r.canonical === 'plugin creator')!;
153
+ expect(creator.members).toEqual(['jane@x.io']); // group ref split out
154
+ expect(creator.groups).toEqual(['engineering']);
155
+ expect(creator.capability).toBeNull(); // not in the registry (yet)
156
+
157
+ const product = roster.find((r) => r.canonical === 'product')!;
158
+ expect(product.capability).toBeNull(); // legacy people-set role
159
+ expect(product.referencedBy).toEqual([{ path: 'access.md', verb: 'read' }]);
160
+ });
161
+
162
+ it('assigns and unassigns a group on a non-Admin role', async () => {
163
+ await service.assignGroup(ADMIN, 'product', 'GTM Team');
164
+ expect(await rolesYaml()).toContain('- group:gtm team');
165
+
166
+ const roster = await service.unassignGroup(ADMIN, 'product', 'GTM Team');
167
+ expect(await rolesYaml()).not.toContain('group:gtm team');
168
+ expect(roster.find((r) => r.canonical === 'product')?.groups).toEqual([]);
169
+ });
170
+
171
+ it('refuses to assign a group the active source does not know (dead grant)', async () => {
172
+ const before = await rolesYaml();
173
+ await expect(service.assignGroup(ADMIN, 'product', 'Ghost Team')).rejects.toMatchObject({
174
+ status: 404,
175
+ payload: { kind: 'unknown-group', group: 'Ghost Team' },
176
+ });
177
+ expect(await rolesYaml()).toBe(before);
178
+ });
179
+
180
+ it('assigns a group to Admin (NEW) — the invariant lives in the direct-email rule, not a ban', async () => {
181
+ await service.assignGroup(ADMIN, 'admin', 'GTM Team');
182
+ expect(await rolesYaml()).toContain('- group:gtm team');
183
+ const roster = await service.getRoster();
184
+ expect(roster.find((r) => r.canonical === 'admin')?.groups).toEqual(['gtm team']);
185
+ // But Admin can never drop its LAST direct email — group refs don't count.
186
+ await expect(
187
+ service.removeMember(ADMIN, 'admin', 'admin@x.io', true),
188
+ ).rejects.toMatchObject({ status: 422, message: expect.stringContaining('direct email') });
189
+ expect(await rolesYaml()).toContain('- admin@x.io');
190
+ });
191
+
192
+ it('converts a legacy role to a group atomically; grants keep the name', async () => {
193
+ const roster = await service.convertRoleToGroup(ADMIN, 'product');
194
+
195
+ expect(roster.some((r) => r.canonical === 'product')).toBe(false);
196
+ expect(await rolesYaml()).not.toContain('Product');
197
+ expect(await groupsYaml()).toContain('Product:\n - felix@x.io\n - sara@x.io');
198
+ expect(commits).toHaveLength(1);
199
+ expect(commits[0].summary).toContain('Convert role Product to a group');
200
+ // The grant text was never touched — the name carries over.
201
+ const accessMd = await fs.readFile(path.join(repo, 'access.md'), 'utf-8');
202
+ expect(accessMd).toContain('- Product');
203
+ });
204
+
205
+ it('manual-mode conversion works for a HUMAN admin: the synced file is held as a COORDINATION lock', async () => {
206
+ // synced-groups.yaml is machine-owned — the lock-time write gate refuses
207
+ // every human actor (the stub's acquireLock enforces this exactly like
208
+ // production). The conversion still needs the file's lock to serialize
209
+ // with a provisioning push, so it must take it as a coordination hold
210
+ // that claims no write authority. A write-intent acquire here would fail
211
+ // EVERY manual-mode conversion at lock acquisition.
212
+ const workspace = stubWorkspace(root);
213
+ const workflow = stubWorkflow();
214
+ const svc = new RolesAdminService(
215
+ workspace,
216
+ workflow.svc,
217
+ new AccessControlService(workspace as never, KB),
218
+ KB,
219
+ () => DEFAULT_BRANCH,
220
+ );
221
+ const roster = await svc.convertRoleToGroup(ADMIN, 'product');
222
+ expect(roster.some((r) => r.canonical === 'product')).toBe(false);
223
+ expect(await groupsYaml()).toContain('Product:');
224
+ // The synced file WAS locked (the serialization is real), via coordination;
225
+ // the written files keep their write-gated acquires.
226
+ expect(workflow.acquireCalls).toContainEqual({
227
+ path: `${KB}/synced-groups.yaml`,
228
+ coordination: true,
229
+ });
230
+ expect(workflow.acquireCalls).toContainEqual({ path: `${KB}/roles.yaml`, coordination: false });
231
+ expect(workflow.acquireCalls).toContainEqual({ path: `${KB}/groups.yaml`, coordination: false });
232
+ });
233
+
234
+ it('conversion refusals: capability role, group-assigned role, IdP mode', async () => {
235
+ await expect(service.convertRoleToGroup(ADMIN, 'admin')).rejects.toMatchObject({
236
+ status: 422,
237
+ message: expect.stringContaining('capability role'),
238
+ });
239
+ await expect(service.convertRoleToGroup(ADMIN, 'plugin creator')).rejects.toMatchObject({
240
+ status: 422,
241
+ message: expect.stringContaining('assigned to groups'),
242
+ });
243
+
244
+ await write(repo, 'synced-groups.yaml', 'groups:\n Engineering:\n - ada@x.io\n');
245
+ await expect(service.convertRoleToGroup(ADMIN, 'product')).rejects.toMatchObject({
246
+ status: 409,
247
+ payload: { kind: 'idp-mode' },
248
+ });
249
+ });
250
+
251
+ it('conversion re-checks IdP mode UNDER the synced file lock (TOCTOU: a sync landing mid-flight refuses)', async () => {
252
+ // The pre-lock IdP check passes (no synced file yet). Directory sync then
253
+ // materializes synced-groups.yaml just before the conversion gets its
254
+ // locks — the same interleaving the synced file's lock serializes in
255
+ // production. The under-lock recheck must refuse with the typed 409 and
256
+ // write nothing (committing a groups.yaml group in IdP mode writes a
257
+ // retired file).
258
+ const workspace = stubWorkspace(root);
259
+ const workflow = stubWorkflow();
260
+ const wf = workflow.svc as unknown as {
261
+ acquireLock: (
262
+ w: string,
263
+ b: string,
264
+ p: string,
265
+ u: AuthUser,
266
+ opts?: { coordination?: boolean },
267
+ ) => Promise<unknown>;
268
+ };
269
+ const origAcquire = wf.acquireLock.bind(workflow.svc);
270
+ wf.acquireLock = async (w, b, p, u, opts) => {
271
+ if (p === `${KB}/synced-groups.yaml`) {
272
+ // The sync's provisioning push won the race for this lock and landed.
273
+ await write(repo, 'synced-groups.yaml', 'groups:\n Engineering:\n - ada@x.io\n');
274
+ }
275
+ return origAcquire(w, b, p, u, opts);
276
+ };
277
+ const svc = new RolesAdminService(
278
+ workspace,
279
+ workflow.svc,
280
+ new AccessControlService(workspace as never, KB),
281
+ KB,
282
+ () => DEFAULT_BRANCH,
283
+ );
284
+ await expect(svc.convertRoleToGroup(ADMIN, 'product')).rejects.toMatchObject({
285
+ status: 409,
286
+ payload: { kind: 'idp-mode' },
287
+ });
288
+ // Nothing moved.
289
+ expect(await rolesYaml()).toBe(ROLES);
290
+ expect(await groupsYaml()).not.toContain('Product');
291
+ expect(workflow.commits).toHaveLength(0);
292
+ });
293
+
294
+ it('a failed conversion with NO pre-existing groups.yaml leaves no groups.yaml behind', async () => {
295
+ await fs.rm(path.join(repo, 'groups.yaml'));
296
+ const workspace = stubWorkspace(root);
297
+ const workflow = stubWorkflow();
298
+ (workflow.svc as unknown as { commitChanges: unknown }).commitChanges = async () => {
299
+ throw new Error('commit exploded');
300
+ };
301
+ const svc = new RolesAdminService(
302
+ workspace,
303
+ workflow.svc,
304
+ new AccessControlService(workspace as never, KB),
305
+ KB,
306
+ () => DEFAULT_BRANCH,
307
+ );
308
+ await expect(svc.convertRoleToGroup(ADMIN, 'product')).rejects.toThrow('commit exploded');
309
+ // groups.yaml was CREATED by this conversion → rollback deletes it
310
+ // instead of publishing an empty file as a brand-new artifact.
311
+ await expect(groupsYaml()).rejects.toMatchObject({ code: 'ENOENT' });
312
+ // …and roles.yaml got its original bytes back: the role survives.
313
+ expect(await rolesYaml()).toBe(ROLES);
314
+ });
315
+
316
+ it('conversion refuses a groups.yaml name collision', async () => {
317
+ await write(repo, 'groups.yaml', 'groups:\n Product:\n - other@x.io\n');
318
+ await expect(service.convertRoleToGroup(ADMIN, 'product')).rejects.toMatchObject({
319
+ status: 422,
320
+ message: expect.stringContaining('already exists'),
321
+ });
322
+ // Nothing moved: the role survives an aborted conversion.
323
+ expect(await rolesYaml()).toContain('Product:');
324
+ });
325
+ });
@@ -3,12 +3,12 @@ import { describe, it, expect } from 'vitest';
3
3
  import {
4
4
  parseRolesModel,
5
5
  emitRolesModel,
6
- createRole,
7
6
  deleteRole,
8
7
  addMember,
9
8
  removeMember,
10
- renameRoleDisplay,
11
- assertSafeRoleDisplayName,
9
+ removeGroupRefsEverywhere,
10
+ removeRoleGroupRef,
11
+ renameGroupRefs,
12
12
  RolesEditError,
13
13
  } from '../roles-edit.js';
14
14
  import { parseYamlSubset } from '../access-control.service.js';
@@ -64,19 +64,6 @@ describe('roles-edit: parse + emit', () => {
64
64
  });
65
65
  });
66
66
 
67
- describe('roles-edit: name guard', () => {
68
- it('rejects structurally unsafe names', () => {
69
- for (const bad of ['Sales: West', '#temp', 'a<b', 'a>b', '-leading', '']) {
70
- expect(() => assertSafeRoleDisplayName(bad)).toThrow(RolesEditError);
71
- }
72
- });
73
-
74
- it('allows ordinary multi-word names', () => {
75
- expect(() => assertSafeRoleDisplayName('Product Team')).not.toThrow();
76
- expect(() => assertSafeRoleDisplayName('GTM Team')).not.toThrow();
77
- });
78
- });
79
-
80
67
  describe('roles-edit: mutations', () => {
81
68
  it('addMember is idempotent (no-op when already a member)', () => {
82
69
  const r = addMember(BASE, 'admin', 'JUAN@bevel.software');
@@ -100,12 +87,11 @@ describe('roles-edit: mutations', () => {
100
87
  expect(removeMember(BASE, 'admin', 'ghost@x.eu').changed).toBe(false);
101
88
  });
102
89
 
103
- it('createRole appends `Name: []`, rejects reserved + duplicate', () => {
104
- const r = createRole(BASE, 'Marketing');
105
- expect(r.text).toContain('Marketing: []');
106
- expect(() => createRole(BASE, 'everyone')).toThrow(RolesEditError);
107
- expect(() => createRole(BASE, 'deny')).toThrow(RolesEditError);
108
- expect(() => createRole(BASE, 'admin')).toThrow(RolesEditError); // duplicate canonical
90
+ it("addMember rejects a 'group:'-prefixed value with an actionable message", () => {
91
+ // 'group:lee@x.io' passes the email regex — without the guard it would
92
+ // land a dead group ref the resolver reads as an unknown group.
93
+ expect(() => addMember(BASE, 'admin', 'group:lee@x.io')).toThrow(/group assignment/);
94
+ expect(() => addMember(BASE, 'admin', 'group:gtm team')).toThrow(RolesEditError);
109
95
  });
110
96
 
111
97
  it('deleteRole removes the role; 404 when absent', () => {
@@ -114,14 +100,104 @@ describe('roles-edit: mutations', () => {
114
100
  expect(() => deleteRole(BASE, 'ghost')).toThrow(/not found/);
115
101
  });
116
102
 
117
- it('renameRoleDisplay: casing-only keeps members, changes label', () => {
118
- const r = renameRoleDisplay(BASE, 'product team', 'PRODUCT TEAM');
119
- const role = parseRolesModel(r.text).find((x) => x.members.includes('felix@example.com'))!;
120
- expect(role.displayName).toBe('PRODUCT TEAM');
103
+ it('removeGroupRefsEverywhere strips every ref to the group across roles', () => {
104
+ const text = [
105
+ 'roles:',
106
+ ' Admin:',
107
+ ' - razvan@bevel.software',
108
+ ' Product Team:',
109
+ ' - felix@example.com',
110
+ ' - group:gtm team',
111
+ ' Ops:',
112
+ ' - group:GTM Team', // un-normalized hand edit — canonical matching must catch it
113
+ '',
114
+ ].join('\n');
115
+ const r = removeGroupRefsEverywhere(text, 'gtm team');
116
+ expect(r.changed).toBe(true);
117
+ expect(r.text).not.toContain('gtm');
118
+ const model = parseRolesModel(r.text);
119
+ expect(model.find((x) => x.displayName === 'Product Team')?.members).toEqual(['felix@example.com']);
120
+ expect(model.find((x) => x.displayName === 'Ops')?.members).toEqual([]);
121
+ expect(removeGroupRefsEverywhere(BASE, 'gtm team').changed).toBe(false);
122
+ });
123
+
124
+ it('renameGroupRefs: rewrites group:<ref> members across roles, deduping an existing target', () => {
125
+ const text = [
126
+ 'roles:',
127
+ ' Admin:',
128
+ ' - razvan@bevel.software',
129
+ ' Product Team:',
130
+ ' - felix@example.com',
131
+ ' - group:gtm team',
132
+ ' Ops:',
133
+ ' - group:gtm team',
134
+ ' - group:go to market', // target already present → dedupe, not duplicate
135
+ '',
136
+ ].join('\n');
137
+ const r = renameGroupRefs(text, 'gtm team', 'go to market');
138
+ expect(r.changed).toBe(true);
139
+ expect(r.text).not.toContain('group:gtm team');
140
+ const model = parseRolesModel(r.text);
141
+ expect(model.find((x) => x.displayName === 'Product Team')?.members).toContain('group:go to market');
142
+ expect(
143
+ model.find((x) => x.displayName === 'Ops')?.members.filter((m) => m === 'group:go to market'),
144
+ ).toHaveLength(1);
145
+ // Untouched role stays untouched.
146
+ expect(model.find((x) => x.displayName === 'Admin')?.members).toEqual(['razvan@bevel.software']);
147
+ });
148
+
149
+ it('renameGroupRefs: no-op when nothing references the old name', () => {
150
+ const r = renameGroupRefs(BASE, 'gtm team', 'go to market');
151
+ expect(r.changed).toBe(false);
121
152
  });
122
153
 
123
- it('renameRoleDisplay: rejects collision with another existing role', () => {
124
- expect(() => renameRoleDisplay(BASE, 'product team', 'Admin')).toThrow(/already exists/);
154
+ it('renameGroupRefs rewrites EVERY matching ref in a role, collapsing to one', () => {
155
+ // Two differently-formatted refs to the same group: both must follow the
156
+ // rename (a straggler would point at the renamed-away name forever), and
157
+ // only one ref to the new name survives.
158
+ const text = 'roles:\n Ops:\n - group:gtm team\n - group:gtm team\n';
159
+ const r = renameGroupRefs(text, 'gtm team', 'go to market');
160
+ expect(r.changed).toBe(true);
161
+ const ops = parseRolesModel(r.text).find((x) => x.displayName === 'Ops')!;
162
+ expect(ops.members.filter((m) => m === 'group:go to market')).toHaveLength(1);
163
+ expect(ops.members.some((m) => m.includes('gtm'))).toBe(false);
164
+ });
165
+
166
+ it('group-ref matching is canonical: an un-normalized hand-written ref still matches', () => {
167
+ // The resolver canonicalizes the ref's suffix (collapsing runs of
168
+ // spaces), so a hand-written `group:gtm team` grants — the editors must
169
+ // find it too, or unassign/rename no-op while the roster still shows the
170
+ // group. (Parse lowercases members but keeps the double space.)
171
+ const text = 'roles:\n Ops:\n - group:GTM Team\n';
172
+ const removed = removeMember(text, 'ops', 'nobody@x.io'); // sanity: parse keeps the un-collapsed ref
173
+ expect(removed.text).toContain('group:gtm team');
174
+ const renamed = renameGroupRefs(text, 'gtm team', 'go to market');
175
+ expect(renamed.changed).toBe(true);
176
+ expect(renamed.text).toContain('group:go to market');
177
+ expect(renamed.text).not.toContain('gtm team');
178
+ });
179
+
180
+ it('removeRoleGroupRef strips EVERY duplicate ref to the group, not just the first', () => {
181
+ // A hand-edited file may carry several differently-formatted refs to the
182
+ // same group. Removing only the first leaves stragglers the resolver
183
+ // still honors — the role silently keeps the group after an
184
+ // apparently-successful unassign.
185
+ const text = [
186
+ 'roles:',
187
+ ' Ops:',
188
+ ' - lead@x.io',
189
+ ' - group:gtm team',
190
+ ' - group:GTM Team', // un-normalized duplicate — must go too
191
+ '',
192
+ ].join('\n');
193
+ const r = removeRoleGroupRef(text, 'ops', 'gtm team');
194
+ expect(r.changed).toBe(true);
195
+ const ops = parseRolesModel(r.text).find((x) => x.displayName === 'Ops')!;
196
+ expect(ops.members).toEqual(['lead@x.io']);
197
+ // Idempotent afterwards.
198
+ expect(removeRoleGroupRef(r.text, 'ops', 'gtm team').changed).toBe(false);
199
+ // Other roles/refs untouched.
200
+ expect(() => removeRoleGroupRef(text, 'ghost', 'gtm team')).toThrow(/not found/);
125
201
  });
126
202
 
127
203
  it('only the changed role moves in the diff (other roles untouched)', () => {
@@ -17,10 +17,11 @@ import { createAccessRoutes } from '../access.routes.js';
17
17
 
18
18
  /**
19
19
  * HTTP-level contract tests for the /api/access/roles routes: the admin gate
20
- * (non-admin → 403), the GET roster shape, and that service-layer invariant
21
- * errors (reserved name → 422, self-admin-removal → 409 { kind }) surface with
22
- * the right status. The CRUD logic itself is covered by roles-admin.service.test;
23
- * here we pin the route wiring + error mapping.
20
+ * (non-admin → 403), the GET roster shape, the REMOVED role-CRUD endpoints
21
+ * (404 — roles are app-defined capabilities), and that service-layer
22
+ * invariant errors (self-admin-removal → 409 { kind }) surface with the
23
+ * right status. The membership logic itself is covered by
24
+ * roles-admin.service.test; here we pin the route wiring + error mapping.
24
25
  */
25
26
 
26
27
  const KB = 'knowledge-base';
@@ -64,7 +65,6 @@ async function makeHarness(opts: { isAdmin?: boolean } = {}): Promise<{ server:
64
65
  eligibleWriters: vi.fn(async () => ({ roles: ['Admin'], users: [] })),
65
66
  invalidate: vi.fn(),
66
67
  validateRolesYaml: (t: string) => realAccess.validateRolesYaml(t),
67
- referencesToRole: vi.fn(async () => []),
68
68
  } as unknown as IAccessControl;
69
69
 
70
70
  const resolve = (wsRel: string) => path.join(workspaceDir, wsRel);
@@ -103,17 +103,23 @@ async function makeHarness(opts: { isAdmin?: boolean } = {}): Promise<{ server:
103
103
 
104
104
  // Lock holder tracked so the service's getLock pre-check passes; the single-
105
105
  // file write itself lands on disk via LockingFilesystem → LocalFilesystem.
106
- let holder: { id: string; name: string } | null = null;
106
+ // Strict per-path locks — a live row is contended even for the same user,
107
+ // exactly like FileLockService (nesting bugs must fail here too).
108
+ const locks = new Map<string, { id: string; name: string }>();
107
109
  const lockRow = (h: { id: string; name: string }) => ({ holderUserId: h.id, holderName: h.name });
108
110
  const workflowService = {
109
- getLock: vi.fn(async () => (holder ? lockRow(holder) : null)),
110
- acquireLock: vi.fn(async (_w: string, _b: string, _p: string, user: { id: string; name: string }) =>
111
- holder && holder.id !== user.id
112
- ? { acquired: false, lock: lockRow(holder) }
113
- : ((holder = user), { acquired: true, lock: lockRow(user) }),
114
- ),
115
- releaseLock: vi.fn(async () => void (holder = null)),
116
- releaseLockNoCommit: vi.fn(async () => void (holder = null)),
111
+ getLock: vi.fn(async (_w: string, _b: string, p: string) => {
112
+ const h = locks.get(p);
113
+ return h ? lockRow(h) : null;
114
+ }),
115
+ acquireLock: vi.fn(async (_w: string, _b: string, p: string, user: { id: string; name: string }) => {
116
+ const h = locks.get(p);
117
+ if (h) return { acquired: false, lock: lockRow(h) };
118
+ locks.set(p, user);
119
+ return { acquired: true, lock: lockRow(user) };
120
+ }),
121
+ releaseLock: vi.fn(async (_w: string, _b: string, p: string) => void locks.delete(p)),
122
+ releaseLockNoCommit: vi.fn(async (_w: string, _b: string, p: string) => void locks.delete(p)),
117
123
  // Write-free: the lock-aware filesystem writes to disk; this just commits the
118
124
  // dirty tree. Not exercised by these route tests (no rename), but kept honest.
119
125
  commitChanges: vi.fn(async () => ({})),
@@ -166,35 +172,58 @@ describe('/api/access/roles routes', () => {
166
172
  expect(res.status).toBe(403);
167
173
  });
168
174
 
169
- it('a non-admin is refused on every mutating route (403)', async () => {
175
+ it('a non-admin is refused on the surviving mutating routes (403)', async () => {
170
176
  const h = await makeHarness({ isAdmin: false });
171
177
  server = h.server;
172
- const post = await fetch(`${h.baseUrl}/api/access/roles`, {
178
+ const addMember = await fetch(`${h.baseUrl}/api/access/roles/sales/members`, {
173
179
  method: 'POST',
174
180
  headers: { 'Content-Type': 'application/json' },
175
- body: JSON.stringify({ displayName: 'Marketing' }),
181
+ body: JSON.stringify({ email: 'x@example.com' }),
176
182
  });
177
- expect(post.status).toBe(403);
178
- const del = await fetch(`${h.baseUrl}/api/access/roles/sales`, { method: 'DELETE' });
179
- expect(del.status).toBe(403);
180
- });
181
-
182
- it('create with a reserved name → 422', async () => {
183
- const h = await makeHarness();
184
- server = h.server;
185
- const res = await fetch(`${h.baseUrl}/api/access/roles`, {
183
+ expect(addMember.status).toBe(403);
184
+ const assignGroup = await fetch(`${h.baseUrl}/api/access/roles/sales/groups`, {
186
185
  method: 'POST',
187
186
  headers: { 'Content-Type': 'application/json' },
188
- body: JSON.stringify({ displayName: 'everyone' }),
187
+ body: JSON.stringify({ group: 'GTM Team' }),
188
+ });
189
+ expect(assignGroup.status).toBe(403);
190
+ const removeMember = await fetch(
191
+ `${h.baseUrl}/api/access/roles/sales/members/${encodeURIComponent('felix@example.com')}`,
192
+ { method: 'DELETE' },
193
+ );
194
+ expect(removeMember.status).toBe(403);
195
+ const unassignGroup = await fetch(
196
+ `${h.baseUrl}/api/access/roles/sales/groups/${encodeURIComponent('GTM Team')}`,
197
+ { method: 'DELETE' },
198
+ );
199
+ expect(unassignGroup.status).toBe(403);
200
+ const convert = await fetch(`${h.baseUrl}/api/access/roles/sales/convert-to-group`, {
201
+ method: 'POST',
189
202
  });
190
- expect(res.status).toBe(422);
203
+ expect(convert.status).toBe(403);
191
204
  });
192
205
 
193
- it('delete Admin → 422', async () => {
206
+ it('role CRUD endpoints are GONE: create/rename/delete answer 404 even for admins', async () => {
194
207
  const h = await makeHarness();
195
208
  server = h.server;
196
- const res = await fetch(`${h.baseUrl}/api/access/roles/admin`, { method: 'DELETE' });
197
- expect(res.status).toBe(422);
209
+ const created = await fetch(`${h.baseUrl}/api/access/roles`, {
210
+ method: 'POST',
211
+ headers: { 'Content-Type': 'application/json' },
212
+ body: JSON.stringify({ displayName: 'Marketing' }),
213
+ });
214
+ expect(created.status).toBe(404);
215
+ const renamed = await fetch(`${h.baseUrl}/api/access/roles/sales`, {
216
+ method: 'PATCH',
217
+ headers: { 'Content-Type': 'application/json' },
218
+ body: JSON.stringify({ newDisplayName: 'Sales Team' }),
219
+ });
220
+ expect(renamed.status).toBe(404);
221
+ const deleted = await fetch(`${h.baseUrl}/api/access/roles/sales`, { method: 'DELETE' });
222
+ expect(deleted.status).toBe(404);
223
+ // The roster is untouched by the dead endpoints.
224
+ const roster = await fetch(`${h.baseUrl}/api/access/roles`);
225
+ const body = (await roster.json()) as { roles: { canonical: string }[] };
226
+ expect(body.roles.map((r) => r.canonical)).toEqual(['admin', 'sales']);
198
227
  });
199
228
 
200
229
  it('removing own last Admin membership → 409 { kind: self-admin-removal }', async () => {
@@ -213,22 +242,16 @@ describe('/api/access/roles routes', () => {
213
242
  expect(body.kind).toBe('self-admin-removal');
214
243
  });
215
244
 
216
- it('create a role then add a member → 200 fresh roster', async () => {
245
+ it('add a member to an existing (legacy) role → 200 fresh roster', async () => {
217
246
  const h = await makeHarness();
218
247
  server = h.server;
219
- const created = await fetch(`${h.baseUrl}/api/access/roles`, {
220
- method: 'POST',
221
- headers: { 'Content-Type': 'application/json' },
222
- body: JSON.stringify({ displayName: 'Marketing' }),
223
- });
224
- expect(created.status).toBe(200);
225
- const add = await fetch(`${h.baseUrl}/api/access/roles/marketing/members`, {
248
+ const add = await fetch(`${h.baseUrl}/api/access/roles/sales/members`, {
226
249
  method: 'POST',
227
250
  headers: { 'Content-Type': 'application/json' },
228
251
  body: JSON.stringify({ email: 'mkt@example.com' }),
229
252
  });
230
253
  expect(add.status).toBe(200);
231
254
  const body = (await add.json()) as { roles: { canonical: string; members: string[] }[] };
232
- expect(body.roles.find((r) => r.canonical === 'marketing')!.members).toContain('mkt@example.com');
255
+ expect(body.roles.find((r) => r.canonical === 'sales')!.members).toContain('mkt@example.com');
233
256
  });
234
257
  });