@bevel-software/platform-core-backend 0.9.1 → 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 (212) hide show
  1. package/THIRD-PARTY-NOTICES.md +2 -2
  2. package/dist/core/create-core-server.d.ts.map +1 -1
  3. package/dist/core/create-core-server.js +16 -1
  4. package/dist/core/create-core-server.js.map +1 -1
  5. package/dist/core/create-core-services.d.ts +25 -0
  6. package/dist/core/create-core-services.d.ts.map +1 -1
  7. package/dist/core/create-core-services.js +45 -1
  8. package/dist/core/create-core-services.js.map +1 -1
  9. package/dist/core-config.d.ts +19 -1
  10. package/dist/core-config.d.ts.map +1 -1
  11. package/dist/core-config.js +44 -4
  12. package/dist/core-config.js.map +1 -1
  13. package/dist/index.d.ts +2 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +4 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/modules/access/access-control.interface.d.ts +54 -28
  18. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  19. package/dist/modules/access/access-control.service.d.ts +129 -14
  20. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  21. package/dist/modules/access/access-control.service.js +435 -66
  22. package/dist/modules/access/access-control.service.js.map +1 -1
  23. package/dist/modules/access/access-declarations.d.ts.map +1 -1
  24. package/dist/modules/access/access-declarations.js +5 -3
  25. package/dist/modules/access/access-declarations.js.map +1 -1
  26. package/dist/modules/access/access-mutation.service.d.ts +39 -6
  27. package/dist/modules/access/access-mutation.service.d.ts.map +1 -1
  28. package/dist/modules/access/access-mutation.service.js +78 -18
  29. package/dist/modules/access/access-mutation.service.js.map +1 -1
  30. package/dist/modules/access/access-splice.d.ts +31 -4
  31. package/dist/modules/access/access-splice.d.ts.map +1 -1
  32. package/dist/modules/access/access-splice.js +40 -16
  33. package/dist/modules/access/access-splice.js.map +1 -1
  34. package/dist/modules/access/access.routes.d.ts.map +1 -1
  35. package/dist/modules/access/access.routes.js +204 -82
  36. package/dist/modules/access/access.routes.js.map +1 -1
  37. package/dist/modules/access/admin-locked-commit.d.ts +134 -0
  38. package/dist/modules/access/admin-locked-commit.d.ts.map +1 -0
  39. package/dist/modules/access/admin-locked-commit.js +277 -0
  40. package/dist/modules/access/admin-locked-commit.js.map +1 -0
  41. package/dist/modules/access/admin-route-helpers.d.ts +32 -0
  42. package/dist/modules/access/admin-route-helpers.d.ts.map +1 -0
  43. package/dist/modules/access/admin-route-helpers.js +44 -0
  44. package/dist/modules/access/admin-route-helpers.js.map +1 -0
  45. package/dist/modules/access/capability-registry.d.ts +41 -0
  46. package/dist/modules/access/capability-registry.d.ts.map +1 -0
  47. package/dist/modules/access/capability-registry.js +46 -0
  48. package/dist/modules/access/capability-registry.js.map +1 -0
  49. package/dist/modules/access/directory-sync-bot.d.ts +13 -0
  50. package/dist/modules/access/directory-sync-bot.d.ts.map +1 -0
  51. package/dist/modules/access/directory-sync-bot.js +64 -0
  52. package/dist/modules/access/directory-sync-bot.js.map +1 -0
  53. package/dist/modules/access/group-files.d.ts +83 -0
  54. package/dist/modules/access/group-files.d.ts.map +1 -0
  55. package/dist/modules/access/group-files.js +167 -0
  56. package/dist/modules/access/group-files.js.map +1 -0
  57. package/dist/modules/access/groups-admin.routes.d.ts +19 -0
  58. package/dist/modules/access/groups-admin.routes.d.ts.map +1 -0
  59. package/dist/modules/access/groups-admin.routes.js +98 -0
  60. package/dist/modules/access/groups-admin.routes.js.map +1 -0
  61. package/dist/modules/access/groups-admin.service.d.ts +166 -0
  62. package/dist/modules/access/groups-admin.service.d.ts.map +1 -0
  63. package/dist/modules/access/groups-admin.service.js +442 -0
  64. package/dist/modules/access/groups-admin.service.js.map +1 -0
  65. package/dist/modules/access/groups-edit.d.ts +58 -0
  66. package/dist/modules/access/groups-edit.d.ts.map +1 -0
  67. package/dist/modules/access/groups-edit.js +162 -0
  68. package/dist/modules/access/groups-edit.js.map +1 -0
  69. package/dist/modules/access/reference-scan.d.ts +141 -0
  70. package/dist/modules/access/reference-scan.d.ts.map +1 -0
  71. package/dist/modules/access/reference-scan.js +440 -0
  72. package/dist/modules/access/reference-scan.js.map +1 -0
  73. package/dist/modules/access/roles-admin.service.d.ts +88 -119
  74. package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
  75. package/dist/modules/access/roles-admin.service.js +230 -384
  76. package/dist/modules/access/roles-admin.service.js.map +1 -1
  77. package/dist/modules/access/roles-edit.d.ts +51 -25
  78. package/dist/modules/access/roles-edit.d.ts.map +1 -1
  79. package/dist/modules/access/roles-edit.js +133 -59
  80. package/dist/modules/access/roles-edit.js.map +1 -1
  81. package/dist/modules/access/synced-groups-committer.d.ts +28 -0
  82. package/dist/modules/access/synced-groups-committer.d.ts.map +1 -0
  83. package/dist/modules/access/synced-groups-committer.js +139 -0
  84. package/dist/modules/access/synced-groups-committer.js.map +1 -0
  85. package/dist/modules/access/synced-groups-writer.d.ts +78 -0
  86. package/dist/modules/access/synced-groups-writer.d.ts.map +1 -0
  87. package/dist/modules/access/synced-groups-writer.js +219 -0
  88. package/dist/modules/access/synced-groups-writer.js.map +1 -0
  89. package/dist/modules/database/core-schema.d.ts +17 -0
  90. package/dist/modules/database/core-schema.d.ts.map +1 -1
  91. package/dist/modules/database/core-schema.js +9 -0
  92. package/dist/modules/database/core-schema.js.map +1 -1
  93. package/dist/modules/mcp/mcp-auth.middleware.d.ts +13 -2
  94. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  95. package/dist/modules/mcp/mcp-auth.middleware.js +61 -2
  96. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  97. package/dist/modules/mcp/mcp.routes.d.ts +9 -3
  98. package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
  99. package/dist/modules/mcp/mcp.routes.js +126 -2
  100. package/dist/modules/mcp/mcp.routes.js.map +1 -1
  101. package/dist/modules/mcp/mcp.service.d.ts +14 -0
  102. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  103. package/dist/modules/mcp/mcp.service.js +6 -1
  104. package/dist/modules/mcp/mcp.service.js.map +1 -1
  105. package/dist/modules/update-check/update-check.routes.d.ts +13 -0
  106. package/dist/modules/update-check/update-check.routes.d.ts.map +1 -0
  107. package/dist/modules/update-check/update-check.routes.js +21 -0
  108. package/dist/modules/update-check/update-check.routes.js.map +1 -0
  109. package/dist/modules/update-check/update-check.service.d.ts +57 -0
  110. package/dist/modules/update-check/update-check.service.d.ts.map +1 -0
  111. package/dist/modules/update-check/update-check.service.js +109 -0
  112. package/dist/modules/update-check/update-check.service.js.map +1 -0
  113. package/dist/modules/workflow/file-lock.service.d.ts +11 -1
  114. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  115. package/dist/modules/workflow/file-lock.service.js +15 -1
  116. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  117. package/dist/modules/workflow/git/git.service.d.ts +34 -11
  118. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  119. package/dist/modules/workflow/git/git.service.js +173 -37
  120. package/dist/modules/workflow/git/git.service.js.map +1 -1
  121. package/dist/modules/workflow/locking-filesystem.d.ts +4 -0
  122. package/dist/modules/workflow/locking-filesystem.d.ts.map +1 -1
  123. package/dist/modules/workflow/locking-filesystem.js +181 -28
  124. package/dist/modules/workflow/locking-filesystem.js.map +1 -1
  125. package/dist/modules/workflow/pending-commits.service.d.ts +10 -0
  126. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  127. package/dist/modules/workflow/pending-commits.service.js +19 -1
  128. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  129. package/dist/modules/workflow/workflow.service.d.ts +17 -2
  130. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  131. package/dist/modules/workflow/workflow.service.js +113 -19
  132. package/dist/modules/workflow/workflow.service.js.map +1 -1
  133. package/dist/version.d.ts +14 -0
  134. package/dist/version.d.ts.map +1 -1
  135. package/dist/version.js +25 -0
  136. package/dist/version.js.map +1 -1
  137. package/kb-template/AGENTS.md +4 -1
  138. package/migrations/0004_file_lock_mode.sql +2 -0
  139. package/migrations/meta/0004_snapshot.json +1564 -0
  140. package/migrations/meta/_journal.json +7 -0
  141. package/package.json +4 -4
  142. package/src/__tests__/core-config.domain.test.ts +92 -0
  143. package/src/core/create-core-server.ts +21 -0
  144. package/src/core/create-core-services.ts +82 -0
  145. package/src/core-config.ts +48 -4
  146. package/src/index.ts +10 -0
  147. package/src/modules/access/__tests__/access-control.service.test.ts +9 -3
  148. package/src/modules/access/__tests__/access-groups.test.ts +427 -0
  149. package/src/modules/access/__tests__/access-mutation.service.test.ts +171 -5
  150. package/src/modules/access/__tests__/access-splice.test.ts +65 -0
  151. package/src/modules/access/__tests__/access.routes.group-grant.test.ts +337 -0
  152. package/src/modules/access/__tests__/access.routes.revoke.test.ts +48 -0
  153. package/src/modules/access/__tests__/admin-locked-commit.test.ts +221 -0
  154. package/src/modules/access/__tests__/admin-route-helpers.test.ts +61 -0
  155. package/src/modules/access/__tests__/directory-sync-bot.test.ts +106 -0
  156. package/src/modules/access/__tests__/grant-sources.test.ts +67 -0
  157. package/src/modules/access/__tests__/groups-admin.service.test.ts +440 -0
  158. package/src/modules/access/__tests__/reference-scan.test.ts +288 -0
  159. package/src/modules/access/__tests__/roles-admin.service.test.ts +161 -83
  160. package/src/modules/access/__tests__/roles-capabilities.test.ts +325 -0
  161. package/src/modules/access/__tests__/roles-edit.test.ts +104 -28
  162. package/src/modules/access/__tests__/roles.routes.test.ts +63 -40
  163. package/src/modules/access/__tests__/synced-groups-committer.test.ts +238 -0
  164. package/src/modules/access/__tests__/synced-groups-writer.test.ts +249 -0
  165. package/src/modules/access/access-control.interface.ts +66 -32
  166. package/src/modules/access/access-control.service.ts +536 -73
  167. package/src/modules/access/access-declarations.ts +5 -2
  168. package/src/modules/access/access-mutation.service.ts +88 -14
  169. package/src/modules/access/access-splice.ts +55 -17
  170. package/src/modules/access/access.routes.ts +227 -93
  171. package/src/modules/access/admin-locked-commit.ts +331 -0
  172. package/src/modules/access/admin-route-helpers.ts +55 -0
  173. package/src/modules/access/capability-registry.ts +74 -0
  174. package/src/modules/access/directory-sync-bot.ts +76 -0
  175. package/src/modules/access/group-files.ts +212 -0
  176. package/src/modules/access/groups-admin.routes.ts +113 -0
  177. package/src/modules/access/groups-admin.service.ts +551 -0
  178. package/src/modules/access/groups-edit.ts +187 -0
  179. package/src/modules/access/reference-scan.ts +513 -0
  180. package/src/modules/access/roles-admin.service.ts +290 -418
  181. package/src/modules/access/roles-edit.ts +134 -61
  182. package/src/modules/access/synced-groups-committer.ts +177 -0
  183. package/src/modules/access/synced-groups-writer.ts +303 -0
  184. package/src/modules/database/core-schema.ts +9 -0
  185. package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +116 -0
  186. package/src/modules/mcp/__tests__/mcp.e2e.test.ts +3 -1
  187. package/src/modules/mcp/__tests__/mcp.routes.delete.test.ts +6 -1
  188. package/src/modules/mcp/__tests__/mcp.routes.local-token.test.ts +301 -0
  189. package/src/modules/mcp/mcp-auth.middleware.ts +61 -1
  190. package/src/modules/mcp/mcp.routes.ts +137 -2
  191. package/src/modules/mcp/mcp.service.ts +6 -1
  192. package/src/modules/update-check/__tests__/update-check.routes.test.ts +69 -0
  193. package/src/modules/update-check/__tests__/update-check.service.test.ts +170 -0
  194. package/src/modules/update-check/update-check.routes.ts +28 -0
  195. package/src/modules/update-check/update-check.service.ts +130 -0
  196. package/src/modules/workflow/__tests__/locking-filesystem.test.ts +336 -0
  197. package/src/modules/workflow/__tests__/preserve-roles-yaml.test.ts +1 -1
  198. package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +32 -0
  199. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +13 -2
  200. package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +139 -1
  201. package/src/modules/workflow/file-lock.service.ts +15 -0
  202. package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +0 -1
  203. package/src/modules/workflow/git/__tests__/git.service.commitChanges.test.ts +132 -0
  204. package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +0 -1
  205. package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +0 -1
  206. package/src/modules/workflow/git/git.service.ts +182 -35
  207. package/src/modules/workflow/locking-filesystem.ts +188 -26
  208. package/src/modules/workflow/pending-commits.service.ts +27 -1
  209. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +0 -1
  210. package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +0 -1
  211. package/src/modules/workflow/workflow.service.ts +140 -20
  212. package/src/version.ts +28 -0
@@ -1,51 +1,48 @@
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.
26
- *
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.
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.
35
27
  *
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
- import path from 'node:path';
41
- import { promises as fs } from 'node:fs';
42
34
  import { workspaceIdForBranch } from '../workspace/workspace.service.js';
43
- import { LockingFilesystem } from '../workflow/locking-filesystem.js';
44
35
  import { WorkflowDomainError } from '../workflow/workflow.errors.js';
45
- import { ADMIN_CANONICAL, canonicalRoleName, canonicalEmail, parseAccessEntry, } from './access-control.service.js';
36
+ import { ADMIN_CANONICAL, ROLE_TOKEN_PREFIX, canonicalRoleName, canonicalEmail, loadActiveGroups, } from './access-control.service.js';
46
37
  import { makeRolesYamlWriteValidator } from './roles-yaml-guard.js';
47
38
  import { renderRolesYaml } from './render-roles-yaml.js';
48
- import { parseRolesModel, createRole as editCreateRole, deleteRole as editDeleteRole, addMember as editAddMember, removeMember as editRemoveMember, renameRoleDisplay as editRenameRoleDisplay, RolesEditError, } from './roles-edit.js';
39
+ import { parseRolesModel, deleteRole as editDeleteRole, addMember as editAddMember, removeMember as editRemoveMember, addRoleGroupRef as editAddGroupRef, removeRoleGroupRef as editRemoveGroupRef, isGroupRefMember, RolesEditError, } from './roles-edit.js';
40
+ import { GROUPS_YAML, SYNCED_GROUPS_YAML, validateGroupsFile } from './group-files.js';
41
+ import { GroupsEditError, assertSafeGroupDisplayName, emitGroupsModel, parseGroupsModel, } from './groups-edit.js';
42
+ import { capabilityRoleFor, isLegacyPeopleSetRole } from './capability-registry.js';
43
+ import { GROUP_REF_PREFIX, RESERVED_ROLE_NAMES } from './access-control.service.js';
44
+ import { AdminLockedCommits } from './admin-locked-commit.js';
45
+ import { KbReferenceScanner } from './reference-scan.js';
49
46
  /** A mutation that cannot proceed (invariant violation, contention, not found). */
50
47
  export class RolesAdminError extends WorkflowDomainError {
51
48
  constructor(message, status = 422, payload) {
@@ -80,6 +77,10 @@ export class RolesAdminService {
80
77
  defaultBranchOf;
81
78
  eventBus;
82
79
  recoveryAdmins;
80
+ /** Shared lock/commit plumbing — see module doc. */
81
+ locked;
82
+ /** Shared cached reference scanner — see module doc. */
83
+ references;
83
84
  constructor(workspaceService, workflowService, accessControl, kbDirName,
84
85
  /**
85
86
  * The branch whose roles.yaml is authoritative for admin status, read
@@ -111,6 +112,23 @@ export class RolesAdminService {
111
112
  this.defaultBranchOf = defaultBranchOf;
112
113
  this.eventBus = eventBus;
113
114
  this.recoveryAdmins = recoveryAdmins;
115
+ this.locked = new AdminLockedCommits({
116
+ workspaceService,
117
+ workflowService,
118
+ kbDirName,
119
+ defaultBranchOf,
120
+ makeError: (message, status, payload) => new RolesAdminError(message, status, payload),
121
+ logTag: 'roles-admin',
122
+ contendedSubject: 'Roles',
123
+ // Defence in depth: this surface already validates candidates before
124
+ // writing, but the same pre-disk gate the editor/agent use guarantees a
125
+ // malformed roles.yaml can never land through here either.
126
+ validateWrite: makeRolesYamlWriteValidator(kbDirName),
127
+ });
128
+ // The event bus keeps the roster's `referencedBy` fresh: a share-dialog
129
+ // grant/revoke on any access.md happens outside this service, and the
130
+ // scanner invalidates its cache on those writes' events (TTL as backstop).
131
+ this.references = new KbReferenceScanner(workspaceService, kbDirName, eventBus);
114
132
  }
115
133
  /** Resolved per call — see the constructor note on `defaultBranchOf`. */
116
134
  get defaultBranch() {
@@ -147,33 +165,11 @@ export class RolesAdminService {
147
165
  await this.workspaceService.getOrCreateForBranch(this.defaultBranch);
148
166
  return this.workspaceId;
149
167
  }
150
- async repoDir(workspaceId) {
151
- const wsDir = await this.workspaceService.getWorkspacePath(workspaceId);
152
- return path.join(wsDir, this.kbDirName);
153
- }
154
- /**
155
- * A lock-aware filesystem scoped to `actor` — writes auto-commit + push as the
156
- * actor on release, through the same pipeline as the human editor and agent.
157
- * Built per-op because the lock context captures the acting user.
158
- */
159
- async lockingFsForActor(workspaceId, actor) {
160
- const basePath = await this.workspaceService.getWorkspacePath(workspaceId);
161
- return new LockingFilesystem({ basePath, contained: true }, {
162
- workflow: this.workflowService,
163
- workspaceId,
164
- branch: this.defaultBranch,
165
- user: actor,
166
- // Defence in depth: this surface already validates candidates before
167
- // writing, but the same pre-disk gate the editor/agent use guarantees a
168
- // malformed roles.yaml can never land through here either.
169
- validateWrite: makeRolesYamlWriteValidator(this.kbDirName),
170
- });
171
- }
172
168
  /**
173
169
  * Fail fast with a friendly 409 if roles.yaml is already locked by someone
174
- * else. The actual lock is taken by the LockingFilesystem write that follows;
175
- * this only preserves the explicit "being edited by X" contract (the raw
176
- * LockingFilesystem contention error is status-less and would surface as 500).
170
+ * else. The actual lock is taken by the write that follows; this only
171
+ * preserves the explicit "being edited by X" contract (the raw contention
172
+ * error is status-less and would surface as 500).
177
173
  */
178
174
  async assertRolesUnlocked(workspaceId, actor) {
179
175
  const held = await this.workflowService.getLock(workspaceId, this.defaultBranch, `${this.kbDirName}/${ROLES_YAML}`);
@@ -181,35 +177,8 @@ export class RolesAdminService {
181
177
  throw new RolesAdminError(`Roles are being edited by ${held.holderName || 'another admin'}. Try again in a moment.`, 409);
182
178
  }
183
179
  }
184
- /**
185
- * Run a lock-aware filesystem write, mapping its status-less contention error
186
- * ("Skipped editing … — locked by …") to the friendly 409. {@link
187
- * assertRolesUnlocked} pre-checks the common case (and names the holder), but
188
- * it's a TOCTOU check — another admin can grab the lock between it and the
189
- * actual acquire inside the write. This catches that race so contention always
190
- * surfaces as a 409, never an unhandled 500.
191
- */
192
- async mapLockContention(write) {
193
- try {
194
- return await write();
195
- }
196
- catch (err) {
197
- if (err instanceof Error && /locked by /.test(err.message)) {
198
- throw new RolesAdminError('Roles are being edited by another admin. Try again in a moment.', 409);
199
- }
200
- throw err;
201
- }
202
- }
203
180
  async readRolesYaml(workspaceId) {
204
- try {
205
- return await this.workspaceService.readFile(workspaceId, path.posix.join(this.kbDirName, ROLES_YAML));
206
- }
207
- catch (err) {
208
- const code = err?.code;
209
- if (code === 'ENOENT' || code === 'ENOTDIR')
210
- return '';
211
- throw err;
212
- }
181
+ return (await this.locked.readKbFile(workspaceId, ROLES_YAML)) ?? '';
213
182
  }
214
183
  // ---- Read -------------------------------------------------------------
215
184
  /** The roster: every role on the default branch with members + references. */
@@ -217,20 +186,37 @@ export class RolesAdminService {
217
186
  const workspaceId = await this.ensureWorkspace();
218
187
  const text = await this.readRolesYaml(workspaceId);
219
188
  const model = parseRolesModel(text);
220
- // ONE sound scan of every `.md` (folder access.md + node
221
- // frontmatter), then index the references by canonical role. This is the
222
- // SAME candidate set + parse the rename rewrite uses, so the delete warning
223
- // can never undercount what the rename would actually touch.
224
- const referencesByRole = await this.scanRoleReferences(workspaceId);
189
+ // ONE sound scan of every candidate file (folder access.md + node
190
+ // frontmatter) — cached in the shared scanner, so mutations returning the
191
+ // fresh roster don't rerun the full-KB sweep — indexed by canonical entry
192
+ // token. Attribution per spelling: a `role/<name>` hit is ALWAYS the
193
+ // role's; a BARE hit is the role's only when no group shadows the name —
194
+ // bare tokens resolve group-first, so a shadowed bare grant belongs to the
195
+ // GROUP (and the groups roster attributes it there — the mirror image).
196
+ const [referencesByToken, activeGroups] = await Promise.all([
197
+ this.references.scan(workspaceId),
198
+ loadActiveGroups((f) => this.locked.readKbFile(workspaceId, f)),
199
+ ]);
200
+ const groupOwnedBareNames = new Set(activeGroups.groups.keys());
225
201
  const out = [];
226
202
  for (const role of model) {
227
203
  const canonical = canonicalRoleName(role.displayName);
204
+ const registryEntry = capabilityRoleFor(role.displayName);
228
205
  out.push({
229
206
  canonical,
230
207
  displayName: role.displayName,
231
- members: role.members,
208
+ members: role.members.filter((m) => !isGroupRefMember(m)),
209
+ groups: role.members
210
+ .filter(isGroupRefMember)
211
+ .map((m) => canonicalRoleName(m.slice(GROUP_REF_PREFIX.length))),
232
212
  isAdmin: canonical === ADMIN_CANONICAL,
233
- referencedBy: referencesByRole.get(canonical) ?? [],
213
+ capability: registryEntry
214
+ ? { description: registryEntry.description, groupAssignable: registryEntry.groupAssignable }
215
+ : null,
216
+ referencedBy: [
217
+ ...(groupOwnedBareNames.has(canonical) ? [] : referencesByToken.get(canonical) ?? []),
218
+ ...(referencesByToken.get(`${ROLE_TOKEN_PREFIX}${canonical}`) ?? []),
219
+ ],
234
220
  });
235
221
  }
236
222
  return out;
@@ -288,14 +274,17 @@ export class RolesAdminService {
288
274
  throw new RolesAdminError('roles.yaml is valid — recovery is only available when the file is corrupted.', 409, { kind: 'not-corrupted' });
289
275
  }
290
276
  if (this.recoveryAdmins.length === 0) {
277
+ // 409, not 500: this is a CONFIGURATION refusal in a break-glass flow —
278
+ // the operator needs the actionable message, and the route helper hides
279
+ // every >=500 body behind a generic "Internal error." on purpose.
291
280
  throw new RolesAdminError('Recovery needs a configured admin (ADMIN_EMAIL) to restore — a roles.yaml ' +
292
- 'with no Admin is exactly the unusable state recovery exists to escape.', 500, { kind: 'no-recovery-admins' });
281
+ 'with no Admin is exactly the unusable state recovery exists to escape.', 409, { kind: 'no-recovery-admins' });
293
282
  }
294
283
  await this.assertRolesUnlocked(workspaceId, actor);
295
284
  // Back up the corrupted bytes and restore the good default atomically. The
296
285
  // default is valid, so the resolver loads immediately after this commit.
297
- const fsys = await this.lockingFsForActor(workspaceId, actor);
298
- await this.mapLockContention(() => fsys.writeFiles([
286
+ const fsys = await this.locked.lockingFsForActor(workspaceId, actor);
287
+ await this.locked.mapLockContention(() => fsys.writeFiles([
299
288
  { path: `${this.kbDirName}/${OLD_ROLES_YAML}`, content: current },
300
289
  { path: `${this.kbDirName}/${ROLES_YAML}`, content: renderRecoveryRolesYaml(this.recoveryAdmins) },
301
290
  ], 'Bevel recovery: reset corrupted roles.yaml'));
@@ -304,36 +293,34 @@ export class RolesAdminService {
304
293
  return this.getRoster();
305
294
  }
306
295
  // ---- Mutations --------------------------------------------------------
307
- async createRole(actor, displayName) {
308
- await this.runEdit(actor, (text) => editCreateRole(text, displayName));
309
- return this.getRoster();
310
- }
311
- async deleteRole(actor, canonical) {
312
- if (canonical === ADMIN_CANONICAL) {
313
- throw new RolesAdminError('The Admin role cannot be deleted', 422);
314
- }
315
- await this.runEdit(actor, (text) => editDeleteRole(text, canonical));
316
- return this.getRoster();
317
- }
296
+ //
297
+ // MEMBERSHIP ONLY. There is deliberately no createRole / renameRole /
298
+ // deleteRole: roles are app-defined capabilities (see capability-registry),
299
+ // so the admin surface cannot mint, rebrand, or retire one. Legacy roles
300
+ // migrate out via convertRoleToGroup.
318
301
  async addMember(actor, canonical, email) {
319
302
  await this.runEdit(actor, (text) => editAddMember(text, canonical, email));
320
303
  return this.getRoster();
321
304
  }
322
305
  /**
323
- * Remove a member. Refuses to empty the Admin role (422). Removing the
324
- * caller's OWN last Admin membership requires `confirm` (409 otherwise) — no
325
- * silent self-lockout, since admin status is read live from this file.
306
+ * Remove a member. Refuses to remove the Admin role's LAST DIRECT EMAIL
307
+ * (422) — the kept invariant: whatever groups Admin references, at least
308
+ * one directory-independent email member must remain, or a broken IdP
309
+ * connection becomes an admin lockout. Removing the caller's OWN last
310
+ * Admin membership requires `confirm` (409 otherwise) — no silent
311
+ * self-lockout, since admin status is read live from this file.
326
312
  */
327
313
  async removeMember(actor, canonical, email, confirm) {
328
314
  await this.runEdit(actor, (text) => {
329
315
  const target = canonicalEmail(email);
330
316
  if (canonical === ADMIN_CANONICAL) {
331
317
  const admin = parseRolesModel(text).find((r) => canonicalRoleName(r.displayName) === ADMIN_CANONICAL);
332
- const members = admin?.members ?? [];
333
- if (members.includes(target) && members.length <= 1) {
334
- throw new RolesAdminError('The Admin role must keep at least one member', 422);
318
+ // The invariant counts DIRECT emails only — group refs don't rescue.
319
+ const directMembers = (admin?.members ?? []).filter((m) => !isGroupRefMember(m));
320
+ if (directMembers.includes(target) && directMembers.length <= 1) {
321
+ throw new RolesAdminError('The Admin role must keep at least one direct email member — group references alone are not enough.', 422);
335
322
  }
336
- if (target === canonicalEmail(actor.email) && members.includes(target) && !confirm) {
323
+ if (target === canonicalEmail(actor.email) && directMembers.includes(target) && !confirm) {
337
324
  throw new RolesAdminError('You are about to remove your own admin access', 409, {
338
325
  kind: 'self-admin-removal',
339
326
  });
@@ -344,57 +331,125 @@ export class RolesAdminService {
344
331
  return this.getRoster();
345
332
  }
346
333
  /**
347
- * Rename a role's display name.
348
- * - canonical UNCHANGED (casing/whitespace) → single roles.yaml edit.
349
- * - canonical CHANGES → rewrite every genuine role reference in access.md +
350
- * node frontmatter AND roles.yaml in ONE atomic commit.
351
- * - Admin canonical → non-admin canonical is refused (400): it would break
352
- * isAdminEmail's roles.has('admin') lookup. Casing-only Admin OK.
334
+ * Assign a role to a group — Admin included (the parse-time invariant keeps
335
+ * at least one direct email on Admin, so a group ref can never be its only
336
+ * membership).
337
+ *
338
+ * The ref must name a group the ACTIVE source knows: mergeGroupsIntoRoles
339
+ * ignores an unknown ref with only a log warning, so a typo here would be
340
+ * accepted and then silently grant the role to nobody. The check is
341
+ * BEST-EFFORT validation, not a race-free guarantee: the manual group
342
+ * file's lock is held across validate + write (so a concurrent groups-page
343
+ * deletion/rename can't invalidate the ref mid-flight), but the IdP sync
344
+ * writer commits through its own lock — a provisioning push can retire the
345
+ * group between this check and the next sync. That is fine by design: a
346
+ * dangling ref resolves to nothing, with a resolver warning naming it.
353
347
  */
354
- async renameRole(actor, canonical, newDisplayName) {
355
- const newCanonical = canonicalRoleName(newDisplayName);
356
- if (canonical === ADMIN_CANONICAL && newCanonical !== ADMIN_CANONICAL) {
357
- throw new RolesAdminError('The Admin role cannot be renamed to a different name', 400);
358
- }
348
+ async assignGroup(actor, canonical, groupName) {
359
349
  const workspaceId = await this.ensureWorkspace();
360
- // Preserve the friendly 409 on contention: writeFiles throws a status-less
361
- // lock-skip error (→ 500), so pre-check the roles.yaml lock.
362
- await this.assertRolesUnlocked(workspaceId, actor);
363
- const text = await this.readRolesYaml(workspaceId);
364
- const before = parseRolesModel(text);
365
- if (!before.some((r) => canonicalRoleName(r.displayName) === canonical)) {
366
- throw new RolesAdminError(`role not found: ${canonical}`, 404);
367
- }
368
- const rolesEdit = this.guardEdit(() => editRenameRoleDisplay(text, canonical, newDisplayName));
369
- const writes = [];
370
- // The roles.yaml change itself (skip if the re-emit was a no-op).
371
- // REPO-relative path (bare); the kbDirName prefix is added below.
372
- if (rolesEdit.changed) {
373
- writes.push({ repoRelativePath: ROLES_YAML, content: rolesEdit.text });
350
+ await this.locked.withFileLocks(workspaceId, actor, [GROUPS_YAML], async () => {
351
+ const { groups } = await loadActiveGroups((f) => this.locked.readKbFile(workspaceId, f));
352
+ if (!groups.has(canonicalRoleName(groupName))) {
353
+ throw new RolesAdminError(`No group named "${groupName.trim()}". Pick an existing group.`, 404, {
354
+ kind: 'unknown-group',
355
+ group: groupName.trim(),
356
+ });
357
+ }
358
+ await this.runEdit(actor, (text) => editAddGroupRef(text, canonical, groupName));
359
+ });
360
+ return this.getRoster();
361
+ }
362
+ async unassignGroup(actor, canonical, groupName) {
363
+ await this.runEdit(actor, (text) => editRemoveGroupRef(text, canonical, groupName));
364
+ return this.getRoster();
365
+ }
366
+ /**
367
+ * Convert a LEGACY people-set role into a manual group — the migration the
368
+ * roles/groups split defines: "Product" was never a capability, it was a
369
+ * team. Atomic two-file move (roles.yaml loses the role, groups.yaml gains
370
+ * the group with the same members) in ONE commit; grant references keep
371
+ * working untouched because the NAME does not change.
372
+ *
373
+ * Refusals: capability roles (they ARE roles — Admin included), roles with
374
+ * group assignments (unwind those first: a group containing a group is
375
+ * nesting), IdP mode (groups are managed in the identity provider — this
376
+ * would write a retired file), and a groups.yaml name collision.
377
+ */
378
+ async convertRoleToGroup(actor, canonical) {
379
+ const workspaceId = await this.ensureWorkspace();
380
+ if (!isLegacyPeopleSetRole(canonical)) {
381
+ throw new RolesAdminError(`'${canonical}' is a capability role — it cannot become a group`, 422);
374
382
  }
375
- // Identity change → rewrite every genuine role reference, atomically.
376
- // (Fail-closed: an unreadable candidate throws here, BEFORE any write.)
377
- if (newCanonical !== canonical) {
378
- const repoDir = await this.repoDir(workspaceId);
379
- const refWrites = await this.rewriteRoleReferences(workspaceId, repoDir, canonical, newDisplayName.trim());
380
- writes.push(...refWrites);
383
+ if ((await this.locked.readKbFile(workspaceId, SYNCED_GROUPS_YAML)) !== null) {
384
+ throw new RolesAdminError('Groups are synced from your identity provider — recreate this team there instead.', 409, { kind: 'idp-mode' });
381
385
  }
382
- if (writes.length === 0)
383
- return this.getRoster(); // nothing changed
384
- // The validate-gate: any roles.yaml write must parse via the resolver —
385
- // runs BEFORE the atomic write so a bad candidate never reaches disk.
386
- const rolesWrite = writes.find((w) => w.repoRelativePath === ROLES_YAML);
387
- if (rolesWrite)
388
- this.assertLoadable(rolesWrite.content);
389
- // Rename keeps the ATOMIC multi-file commit (roles.yaml + every rewritten
390
- // reference land as ONE commit, or none): a partial rewrite would leave a
391
- // renamed role with references still pointing at the old name = silent
392
- // access drop. The lock-aware filesystem owns this — it acquires every
393
- // path's lock, writes them, and commits the curated set as one change.
394
- const fsys = await this.lockingFsForActor(workspaceId, actor);
395
- await this.mapLockContention(() => fsys.writeFiles(writes.map((w) => ({ path: `${this.kbDirName}/${w.repoRelativePath}`, content: w.content })), `Rename role ${canonical} → ${newDisplayName.trim()}`));
386
+ await this.assertRolesUnlocked(workspaceId, actor);
387
+ // Hold ALL THREE file locks across read → build → write: candidates are
388
+ // built from a snapshot, and without the hold another admin's edit could
389
+ // land in between and be silently overwritten. The synced file's lock is
390
+ // held too — the directory-sync materializer commits synced-groups.yaml
391
+ // through that same lock, so holding it serializes this conversion with a
392
+ // provisioning push and makes the IdP-mode recheck below race-free. It is
393
+ // a COORDINATION hold (never a write path): synced-groups.yaml is
394
+ // machine-owned — only the sync bot passes its write rule, so a
395
+ // write-intent acquire would refuse every human actor at the gate. The
396
+ // conversion only READS the file; coordination gives it the mutex without
397
+ // claiming write authority.
398
+ await this.locked.withFileLocks(workspaceId, actor, [GROUPS_YAML, ROLES_YAML], async () => {
399
+ // Re-check UNDER the synced file's lock: the pre-lock check above is a
400
+ // fast-path courtesy, but directory sync could materialize
401
+ // synced-groups.yaml between it and this point — and committing a new
402
+ // groups.yaml group in IdP mode writes a retired file.
403
+ if ((await this.locked.readKbFile(workspaceId, SYNCED_GROUPS_YAML)) !== null) {
404
+ throw new RolesAdminError('Groups are synced from your identity provider — recreate this team there instead.', 409, { kind: 'idp-mode' });
405
+ }
406
+ const rolesText = await this.readRolesYaml(workspaceId);
407
+ const role = parseRolesModel(rolesText).find((r) => canonicalRoleName(r.displayName) === canonical);
408
+ if (!role)
409
+ throw new RolesAdminError(`role not found: ${canonical}`, 404);
410
+ const groupRefs = role.members.filter(isGroupRefMember);
411
+ if (groupRefs.length > 0) {
412
+ throw new RolesAdminError('This role is assigned to groups — remove those assignments before converting it.', 422);
413
+ }
414
+ // Build both candidates BEFORE any write, and validate both.
415
+ const rolesEdit = this.guardEdit(() => editDeleteRole(rolesText, canonical));
416
+ this.assertLoadable(rolesEdit.text);
417
+ makeRolesYamlWriteValidator(this.kbDirName)(`${this.kbDirName}/${ROLES_YAML}`, rolesEdit.text);
418
+ // Keep absent (null) distinct from existing-but-empty ('') — rollback
419
+ // must DELETE a groups.yaml it created, not truncate one already there.
420
+ // ONE parse + ONE emit: the group candidate is built on the model
421
+ // directly (name-safety + reserved + duplicate checks inline), not via
422
+ // per-member editor round-trips that each re-parse the whole file.
423
+ const groupsOriginal = await this.locked.readKbFile(workspaceId, GROUPS_YAML);
424
+ let groupsCandidate;
425
+ try {
426
+ assertSafeGroupDisplayName(role.displayName);
427
+ if (RESERVED_ROLE_NAMES.has(canonical)) {
428
+ throw new GroupsEditError(`'${role.displayName}' is a reserved name and cannot be a group`);
429
+ }
430
+ const groupsModel = parseGroupsModel(groupsOriginal ?? '');
431
+ if (groupsModel.some((g) => canonicalRoleName(g.displayName) === canonical)) {
432
+ throw new GroupsEditError(`a group named '${role.displayName}' already exists`);
433
+ }
434
+ groupsModel.push({ displayName: role.displayName, members: [...role.members] });
435
+ groupsCandidate = emitGroupsModel(groupsModel);
436
+ }
437
+ catch (err) {
438
+ if (err instanceof GroupsEditError)
439
+ throw new RolesAdminError(err.message, err.status);
440
+ throw err;
441
+ }
442
+ const groupsValid = validateGroupsFile(groupsCandidate, GROUPS_YAML);
443
+ if (!groupsValid.ok) {
444
+ throw new RolesAdminError(`groups.yaml would be invalid: ${groupsValid.errors.join('; ')}`, 422);
445
+ }
446
+ await this.locked.writeAndCommitLocked(workspaceId, actor, [
447
+ { repoRel: ROLES_YAML, content: rolesEdit.text, original: rolesText },
448
+ { repoRel: GROUPS_YAML, content: groupsCandidate, original: groupsOriginal },
449
+ ], `Convert role ${role.displayName} to a group`);
450
+ }, { coordinationFiles: [SYNCED_GROUPS_YAML] });
396
451
  this.accessControl.invalidate(workspaceId);
397
- this.emitWrites(workspaceId, actor, writes.map((w) => w.repoRelativePath));
452
+ this.emitWrites(workspaceId, actor, [ROLES_YAML, GROUPS_YAML]);
398
453
  return this.getRoster();
399
454
  }
400
455
  // ---- Internals --------------------------------------------------------
@@ -416,240 +471,31 @@ export class RolesAdminService {
416
471
  }
417
472
  }
418
473
  /**
419
- * Single-file roles.yaml mutation. `pre` produces the candidate (and may throw
420
- * RolesAdminError for invariant violations before any write). Skips on a no-op.
421
- *
422
- * The write goes through a {@link LockingFilesystem}: acquiring the per-file
423
- * lock, writing, then releasing — and release IS the commit + push (attributed
424
- * to `actor`), the same pipeline the human editor and agent use. So unlike the
425
- * rename's synchronous atomic commit, the git commit here lands on release
426
- * (a beat after the write); the HTTP response reads the working tree, which is
427
- * already on disk, so the returned roster is correct.
474
+ * Single-file roles.yaml mutation under the shared file-lock/commit helper.
475
+ * `pre` produces the candidate (and may throw RolesAdminError for invariant
476
+ * violations before any write). Skips on a no-op. Every candidate passes
477
+ * the resolver's own parser (assertLoadable) plus the pre-disk validator
478
+ * before a byte lands.
428
479
  */
429
480
  async runEdit(actor, pre) {
430
481
  const workspaceId = await this.ensureWorkspace();
431
482
  await this.assertRolesUnlocked(workspaceId, actor);
483
+ return this.locked.withFileLocks(workspaceId, actor, [ROLES_YAML], () => this.runEditLocked(workspaceId, actor, pre));
484
+ }
485
+ async runEditLocked(workspaceId, actor, pre) {
432
486
  const text = await this.readRolesYaml(workspaceId);
433
487
  const result = this.guardEdit(() => pre(text));
434
488
  if (!result.changed)
435
489
  return;
436
490
  this.assertLoadable(result.text);
437
- // LockingFilesystem paths are WORKSPACE-relative, so carry the kbDirName
438
- // prefix (unlike commitChanges' bare repo-relative paths). The release
439
- // pipeline writes a default per-file commit summary ("Update roles.yaml");
440
- // a bespoke summary isn't threadable through this path, which is fine for a
441
- // single-file roles edit. The rename keeps its descriptive summary because
442
- // it commits atomically via writeFiles.
443
- const fsys = await this.lockingFsForActor(workspaceId, actor);
444
- await this.mapLockContention(() => fsys.writeFile(`${this.kbDirName}/${ROLES_YAML}`, result.text));
491
+ // The lock is ALREADY OURS (withFileLocks; strict same-user acquire), so
492
+ // LockingFilesystem would contend against our own hold. Apply the same
493
+ // pre-disk validator it would have run, then plain-write + a path-scoped
494
+ // atomic commit.
495
+ makeRolesYamlWriteValidator(this.kbDirName)(`${this.kbDirName}/${ROLES_YAML}`, result.text);
496
+ await this.locked.writeAndCommitLocked(workspaceId, actor, [{ repoRel: ROLES_YAML, content: result.text, original: text }], `Update ${ROLES_YAML}`);
445
497
  this.accessControl.invalidate(workspaceId);
446
498
  this.emitWrites(workspaceId, actor, [ROLES_YAML]);
447
499
  }
448
- /**
449
- * Find every genuine role reference to `oldCanonical` across folder access.md
450
- * AND node frontmatter, and rewrite each to `newDisplayName`. Reference-aware:
451
- * a line is rewritten ONLY if it PARSES as a role entry whose canonical name
452
- * == oldCanonical. Prose, comments, `Name <email>` user entries, other keys,
453
- * and substrings never match. Returns the repo-relative writes to commit.
454
- */
455
- async rewriteRoleReferences(workspaceId, repoDir, oldCanonical, newDisplayName) {
456
- const writes = [];
457
- const candidates = await this.collectCandidateFiles(workspaceId);
458
- for (const repoRel of candidates) {
459
- const abs = path.join(repoDir, repoRel);
460
- let text;
461
- try {
462
- text = await fs.readFile(abs, 'utf-8');
463
- }
464
- catch (err) {
465
- // Fail closed: a candidate we cannot read might reference the
466
- // old role. Skipping it would commit a partial rewrite (a half-renamed
467
- // role pointing at the old name) and silently drop access. Abort the
468
- // whole atomic rename instead so the admin can retry cleanly.
469
- throw new RolesAdminError(`Cannot read ${repoRel} while rewriting role references; rename aborted with no changes`, 422, { cause: err?.message });
470
- }
471
- const rewritten = rewriteRoleTokensInText(text, oldCanonical, newDisplayName);
472
- if (rewritten !== text) {
473
- // REPO-relative (bare repoRel) for commitChanges — repoDir already points
474
- // at <workspaceDir>/<kbDirName>.
475
- writes.push({ repoRelativePath: repoRel, content: rewritten });
476
- }
477
- }
478
- return writes;
479
- }
480
- /**
481
- * Repo-relative paths of every file that could carry a role reference: all
482
- * `access.md` files plus every `.md` node (its own frontmatter). Sourced from
483
- * the workspace file tree (which already skips `.git` and honours
484
- * `.bevelignore`), filtered to `.md` under the KB dir and returned bare
485
- * repo-relative. (`roles.yaml` isn't `.md`, so it's excluded; the rename
486
- * commits it separately.) Under save=share the working tree matches the
487
- * committed set, so this is the same candidate list git-tracking would give.
488
- */
489
- async collectCandidateFiles(workspaceId) {
490
- const tree = await this.workspaceService.listFiles(workspaceId);
491
- const prefix = `${this.kbDirName}/`;
492
- const out = [];
493
- const visit = (node) => {
494
- if (node.type === 'file') {
495
- if (node.relativePath.startsWith(prefix) && node.relativePath.endsWith('.md')) {
496
- out.push(node.relativePath.slice(prefix.length));
497
- }
498
- return;
499
- }
500
- for (const child of node.children ?? [])
501
- visit(child);
502
- };
503
- visit(tree);
504
- return out;
505
- }
506
- /**
507
- * Sound scan of EVERY `.md` (folder `access.md` + node frontmatter)
508
- * for genuine role references, indexed by canonical role name. Shares the
509
- * candidate set and the config-region role-entry parse with the rename
510
- * rewrite (`findRoleRefsInText` / `rewriteRoleTokensInText`), so the delete
511
- * warning and the rename rewrite see the SAME references — the warning can no
512
- * longer undercount frontmatter the rename would touch. A file we cannot read
513
- * is skipped (this is an advisory read, not the atomic write path): missing a
514
- * reference here only weakens the warning, it cannot drop access.
515
- */
516
- async scanRoleReferences(workspaceId) {
517
- const repoDir = await this.repoDir(workspaceId);
518
- const candidates = await this.collectCandidateFiles(workspaceId);
519
- const byRole = new Map();
520
- for (const repoRel of candidates) {
521
- let text;
522
- try {
523
- text = await fs.readFile(path.join(repoDir, repoRel), 'utf-8');
524
- }
525
- catch {
526
- continue;
527
- }
528
- for (const ref of findRoleRefsInText(text)) {
529
- const list = byRole.get(ref.role);
530
- if (list)
531
- list.push({ path: repoRel, verb: ref.verb });
532
- else
533
- byRole.set(ref.role, [{ path: repoRel, verb: ref.verb }]);
534
- }
535
- }
536
- return byRole;
537
- }
538
- }
539
- /**
540
- * Resolve the [start, end) line range that role rewrites may touch — the YAML
541
- * config region only, NEVER the markdown body (CodeRabbit: a body line like
542
- * `- Sales` or `owner: Sales` must not be rewritten).
543
- *
544
- * - A file with leading `---` frontmatter (folder `access.md`, node `.md`):
545
- * only the lines BETWEEN the opening and closing `---` are eligible.
546
- * - A fence-less file (e.g. a bare `roles.yaml`-style access config with no
547
- * `---`): the whole file is config, so all lines are eligible.
548
- * - A `.md` file with no frontmatter fence: no config region → empty range,
549
- * nothing is rewritten.
550
- */
551
- function configLineRange(lines, isMarkdown) {
552
- if (lines.length > 0 && lines[0].trim() === '---') {
553
- for (let i = 1; i < lines.length; i++) {
554
- if (lines[i].trim() === '---')
555
- return { start: 1, end: i };
556
- }
557
- // Unterminated frontmatter — treat nothing as eligible (don't risk the body).
558
- return { start: 0, end: 0 };
559
- }
560
- // No fence: a markdown file has no config region; a non-markdown access file
561
- // (no body) is entirely config.
562
- return isMarkdown ? { start: 0, end: 0 } : { start: 0, end: lines.length };
563
- }
564
- /** A known access verb key: heads a block list or holds a scalar role value. */
565
- const VERB_KEY_RE = /^(\s*)(read|write|download|owner)(:\s*)(.*)$/;
566
- /** A block-list item: ` - <token>` (token may carry a leading `deny `). */
567
- const LIST_ITEM_RE = /^(\s*-\s+)(.*)$/;
568
- /**
569
- * Walk the CONFIG-REGION lines of `text`, invoking `onRoleRef` for every line
570
- * that PARSES as a genuine role entry — both the block-list form (`- <token>`
571
- * under a `read:`/`write:`/… key) and the inline scalar form (`owner: <token>`).
572
- * `verb` is the access verb the reference sits under; for a block list it is the
573
- * nearest enclosing verb key (lines before any verb key, or under an unknown
574
- * key, are skipped). This is the SINGLE source of truth for "what is a role
575
- * reference" — both the delete-warning scan and the rename rewrite drive off it,
576
- * so they cannot disagree. The callback may mutate `lines[i]` (the rewrite does;
577
- * the scan does not). User entries, other keys, comments and substrings never
578
- * fire it.
579
- */
580
- function walkRoleRefs(lines, start, end, onRoleRef) {
581
- let currentVerb = null;
582
- /** A role-entry value → its parsed role entry, else null (user/empty/other). */
583
- const roleEntry = (rawValue) => {
584
- const parsed = parseAccessEntry(rawValue.replace(/\s+$/, ''));
585
- return parsed.ok && parsed.entry.kind === 'role'
586
- ? { role: parsed.entry.role, deny: parsed.entry.deny }
587
- : null;
588
- };
589
- for (let i = start; i < end; i++) {
590
- const line = lines[i];
591
- // A verb key resets the block context. Its inline value (scalar form,
592
- // `owner: Sales`) is itself a candidate reference under that same verb.
593
- const kvM = line.match(VERB_KEY_RE);
594
- if (kvM) {
595
- currentVerb = kvM[2];
596
- const inlineValue = kvM[4];
597
- if (inlineValue.trim() !== '') {
598
- const entry = roleEntry(inlineValue);
599
- if (entry)
600
- onRoleRef({ i, verb: currentVerb, entry, indent: `${kvM[1]}${kvM[2]}${kvM[3]}`, prefix: '' });
601
- }
602
- continue;
603
- }
604
- // Block-list item — belongs to the nearest enclosing verb key. A list item
605
- // with no verb in scope is not a resolvable access rule; skip it.
606
- const listM = line.match(LIST_ITEM_RE);
607
- if (listM && currentVerb !== null) {
608
- const entry = roleEntry(listM[2]);
609
- if (entry)
610
- onRoleRef({ i, verb: currentVerb, entry, indent: '', prefix: listM[1] });
611
- continue;
612
- }
613
- // A non-empty, non-list, non-kv line ends the current block (e.g. a new
614
- // top-level key whose value isn't a verb, or stray prose in config).
615
- if (line.trim() !== '' && !listM)
616
- currentVerb = null;
617
- }
618
- }
619
- /** Every genuine role reference in `text`'s config region, as {role, verb}. */
620
- export function findRoleRefsInText(text, isMarkdown = true) {
621
- const lines = text.split('\n');
622
- const { start, end } = configLineRange(lines, isMarkdown);
623
- const out = [];
624
- if (start >= end)
625
- return out;
626
- walkRoleRefs(lines, start, end, ({ verb, entry }) => out.push({ role: entry.role, verb }));
627
- return out;
628
- }
629
- /**
630
- * Rewrite every CONFIG-REGION line that PARSES as a role reference whose
631
- * canonical name == `oldCanonical`, replacing the role token with
632
- * `newDisplayName` (preserving any leading `deny ` and indentation). Only the
633
- * frontmatter block of a markdown file is touched — the body is left
634
- * byte-for-byte intact, so a prose line like `- Sales` is never corrupted.
635
- * Lines that don't parse as a matching role entry (user entries, other keys,
636
- * substrings) are also untouched. Exported for test.
637
- *
638
- * `isMarkdown` (default true) marks files that carry a markdown body below the
639
- * frontmatter; pass false only for a pure-config file with no body.
640
- */
641
- export function rewriteRoleTokensInText(text, oldCanonical, newDisplayName, isMarkdown = true) {
642
- const lines = text.split('\n');
643
- const { start, end } = configLineRange(lines, isMarkdown);
644
- if (start >= end)
645
- return text;
646
- let changed = false;
647
- walkRoleRefs(lines, start, end, ({ i, entry, indent, prefix }) => {
648
- if (entry.role !== oldCanonical)
649
- return;
650
- lines[i] = `${indent}${prefix}${entry.deny ? 'deny ' : ''}${newDisplayName}`;
651
- changed = true;
652
- });
653
- return changed ? lines.join('\n') : text;
654
500
  }
655
501
  //# sourceMappingURL=roles-admin.service.js.map