@bevel-software/platform-core-backend 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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,288 @@
1
+ import { describe, it, expect } 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 { FileTreeEntry, IWorkspaceService } from '@bevel-software/platform-shared';
7
+ import {
8
+ KbReferenceScanner,
9
+ findRoleRefsInText,
10
+ rewriteRoleTokensInText,
11
+ } from '../reference-scan.js';
12
+
13
+ /**
14
+ * Unit tests for the shared reference scanner's parse rules (comments,
15
+ * nested-mapping verb keys) and its candidate/freshness machinery (`.tool`
16
+ * frontmatter coverage, event-bus cache invalidation).
17
+ */
18
+
19
+ describe('findRoleRefsInText / rewriteRoleTokensInText — comment handling', () => {
20
+ it('a trailing comment is stripped for MATCHING (the resolver’s rule)', () => {
21
+ const md = '---\nread:\n - GTM Team # sales folks\n---\n';
22
+ // The token is `gtm team`, never `gtm team # sales folks`.
23
+ expect(findRoleRefsInText(md)).toEqual([{ role: 'gtm team', verb: 'read' }]);
24
+ });
25
+
26
+ it('a trailing comment is PRESERVED by the rewrite', () => {
27
+ const md = '---\nread:\n - GTM Team # sales folks\nwrite:\n - deny GTM Team # blocked\n---\n';
28
+ const out = rewriteRoleTokensInText(md, 'gtm team', 'Go To Market');
29
+ expect(out).toContain(' - Go To Market # sales folks');
30
+ expect(out).toContain(' - deny Go To Market # blocked');
31
+ });
32
+
33
+ it('an inline scalar with a trailing comment matches and rewrites (comment kept)', () => {
34
+ const md = '---\nowner: Sales # the owning team\n---\n';
35
+ expect(findRoleRefsInText(md)).toEqual([{ role: 'sales', verb: 'owner' }]);
36
+ const out = rewriteRoleTokensInText(md, 'sales', 'Revenue');
37
+ expect(out).toContain('owner: Revenue # the owning team');
38
+ });
39
+
40
+ it('a full-line comment inside a block neither ends the block nor is an entry', () => {
41
+ const md = '---\nread:\n - Sales\n # a note between items\n - Support\n---\n';
42
+ expect(findRoleRefsInText(md)).toEqual([
43
+ { role: 'sales', verb: 'read' },
44
+ { role: 'support', verb: 'read' },
45
+ ]);
46
+ });
47
+
48
+ it('a verb key whose value is only a comment is not a reference', () => {
49
+ const md = '---\nread: # to be filled in\n - Sales\n---\n';
50
+ expect(findRoleRefsInText(md)).toEqual([{ role: 'sales', verb: 'read' }]);
51
+ });
52
+ });
53
+
54
+ describe('findRoleRefsInText / rewriteRoleTokensInText — nested-mapping verb keys are not rules', () => {
55
+ // The resolver only parses verbs at the rule mapping's ROOT (indent 0) —
56
+ // an indented `read:` inside some unrelated config mapping must never be
57
+ // scanned as a rule or rewritten during a rename.
58
+ const md = [
59
+ '---',
60
+ 'read:',
61
+ ' - Sales',
62
+ 'someConfig:',
63
+ ' nested:',
64
+ ' read:',
65
+ ' - Sales',
66
+ '---',
67
+ '',
68
+ ].join('\n');
69
+
70
+ it('scan skips the nested verb-looking key’s items', () => {
71
+ expect(findRoleRefsInText(md)).toEqual([{ role: 'sales', verb: 'read' }]);
72
+ });
73
+
74
+ it('rename rewrites only the root-level rule, never the nested config', () => {
75
+ const out = rewriteRoleTokensInText(md, 'sales', 'Revenue');
76
+ const lines = out.split('\n');
77
+ expect(lines).toContain(' - Revenue'); // the real rule, rewritten
78
+ expect(lines).toContain(' - Sales'); // the nested config, untouched
79
+ });
80
+ });
81
+
82
+ describe('findRoleRefsInText / rewriteRoleTokensInText — indented ROOT mappings are live rules', () => {
83
+ // The resolver's YAML-subset parser accepts a root mapping at ANY indent
84
+ // (its root frame sits at indent -1), so ` read:` at column 2 with nothing
85
+ // enclosing it is a rule the resolver ENFORCES. A column-zero-only scanner
86
+ // would skip it — and a rename would strand the live grant.
87
+ const md = ['---', ' read:', ' - Sales', '---', ''].join('\n');
88
+
89
+ it('scan sees a uniformly-indented root verb key', () => {
90
+ expect(findRoleRefsInText(md)).toEqual([{ role: 'sales', verb: 'read' }]);
91
+ });
92
+
93
+ it('rename rewrites the indented root rule, preserving indentation', () => {
94
+ const out = rewriteRoleTokensInText(md, 'sales', 'Revenue');
95
+ expect(out.split('\n')).toContain(' - Revenue');
96
+ expect(out).not.toContain('Sales');
97
+ });
98
+
99
+ it('an indented root inline scalar matches and rewrites', () => {
100
+ const scalar = '---\n owner: Sales # team\n---\n';
101
+ expect(findRoleRefsInText(scalar)).toEqual([{ role: 'sales', verb: 'owner' }]);
102
+ expect(rewriteRoleTokensInText(scalar, 'sales', 'Revenue')).toContain(
103
+ ' owner: Revenue # team',
104
+ );
105
+ });
106
+
107
+ it('a verb key nested under an INDENTED root mapping is still not a rule', () => {
108
+ // `config:` owns the root at indent 2; `read:` at indent 4 is ITS nested
109
+ // key, not a root verb — the resolver never parses it as a rule.
110
+ const nested = ['---', ' config:', ' read:', ' - Sales', '---', ''].join('\n');
111
+ expect(findRoleRefsInText(nested)).toEqual([]);
112
+ expect(rewriteRoleTokensInText(nested, 'sales', 'Revenue')).toBe(nested);
113
+ });
114
+
115
+ it('a structurally-broken region (the resolver rejects it) yields no refs and no rewrites', () => {
116
+ // `- Sales` at the SAME indent as its key is a parse error to the subset
117
+ // parser ("list item with no enclosing list") — the resolver enforces
118
+ // nothing from this file, so the scan must not report a live grant.
119
+ const broken = '---\nread:\n- Sales\n---\n';
120
+ expect(findRoleRefsInText(broken)).toEqual([]);
121
+ expect(rewriteRoleTokensInText(broken, 'sales', 'Revenue')).toBe(broken);
122
+ });
123
+ });
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // KbReferenceScanner — candidates + freshness
127
+ // ---------------------------------------------------------------------------
128
+
129
+ const KB = 'knowledge-base';
130
+
131
+ function stubWorkspace(workspaceDir: string): IWorkspaceService {
132
+ const buildTree = async (absDir: string): Promise<FileTreeEntry> => {
133
+ const rel = path.relative(workspaceDir, absDir).replace(/\\/g, '/');
134
+ const children: FileTreeEntry[] = [];
135
+ for (const e of await fs.readdir(absDir, { withFileTypes: true })) {
136
+ if (e.name === '.git') continue;
137
+ const childAbs = path.join(absDir, e.name);
138
+ if (e.isDirectory()) children.push(await buildTree(childAbs));
139
+ else if (e.isFile()) {
140
+ children.push({
141
+ name: e.name,
142
+ relativePath: path.relative(workspaceDir, childAbs).replace(/\\/g, '/'),
143
+ type: 'file',
144
+ });
145
+ }
146
+ }
147
+ return { name: path.basename(absDir), relativePath: rel || '.', type: 'directory', children };
148
+ };
149
+ return {
150
+ getWorkspacePath: async () => workspaceDir,
151
+ listFiles: async () => buildTree(workspaceDir),
152
+ } as unknown as IWorkspaceService;
153
+ }
154
+
155
+ async function write(repo: string, rel: string, contents: string): Promise<void> {
156
+ const abs = path.join(repo, rel);
157
+ await fs.mkdir(path.dirname(abs), { recursive: true });
158
+ await fs.writeFile(abs, contents);
159
+ }
160
+
161
+ describe('KbReferenceScanner — .tool frontmatter is scanned and rewritten like .md', () => {
162
+ it('finds a .tool frontmatter grant and a rename rewrites it (body untouched)', async () => {
163
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-refscan-'));
164
+ try {
165
+ const repo = path.join(root, KB);
166
+ const toolBody = 'name: crm\ntype: mcp\n# not frontmatter below\n';
167
+ await write(repo, 'Plugins/GTM/crm.tool', `---\nread:\n - GTM Team\n---\n${toolBody}`);
168
+ await write(repo, 'Plugins/GTM/notes.md', '---\nowner: GTM Team\n---\n# notes\n');
169
+ const scanner = new KbReferenceScanner(stubWorkspace(root), KB);
170
+
171
+ const byToken = await scanner.scan('ws-1');
172
+ const hits = (byToken.get('gtm team') ?? []).map((h) => h.path).sort();
173
+ expect(hits).toEqual(['Plugins/GTM/crm.tool', 'Plugins/GTM/notes.md']);
174
+
175
+ const writes = await scanner.rewriteReferences('ws-1', 'gtm team', 'Go To Market', (m) => new Error(m));
176
+ const toolWrite = writes.find((w) => w.repoRelativePath === 'Plugins/GTM/crm.tool')!;
177
+ expect(toolWrite.content).toContain(' - Go To Market');
178
+ // The .tool body below the frontmatter is byte-for-byte intact.
179
+ expect(toolWrite.content).toContain(toolBody);
180
+ expect(writes.some((w) => w.repoRelativePath === 'Plugins/GTM/notes.md')).toBe(true);
181
+ } finally {
182
+ await fs.rm(root, { recursive: true, force: true });
183
+ }
184
+ });
185
+ });
186
+
187
+ describe('KbReferenceScanner — event-bus cache invalidation', () => {
188
+ it('drops the cached scan when a scanned-extension file changes in the workspace', async () => {
189
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-refscan-bus-'));
190
+ try {
191
+ const repo = path.join(root, KB);
192
+ await write(repo, 'team/access.md', '---\nread:\n - Sales\n---\n');
193
+ const listeners: ((e: { kind: string; workspaceId?: string; path?: string }) => void)[] = [];
194
+ const bus = { onEmit: (l: (typeof listeners)[number]) => (listeners.push(l), () => undefined) };
195
+ const scanner = new KbReferenceScanner(stubWorkspace(root), KB, bus);
196
+
197
+ expect((await scanner.scan('ws-1')).get('sales')).toHaveLength(1);
198
+
199
+ // A grant lands through the share dialog (outside the admin services):
200
+ // the write itself, then its file-changed event.
201
+ await write(repo, 'team/access.md', '---\nread:\n - Sales\n - Support\n---\n');
202
+ // Without invalidation the cached scan (30s TTL) would still miss Support.
203
+ expect((await scanner.scan('ws-1')).get('support')).toBeUndefined();
204
+
205
+ for (const l of listeners) {
206
+ l({ kind: 'file-changed', workspaceId: 'ws-1', path: `${KB}/team/access.md` });
207
+ }
208
+ expect((await scanner.scan('ws-1')).get('support')).toHaveLength(1);
209
+ } finally {
210
+ await fs.rm(root, { recursive: true, force: true });
211
+ }
212
+ });
213
+
214
+ it('an invalidation landing MID-SCAN keeps the stale snapshot out of the cache', async () => {
215
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-refscan-race-'));
216
+ try {
217
+ const repo = path.join(root, KB);
218
+ await write(repo, 'team/access.md', '---\nread:\n - Sales\n---\n');
219
+ // The workspace stub invalidates DURING the first scan's tree listing —
220
+ // modelling a write that lands after the scan started reading.
221
+ const base = stubWorkspace(root);
222
+ let armed = true;
223
+ const ws = {
224
+ ...base,
225
+ listFiles: async (id: string) => {
226
+ const tree = await base.listFiles(id);
227
+ if (armed) {
228
+ armed = false;
229
+ scanner.invalidate('ws-1');
230
+ }
231
+ return tree;
232
+ },
233
+ } as unknown as IWorkspaceService;
234
+ const scanner = new KbReferenceScanner(ws, KB);
235
+
236
+ // The caller still gets the snapshot it asked for...
237
+ expect((await scanner.scan('ws-1')).get('sales')).toHaveLength(1);
238
+ // ...but the snapshot must not have STUCK in the cache: the write the
239
+ // invalidation announced is visible on the very next scan, well inside
240
+ // the TTL and with no further events.
241
+ await write(repo, 'team/access.md', '---\nread:\n - Sales\n - Support\n---\n');
242
+ expect((await scanner.scan('ws-1')).get('support')).toHaveLength(1);
243
+ } finally {
244
+ await fs.rm(root, { recursive: true, force: true });
245
+ }
246
+ });
247
+
248
+ it('ignores a scanned-extension change OUTSIDE the KB directory', async () => {
249
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-refscan-bus3-'));
250
+ try {
251
+ const repo = path.join(root, KB);
252
+ await write(repo, 'team/access.md', '---\nread:\n - Sales\n---\n');
253
+ const listeners: ((e: { kind: string; workspaceId?: string; path?: string }) => void)[] = [];
254
+ const bus = { onEmit: (l: (typeof listeners)[number]) => (listeners.push(l), () => undefined) };
255
+ const scanner = new KbReferenceScanner(stubWorkspace(root), KB, bus);
256
+
257
+ const first = await scanner.scan('ws-1');
258
+ for (const l of listeners) {
259
+ // Right extension, wrong tree: not scan material.
260
+ l({ kind: 'file-changed', workspaceId: 'ws-1', path: 'scratch/notes.md' });
261
+ }
262
+ expect(await scanner.scan('ws-1')).toBe(first);
263
+ } finally {
264
+ await fs.rm(root, { recursive: true, force: true });
265
+ }
266
+ });
267
+
268
+ it('ignores events for non-scanned extensions and other kinds', async () => {
269
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-refscan-bus2-'));
270
+ try {
271
+ const repo = path.join(root, KB);
272
+ await write(repo, 'team/access.md', '---\nread:\n - Sales\n---\n');
273
+ const listeners: ((e: { kind: string; workspaceId?: string; path?: string }) => void)[] = [];
274
+ const bus = { onEmit: (l: (typeof listeners)[number]) => (listeners.push(l), () => undefined) };
275
+ const scanner = new KbReferenceScanner(stubWorkspace(root), KB, bus);
276
+
277
+ const first = await scanner.scan('ws-1');
278
+ for (const l of listeners) {
279
+ l({ kind: 'file-changed', workspaceId: 'ws-1', path: `${KB}/roles.yaml` });
280
+ l({ kind: 'fs-tree-changed', workspaceId: 'ws-1' });
281
+ }
282
+ // Cache retained — same map instance served.
283
+ expect(await scanner.scan('ws-1')).toBe(first);
284
+ } finally {
285
+ await fs.rm(root, { recursive: true, force: true });
286
+ }
287
+ });
288
+ });
@@ -9,7 +9,9 @@ import type { WorkflowEventBus } from '../../workflow/event-bus.js';
9
9
  import type { FileTreeEntry, WorkflowEventPayload } from '@bevel-software/platform-shared';
10
10
  import { workspaceIdForBranch } from '../../workspace/workspace.service.js';
11
11
  import { AccessControlService } from '../access-control.service.js';
12
- import { RolesAdminService, rewriteRoleTokensInText, findRoleRefsInText } from '../roles-admin.service.js';
12
+ import { RolesAdminService } from '../roles-admin.service.js';
13
+ import { rewriteRoleTokensInText, findRoleRefsInText } from '../reference-scan.js';
14
+ import { PushNeedsAgentResolutionError } from '../../workflow/workflow.errors.js';
13
15
  import type { AuthUser } from '@bevel-software/platform-shared';
14
16
  import { DEFAULT_BRANCH } from '@bevel-software/platform-shared';
15
17
 
@@ -89,24 +91,30 @@ function stubWorkspace(workspaceDir: string): WorkspaceService {
89
91
  function stubWorkflow() {
90
92
  const commits: { summary: string }[] = [];
91
93
  const resets: string[] = [];
92
- /** Current holder of the roles.yaml lock, or null. */
93
- let holder: AuthUser | null = null;
94
+ /** Per-path lock rows with STRICT acquire — mirrors FileLockService: a
95
+ * live lock is contended EVEN FOR THE SAME USER (only expired rows are
96
+ * reclaimed), so a service nesting a second acquire under its own hold
97
+ * fails here exactly like production. */
98
+ const locks = new Map<string, AuthUser>();
94
99
  const lockRow = (h: AuthUser) => ({ holderUserId: h.id, holderName: h.name });
95
100
  const svc = {
96
- getLock: async () => (holder ? lockRow(holder) : null),
97
- acquireLock: async (...a: unknown[]) => {
98
- const user = a[3] as AuthUser;
99
- return holder && holder.id !== user.id
100
- ? { acquired: false, lock: lockRow(holder) }
101
- : ((holder = user), { acquired: true, lock: lockRow(user) });
101
+ getLock: async (_w: string, _b: string, p: string) => {
102
+ const h = locks.get(p);
103
+ return h ? lockRow(h) : null;
104
+ },
105
+ acquireLock: async (_w: string, _b: string, p: string, user: AuthUser) => {
106
+ const h = locks.get(p);
107
+ if (h) return { acquired: false, lock: lockRow(h) };
108
+ locks.set(p, user);
109
+ return { acquired: true, lock: lockRow(user) };
102
110
  },
103
111
  // LockingFilesystem's success path: the LocalFilesystem write already hit
104
112
  // disk, so releasing just drops the lock (the "commit" is a no-op in-memory).
105
- releaseLock: async () => {
106
- holder = null;
113
+ releaseLock: async (_w: string, _b: string, p: string) => {
114
+ locks.delete(p);
107
115
  },
108
- releaseLockNoCommit: async () => {
109
- holder = null;
116
+ releaseLockNoCommit: async (_w: string, _b: string, p: string) => {
117
+ locks.delete(p);
110
118
  },
111
119
  // The rename's atomic batch flows through LockingFilesystem.writeFiles, which
112
120
  // writes every file to disk (real LocalFilesystem) then calls commitChanges
@@ -237,8 +245,10 @@ describe('RolesAdminService', () => {
237
245
  await write(repo, 'roles.yaml', corrupt);
238
246
  access.invalidate(WS);
239
247
  const adminless = new RolesAdminService(ws, workflow.svc, access, KB, () => DEFAULT_BRANCH, bus.bus);
248
+ // 409, not 500: the route helper hides >=500 bodies behind a generic
249
+ // message, and this break-glass refusal must reach the operator verbatim.
240
250
  await expect(adminless.recover(ADMIN)).rejects.toMatchObject({
241
- status: 500,
251
+ status: 409,
242
252
  payload: { kind: 'no-recovery-admins' },
243
253
  });
244
254
  // Nothing parked, nothing overwritten.
@@ -292,42 +302,29 @@ describe('RolesAdminService', () => {
292
302
  expect(await readRoles()).not.toContain('juan@bevel.software');
293
303
  });
294
304
 
295
- it('createRole rejects reserved/duplicate; deleteRole refuses Admin', async () => {
296
- await expect(svc.createRole(ADMIN, 'everyone')).rejects.toMatchObject({ status: 422 });
297
- await expect(svc.createRole(ADMIN, 'Sales')).rejects.toMatchObject({ status: 422 });
298
- await expect(svc.deleteRole(ADMIN, 'admin')).rejects.toMatchObject({ status: 422 });
299
- await svc.createRole(ADMIN, 'Marketing');
300
- expect(await readRoles()).toContain('Marketing: []');
301
- });
302
-
303
- it('rename: Admin canonical change is refused (400); casing-only allowed', async () => {
304
- await expect(svc.renameRole(ADMIN, 'admin', 'Administrators')).rejects.toMatchObject({ status: 400 });
305
- await svc.renameRole(ADMIN, 'admin', 'ADMIN'); // casing-only OK
306
- const roster = await svc.getRoster();
307
- expect(roster.find((r) => r.canonical === 'admin')!.displayName).toBe('ADMIN');
305
+ it('role CRUD is GONE from the service — roles are app-defined capabilities', () => {
306
+ // The editors were removed outright, not just un-routed: legacy roles stay
307
+ // membership-editable, but nothing can mint/rename/delete a role anymore.
308
+ const anySvc = svc as unknown as Record<string, unknown>;
309
+ expect(anySvc.createRole).toBeUndefined();
310
+ expect(anySvc.renameRole).toBeUndefined();
311
+ expect(anySvc.deleteRole).toBeUndefined();
308
312
  });
309
313
 
310
- it('rename (identity change) rewrites access.md AND node frontmatter in ONE commit', async () => {
311
- await write(repo, 'Sales/access.md', `---\nwrite:\n - Sales\n - Other Team\n---\n# Sales folder\n`);
312
- await write(repo, 'Sales/Deal.md', `---\nowner: Sales\n---\n# A deal\n`);
313
- access.invalidate(WS);
314
- const commitsBefore = workflow.commits.length;
315
-
316
- await svc.renameRole(ADMIN, 'sales', 'Sales Team');
317
-
318
- // roles.yaml + both referencing files, all in a single commit (verified by
319
- // commits.length += 1; the per-file content is asserted on disk below).
320
- expect(workflow.commits.length).toBe(commitsBefore + 1);
321
- expect(await fs.readFile(path.join(repo, 'Sales/access.md'), 'utf-8')).toContain('- Sales Team');
322
- expect(await fs.readFile(path.join(repo, 'Sales/access.md'), 'utf-8')).toContain('- Other Team'); // untouched
323
- expect(await fs.readFile(path.join(repo, 'Sales/Deal.md'), 'utf-8')).toContain('owner: Sales Team');
324
- expect(await readRoles()).toContain('Sales Team:');
314
+ it("addMember refuses a 'group:'-prefixed member with an actionable message", async () => {
315
+ await expect(svc.addMember(ADMIN, 'sales', 'group:lee@x.io')).rejects.toMatchObject({
316
+ status: 422,
317
+ });
318
+ await expect(svc.addMember(ADMIN, 'sales', 'group:gtm team')).rejects.toThrow(/group assignment/);
319
+ expect(await readRoles()).not.toContain('group:');
325
320
  });
326
321
 
327
322
  it('LOCKOUT-IMPOSSIBILITY: the validator rejects garbage in isolation', async () => {
328
323
  expect(access.validateRolesYaml('roles:\n : []\n').ok).toBe(false);
324
+ // Group-only Admin is garbage too — the kept invariant.
325
+ expect(access.validateRolesYaml('roles:\n Admin:\n - group:ops\n').ok).toBe(false);
329
326
  // Happy path still keeps admins resolving (sanity baseline for the gate).
330
- await svc.createRole(ADMIN, 'Marketing');
327
+ await svc.addMember(ADMIN, 'sales', 'marketing@example.com');
331
328
  expect(await access.canWrite(WS, 'razvan@bevel.software', 'roles.yaml')).toBe(true);
332
329
  });
333
330
 
@@ -342,7 +339,7 @@ describe('RolesAdminService', () => {
342
339
  const realValidate = access.validateRolesYaml.bind(access);
343
340
  access.validateRolesYaml = () => ({ ok: false, errors: ['forced parse failure'] });
344
341
  try {
345
- await expect(svc.createRole(ADMIN, 'Marketing')).rejects.toMatchObject({ status: 422 });
342
+ await expect(svc.addMember(ADMIN, 'sales', 'x@example.com')).rejects.toMatchObject({ status: 422 });
346
343
  } finally {
347
344
  access.validateRolesYaml = realValidate;
348
345
  }
@@ -351,31 +348,30 @@ describe('RolesAdminService', () => {
351
348
  expect(await access.canWrite(WS, 'razvan@bevel.software', 'roles.yaml')).toBe(true);
352
349
  });
353
350
 
354
- it('rename fails closed when a candidate cannot be read (no partial rewrite)', async () => {
355
- // A referenced file that the rewrite cannot read must abort the WHOLE atomic
356
- // rename — committing roles.yaml + the readable files while silently skipping
357
- // the unreadable one would leave a half-renamed role pointing at the old name
358
- // (lost access). Assert: the op throws, nothing is committed, roles.yaml is
359
- // unchanged, and the role keeps its old identity.
360
- await write(repo, 'Sales/access.md', `---\nwrite:\n - Sales\n---\n# Sales\n`);
361
- access.invalidate(WS);
362
- const before = await readRoles();
363
- const commitsBefore = workflow.commits.length;
364
- // Point the candidate list at a path that does not exist on disk, so the
365
- // rewrite's readFile throws and the fail-closed guard fires.
366
- (ws as unknown as { listFiles: () => Promise<FileTreeEntry> }).listFiles = async () => ({
367
- name: WS,
368
- relativePath: '.',
369
- type: 'directory',
370
- children: [
371
- { name: 'does-not-exist.md', relativePath: `${KB}/Sales/does-not-exist.md`, type: 'file' },
372
- ],
351
+ it('a post-commit push failure does NOT revert the landed edit (saved; retried later)', async () => {
352
+ // workflowService.commitChanges throwing PushNeedsAgentResolutionError
353
+ // means the COMMIT landed and only the push needs help. The old twins
354
+ // restored pre-edit bytes here — publishing a compensating revert of a
355
+ // landed commit. The shared helper must keep the new bytes and surface
356
+ // the typed 409 as-is.
357
+ const failing = workflow.svc as unknown as {
358
+ commitChanges: (...args: unknown[]) => Promise<unknown>;
359
+ };
360
+ const realCommit = failing.commitChanges.bind(workflow.svc);
361
+ failing.commitChanges = async (...args: unknown[]) => {
362
+ await realCommit(...args); // the commit records — it "landed"
363
+ throw new PushNeedsAgentResolutionError(DEFAULT_BRANCH, 'roles.yaml', 'rejected', 'conflict');
364
+ };
365
+ await expect(svc.addMember(ADMIN, 'sales', 'pushed@example.com')).rejects.toMatchObject({
366
+ status: 409,
367
+ payload: { kind: 'push-needs-resolution' },
373
368
  });
374
-
375
- await expect(svc.renameRole(ADMIN, 'sales', 'Sales Team')).rejects.toMatchObject({ status: 422 });
376
-
377
- expect(workflow.commits.length).toBe(commitsBefore); // nothing committed
378
- expect(await readRoles()).toBe(before); // roles.yaml untouched — role keeps old identity
369
+ // The edit SURVIVES on disk — no compensating restore of pre-edit bytes.
370
+ expect(await readRoles()).toContain('pushed@example.com');
371
+ // And the lock was released normally (a follow-up edit works).
372
+ failing.commitChanges = realCommit;
373
+ await svc.addMember(ADMIN, 'sales', 'after@example.com');
374
+ expect(await readRoles()).toContain('after@example.com');
379
375
  });
380
376
 
381
377
  it('concurrent edit is serialized — a mutation 409s while another admin holds the lock', async () => {
@@ -399,21 +395,6 @@ describe('RolesAdminService', () => {
399
395
  expect(bus.events).toHaveLength(0);
400
396
  });
401
397
 
402
- it('rename emits file-changed for EVERY rewritten file (roles.yaml + refs)', async () => {
403
- await write(repo, 'Sales/access.md', `---\nwrite:\n - Sales\n---\n# Sales\n`);
404
- await write(repo, 'Sales/Deal.md', `---\nowner: Sales\n---\n# A deal\n`);
405
- access.invalidate(WS);
406
-
407
- await svc.renameRole(ADMIN, 'sales', 'Sales Team');
408
-
409
- const paths = bus.events
410
- .filter((e) => e.kind === 'file-changed')
411
- .map((e) => (e as { path: string }).path)
412
- .sort();
413
- expect(paths).toEqual(
414
- [`${KB}/roles.yaml`, `${KB}/Sales/access.md`, `${KB}/Sales/Deal.md`].sort(),
415
- );
416
- });
417
398
  });
418
399
 
419
400
  describe('rewriteRoleTokensInText (reference-aware, frontmatter-only)', () => {
@@ -476,6 +457,57 @@ describe('rewriteRoleTokensInText (reference-aware, frontmatter-only)', () => {
476
457
  });
477
458
  });
478
459
 
460
+ describe('rewriteRoleTokensInText — body-governed access.md (isAccessMd)', () => {
461
+ it('rewrites BODY rules and frontmatter self-rules of a body-governed access.md', () => {
462
+ const text = [
463
+ '---',
464
+ 'write:',
465
+ ' - Sales', // self-rule: who may edit access.md itself
466
+ '---',
467
+ 'read:',
468
+ ' - Sales', // the FOLDER rule the resolver enforces
469
+ ' - deny Sales',
470
+ 'write:',
471
+ ' - Admin',
472
+ ].join('\n');
473
+ const out = rewriteRoleTokensInText(text, 'sales', 'Sales Team', true, true);
474
+ const lines = out.split('\n');
475
+ expect(lines[2]).toBe(' - Sales Team'); // frontmatter self-rule
476
+ expect(lines[5]).toBe(' - Sales Team'); // body rule
477
+ expect(lines[6]).toBe(' - deny Sales Team'); // deny preserved in body
478
+ expect(out).toContain(' - Admin'); // other roles untouched
479
+ });
480
+
481
+ it('a LEGACY access.md (prose body) keeps its body untouched even with isAccessMd', () => {
482
+ const text = [
483
+ '---',
484
+ 'read:',
485
+ ' - Sales',
486
+ '---',
487
+ '# About this folder',
488
+ '- Sales', // prose bullet, not a rule — the body does not parse as rules
489
+ ].join('\n');
490
+ const out = rewriteRoleTokensInText(text, 'sales', 'Sales Team', true, true);
491
+ const lines = out.split('\n');
492
+ expect(lines[2]).toBe(' - Sales Team'); // legacy frontmatter rules rewritten
493
+ expect(lines[5]).toBe('- Sales'); // prose untouched
494
+ });
495
+
496
+ it('a fence-less access.md is untouched — the resolver hard-errors it, so it is no rule source', () => {
497
+ const text = 'read:\n - Sales\n';
498
+ expect(rewriteRoleTokensInText(text, 'sales', 'Sales Team', true, true)).toBe(text);
499
+ });
500
+
501
+ it('findRoleRefsInText sees body rules of a body-governed access.md', () => {
502
+ const text = ['---', 'write:', ' - Admin', '---', 'read:', ' - Sales'].join('\n');
503
+ const refs = findRoleRefsInText(text, true, true);
504
+ expect(refs).toContainEqual({ role: 'sales', verb: 'read' });
505
+ expect(refs).toContainEqual({ role: 'admin', verb: 'write' });
506
+ // Without the access.md flag the body stays invisible (node files, prose).
507
+ expect(findRoleRefsInText(text, true, false)).not.toContainEqual({ role: 'sales', verb: 'read' });
508
+ });
509
+ });
510
+
479
511
  describe('findRoleRefsInText (sound reference scan — shared with the rewrite)', () => {
480
512
  it('reports every genuine role ref with its enclosing verb, ignoring users/prose', () => {
481
513
  const text = [
@@ -501,3 +533,49 @@ describe('findRoleRefsInText (sound reference scan — shared with the rewrite)'
501
533
  expect(findRoleRefsInText('# Doc\nread:\n - Sales\n')).toEqual([]);
502
534
  });
503
535
  });
536
+
537
+ describe('roster referencedBy under group shadowing', () => {
538
+ let root: string;
539
+ let repo: string;
540
+ let svc: RolesAdminService;
541
+
542
+ beforeEach(async () => {
543
+ root = await mkTmpRoot();
544
+ const workspaceDir = path.join(root, WS);
545
+ repo = path.join(workspaceDir, KB);
546
+ await write(repo, 'roles.yaml', ROLES);
547
+ const ws = stubWorkspace(workspaceDir);
548
+ svc = new RolesAdminService(
549
+ ws,
550
+ stubWorkflow().svc,
551
+ new AccessControlService(ws, KB),
552
+ KB,
553
+ () => DEFAULT_BRANCH,
554
+ stubEventBus().bus,
555
+ );
556
+ });
557
+
558
+ afterEach(async () => {
559
+ await fs.rm(root, { recursive: true, force: true });
560
+ });
561
+
562
+ it('a bare-token hit is the GROUP’s when a group shadows the role name; role/<name> hits stay the role’s', async () => {
563
+ // A group named Sales shadows the Sales role: bare tokens resolve to the
564
+ // GROUP, so the roles roster must not claim them as role references.
565
+ await write(repo, 'groups.yaml', 'groups:\n Sales:\n - pat@x.io\n');
566
+ await write(repo, 'team/access.md', '---\nread:\n - Sales\nwrite:\n - role/Sales\n---\n');
567
+
568
+ const sales = (await svc.getRoster()).find((r) => r.canonical === 'sales')!;
569
+ // Only the explicit spelling counts for the ROLE.
570
+ expect(sales.referencedBy).toEqual([{ path: 'team/access.md', verb: 'write' }]);
571
+ });
572
+
573
+ it('unshadowed: bare hits still count for the role (both spellings)', async () => {
574
+ await write(repo, 'team/access.md', '---\nread:\n - Sales\nwrite:\n - role/Sales\n---\n');
575
+ const sales = (await svc.getRoster()).find((r) => r.canonical === 'sales')!;
576
+ expect(sales.referencedBy).toEqual([
577
+ { path: 'team/access.md', verb: 'read' },
578
+ { path: 'team/access.md', verb: 'write' },
579
+ ]);
580
+ });
581
+ });