@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,513 @@
1
+ /**
2
+ * SHARED grant-reference machinery — the single implementation both admin
3
+ * services (roles + groups) drive for "which access rules name this
4
+ * principal" and "rewrite every reference atomically":
5
+ *
6
+ * - `findRoleRefsInText` / `rewriteRoleTokensInText`: the config-region
7
+ * parse that decides what IS a principal reference. One source of truth,
8
+ * so the delete/rename warning and the rename rewrite can never disagree.
9
+ * - `KbReferenceScanner`: candidate collection (every KB file with an
10
+ * access-frontmatter extension — `.md` and `.tool`, the resolver's own
11
+ * set), the advisory reference scan (cached — roster mutations must not
12
+ * rerun a full-KB read sweep, invalidated by file-changed events and a
13
+ * TTL backstop), and the fail-closed reference rewrite (returns each
14
+ * file's ORIGINAL text too, so rollback snapshots reuse the read the
15
+ * rewrite already did instead of a second full-KB pass).
16
+ *
17
+ * Tokens here are the ENTRY-GRAMMAR canonical tokens: a bare name (group
18
+ * first, then role) or an explicit `role/<name>`. Callers translate: a ROLE's
19
+ * references are its bare token plus its `role/` token; a GROUP's are its
20
+ * bare token only.
21
+ */
22
+
23
+ import path from 'node:path';
24
+ import { promises as fs } from 'node:fs';
25
+
26
+ import type { FileTreeEntry, IWorkspaceService } from '@bevel-software/platform-shared';
27
+ import {
28
+ KNOWN_VERBS,
29
+ accessMdDeclaresBodyRules,
30
+ hasAccessFrontmatterExtension,
31
+ isAccessMdPath,
32
+ parseAccessEntry,
33
+ stripComment,
34
+ } from './access-control.service.js';
35
+
36
+ /**
37
+ * Resolve the [start, end) line range that role rewrites may touch — the YAML
38
+ * config region only, NEVER the markdown body (CodeRabbit: a body line like
39
+ * `- Sales` or `owner: Sales` must not be rewritten).
40
+ *
41
+ * - A file with leading `---` frontmatter (folder `access.md`, node `.md`):
42
+ * only the lines BETWEEN the opening and closing `---` are eligible.
43
+ * - A fence-less file (e.g. a bare `roles.yaml`-style access config with no
44
+ * `---`): the whole file is config, so all lines are eligible.
45
+ * - A `.md` file with no frontmatter fence: no config region → empty range,
46
+ * nothing is rewritten.
47
+ */
48
+ function configLineRange(lines: string[], isMarkdown: boolean): { start: number; end: number } {
49
+ if (lines.length > 0 && lines[0].trim() === '---') {
50
+ for (let i = 1; i < lines.length; i++) {
51
+ if (lines[i].trim() === '---') return { start: 1, end: i };
52
+ }
53
+ // Unterminated frontmatter — treat nothing as eligible (don't risk the body).
54
+ return { start: 0, end: 0 };
55
+ }
56
+ // No fence: a markdown file has no config region; a non-markdown access file
57
+ // (no body) is entirely config.
58
+ return isMarkdown ? { start: 0, end: 0 } : { start: 0, end: lines.length };
59
+ }
60
+
61
+ /** The line range AFTER the closing frontmatter fence — empty when there is
62
+ * no fence or it never closes, mirroring `bodyAfterFrontmatter` (a
63
+ * fence-less access.md is a hard parse error to the resolver, so it is not
64
+ * a rule source and must not be rewritten). */
65
+ function bodyLineRange(lines: string[]): { start: number; end: number } {
66
+ if (lines.length > 0 && lines[0].trim() === '---') {
67
+ for (let i = 1; i < lines.length; i++) {
68
+ if (lines[i].trim() === '---') return { start: i + 1, end: lines.length };
69
+ }
70
+ }
71
+ return { start: 0, end: 0 };
72
+ }
73
+
74
+ /**
75
+ * The line ranges that are RULE SOURCES for this file — the ranges the
76
+ * resolver actually parses rules from, so the scan/rewrite can never miss a
77
+ * rule the resolver enforces:
78
+ *
79
+ * - A body-governed `access.md` (see {@link accessMdDeclaresBodyRules}):
80
+ * the BODY carries the folder's rules and the frontmatter carries the
81
+ * file's own rules — both are rule sources.
82
+ * - Everything else: the config region as before (frontmatter for markdown,
83
+ * the whole file for a fence-less config file). A legacy `access.md`'s
84
+ * body and a node file's body are prose and stay untouchable.
85
+ */
86
+ function ruleLineRanges(
87
+ text: string,
88
+ lines: string[],
89
+ isMarkdown: boolean,
90
+ isAccessMd: boolean,
91
+ ): { start: number; end: number }[] {
92
+ if (isAccessMd && accessMdDeclaresBodyRules(text)) {
93
+ return [configLineRange(lines, true), bodyLineRange(lines)];
94
+ }
95
+ return [configLineRange(lines, isMarkdown)];
96
+ }
97
+
98
+ /** The access verb keys the resolver reads (kept in lockstep via KNOWN_VERBS). */
99
+ const VERB_KEYS: ReadonlySet<string> = new Set<string>(KNOWN_VERBS);
100
+
101
+ /**
102
+ * Walk the CONFIG-REGION lines of `text`, invoking `onRoleRef` for every line
103
+ * that PARSES as a genuine role entry — both the block-list form (`- <token>`
104
+ * under a ROOT-mapping `read:`/`write:`/… key) and the inline scalar form
105
+ * (`owner: <token>`). `verb` is the access verb the reference sits under.
106
+ * This is the SINGLE source of truth for "what is a role reference" — both
107
+ * the delete-warning scan and the rename rewrite drive off it, so they cannot
108
+ * disagree. The callback may mutate `lines[i]` (the rewrite does; the scan
109
+ * does not). User entries, other keys, nested mappings' verb-looking keys,
110
+ * comments and substrings never fire it.
111
+ *
112
+ * "Root" follows the resolver's OWN semantics, not column zero: the
113
+ * YAML-subset parser's root frame sits at indent -1 (`parseYamlSubset`), so a
114
+ * uniformly-INDENTED root mapping (` read:` at column 2 with nothing
115
+ * enclosing it) is a live rule mapping the resolver enforces — a column-zero
116
+ * regex would silently skip it, and a rename would strand those grants. This
117
+ * walker mirrors the parser's frame stack: a key whose enclosing frame is the
118
+ * root is a root key; a verb key nested under some other mapping is not a
119
+ * rule. On a STRUCTURAL error the resolver rejects the whole region (an
120
+ * access.md hard-errors; node frontmatter yields no own-entries), so the walk
121
+ * stops — nothing there is an enforced rule.
122
+ *
123
+ * Comments follow the resolver's tokeniser rule (`stripComment`): they are
124
+ * invisible to MATCHING — a trailing `# note` never becomes part of the
125
+ * token, and a full-line comment neither ends a block nor is an entry — but
126
+ * the rewrite preserves them: `suffix` carries the stripped tail (trailing
127
+ * whitespace + comment) verbatim for re-append.
128
+ */
129
+ function walkRoleRefs(
130
+ lines: string[],
131
+ start: number,
132
+ end: number,
133
+ onRoleRef: (ctx: {
134
+ i: number;
135
+ verb: string;
136
+ entry: { role: string; deny: boolean };
137
+ indent: string;
138
+ prefix: string;
139
+ /** Trailing whitespace + `# comment` of the original line, preserved on rewrite. */
140
+ suffix: string;
141
+ }) => void,
142
+ ): void {
143
+ /** A role-entry value → its parsed role entry, else null (user/empty/other). */
144
+ const roleEntry = (rawValue: string): { role: string; deny: boolean } | null => {
145
+ const parsed = parseAccessEntry(rawValue);
146
+ return parsed.ok && parsed.entry.kind === 'role'
147
+ ? { role: parsed.entry.role, deny: parsed.entry.deny }
148
+ : null;
149
+ };
150
+ // Mirror of the subset parser's frame stack. `pending` is a key whose value
151
+ // is still unknown — the next token decides whether it opens a list (an
152
+ // item deeper than the key), a nested mapping (a kv deeper than the key),
153
+ // or nothing (a peer/outdented token pops it, i.e. the key was null).
154
+ // `verb` is set only on a ROOT verb key's frame — its list items are the
155
+ // entries the resolver reads for that verb.
156
+ type Frame = { indent: number; kind: 'pending' | 'list' | 'map'; verb: string | null };
157
+ const stack: Frame[] = [{ indent: -1, kind: 'map', verb: null }];
158
+ for (let i = start; i < end; i++) {
159
+ const raw = lines[i];
160
+ // Match against the COMMENT-STRIPPED line — the resolver's tokeniser view.
161
+ // `suffix` is everything stripping removed (trailing whitespace + comment),
162
+ // kept so a rewrite can re-append it byte-for-byte.
163
+ const line = stripComment(raw).replace(/\s+$/, '');
164
+ const suffix = raw.slice(line.length);
165
+ // A full-line comment (or blank line) is invisible: not an entry, and it
166
+ // does NOT end the current block — the tokeniser skips it entirely.
167
+ if (!line.trim()) continue;
168
+ // Same indent rule as the tokeniser: leading SPACES only.
169
+ const indent = /^( *)/.exec(line)![1].length;
170
+ const content = line.slice(indent);
171
+ while (stack.length > 1 && stack[stack.length - 1].indent >= indent) stack.pop();
172
+ const top = stack[stack.length - 1];
173
+
174
+ if (content === '-' || content.startsWith('- ')) {
175
+ if (top.kind === 'pending') top.kind = 'list';
176
+ // An item with no enclosing list is a structural error — the resolver
177
+ // rejects the whole region, so nothing here is an enforced rule.
178
+ if (top.kind !== 'list') return;
179
+ if (top.verb !== null) {
180
+ const rest = content.slice(2);
181
+ const ws = /^\s*/.exec(rest)![0];
182
+ const entry = roleEntry(rest.trim());
183
+ if (entry) {
184
+ onRoleRef({
185
+ i,
186
+ verb: top.verb,
187
+ entry,
188
+ indent: '',
189
+ prefix: line.slice(0, indent + 2) + ws,
190
+ suffix,
191
+ });
192
+ }
193
+ }
194
+ continue;
195
+ }
196
+
197
+ const colonIdx = content.indexOf(':');
198
+ // Not a `key:` or `- value` line, or an empty key: a structural error the
199
+ // resolver rejects — stop, this region enforces nothing.
200
+ if (colonIdx < 0) return;
201
+ const key = content.slice(0, colonIdx).trim();
202
+ if (!key) return;
203
+ if (top.kind === 'pending') {
204
+ // The previous key's value turned out to be a nested MAPPING.
205
+ top.kind = 'map';
206
+ top.verb = null;
207
+ }
208
+ // A kv inside a list is a structural error (mapping key inside a list).
209
+ if (top.kind !== 'map') return;
210
+ // Root per the parser's frames — NOT "column zero".
211
+ const verbKey = stack.length === 1 && VERB_KEYS.has(key) ? key : null;
212
+ const value = content.slice(colonIdx + 1).trim();
213
+ if (value !== '') {
214
+ // Inline value — no frame opens. A scalar under a root verb key is the
215
+ // inline scalar reference form (`owner: Sales`); `[]` is an empty list.
216
+ if (verbKey && value !== '[]') {
217
+ const afterColon = content.slice(colonIdx + 1);
218
+ const ws = /^\s*/.exec(afterColon)![0];
219
+ const entry = roleEntry(value);
220
+ if (entry) {
221
+ onRoleRef({
222
+ i,
223
+ verb: verbKey,
224
+ entry,
225
+ indent: line.slice(0, indent + colonIdx + 1) + ws,
226
+ prefix: '',
227
+ suffix,
228
+ });
229
+ }
230
+ }
231
+ continue;
232
+ }
233
+ // Empty value: what it opens (list/map/null) is decided by the next token.
234
+ stack.push({ indent, kind: 'pending', verb: verbKey });
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Every genuine role/group reference in `text`'s rule regions, as
240
+ * {role, verb} where `role` is the canonical ENTRY TOKEN (bare name or
241
+ * `role/<name>`). `isAccessMd` marks an `access.md` file, whose BODY is a
242
+ * rule source in the body-governed format — see {@link ruleLineRanges}.
243
+ */
244
+ export function findRoleRefsInText(
245
+ text: string,
246
+ isMarkdown = true,
247
+ isAccessMd = false,
248
+ ): { role: string; verb: string }[] {
249
+ const lines = text.split('\n');
250
+ const out: { role: string; verb: string }[] = [];
251
+ for (const { start, end } of ruleLineRanges(text, lines, isMarkdown, isAccessMd)) {
252
+ if (start >= end) continue;
253
+ walkRoleRefs(lines, start, end, ({ verb, entry }) => out.push({ role: entry.role, verb }));
254
+ }
255
+ return out;
256
+ }
257
+
258
+ /**
259
+ * Rewrite every RULE-REGION line that PARSES as a role reference whose
260
+ * canonical token == `oldToken`, replacing the token with `newDisplayName`
261
+ * (preserving any leading `deny ` and indentation). A prose body is never
262
+ * touched — a line like `- Sales` in a node file or a legacy `access.md`
263
+ * stays byte-for-byte intact; the body is eligible ONLY when the file is a
264
+ * body-governed `access.md` (`isAccessMd` + the body parses as rules), where
265
+ * the body IS what the resolver enforces — see {@link ruleLineRanges}. Lines
266
+ * that don't parse as a matching role entry (user entries, other keys,
267
+ * substrings) are also untouched. Exported for test.
268
+ *
269
+ * `isMarkdown` (default true) marks files that carry a markdown body below the
270
+ * frontmatter; pass false only for a pure-config file with no body.
271
+ */
272
+ export function rewriteRoleTokensInText(
273
+ text: string,
274
+ oldToken: string,
275
+ newDisplayName: string,
276
+ isMarkdown = true,
277
+ isAccessMd = false,
278
+ ): string {
279
+ const lines = text.split('\n');
280
+ let changed = false;
281
+ for (const { start, end } of ruleLineRanges(text, lines, isMarkdown, isAccessMd)) {
282
+ if (start >= end) continue;
283
+ walkRoleRefs(lines, start, end, ({ i, entry, indent, prefix, suffix }) => {
284
+ if (entry.role !== oldToken) return;
285
+ // `suffix` re-appends the trailing whitespace + `# comment` the match
286
+ // ignored — a rename must not eat an entry's comment.
287
+ lines[i] = `${indent}${prefix}${entry.deny ? 'deny ' : ''}${newDisplayName}${suffix}`;
288
+ changed = true;
289
+ });
290
+ }
291
+ return changed ? lines.join('\n') : text;
292
+ }
293
+
294
+ export interface ReferenceHit {
295
+ path: string;
296
+ verb: string;
297
+ }
298
+
299
+ /** One rewritten file: the new content plus the ORIGINAL text the rewrite
300
+ * read (rollback snapshots reuse it — no second full-KB read). */
301
+ export interface ReferenceRewrite {
302
+ repoRelativePath: string;
303
+ content: string;
304
+ original: string;
305
+ }
306
+
307
+ /** How long a cached reference scan may serve after its load. Backstop only —
308
+ * reference-changing writes invalidate explicitly (the admin services after
309
+ * their own rewrites, and the event-bus tap below for everyone else's). */
310
+ const SCAN_TTL_MS = 30_000;
311
+
312
+ /**
313
+ * The slice of the workflow event bus the scanner taps for cache
314
+ * invalidation. Structural (not the concrete class) so test doubles and the
315
+ * services' optional bus stay compatible.
316
+ */
317
+ export interface ReferenceScanInvalidationBus {
318
+ /** Optional so record-only test doubles (emit-only stubs) stay valid. */
319
+ onEmit?(listener: (event: { kind: string; workspaceId?: string; path?: string }) => void): () => void;
320
+ }
321
+
322
+ /**
323
+ * Candidate collection + cached scan + fail-closed rewrite over one KB.
324
+ * Constructed per admin service; the cache means roster MUTATIONS never rerun
325
+ * the full-KB reference sweep (only the roster GET pays it, at most once per
326
+ * TTL), and `invalidate` drops it whenever a write could have moved
327
+ * references (rename/delete rewrites, or any external change signal).
328
+ *
329
+ * FRESHNESS: an `access.md` grant/revoke (or any other write to a scanned
330
+ * file) lands OUTSIDE the admin services, so their explicit invalidations
331
+ * can't see it — the roster's `referencedBy` would serve up to `SCAN_TTL_MS`
332
+ * of staleness. When an `eventBus` is provided, the scanner taps its emits
333
+ * and drops the workspace's cache on every path-carrying event
334
+ * (`file-changed` from the commit pipeline, and `lock-released` — which the
335
+ * share routes emit synchronously at write time, before the async commit's
336
+ * `file-changed` lands) whose path has a scanned extension. The TTL stays as
337
+ * the backstop for deployments/tests without a bus.
338
+ */
339
+ export class KbReferenceScanner {
340
+ private readonly cache = new Map<
341
+ string,
342
+ { loadedAt: number; byToken: Map<string, ReferenceHit[]> }
343
+ >();
344
+ /**
345
+ * Per-workspace in-flight scan state: a scan may only CACHE its result if
346
+ * no invalidation landed after it started — otherwise the pre-change
347
+ * snapshot would repopulate the cache and stick for a whole TTL. Tracked
348
+ * ONLY while scans run (a future scan reads post-write state by nature, so
349
+ * an invalidation with nothing in flight needs only the cache drop): the
350
+ * entry is created by the first concurrent scan and removed by the last,
351
+ * so the map never accumulates workspaces.
352
+ */
353
+ private readonly inFlight = new Map<string, { gen: number; scans: number }>();
354
+
355
+ constructor(
356
+ private readonly workspaceService: IWorkspaceService,
357
+ private readonly kbDirName: string,
358
+ eventBus?: ReferenceScanInvalidationBus,
359
+ ) {
360
+ eventBus?.onEmit?.((event) => {
361
+ if (event.kind !== 'file-changed' && event.kind !== 'lock-released') return;
362
+ if (!event.workspaceId || !event.path) return;
363
+ // Paths on the bus are workspace-relative; only writes UNDER the KB dir
364
+ // to files the scan reads can move references — a `.md` elsewhere in
365
+ // the workspace is not scan material.
366
+ if (!event.path.startsWith(`${this.kbDirName}/`)) return;
367
+ if (!hasAccessFrontmatterExtension(event.path)) return;
368
+ this.invalidate(event.workspaceId);
369
+ });
370
+ }
371
+
372
+ invalidate(workspaceId: string): void {
373
+ this.cache.delete(workspaceId);
374
+ const flight = this.inFlight.get(workspaceId);
375
+ if (flight) flight.gen++;
376
+ }
377
+
378
+ /**
379
+ * Repo-relative paths of every file that could carry a principal reference:
380
+ * all `access.md` files plus every node file whose own frontmatter the
381
+ * resolver reads access verbs from — the shared
382
+ * `ACCESS_FRONTMATTER_EXTENSIONS` set (`.md` AND `.tool`; a rename that
383
+ * skipped `.tool` would strand a live frontmatter grant there). Sourced
384
+ * from the workspace file tree (which already skips `.git` and honours
385
+ * `.bevelignore`), filtered under the KB dir and returned bare
386
+ * repo-relative. (`roles.yaml` matches no scanned extension, so it's
387
+ * excluded; callers commit it separately.) Under save=share the working
388
+ * tree matches the committed set, so this is the same candidate list
389
+ * git-tracking would give.
390
+ */
391
+ async collectCandidateFiles(workspaceId: string): Promise<string[]> {
392
+ const tree = await this.workspaceService.listFiles(workspaceId);
393
+ const prefix = `${this.kbDirName}/`;
394
+ const out: string[] = [];
395
+ const visit = (node: FileTreeEntry): void => {
396
+ if (node.type === 'file') {
397
+ if (node.relativePath.startsWith(prefix) && hasAccessFrontmatterExtension(node.relativePath)) {
398
+ out.push(node.relativePath.slice(prefix.length));
399
+ }
400
+ return;
401
+ }
402
+ for (const child of node.children ?? []) visit(child);
403
+ };
404
+ visit(tree);
405
+ return out;
406
+ }
407
+
408
+ /**
409
+ * Sound scan of EVERY candidate file (folder `access.md` + `.md`/`.tool`
410
+ * node frontmatter) for genuine principal references, indexed by canonical
411
+ * entry TOKEN. Shares the
412
+ * candidate set and the config-region parse with the rename rewrite, so the
413
+ * delete warning and the rewrite see the SAME references. A file we cannot
414
+ * read is skipped (this is an advisory read, not the atomic write path).
415
+ * Cached per workspace (see class doc); reads are batched with Promise.all
416
+ * rather than serial per-file awaits.
417
+ */
418
+ async scan(workspaceId: string): Promise<Map<string, ReferenceHit[]>> {
419
+ const hit = this.cache.get(workspaceId);
420
+ if (hit && Date.now() - hit.loadedAt < SCAN_TTL_MS) return hit.byToken;
421
+
422
+ let flight = this.inFlight.get(workspaceId);
423
+ if (!flight) {
424
+ flight = { gen: 0, scans: 0 };
425
+ this.inFlight.set(workspaceId, flight);
426
+ }
427
+ flight.scans++;
428
+ const startedUnder = flight.gen;
429
+ try {
430
+ const repoDir = await this.repoDir(workspaceId);
431
+ const candidates = await this.collectCandidateFiles(workspaceId);
432
+ const texts = await Promise.all(
433
+ candidates.map(async (repoRel) => {
434
+ try {
435
+ return await fs.readFile(path.join(repoDir, repoRel), 'utf-8');
436
+ } catch {
437
+ return null;
438
+ }
439
+ }),
440
+ );
441
+ const byToken = new Map<string, ReferenceHit[]>();
442
+ candidates.forEach((repoRel, i) => {
443
+ const text = texts[i];
444
+ if (text === null) return;
445
+ for (const ref of findRoleRefsInText(text, true, isAccessMdPath(repoRel))) {
446
+ const list = byToken.get(ref.role);
447
+ if (list) list.push({ path: repoRel, verb: ref.verb });
448
+ else byToken.set(ref.role, [{ path: repoRel, verb: ref.verb }]);
449
+ }
450
+ });
451
+ // Cache only when no invalidation landed mid-scan; the caller still gets
452
+ // this snapshot (at worst one write stale — same as the pre-scan world),
453
+ // but a stale snapshot must never STICK for a TTL. The next call re-scans.
454
+ if (flight.gen === startedUnder) {
455
+ this.cache.set(workspaceId, { loadedAt: Date.now(), byToken });
456
+ }
457
+ return byToken;
458
+ } finally {
459
+ flight.scans--;
460
+ if (flight.scans === 0) this.inFlight.delete(workspaceId);
461
+ }
462
+ }
463
+
464
+ /**
465
+ * Find every genuine reference to `oldToken` and rewrite it to
466
+ * `newDisplayName`. FAIL-CLOSED: a candidate we cannot read might reference
467
+ * the old token, and skipping it would commit a partial rewrite (a
468
+ * half-renamed principal still pointed at by stragglers = silent access
469
+ * drop) — so any read failure aborts via `makeError` with nothing written.
470
+ * Returns the writes WITH each file's original text (rollback snapshots).
471
+ */
472
+ async rewriteReferences(
473
+ workspaceId: string,
474
+ oldToken: string,
475
+ newDisplayName: string,
476
+ makeError: (message: string, cause?: string) => Error,
477
+ ): Promise<ReferenceRewrite[]> {
478
+ const repoDir = await this.repoDir(workspaceId);
479
+ const writes: ReferenceRewrite[] = [];
480
+ const candidates = await this.collectCandidateFiles(workspaceId);
481
+ const texts = await Promise.all(
482
+ candidates.map(async (repoRel) => {
483
+ try {
484
+ return { ok: true as const, text: await fs.readFile(path.join(repoDir, repoRel), 'utf-8') };
485
+ } catch (err) {
486
+ return { ok: false as const, repoRel, cause: (err as Error)?.message };
487
+ }
488
+ }),
489
+ );
490
+ for (const t of texts) {
491
+ if (!t.ok) {
492
+ throw makeError(
493
+ `Cannot read ${t.repoRel} while rewriting references; rename aborted with no changes`,
494
+ t.cause,
495
+ );
496
+ }
497
+ }
498
+ candidates.forEach((repoRel, i) => {
499
+ const entry = texts[i];
500
+ if (!entry.ok) return; // unreachable — the loop above threw
501
+ const rewritten = rewriteRoleTokensInText(entry.text, oldToken, newDisplayName, true, isAccessMdPath(repoRel));
502
+ if (rewritten !== entry.text) {
503
+ writes.push({ repoRelativePath: repoRel, content: rewritten, original: entry.text });
504
+ }
505
+ });
506
+ return writes;
507
+ }
508
+
509
+ private async repoDir(workspaceId: string): Promise<string> {
510
+ const wsDir = await this.workspaceService.getWorkspacePath(workspaceId);
511
+ return path.join(wsDir, this.kbDirName);
512
+ }
513
+ }