@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
@@ -1,70 +1,73 @@
1
1
  /**
2
- * Admin Roles & Members service — full CRUD on the default-branch `roles.yaml`.
2
+ * Admin Roles & Members service — MEMBERSHIP editing on the default-branch
3
+ * `roles.yaml`.
3
4
  *
4
- * This is the deferred "Slice 2" of `manage-access-grant-revoke`: the in-product
5
- * surface for creating/deleting roles, adding/removing members, and renaming a
6
- * role. It is the ONLY writer of `roles.yaml` outside a hand-edit, and it is
7
- * built around one non-negotiable safety property:
5
+ * Roles are APP-DEFINED capabilities (see `capability-registry.ts`): the
6
+ * product decides which roles exist; users can never create, rename, or
7
+ * delete one — those editors are gone. What remains admin-editable is
8
+ * MEMBERSHIP: adding/removing individual emails and assigning/unassigning
9
+ * groups (`group:<canonical>` members), on capability roles and on LEGACY
10
+ * people-set roles alike (pre-split roles keep resolving and stay editable;
11
+ * `convertRoleToGroup` is their migration path).
12
+ *
13
+ * It is the primary in-app writer of `roles.yaml` (the groups admin also
14
+ * rewrites its `group:` refs on group rename/delete, through the same shared
15
+ * validated pipeline), and it is built around one non-negotiable safety
16
+ * property:
8
17
  *
9
18
  * roles.yaml has NO admin-rescue (canWrite('roles.yaml') is a pure isAdmin
10
19
  * check) and loadModel HARD-THROWS on a parse failure (which isAdmin swallows
11
20
  * into false for EVERYONE). So a single malformed write = a permanent,
12
21
  * app-wide, in-app-unrecoverable admin lockout. Every mutation therefore runs
13
22
  * the candidate text through the resolver's OWN parser (validateRolesYaml)
14
- * before a byte hits disk; on any error it writes nothing.
15
- *
16
- * Two write paths, both gated by the same friendly pre-checks + validate gate:
17
- *
18
- * Single-file ops (create/delete role, add/remove member) — `runEdit`:
19
- * pre-check roles.yaml isn't locked → RolesAdminError 409
20
- * read CURRENT roles.yaml → parse → edit-in-memory → re-emit candidate
21
- * validateRolesYaml(candidate) → RolesAdminError 422, write nothing
22
- * LockingFilesystem.writeFile(roles.yaml) ← acquire → write → release;
23
- * release IS the commit + push (default per-file summary), so the commit
24
- * lands a beat after the write — same pipeline the human editor + agent
25
- * use. The HTTP roster reads the (synchronously written) working tree.
23
+ * before a byte hits disk; on any error it writes nothing. That parser also
24
+ * carries the Admin invariant — at least one DIRECT email member, group
25
+ * references alone are never enough — so no write path can land a
26
+ * directory-dependent Admin.
26
27
  *
27
- * Rename (`renameRole`) — keeps an ATOMIC multi-file special case because it
28
- * rewrites roles.yaml + every reference in ONE commit (a partial rewrite =
29
- * silent access drop):
30
- * pre-check roles.yaml isn't locked → RolesAdminError 409
31
- * parse + rewrite refs (fail-closed) → validate gate
32
- * LockingFilesystem.writeFiles(roles.yaml + refs) ← acquires every path's
33
- * lock, commits the curated set as ONE change (via the workflow service's
34
- * commitChanges), then releases without a per-file commit.
35
- *
36
- * All reads and writes target the DEFAULT branch — the file admin status itself
37
- * derives from. Editing any other branch's copy would show a roster that
38
- * doesn't match who is actually an admin.
28
+ * Lock/commit plumbing is the SHARED `AdminLockedCommits` helper (one
29
+ * implementation for this service and the groups admin — the twins diverged
30
+ * once into a real bug). Reference scanning is the SHARED
31
+ * `KbReferenceScanner` (cached, so roster mutations don't rerun the full-KB
32
+ * sweep the GET pays for).
39
33
  */
40
34
 
41
- import path from 'node:path';
42
- import { promises as fs } from 'node:fs';
43
-
44
35
  import { workspaceIdForBranch } from '../workspace/workspace.service.js';
45
- import { LockingFilesystem } from '../workflow/locking-filesystem.js';
46
36
  import type { WorkflowEventBus } from '../workflow/event-bus.js';
47
- import type { AuthUser, FileTreeEntry, IWorkspaceService, IWorkflowService } from '@bevel-software/platform-shared';
37
+ import type { AuthUser, IWorkspaceService, IWorkflowService } from '@bevel-software/platform-shared';
48
38
  import { WorkflowDomainError } from '../workflow/workflow.errors.js';
49
39
  import type { IAccessControl } from './access-control.interface.js';
50
40
  import {
51
41
  ADMIN_CANONICAL,
42
+ ROLE_TOKEN_PREFIX,
52
43
  canonicalRoleName,
53
44
  canonicalEmail,
54
- parseAccessEntry,
45
+ loadActiveGroups,
55
46
  } from './access-control.service.js';
56
47
  import { makeRolesYamlWriteValidator } from './roles-yaml-guard.js';
57
48
  import { renderRolesYaml } from './render-roles-yaml.js';
58
49
  import {
59
50
  parseRolesModel,
60
- createRole as editCreateRole,
61
51
  deleteRole as editDeleteRole,
62
52
  addMember as editAddMember,
63
53
  removeMember as editRemoveMember,
64
- renameRoleDisplay as editRenameRoleDisplay,
54
+ addRoleGroupRef as editAddGroupRef,
55
+ removeRoleGroupRef as editRemoveGroupRef,
56
+ isGroupRefMember,
65
57
  RolesEditError,
66
58
  type EditResult,
67
59
  } from './roles-edit.js';
60
+ import { GROUPS_YAML, SYNCED_GROUPS_YAML, validateGroupsFile } from './group-files.js';
61
+ import {
62
+ GroupsEditError,
63
+ assertSafeGroupDisplayName,
64
+ emitGroupsModel,
65
+ parseGroupsModel,
66
+ } from './groups-edit.js';
67
+ import { capabilityRoleFor, isLegacyPeopleSetRole } from './capability-registry.js';
68
+ import { GROUP_REF_PREFIX, RESERVED_ROLE_NAMES } from './access-control.service.js';
69
+ import { AdminLockedCommits } from './admin-locked-commit.js';
70
+ import { KbReferenceScanner } from './reference-scan.js';
68
71
 
69
72
  /** A mutation that cannot proceed (invariant violation, contention, not found). */
70
73
  export class RolesAdminError extends WorkflowDomainError {
@@ -78,12 +81,24 @@ export class RolesAdminError extends WorkflowDomainError {
78
81
  export interface RoleRosterEntry {
79
82
  canonical: string;
80
83
  displayName: string;
84
+ /** Individual member EMAILS (group assignments are split into `groups`). */
81
85
  members: string[];
86
+ /** Canonical names of groups this role is assigned to (`group:` members). */
87
+ groups: string[];
82
88
  isAdmin: boolean;
83
89
  /**
84
- * Every access rule referencing this role — folder `access.md` AND node
85
- * frontmatter. Sound: this is the SAME scan the rename rewrite uses, so the
86
- * delete warning's count matches what a delete/rename would actually touch.
90
+ * Registry metadata when this is a CAPABILITY role (Admin today); null for
91
+ * a legacy pre-split people-set role, which the UI flags with a
92
+ * convert-to-group action.
93
+ */
94
+ capability: { description: string; groupAssignable: boolean } | null;
95
+ /**
96
+ * Every access rule referencing this ROLE — folder `access.md` AND node
97
+ * frontmatter. `role/<name>` hits always count; a BARE-token hit counts
98
+ * only when no group shadows the name (bare tokens resolve group-first, so
99
+ * a shadowed bare grant is the GROUP's reference, listed on the groups
100
+ * roster instead). Sound: this is the SAME scan the group rename/delete
101
+ * rewrites use.
87
102
  */
88
103
  referencedBy: { path: string; verb: string }[];
89
104
  }
@@ -116,6 +131,11 @@ export interface RolesConfigHealth {
116
131
  }
117
132
 
118
133
  export class RolesAdminService {
134
+ /** Shared lock/commit plumbing — see module doc. */
135
+ private readonly locked: AdminLockedCommits;
136
+ /** Shared cached reference scanner — see module doc. */
137
+ private readonly references: KbReferenceScanner;
138
+
119
139
  constructor(
120
140
  private readonly workspaceService: IWorkspaceService,
121
141
  private readonly workflowService: IWorkflowService,
@@ -144,7 +164,25 @@ export class RolesAdminService {
144
164
  * recovery exists to escape.
145
165
  */
146
166
  private readonly recoveryAdmins: readonly string[] = [],
147
- ) {}
167
+ ) {
168
+ this.locked = new AdminLockedCommits({
169
+ workspaceService,
170
+ workflowService,
171
+ kbDirName,
172
+ defaultBranchOf,
173
+ makeError: (message, status, payload) => new RolesAdminError(message, status, payload),
174
+ logTag: 'roles-admin',
175
+ contendedSubject: 'Roles',
176
+ // Defence in depth: this surface already validates candidates before
177
+ // writing, but the same pre-disk gate the editor/agent use guarantees a
178
+ // malformed roles.yaml can never land through here either.
179
+ validateWrite: makeRolesYamlWriteValidator(kbDirName),
180
+ });
181
+ // The event bus keeps the roster's `referencedBy` fresh: a share-dialog
182
+ // grant/revoke on any access.md happens outside this service, and the
183
+ // scanner invalidates its cache on those writes' events (TTL as backstop).
184
+ this.references = new KbReferenceScanner(workspaceService, kbDirName, eventBus);
185
+ }
148
186
 
149
187
  /** Resolved per call — see the constructor note on `defaultBranchOf`. */
150
188
  private get defaultBranch(): string {
@@ -184,38 +222,11 @@ export class RolesAdminService {
184
222
  return this.workspaceId;
185
223
  }
186
224
 
187
- private async repoDir(workspaceId: string): Promise<string> {
188
- const wsDir = await this.workspaceService.getWorkspacePath(workspaceId);
189
- return path.join(wsDir, this.kbDirName);
190
- }
191
-
192
- /**
193
- * A lock-aware filesystem scoped to `actor` — writes auto-commit + push as the
194
- * actor on release, through the same pipeline as the human editor and agent.
195
- * Built per-op because the lock context captures the acting user.
196
- */
197
- private async lockingFsForActor(workspaceId: string, actor: AuthUser): Promise<LockingFilesystem> {
198
- const basePath = await this.workspaceService.getWorkspacePath(workspaceId);
199
- return new LockingFilesystem(
200
- { basePath, contained: true },
201
- {
202
- workflow: this.workflowService,
203
- workspaceId,
204
- branch: this.defaultBranch,
205
- user: actor,
206
- // Defence in depth: this surface already validates candidates before
207
- // writing, but the same pre-disk gate the editor/agent use guarantees a
208
- // malformed roles.yaml can never land through here either.
209
- validateWrite: makeRolesYamlWriteValidator(this.kbDirName),
210
- },
211
- );
212
- }
213
-
214
225
  /**
215
226
  * Fail fast with a friendly 409 if roles.yaml is already locked by someone
216
- * else. The actual lock is taken by the LockingFilesystem write that follows;
217
- * this only preserves the explicit "being edited by X" contract (the raw
218
- * LockingFilesystem contention error is status-less and would surface as 500).
227
+ * else. The actual lock is taken by the write that follows; this only
228
+ * preserves the explicit "being edited by X" contract (the raw contention
229
+ * error is status-less and would surface as 500).
219
230
  */
220
231
  private async assertRolesUnlocked(workspaceId: string, actor: AuthUser): Promise<void> {
221
232
  const held = await this.workflowService.getLock(
@@ -231,33 +242,8 @@ export class RolesAdminService {
231
242
  }
232
243
  }
233
244
 
234
- /**
235
- * Run a lock-aware filesystem write, mapping its status-less contention error
236
- * ("Skipped editing … — locked by …") to the friendly 409. {@link
237
- * assertRolesUnlocked} pre-checks the common case (and names the holder), but
238
- * it's a TOCTOU check — another admin can grab the lock between it and the
239
- * actual acquire inside the write. This catches that race so contention always
240
- * surfaces as a 409, never an unhandled 500.
241
- */
242
- private async mapLockContention<T>(write: () => Promise<T>): Promise<T> {
243
- try {
244
- return await write();
245
- } catch (err) {
246
- if (err instanceof Error && /locked by /.test(err.message)) {
247
- throw new RolesAdminError('Roles are being edited by another admin. Try again in a moment.', 409);
248
- }
249
- throw err;
250
- }
251
- }
252
-
253
245
  private async readRolesYaml(workspaceId: string): Promise<string> {
254
- try {
255
- return await this.workspaceService.readFile(workspaceId, path.posix.join(this.kbDirName, ROLES_YAML));
256
- } catch (err) {
257
- const code = (err as NodeJS.ErrnoException | null)?.code;
258
- if (code === 'ENOENT' || code === 'ENOTDIR') return '';
259
- throw err;
260
- }
246
+ return (await this.locked.readKbFile(workspaceId, ROLES_YAML)) ?? '';
261
247
  }
262
248
 
263
249
  // ---- Read -------------------------------------------------------------
@@ -267,20 +253,37 @@ export class RolesAdminService {
267
253
  const workspaceId = await this.ensureWorkspace();
268
254
  const text = await this.readRolesYaml(workspaceId);
269
255
  const model = parseRolesModel(text);
270
- // ONE sound scan of every `.md` (folder access.md + node
271
- // frontmatter), then index the references by canonical role. This is the
272
- // SAME candidate set + parse the rename rewrite uses, so the delete warning
273
- // can never undercount what the rename would actually touch.
274
- const referencesByRole = await this.scanRoleReferences(workspaceId);
256
+ // ONE sound scan of every candidate file (folder access.md + node
257
+ // frontmatter) — cached in the shared scanner, so mutations returning the
258
+ // fresh roster don't rerun the full-KB sweep — indexed by canonical entry
259
+ // token. Attribution per spelling: a `role/<name>` hit is ALWAYS the
260
+ // role's; a BARE hit is the role's only when no group shadows the name —
261
+ // bare tokens resolve group-first, so a shadowed bare grant belongs to the
262
+ // GROUP (and the groups roster attributes it there — the mirror image).
263
+ const [referencesByToken, activeGroups] = await Promise.all([
264
+ this.references.scan(workspaceId),
265
+ loadActiveGroups((f) => this.locked.readKbFile(workspaceId, f)),
266
+ ]);
267
+ const groupOwnedBareNames = new Set(activeGroups.groups.keys());
275
268
  const out: RoleRosterEntry[] = [];
276
269
  for (const role of model) {
277
270
  const canonical = canonicalRoleName(role.displayName);
271
+ const registryEntry = capabilityRoleFor(role.displayName);
278
272
  out.push({
279
273
  canonical,
280
274
  displayName: role.displayName,
281
- members: role.members,
275
+ members: role.members.filter((m) => !isGroupRefMember(m)),
276
+ groups: role.members
277
+ .filter(isGroupRefMember)
278
+ .map((m) => canonicalRoleName(m.slice(GROUP_REF_PREFIX.length))),
282
279
  isAdmin: canonical === ADMIN_CANONICAL,
283
- referencedBy: referencesByRole.get(canonical) ?? [],
280
+ capability: registryEntry
281
+ ? { description: registryEntry.description, groupAssignable: registryEntry.groupAssignable }
282
+ : null,
283
+ referencedBy: [
284
+ ...(groupOwnedBareNames.has(canonical) ? [] : referencesByToken.get(canonical) ?? []),
285
+ ...(referencesByToken.get(`${ROLE_TOKEN_PREFIX}${canonical}`) ?? []),
286
+ ],
284
287
  });
285
288
  }
286
289
  return out;
@@ -350,10 +353,13 @@ export class RolesAdminService {
350
353
  }
351
354
 
352
355
  if (this.recoveryAdmins.length === 0) {
356
+ // 409, not 500: this is a CONFIGURATION refusal in a break-glass flow —
357
+ // the operator needs the actionable message, and the route helper hides
358
+ // every >=500 body behind a generic "Internal error." on purpose.
353
359
  throw new RolesAdminError(
354
360
  'Recovery needs a configured admin (ADMIN_EMAIL) to restore — a roles.yaml ' +
355
361
  'with no Admin is exactly the unusable state recovery exists to escape.',
356
- 500,
362
+ 409,
357
363
  { kind: 'no-recovery-admins' },
358
364
  );
359
365
  }
@@ -362,8 +368,8 @@ export class RolesAdminService {
362
368
 
363
369
  // Back up the corrupted bytes and restore the good default atomically. The
364
370
  // default is valid, so the resolver loads immediately after this commit.
365
- const fsys = await this.lockingFsForActor(workspaceId, actor);
366
- await this.mapLockContention(() =>
371
+ const fsys = await this.locked.lockingFsForActor(workspaceId, actor);
372
+ await this.locked.mapLockContention(() =>
367
373
  fsys.writeFiles(
368
374
  [
369
375
  { path: `${this.kbDirName}/${OLD_ROLES_YAML}`, content: current },
@@ -378,19 +384,11 @@ export class RolesAdminService {
378
384
  }
379
385
 
380
386
  // ---- Mutations --------------------------------------------------------
381
-
382
- async createRole(actor: AuthUser, displayName: string): Promise<RoleRosterEntry[]> {
383
- await this.runEdit(actor, (text) => editCreateRole(text, displayName));
384
- return this.getRoster();
385
- }
386
-
387
- async deleteRole(actor: AuthUser, canonical: string): Promise<RoleRosterEntry[]> {
388
- if (canonical === ADMIN_CANONICAL) {
389
- throw new RolesAdminError('The Admin role cannot be deleted', 422);
390
- }
391
- await this.runEdit(actor, (text) => editDeleteRole(text, canonical));
392
- return this.getRoster();
393
- }
387
+ //
388
+ // MEMBERSHIP ONLY. There is deliberately no createRole / renameRole /
389
+ // deleteRole: roles are app-defined capabilities (see capability-registry),
390
+ // so the admin surface cannot mint, rebrand, or retire one. Legacy roles
391
+ // migrate out via convertRoleToGroup.
394
392
 
395
393
  async addMember(actor: AuthUser, canonical: string, email: string): Promise<RoleRosterEntry[]> {
396
394
  await this.runEdit(actor, (text) => editAddMember(text, canonical, email));
@@ -398,9 +396,12 @@ export class RolesAdminService {
398
396
  }
399
397
 
400
398
  /**
401
- * Remove a member. Refuses to empty the Admin role (422). Removing the
402
- * caller's OWN last Admin membership requires `confirm` (409 otherwise) — no
403
- * silent self-lockout, since admin status is read live from this file.
399
+ * Remove a member. Refuses to remove the Admin role's LAST DIRECT EMAIL
400
+ * (422) — the kept invariant: whatever groups Admin references, at least
401
+ * one directory-independent email member must remain, or a broken IdP
402
+ * connection becomes an admin lockout. Removing the caller's OWN last
403
+ * Admin membership requires `confirm` (409 otherwise) — no silent
404
+ * self-lockout, since admin status is read live from this file.
404
405
  */
405
406
  async removeMember(
406
407
  actor: AuthUser,
@@ -412,11 +413,15 @@ export class RolesAdminService {
412
413
  const target = canonicalEmail(email);
413
414
  if (canonical === ADMIN_CANONICAL) {
414
415
  const admin = parseRolesModel(text).find((r) => canonicalRoleName(r.displayName) === ADMIN_CANONICAL);
415
- const members = admin?.members ?? [];
416
- if (members.includes(target) && members.length <= 1) {
417
- throw new RolesAdminError('The Admin role must keep at least one member', 422);
416
+ // The invariant counts DIRECT emails only — group refs don't rescue.
417
+ const directMembers = (admin?.members ?? []).filter((m) => !isGroupRefMember(m));
418
+ if (directMembers.includes(target) && directMembers.length <= 1) {
419
+ throw new RolesAdminError(
420
+ 'The Admin role must keep at least one direct email member — group references alone are not enough.',
421
+ 422,
422
+ );
418
423
  }
419
- if (target === canonicalEmail(actor.email) && members.includes(target) && !confirm) {
424
+ if (target === canonicalEmail(actor.email) && directMembers.includes(target) && !confirm) {
420
425
  throw new RolesAdminError('You are about to remove your own admin access', 409, {
421
426
  kind: 'self-admin-removal',
422
427
  });
@@ -428,68 +433,149 @@ export class RolesAdminService {
428
433
  }
429
434
 
430
435
  /**
431
- * Rename a role's display name.
432
- * - canonical UNCHANGED (casing/whitespace) → single roles.yaml edit.
433
- * - canonical CHANGES → rewrite every genuine role reference in access.md +
434
- * node frontmatter AND roles.yaml in ONE atomic commit.
435
- * - Admin canonical → non-admin canonical is refused (400): it would break
436
- * isAdminEmail's roles.has('admin') lookup. Casing-only Admin OK.
436
+ * Assign a role to a group — Admin included (the parse-time invariant keeps
437
+ * at least one direct email on Admin, so a group ref can never be its only
438
+ * membership).
439
+ *
440
+ * The ref must name a group the ACTIVE source knows: mergeGroupsIntoRoles
441
+ * ignores an unknown ref with only a log warning, so a typo here would be
442
+ * accepted and then silently grant the role to nobody. The check is
443
+ * BEST-EFFORT validation, not a race-free guarantee: the manual group
444
+ * file's lock is held across validate + write (so a concurrent groups-page
445
+ * deletion/rename can't invalidate the ref mid-flight), but the IdP sync
446
+ * writer commits through its own lock — a provisioning push can retire the
447
+ * group between this check and the next sync. That is fine by design: a
448
+ * dangling ref resolves to nothing, with a resolver warning naming it.
437
449
  */
438
- async renameRole(actor: AuthUser, canonical: string, newDisplayName: string): Promise<RoleRosterEntry[]> {
439
- const newCanonical = canonicalRoleName(newDisplayName);
440
- if (canonical === ADMIN_CANONICAL && newCanonical !== ADMIN_CANONICAL) {
441
- throw new RolesAdminError('The Admin role cannot be renamed to a different name', 400);
442
- }
443
-
450
+ async assignGroup(actor: AuthUser, canonical: string, groupName: string): Promise<RoleRosterEntry[]> {
444
451
  const workspaceId = await this.ensureWorkspace();
445
- // Preserve the friendly 409 on contention: writeFiles throws a status-less
446
- // lock-skip error (→ 500), so pre-check the roles.yaml lock.
447
- await this.assertRolesUnlocked(workspaceId, actor);
448
-
449
- const text = await this.readRolesYaml(workspaceId);
450
- const before = parseRolesModel(text);
451
- if (!before.some((r) => canonicalRoleName(r.displayName) === canonical)) {
452
- throw new RolesAdminError(`role not found: ${canonical}`, 404);
453
- }
452
+ await this.locked.withFileLocks(workspaceId, actor, [GROUPS_YAML], async () => {
453
+ const { groups } = await loadActiveGroups((f) => this.locked.readKbFile(workspaceId, f));
454
+ if (!groups.has(canonicalRoleName(groupName))) {
455
+ throw new RolesAdminError(`No group named "${groupName.trim()}". Pick an existing group.`, 404, {
456
+ kind: 'unknown-group',
457
+ group: groupName.trim(),
458
+ });
459
+ }
460
+ await this.runEdit(actor, (text) => editAddGroupRef(text, canonical, groupName));
461
+ });
462
+ return this.getRoster();
463
+ }
454
464
 
455
- const rolesEdit = this.guardEdit(() => editRenameRoleDisplay(text, canonical, newDisplayName));
456
- const writes: { repoRelativePath: string; content: string }[] = [];
465
+ async unassignGroup(actor: AuthUser, canonical: string, groupName: string): Promise<RoleRosterEntry[]> {
466
+ await this.runEdit(actor, (text) => editRemoveGroupRef(text, canonical, groupName));
467
+ return this.getRoster();
468
+ }
457
469
 
458
- // The roles.yaml change itself (skip if the re-emit was a no-op).
459
- // REPO-relative path (bare); the kbDirName prefix is added below.
460
- if (rolesEdit.changed) {
461
- writes.push({ repoRelativePath: ROLES_YAML, content: rolesEdit.text });
470
+ /**
471
+ * Convert a LEGACY people-set role into a manual group — the migration the
472
+ * roles/groups split defines: "Product" was never a capability, it was a
473
+ * team. Atomic two-file move (roles.yaml loses the role, groups.yaml gains
474
+ * the group with the same members) in ONE commit; grant references keep
475
+ * working untouched because the NAME does not change.
476
+ *
477
+ * Refusals: capability roles (they ARE roles — Admin included), roles with
478
+ * group assignments (unwind those first: a group containing a group is
479
+ * nesting), IdP mode (groups are managed in the identity provider — this
480
+ * would write a retired file), and a groups.yaml name collision.
481
+ */
482
+ async convertRoleToGroup(actor: AuthUser, canonical: string): Promise<RoleRosterEntry[]> {
483
+ const workspaceId = await this.ensureWorkspace();
484
+ if (!isLegacyPeopleSetRole(canonical)) {
485
+ throw new RolesAdminError(`'${canonical}' is a capability role — it cannot become a group`, 422);
462
486
  }
463
-
464
- // Identity change → rewrite every genuine role reference, atomically.
465
- // (Fail-closed: an unreadable candidate throws here, BEFORE any write.)
466
- if (newCanonical !== canonical) {
467
- const repoDir = await this.repoDir(workspaceId);
468
- const refWrites = await this.rewriteRoleReferences(workspaceId, repoDir, canonical, newDisplayName.trim());
469
- writes.push(...refWrites);
487
+ if ((await this.locked.readKbFile(workspaceId, SYNCED_GROUPS_YAML)) !== null) {
488
+ throw new RolesAdminError(
489
+ 'Groups are synced from your identity provider — recreate this team there instead.',
490
+ 409,
491
+ { kind: 'idp-mode' },
492
+ );
470
493
  }
494
+ await this.assertRolesUnlocked(workspaceId, actor);
471
495
 
472
- if (writes.length === 0) return this.getRoster(); // nothing changed
496
+ // Hold ALL THREE file locks across read → build → write: candidates are
497
+ // built from a snapshot, and without the hold another admin's edit could
498
+ // land in between and be silently overwritten. The synced file's lock is
499
+ // held too — the directory-sync materializer commits synced-groups.yaml
500
+ // through that same lock, so holding it serializes this conversion with a
501
+ // provisioning push and makes the IdP-mode recheck below race-free. It is
502
+ // a COORDINATION hold (never a write path): synced-groups.yaml is
503
+ // machine-owned — only the sync bot passes its write rule, so a
504
+ // write-intent acquire would refuse every human actor at the gate. The
505
+ // conversion only READS the file; coordination gives it the mutex without
506
+ // claiming write authority.
507
+ await this.locked.withFileLocks(
508
+ workspaceId,
509
+ actor,
510
+ [GROUPS_YAML, ROLES_YAML],
511
+ async () => {
512
+ // Re-check UNDER the synced file's lock: the pre-lock check above is a
513
+ // fast-path courtesy, but directory sync could materialize
514
+ // synced-groups.yaml between it and this point — and committing a new
515
+ // groups.yaml group in IdP mode writes a retired file.
516
+ if ((await this.locked.readKbFile(workspaceId, SYNCED_GROUPS_YAML)) !== null) {
517
+ throw new RolesAdminError(
518
+ 'Groups are synced from your identity provider — recreate this team there instead.',
519
+ 409,
520
+ { kind: 'idp-mode' },
521
+ );
522
+ }
523
+ const rolesText = await this.readRolesYaml(workspaceId);
524
+ const role = parseRolesModel(rolesText).find((r) => canonicalRoleName(r.displayName) === canonical);
525
+ if (!role) throw new RolesAdminError(`role not found: ${canonical}`, 404);
526
+ const groupRefs = role.members.filter(isGroupRefMember);
527
+ if (groupRefs.length > 0) {
528
+ throw new RolesAdminError(
529
+ 'This role is assigned to groups — remove those assignments before converting it.',
530
+ 422,
531
+ );
532
+ }
473
533
 
474
- // The validate-gate: any roles.yaml write must parse via the resolver —
475
- // runs BEFORE the atomic write so a bad candidate never reaches disk.
476
- const rolesWrite = writes.find((w) => w.repoRelativePath === ROLES_YAML);
477
- if (rolesWrite) this.assertLoadable(rolesWrite.content);
534
+ // Build both candidates BEFORE any write, and validate both.
535
+ const rolesEdit = this.guardEdit(() => editDeleteRole(rolesText, canonical));
536
+ this.assertLoadable(rolesEdit.text);
537
+ makeRolesYamlWriteValidator(this.kbDirName)(`${this.kbDirName}/${ROLES_YAML}`, rolesEdit.text);
538
+ // Keep absent (null) distinct from existing-but-empty ('') — rollback
539
+ // must DELETE a groups.yaml it created, not truncate one already there.
540
+ // ONE parse + ONE emit: the group candidate is built on the model
541
+ // directly (name-safety + reserved + duplicate checks inline), not via
542
+ // per-member editor round-trips that each re-parse the whole file.
543
+ const groupsOriginal = await this.locked.readKbFile(workspaceId, GROUPS_YAML);
544
+ let groupsCandidate: string;
545
+ try {
546
+ assertSafeGroupDisplayName(role.displayName);
547
+ if (RESERVED_ROLE_NAMES.has(canonical)) {
548
+ throw new GroupsEditError(`'${role.displayName}' is a reserved name and cannot be a group`);
549
+ }
550
+ const groupsModel = parseGroupsModel(groupsOriginal ?? '');
551
+ if (groupsModel.some((g) => canonicalRoleName(g.displayName) === canonical)) {
552
+ throw new GroupsEditError(`a group named '${role.displayName}' already exists`);
553
+ }
554
+ groupsModel.push({ displayName: role.displayName, members: [...role.members] });
555
+ groupsCandidate = emitGroupsModel(groupsModel);
556
+ } catch (err) {
557
+ if (err instanceof GroupsEditError) throw new RolesAdminError(err.message, err.status);
558
+ throw err;
559
+ }
560
+ const groupsValid = validateGroupsFile(groupsCandidate, GROUPS_YAML);
561
+ if (!groupsValid.ok) {
562
+ throw new RolesAdminError(`groups.yaml would be invalid: ${groupsValid.errors.join('; ')}`, 422);
563
+ }
478
564
 
479
- // Rename keeps the ATOMIC multi-file commit (roles.yaml + every rewritten
480
- // reference land as ONE commit, or none): a partial rewrite would leave a
481
- // renamed role with references still pointing at the old name = silent
482
- // access drop. The lock-aware filesystem owns this — it acquires every
483
- // path's lock, writes them, and commits the curated set as one change.
484
- const fsys = await this.lockingFsForActor(workspaceId, actor);
485
- await this.mapLockContention(() =>
486
- fsys.writeFiles(
487
- writes.map((w) => ({ path: `${this.kbDirName}/${w.repoRelativePath}`, content: w.content })),
488
- `Rename role ${canonical} → ${newDisplayName.trim()}`,
489
- ),
565
+ await this.locked.writeAndCommitLocked(
566
+ workspaceId,
567
+ actor,
568
+ [
569
+ { repoRel: ROLES_YAML, content: rolesEdit.text, original: rolesText },
570
+ { repoRel: GROUPS_YAML, content: groupsCandidate, original: groupsOriginal },
571
+ ],
572
+ `Convert role ${role.displayName} to a group`,
573
+ );
574
+ },
575
+ { coordinationFiles: [SYNCED_GROUPS_YAML] },
490
576
  );
491
577
  this.accessControl.invalidate(workspaceId);
492
- this.emitWrites(workspaceId, actor, writes.map((w) => w.repoRelativePath));
578
+ this.emitWrites(workspaceId, actor, [ROLES_YAML, GROUPS_YAML]);
493
579
  return this.getRoster();
494
580
  }
495
581
 
@@ -513,15 +599,11 @@ export class RolesAdminService {
513
599
  }
514
600
 
515
601
  /**
516
- * Single-file roles.yaml mutation. `pre` produces the candidate (and may throw
517
- * RolesAdminError for invariant violations before any write). Skips on a no-op.
518
- *
519
- * The write goes through a {@link LockingFilesystem}: acquiring the per-file
520
- * lock, writing, then releasing — and release IS the commit + push (attributed
521
- * to `actor`), the same pipeline the human editor and agent use. So unlike the
522
- * rename's synchronous atomic commit, the git commit here lands on release
523
- * (a beat after the write); the HTTP response reads the working tree, which is
524
- * already on disk, so the returned roster is correct.
602
+ * Single-file roles.yaml mutation under the shared file-lock/commit helper.
603
+ * `pre` produces the candidate (and may throw RolesAdminError for invariant
604
+ * violations before any write). Skips on a no-op. Every candidate passes
605
+ * the resolver's own parser (assertLoadable) plus the pre-disk validator
606
+ * before a byte lands.
525
607
  */
526
608
  private async runEdit(
527
609
  actor: AuthUser,
@@ -529,242 +611,32 @@ export class RolesAdminService {
529
611
  ): Promise<void> {
530
612
  const workspaceId = await this.ensureWorkspace();
531
613
  await this.assertRolesUnlocked(workspaceId, actor);
614
+ return this.locked.withFileLocks(workspaceId, actor, [ROLES_YAML], () =>
615
+ this.runEditLocked(workspaceId, actor, pre),
616
+ );
617
+ }
618
+
619
+ private async runEditLocked(
620
+ workspaceId: string,
621
+ actor: AuthUser,
622
+ pre: (currentText: string) => EditResult,
623
+ ): Promise<void> {
532
624
  const text = await this.readRolesYaml(workspaceId);
533
625
  const result = this.guardEdit(() => pre(text));
534
626
  if (!result.changed) return;
535
627
  this.assertLoadable(result.text);
536
- // LockingFilesystem paths are WORKSPACE-relative, so carry the kbDirName
537
- // prefix (unlike commitChanges' bare repo-relative paths). The release
538
- // pipeline writes a default per-file commit summary ("Update roles.yaml");
539
- // a bespoke summary isn't threadable through this path, which is fine for a
540
- // single-file roles edit. The rename keeps its descriptive summary because
541
- // it commits atomically via writeFiles.
542
- const fsys = await this.lockingFsForActor(workspaceId, actor);
543
- await this.mapLockContention(() => fsys.writeFile(`${this.kbDirName}/${ROLES_YAML}`, result.text));
628
+ // The lock is ALREADY OURS (withFileLocks; strict same-user acquire), so
629
+ // LockingFilesystem would contend against our own hold. Apply the same
630
+ // pre-disk validator it would have run, then plain-write + a path-scoped
631
+ // atomic commit.
632
+ makeRolesYamlWriteValidator(this.kbDirName)(`${this.kbDirName}/${ROLES_YAML}`, result.text);
633
+ await this.locked.writeAndCommitLocked(
634
+ workspaceId,
635
+ actor,
636
+ [{ repoRel: ROLES_YAML, content: result.text, original: text }],
637
+ `Update ${ROLES_YAML}`,
638
+ );
544
639
  this.accessControl.invalidate(workspaceId);
545
640
  this.emitWrites(workspaceId, actor, [ROLES_YAML]);
546
641
  }
547
-
548
- /**
549
- * Find every genuine role reference to `oldCanonical` across folder access.md
550
- * AND node frontmatter, and rewrite each to `newDisplayName`. Reference-aware:
551
- * a line is rewritten ONLY if it PARSES as a role entry whose canonical name
552
- * == oldCanonical. Prose, comments, `Name <email>` user entries, other keys,
553
- * and substrings never match. Returns the repo-relative writes to commit.
554
- */
555
- private async rewriteRoleReferences(
556
- workspaceId: string,
557
- repoDir: string,
558
- oldCanonical: string,
559
- newDisplayName: string,
560
- ): Promise<{ repoRelativePath: string; content: string }[]> {
561
- const writes: { repoRelativePath: string; content: string }[] = [];
562
- const candidates = await this.collectCandidateFiles(workspaceId);
563
- for (const repoRel of candidates) {
564
- const abs = path.join(repoDir, repoRel);
565
- let text: string;
566
- try {
567
- text = await fs.readFile(abs, 'utf-8');
568
- } catch (err) {
569
- // Fail closed: a candidate we cannot read might reference the
570
- // old role. Skipping it would commit a partial rewrite (a half-renamed
571
- // role pointing at the old name) and silently drop access. Abort the
572
- // whole atomic rename instead so the admin can retry cleanly.
573
- throw new RolesAdminError(
574
- `Cannot read ${repoRel} while rewriting role references; rename aborted with no changes`,
575
- 422,
576
- { cause: (err as Error)?.message },
577
- );
578
- }
579
- const rewritten = rewriteRoleTokensInText(text, oldCanonical, newDisplayName);
580
- if (rewritten !== text) {
581
- // REPO-relative (bare repoRel) for commitChanges — repoDir already points
582
- // at <workspaceDir>/<kbDirName>.
583
- writes.push({ repoRelativePath: repoRel, content: rewritten });
584
- }
585
- }
586
- return writes;
587
- }
588
-
589
- /**
590
- * Repo-relative paths of every file that could carry a role reference: all
591
- * `access.md` files plus every `.md` node (its own frontmatter). Sourced from
592
- * the workspace file tree (which already skips `.git` and honours
593
- * `.bevelignore`), filtered to `.md` under the KB dir and returned bare
594
- * repo-relative. (`roles.yaml` isn't `.md`, so it's excluded; the rename
595
- * commits it separately.) Under save=share the working tree matches the
596
- * committed set, so this is the same candidate list git-tracking would give.
597
- */
598
- private async collectCandidateFiles(workspaceId: string): Promise<string[]> {
599
- const tree = await this.workspaceService.listFiles(workspaceId);
600
- const prefix = `${this.kbDirName}/`;
601
- const out: string[] = [];
602
- const visit = (node: FileTreeEntry): void => {
603
- if (node.type === 'file') {
604
- if (node.relativePath.startsWith(prefix) && node.relativePath.endsWith('.md')) {
605
- out.push(node.relativePath.slice(prefix.length));
606
- }
607
- return;
608
- }
609
- for (const child of node.children ?? []) visit(child);
610
- };
611
- visit(tree);
612
- return out;
613
- }
614
-
615
- /**
616
- * Sound scan of EVERY `.md` (folder `access.md` + node frontmatter)
617
- * for genuine role references, indexed by canonical role name. Shares the
618
- * candidate set and the config-region role-entry parse with the rename
619
- * rewrite (`findRoleRefsInText` / `rewriteRoleTokensInText`), so the delete
620
- * warning and the rename rewrite see the SAME references — the warning can no
621
- * longer undercount frontmatter the rename would touch. A file we cannot read
622
- * is skipped (this is an advisory read, not the atomic write path): missing a
623
- * reference here only weakens the warning, it cannot drop access.
624
- */
625
- private async scanRoleReferences(
626
- workspaceId: string,
627
- ): Promise<Map<string, { path: string; verb: string }[]>> {
628
- const repoDir = await this.repoDir(workspaceId);
629
- const candidates = await this.collectCandidateFiles(workspaceId);
630
- const byRole = new Map<string, { path: string; verb: string }[]>();
631
- for (const repoRel of candidates) {
632
- let text: string;
633
- try {
634
- text = await fs.readFile(path.join(repoDir, repoRel), 'utf-8');
635
- } catch {
636
- continue;
637
- }
638
- for (const ref of findRoleRefsInText(text)) {
639
- const list = byRole.get(ref.role);
640
- if (list) list.push({ path: repoRel, verb: ref.verb });
641
- else byRole.set(ref.role, [{ path: repoRel, verb: ref.verb }]);
642
- }
643
- }
644
- return byRole;
645
- }
646
- }
647
-
648
- /**
649
- * Resolve the [start, end) line range that role rewrites may touch — the YAML
650
- * config region only, NEVER the markdown body (CodeRabbit: a body line like
651
- * `- Sales` or `owner: Sales` must not be rewritten).
652
- *
653
- * - A file with leading `---` frontmatter (folder `access.md`, node `.md`):
654
- * only the lines BETWEEN the opening and closing `---` are eligible.
655
- * - A fence-less file (e.g. a bare `roles.yaml`-style access config with no
656
- * `---`): the whole file is config, so all lines are eligible.
657
- * - A `.md` file with no frontmatter fence: no config region → empty range,
658
- * nothing is rewritten.
659
- */
660
- function configLineRange(lines: string[], isMarkdown: boolean): { start: number; end: number } {
661
- if (lines.length > 0 && lines[0].trim() === '---') {
662
- for (let i = 1; i < lines.length; i++) {
663
- if (lines[i].trim() === '---') return { start: 1, end: i };
664
- }
665
- // Unterminated frontmatter — treat nothing as eligible (don't risk the body).
666
- return { start: 0, end: 0 };
667
- }
668
- // No fence: a markdown file has no config region; a non-markdown access file
669
- // (no body) is entirely config.
670
- return isMarkdown ? { start: 0, end: 0 } : { start: 0, end: lines.length };
671
- }
672
-
673
- /** A known access verb key: heads a block list or holds a scalar role value. */
674
- const VERB_KEY_RE = /^(\s*)(read|write|download|owner)(:\s*)(.*)$/;
675
- /** A block-list item: ` - <token>` (token may carry a leading `deny `). */
676
- const LIST_ITEM_RE = /^(\s*-\s+)(.*)$/;
677
-
678
- /**
679
- * Walk the CONFIG-REGION lines of `text`, invoking `onRoleRef` for every line
680
- * that PARSES as a genuine role entry — both the block-list form (`- <token>`
681
- * under a `read:`/`write:`/… key) and the inline scalar form (`owner: <token>`).
682
- * `verb` is the access verb the reference sits under; for a block list it is the
683
- * nearest enclosing verb key (lines before any verb key, or under an unknown
684
- * key, are skipped). This is the SINGLE source of truth for "what is a role
685
- * reference" — both the delete-warning scan and the rename rewrite drive off it,
686
- * so they cannot disagree. The callback may mutate `lines[i]` (the rewrite does;
687
- * the scan does not). User entries, other keys, comments and substrings never
688
- * fire it.
689
- */
690
- function walkRoleRefs(
691
- lines: string[],
692
- start: number,
693
- end: number,
694
- onRoleRef: (ctx: { i: number; verb: string; entry: { role: string; deny: boolean }; indent: string; prefix: string }) => void,
695
- ): void {
696
- let currentVerb: string | null = null;
697
- /** A role-entry value → its parsed role entry, else null (user/empty/other). */
698
- const roleEntry = (rawValue: string): { role: string; deny: boolean } | null => {
699
- const parsed = parseAccessEntry(rawValue.replace(/\s+$/, ''));
700
- return parsed.ok && parsed.entry.kind === 'role'
701
- ? { role: parsed.entry.role, deny: parsed.entry.deny }
702
- : null;
703
- };
704
- for (let i = start; i < end; i++) {
705
- const line = lines[i];
706
- // A verb key resets the block context. Its inline value (scalar form,
707
- // `owner: Sales`) is itself a candidate reference under that same verb.
708
- const kvM = line.match(VERB_KEY_RE);
709
- if (kvM) {
710
- currentVerb = kvM[2];
711
- const inlineValue = kvM[4];
712
- if (inlineValue.trim() !== '') {
713
- const entry = roleEntry(inlineValue);
714
- if (entry) onRoleRef({ i, verb: currentVerb, entry, indent: `${kvM[1]}${kvM[2]}${kvM[3]}`, prefix: '' });
715
- }
716
- continue;
717
- }
718
- // Block-list item — belongs to the nearest enclosing verb key. A list item
719
- // with no verb in scope is not a resolvable access rule; skip it.
720
- const listM = line.match(LIST_ITEM_RE);
721
- if (listM && currentVerb !== null) {
722
- const entry = roleEntry(listM[2]);
723
- if (entry) onRoleRef({ i, verb: currentVerb, entry, indent: '', prefix: listM[1] });
724
- continue;
725
- }
726
- // A non-empty, non-list, non-kv line ends the current block (e.g. a new
727
- // top-level key whose value isn't a verb, or stray prose in config).
728
- if (line.trim() !== '' && !listM) currentVerb = null;
729
- }
730
- }
731
-
732
- /** Every genuine role reference in `text`'s config region, as {role, verb}. */
733
- export function findRoleRefsInText(text: string, isMarkdown = true): { role: string; verb: string }[] {
734
- const lines = text.split('\n');
735
- const { start, end } = configLineRange(lines, isMarkdown);
736
- const out: { role: string; verb: string }[] = [];
737
- if (start >= end) return out;
738
- walkRoleRefs(lines, start, end, ({ verb, entry }) => out.push({ role: entry.role, verb }));
739
- return out;
740
- }
741
-
742
- /**
743
- * Rewrite every CONFIG-REGION line that PARSES as a role reference whose
744
- * canonical name == `oldCanonical`, replacing the role token with
745
- * `newDisplayName` (preserving any leading `deny ` and indentation). Only the
746
- * frontmatter block of a markdown file is touched — the body is left
747
- * byte-for-byte intact, so a prose line like `- Sales` is never corrupted.
748
- * Lines that don't parse as a matching role entry (user entries, other keys,
749
- * substrings) are also untouched. Exported for test.
750
- *
751
- * `isMarkdown` (default true) marks files that carry a markdown body below the
752
- * frontmatter; pass false only for a pure-config file with no body.
753
- */
754
- export function rewriteRoleTokensInText(
755
- text: string,
756
- oldCanonical: string,
757
- newDisplayName: string,
758
- isMarkdown = true,
759
- ): string {
760
- const lines = text.split('\n');
761
- const { start, end } = configLineRange(lines, isMarkdown);
762
- if (start >= end) return text;
763
- let changed = false;
764
- walkRoleRefs(lines, start, end, ({ i, entry, indent, prefix }) => {
765
- if (entry.role !== oldCanonical) return;
766
- lines[i] = `${indent}${prefix}${entry.deny ? 'deny ' : ''}${newDisplayName}`;
767
- changed = true;
768
- });
769
- return changed ? lines.join('\n') : text;
770
642
  }