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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/dist/core/create-core-server.d.ts.map +1 -1
  2. package/dist/core/create-core-server.js +12 -1
  3. package/dist/core/create-core-server.js.map +1 -1
  4. package/dist/core/create-core-services.d.ts +22 -0
  5. package/dist/core/create-core-services.d.ts.map +1 -1
  6. package/dist/core/create-core-services.js +34 -1
  7. package/dist/core/create-core-services.js.map +1 -1
  8. package/dist/index.d.ts +2 -0
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +4 -0
  11. package/dist/index.js.map +1 -1
  12. package/dist/modules/access/access-control.interface.d.ts +54 -28
  13. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  14. package/dist/modules/access/access-control.service.d.ts +129 -14
  15. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  16. package/dist/modules/access/access-control.service.js +435 -66
  17. package/dist/modules/access/access-control.service.js.map +1 -1
  18. package/dist/modules/access/access-declarations.d.ts.map +1 -1
  19. package/dist/modules/access/access-declarations.js +5 -3
  20. package/dist/modules/access/access-declarations.js.map +1 -1
  21. package/dist/modules/access/access-mutation.service.d.ts +39 -6
  22. package/dist/modules/access/access-mutation.service.d.ts.map +1 -1
  23. package/dist/modules/access/access-mutation.service.js +78 -18
  24. package/dist/modules/access/access-mutation.service.js.map +1 -1
  25. package/dist/modules/access/access-splice.d.ts +31 -4
  26. package/dist/modules/access/access-splice.d.ts.map +1 -1
  27. package/dist/modules/access/access-splice.js +40 -16
  28. package/dist/modules/access/access-splice.js.map +1 -1
  29. package/dist/modules/access/access.routes.d.ts.map +1 -1
  30. package/dist/modules/access/access.routes.js +204 -82
  31. package/dist/modules/access/access.routes.js.map +1 -1
  32. package/dist/modules/access/admin-locked-commit.d.ts +134 -0
  33. package/dist/modules/access/admin-locked-commit.d.ts.map +1 -0
  34. package/dist/modules/access/admin-locked-commit.js +277 -0
  35. package/dist/modules/access/admin-locked-commit.js.map +1 -0
  36. package/dist/modules/access/admin-route-helpers.d.ts +32 -0
  37. package/dist/modules/access/admin-route-helpers.d.ts.map +1 -0
  38. package/dist/modules/access/admin-route-helpers.js +44 -0
  39. package/dist/modules/access/admin-route-helpers.js.map +1 -0
  40. package/dist/modules/access/capability-registry.d.ts +41 -0
  41. package/dist/modules/access/capability-registry.d.ts.map +1 -0
  42. package/dist/modules/access/capability-registry.js +46 -0
  43. package/dist/modules/access/capability-registry.js.map +1 -0
  44. package/dist/modules/access/directory-sync-bot.d.ts +13 -0
  45. package/dist/modules/access/directory-sync-bot.d.ts.map +1 -0
  46. package/dist/modules/access/directory-sync-bot.js +64 -0
  47. package/dist/modules/access/directory-sync-bot.js.map +1 -0
  48. package/dist/modules/access/group-files.d.ts +83 -0
  49. package/dist/modules/access/group-files.d.ts.map +1 -0
  50. package/dist/modules/access/group-files.js +167 -0
  51. package/dist/modules/access/group-files.js.map +1 -0
  52. package/dist/modules/access/groups-admin.routes.d.ts +19 -0
  53. package/dist/modules/access/groups-admin.routes.d.ts.map +1 -0
  54. package/dist/modules/access/groups-admin.routes.js +98 -0
  55. package/dist/modules/access/groups-admin.routes.js.map +1 -0
  56. package/dist/modules/access/groups-admin.service.d.ts +166 -0
  57. package/dist/modules/access/groups-admin.service.d.ts.map +1 -0
  58. package/dist/modules/access/groups-admin.service.js +442 -0
  59. package/dist/modules/access/groups-admin.service.js.map +1 -0
  60. package/dist/modules/access/groups-edit.d.ts +58 -0
  61. package/dist/modules/access/groups-edit.d.ts.map +1 -0
  62. package/dist/modules/access/groups-edit.js +162 -0
  63. package/dist/modules/access/groups-edit.js.map +1 -0
  64. package/dist/modules/access/reference-scan.d.ts +141 -0
  65. package/dist/modules/access/reference-scan.d.ts.map +1 -0
  66. package/dist/modules/access/reference-scan.js +440 -0
  67. package/dist/modules/access/reference-scan.js.map +1 -0
  68. package/dist/modules/access/roles-admin.service.d.ts +88 -119
  69. package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
  70. package/dist/modules/access/roles-admin.service.js +230 -384
  71. package/dist/modules/access/roles-admin.service.js.map +1 -1
  72. package/dist/modules/access/roles-edit.d.ts +51 -25
  73. package/dist/modules/access/roles-edit.d.ts.map +1 -1
  74. package/dist/modules/access/roles-edit.js +133 -59
  75. package/dist/modules/access/roles-edit.js.map +1 -1
  76. package/dist/modules/access/synced-groups-committer.d.ts +28 -0
  77. package/dist/modules/access/synced-groups-committer.d.ts.map +1 -0
  78. package/dist/modules/access/synced-groups-committer.js +139 -0
  79. package/dist/modules/access/synced-groups-committer.js.map +1 -0
  80. package/dist/modules/access/synced-groups-writer.d.ts +78 -0
  81. package/dist/modules/access/synced-groups-writer.d.ts.map +1 -0
  82. package/dist/modules/access/synced-groups-writer.js +219 -0
  83. package/dist/modules/access/synced-groups-writer.js.map +1 -0
  84. package/dist/modules/database/core-schema.d.ts +17 -0
  85. package/dist/modules/database/core-schema.d.ts.map +1 -1
  86. package/dist/modules/database/core-schema.js +9 -0
  87. package/dist/modules/database/core-schema.js.map +1 -1
  88. package/dist/modules/mcp/mcp-auth.middleware.d.ts +13 -2
  89. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  90. package/dist/modules/mcp/mcp-auth.middleware.js +61 -2
  91. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  92. package/dist/modules/mcp/mcp.routes.d.ts +9 -3
  93. package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
  94. package/dist/modules/mcp/mcp.routes.js +126 -2
  95. package/dist/modules/mcp/mcp.routes.js.map +1 -1
  96. package/dist/modules/mcp/mcp.service.d.ts +14 -0
  97. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  98. package/dist/modules/mcp/mcp.service.js +6 -1
  99. package/dist/modules/mcp/mcp.service.js.map +1 -1
  100. package/dist/modules/workflow/file-lock.service.d.ts +11 -1
  101. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  102. package/dist/modules/workflow/file-lock.service.js +15 -1
  103. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  104. package/dist/modules/workflow/git/git.service.d.ts +34 -11
  105. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  106. package/dist/modules/workflow/git/git.service.js +165 -37
  107. package/dist/modules/workflow/git/git.service.js.map +1 -1
  108. package/dist/modules/workflow/locking-filesystem.d.ts +4 -0
  109. package/dist/modules/workflow/locking-filesystem.d.ts.map +1 -1
  110. package/dist/modules/workflow/locking-filesystem.js +181 -28
  111. package/dist/modules/workflow/locking-filesystem.js.map +1 -1
  112. package/dist/modules/workflow/pending-commits.service.d.ts +10 -0
  113. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  114. package/dist/modules/workflow/pending-commits.service.js +19 -1
  115. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  116. package/dist/modules/workflow/workflow.service.d.ts +17 -2
  117. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  118. package/dist/modules/workflow/workflow.service.js +113 -19
  119. package/dist/modules/workflow/workflow.service.js.map +1 -1
  120. package/kb-template/AGENTS.md +4 -1
  121. package/migrations/0004_file_lock_mode.sql +2 -0
  122. package/migrations/meta/0004_snapshot.json +1564 -0
  123. package/migrations/meta/_journal.json +7 -0
  124. package/package.json +3 -3
  125. package/src/core/create-core-server.ts +13 -0
  126. package/src/core/create-core-services.ts +68 -0
  127. package/src/index.ts +10 -0
  128. package/src/modules/access/__tests__/access-control.service.test.ts +9 -3
  129. package/src/modules/access/__tests__/access-groups.test.ts +427 -0
  130. package/src/modules/access/__tests__/access-mutation.service.test.ts +171 -5
  131. package/src/modules/access/__tests__/access-splice.test.ts +65 -0
  132. package/src/modules/access/__tests__/access.routes.group-grant.test.ts +337 -0
  133. package/src/modules/access/__tests__/access.routes.revoke.test.ts +48 -0
  134. package/src/modules/access/__tests__/admin-locked-commit.test.ts +221 -0
  135. package/src/modules/access/__tests__/admin-route-helpers.test.ts +61 -0
  136. package/src/modules/access/__tests__/directory-sync-bot.test.ts +106 -0
  137. package/src/modules/access/__tests__/grant-sources.test.ts +67 -0
  138. package/src/modules/access/__tests__/groups-admin.service.test.ts +440 -0
  139. package/src/modules/access/__tests__/reference-scan.test.ts +288 -0
  140. package/src/modules/access/__tests__/roles-admin.service.test.ts +161 -83
  141. package/src/modules/access/__tests__/roles-capabilities.test.ts +325 -0
  142. package/src/modules/access/__tests__/roles-edit.test.ts +104 -28
  143. package/src/modules/access/__tests__/roles.routes.test.ts +63 -40
  144. package/src/modules/access/__tests__/synced-groups-committer.test.ts +238 -0
  145. package/src/modules/access/__tests__/synced-groups-writer.test.ts +249 -0
  146. package/src/modules/access/access-control.interface.ts +66 -32
  147. package/src/modules/access/access-control.service.ts +536 -73
  148. package/src/modules/access/access-declarations.ts +5 -2
  149. package/src/modules/access/access-mutation.service.ts +88 -14
  150. package/src/modules/access/access-splice.ts +55 -17
  151. package/src/modules/access/access.routes.ts +227 -93
  152. package/src/modules/access/admin-locked-commit.ts +331 -0
  153. package/src/modules/access/admin-route-helpers.ts +55 -0
  154. package/src/modules/access/capability-registry.ts +74 -0
  155. package/src/modules/access/directory-sync-bot.ts +76 -0
  156. package/src/modules/access/group-files.ts +212 -0
  157. package/src/modules/access/groups-admin.routes.ts +113 -0
  158. package/src/modules/access/groups-admin.service.ts +551 -0
  159. package/src/modules/access/groups-edit.ts +187 -0
  160. package/src/modules/access/reference-scan.ts +513 -0
  161. package/src/modules/access/roles-admin.service.ts +290 -418
  162. package/src/modules/access/roles-edit.ts +134 -61
  163. package/src/modules/access/synced-groups-committer.ts +177 -0
  164. package/src/modules/access/synced-groups-writer.ts +303 -0
  165. package/src/modules/database/core-schema.ts +9 -0
  166. package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +116 -0
  167. package/src/modules/mcp/__tests__/mcp.e2e.test.ts +3 -1
  168. package/src/modules/mcp/__tests__/mcp.routes.delete.test.ts +6 -1
  169. package/src/modules/mcp/__tests__/mcp.routes.local-token.test.ts +301 -0
  170. package/src/modules/mcp/mcp-auth.middleware.ts +61 -1
  171. package/src/modules/mcp/mcp.routes.ts +137 -2
  172. package/src/modules/mcp/mcp.service.ts +6 -1
  173. package/src/modules/workflow/__tests__/locking-filesystem.test.ts +336 -0
  174. package/src/modules/workflow/__tests__/preserve-roles-yaml.test.ts +1 -1
  175. package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +32 -0
  176. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +13 -2
  177. package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +139 -1
  178. package/src/modules/workflow/file-lock.service.ts +15 -0
  179. package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +0 -1
  180. package/src/modules/workflow/git/__tests__/git.service.commitChanges.test.ts +132 -0
  181. package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +0 -1
  182. package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +0 -1
  183. package/src/modules/workflow/git/git.service.ts +174 -35
  184. package/src/modules/workflow/locking-filesystem.ts +188 -26
  185. package/src/modules/workflow/pending-commits.service.ts +27 -1
  186. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +0 -1
  187. package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +0 -1
  188. package/src/modules/workflow/workflow.service.ts +140 -20
@@ -4,6 +4,7 @@ import { accessMdPathForFolder } from './access-mutation.service.js';
4
4
  import {
5
5
  EVERYONE_CANONICAL,
6
6
  KNOWN_VERBS,
7
+ hasAccessFrontmatterExtension,
7
8
  parseAccessFile,
8
9
  parseOwnAccessEntries,
9
10
  type ParsedEntry,
@@ -81,9 +82,11 @@ export class AccessDeclarationsError extends WorkflowDomainError {
81
82
  }
82
83
  }
83
84
 
84
- /** Node files whose own frontmatter may declare verbs. */
85
+ /** Node files whose own frontmatter may declare verbs — the resolver's shared
86
+ * extension set (`ACCESS_FRONTMATTER_EXTENSIONS`), which the reference
87
+ * scanner mirrors. */
85
88
  function isNodeCandidate(name: string): boolean {
86
- return name.endsWith('.md') || name.endsWith('.tool');
89
+ return hasAccessFrontmatterExtension(name);
87
90
  }
88
91
 
89
92
  /** Walk a workspace-relative path down the file tree; null when absent. */
@@ -36,20 +36,26 @@ import path from 'node:path';
36
36
 
37
37
  import type { WorkspaceService } from '../workspace/workspace.service.js';
38
38
  import type { IAccessControl } from './access-control.interface.js';
39
- import { type Verb, KNOWN_VERBS } from './access-control.service.js';
39
+ import {
40
+ type Verb,
41
+ KNOWN_VERBS,
42
+ ROLE_TOKEN_PREFIX,
43
+ canonicalRoleName,
44
+ } from './access-control.service.js';
40
45
  import {
41
46
  spliceRevoke,
42
47
  spliceGrant,
43
48
  validatePrincipal,
44
49
  AccessSpliceError,
45
50
  type Principal,
51
+ type TokenMatch,
46
52
  } from './access-splice.js';
47
53
  import { WorkflowDomainError } from '../workflow/workflow.errors.js';
48
54
 
49
55
  /** Whether the dialog target is a folder (edit folder access.md) or a file (edit node frontmatter). */
50
56
  export type TargetKind = 'folder' | 'file';
51
57
 
52
- /** Bad-request-class mutation failure (invalid principal, unknown plugin, lockout). */
58
+ /** Bad-request-class mutation failure (invalid principal, unknown role/group, lockout). */
53
59
  export class AccessMutationError extends WorkflowDomainError {
54
60
  constructor(message: string, status = 400, payload?: Record<string, unknown>) {
55
61
  super(message, status, payload);
@@ -121,6 +127,41 @@ export class AccessMutationService {
121
127
  return path.posix.join(this.kbDirName, repoRelative);
122
128
  }
123
129
 
130
+ /**
131
+ * How a REVOKE of `principal` matches file tokens — the shadowing rule:
132
+ *
133
+ * - When a GROUP owns the principal's bare name (group-first precedence),
134
+ * the two spellings are DIFFERENT principals: the bare token is the
135
+ * group's, `role/<name>` is the role's. Matching is `'exact'` so
136
+ * revoking the role never strips the group's bare grant (and revoking
137
+ * the group never strips the role's explicit grant).
138
+ * - Unshadowed, both spellings resolve to the ROLE (bare falls back to
139
+ * it), so matching is `'name'`: revoking the role also removes legacy
140
+ * bare spellings — the historical cleanup behavior.
141
+ *
142
+ * Judged against THIS workspace's merged principal index (`kbPrincipals` —
143
+ * the same model the resolver reads), so revoke agrees with resolution on
144
+ * who owns the bare key. A model that fails to load yields no groups, i.e.
145
+ * unshadowed — matching degrades to the pre-groups name-level behavior.
146
+ *
147
+ * GROUP revokes never take the name-level path: the route passes
148
+ * `tokenMatch: 'exact'` explicitly (see `revoke`'s `opts`), because this
149
+ * shadow probe cannot tell a group apart from a role once the group has
150
+ * VANISHED from the active source — the bare name then reads "unshadowed"
151
+ * and a name-level group revoke would strip a same-named role's live
152
+ * `role/<Name>` grant.
153
+ */
154
+ private async revokeTokenMatch(workspaceId: string, principal: Principal): Promise<TokenMatch> {
155
+ if (principal.kind !== 'role') return 'exact'; // user matching ignores the mode
156
+ const canonical = canonicalRoleName(principal.role);
157
+ const bare = canonical.startsWith(ROLE_TOKEN_PREFIX)
158
+ ? canonical.slice(ROLE_TOKEN_PREFIX.length)
159
+ : canonical;
160
+ const { groups } = await this.accessControl.kbPrincipals(workspaceId);
161
+ const shadowed = groups.some((g) => canonicalRoleName(g) === bare);
162
+ return shadowed ? 'exact' : 'name';
163
+ }
164
+
124
165
  /**
125
166
  * Grant `principal` `verb` on `target`. Adds the principal under exactly this
126
167
  * verb and touches no other verb — verbs are independent, so a principal may
@@ -177,26 +218,38 @@ export class AccessMutationService {
177
218
  kind: TargetKind,
178
219
  repoRelTarget: string,
179
220
  principal: Principal,
180
- // Kept on the signature: the route supplies it and the upcoming
181
- // revoke-vs-deny slice needs the acting user. Not consumed yet.
221
+ // Kept on the signature: the route supplies it, for attribution / future
222
+ // per-principal checks. Not consumed yet.
182
223
  _actingUserEmail: string,
183
224
  // When present, strip ONLY this verb (a per-checkbox toggle in the share UI);
184
225
  // absent strips the principal from every verb (the whole-principal Remove).
185
226
  verb?: Verb,
227
+ // `tokenMatch` overrides the shadow-derived matching. The route passes
228
+ // 'exact' for GROUP principals: a group's grant is its bare token only,
229
+ // and once the group has vanished the shadow probe can no longer tell it
230
+ // from a role — name-level matching would then strip a same-named role's
231
+ // `role/<Name>` grant.
232
+ opts?: { tokenMatch?: TokenMatch },
186
233
  ): Promise<{ changed: boolean; editPath: string }> {
187
- void _actingUserEmail; // referenced to satisfy no-unused-vars until Slice 2 consumes it
234
+ void _actingUserEmail; // referenced to satisfy no-unused-vars until something consumes it
188
235
  this.assertPrincipalSafe(principal); // same injection/shape guard grant runs
189
236
  const { editPath } = this.fileToEdit(kind, repoRelTarget);
190
237
  // Allow a missing target only for a folder (no access.md yet is normal); a
191
238
  // missing FILE node is a bad target and should surface, not silently no-op
192
239
  // — same rule grant() uses.
193
240
  const original = await this.readOrEmpty(workspaceId, editPath, kind === 'folder');
241
+ // Alias-tolerant vs exact-token matching, decided by group shadowing —
242
+ // see revokeTokenMatch — unless the caller pinned it.
243
+ const tokenMatch = opts?.tokenMatch ?? (await this.revokeTokenMatch(workspaceId, principal));
194
244
  let next = original;
195
245
  let changed = false;
196
246
  try {
197
247
  const verbsToRevoke = verb ? [verb] : KNOWN_VERBS;
198
248
  for (const v of verbsToRevoke) {
199
- const r = spliceRevoke(next, v, principal, { target: kind === 'folder' ? 'folder' : 'node' });
249
+ const r = spliceRevoke(next, v, principal, {
250
+ target: kind === 'folder' ? 'folder' : 'node',
251
+ tokenMatch,
252
+ });
200
253
  next = r.text;
201
254
  changed = changed || r.changed;
202
255
  }
@@ -248,18 +301,26 @@ export class AccessMutationService {
248
301
  repoRelTarget: string,
249
302
  principal: Principal,
250
303
  verb?: Verb,
304
+ // Same override as `revoke` — the route pins 'exact' for GROUP principals.
305
+ opts?: { tokenMatch?: TokenMatch },
251
306
  ): Promise<{ changed: boolean; editPath: string }> {
252
307
  this.assertPrincipalSafe(principal);
253
308
  const { editPath, allowScalar } = this.fileToEdit(kind, repoRelTarget);
254
309
  const original = await this.readOrEmpty(workspaceId, editPath, kind === 'folder');
255
310
 
256
311
  const verbsToDeny = verb ? [verb] : KNOWN_VERBS;
312
+ // Same shadowing-aware matching as revoke(): the strip must not swallow a
313
+ // same-named OTHER principal's grant (bare = group vs role/<name> = role).
314
+ const tokenMatch = opts?.tokenMatch ?? (await this.revokeTokenMatch(workspaceId, principal));
257
315
  let next = original;
258
316
  try {
259
317
  for (const v of verbsToDeny) {
260
318
  // (1) Strip any same-scope GRANT for this principal so grant-beats-deny
261
319
  // can't silently swallow the deny we're about to add.
262
- next = spliceRevoke(next, v, principal, { target: kind === 'folder' ? 'folder' : 'node' }).text;
320
+ next = spliceRevoke(next, v, principal, {
321
+ target: kind === 'folder' ? 'folder' : 'node',
322
+ tokenMatch,
323
+ }).text;
263
324
  // (2) Add the deny under the same verb.
264
325
  next = spliceGrant(next, v, principal, { allowScalar, deny: true, target: kind === 'folder' ? 'folder' : 'node' }).text;
265
326
  }
@@ -277,13 +338,18 @@ export class AccessMutationService {
277
338
  this.accessControl.invalidate(workspaceId);
278
339
 
279
340
  // (3) Assert the deny actually removed effective access on the targeted
280
- // verb(s) — else roll back.
341
+ // verb(s) — else roll back. The check evaluates the SAME token identity
342
+ // the deny above spliced (`tokenMatch` rides along): with a pinned exact
343
+ // GROUP deny whose group has vanished, alias-tolerant matching would read
344
+ // a same-named role's surviving `role/<Name>` grant as the group still
345
+ // having access — rolling back a fully effective deny as "ineffective".
281
346
  const stillHas = await this.principalStillHasAccess(
282
347
  workspaceId,
283
348
  kind,
284
349
  repoRelTarget,
285
350
  principal,
286
351
  verb,
352
+ tokenMatch,
287
353
  );
288
354
  if (stillHas) {
289
355
  await this.workspaceService.writeFile(workspaceId, wsRelative, original);
@@ -305,7 +371,7 @@ export class AccessMutationService {
305
371
  * access — not just "is there a removable file entry."
306
372
  *
307
373
  * For a USER, that distinction matters: effective access includes admin-rescue
308
- * on `access.md`/`roles.yaml`, plugin membership, and the built-in `everyone` —
374
+ * on `access.md`/`roles.yaml`, role/group membership, and the built-in `everyone` —
309
375
  * none of which `grantSources` reports (it is MECE over file-backed
310
376
  * direct/ancestor entries only). So we ask the same `canRead/canWrite/
311
377
  * canDownload/canOwner` resolver every real access decision uses; if the
@@ -319,6 +385,10 @@ export class AccessMutationService {
319
385
  * ever holds access by being NAMED in a file (no rescue / role-via-role /
320
386
  * everyone indirection applies to a role token), so `grantSources` IS its
321
387
  * complete effective-access answer (scoped to `verb` when given).
388
+ * `tokenMatch` keeps the check on the same token identity the preceding
389
+ * splice used: 'exact' asks `grantSources` to count only the literally
390
+ * spelled token — see the denyHere call site for why that matters when a
391
+ * same-named group and role diverge.
322
392
  */
323
393
  private async principalStillHasAccess(
324
394
  workspaceId: string,
@@ -326,6 +396,7 @@ export class AccessMutationService {
326
396
  repoRelTarget: string,
327
397
  principal: Principal,
328
398
  verb?: Verb,
399
+ tokenMatch?: TokenMatch,
329
400
  ): Promise<boolean> {
330
401
  if (principal.kind === 'user') {
331
402
  const email = principal.email;
@@ -339,10 +410,13 @@ export class AccessMutationService {
339
410
  const results = await Promise.all(verbs.map((v) => canOf[v]()));
340
411
  return results.some(Boolean);
341
412
  }
342
- const sources = await this.accessControl.grantSources(workspaceId, kind, repoRelTarget, {
343
- kind: 'role',
344
- role: principal.role,
345
- });
413
+ const sources = await this.accessControl.grantSources(
414
+ workspaceId,
415
+ kind,
416
+ repoRelTarget,
417
+ { kind: 'role', role: principal.role },
418
+ tokenMatch ? { tokenMatch } : undefined,
419
+ );
346
420
  return verb ? sources[verb] !== undefined : Object.keys(sources).length > 0;
347
421
  }
348
422
 
@@ -351,7 +425,7 @@ export class AccessMutationService {
351
425
  * asynchronously in the route (it needs the roles model). Here we only run the
352
426
  * cheap injection/shape validation so a malformed principal never reaches the
353
427
  * splice. Role-exists-in-roles.yaml is the route's job (so it can offer
354
- * "Create Plugin" for an unknown plugin, an admin-only flow).
428
+ * an honest 404 for an unknown role/group).
355
429
  */
356
430
  private assertPrincipalSafe(principal: Principal): void {
357
431
  try {
@@ -122,7 +122,7 @@ export function validatePrincipal(p: Principal): Principal {
122
122
  }
123
123
  const role = p.role.trim();
124
124
  const canonical = canonicalRoleName(role);
125
- if (!canonical) throw new AccessSpliceError('plugin grant needs a name');
125
+ if (!canonical) throw new AccessSpliceError('role grant needs a name');
126
126
  if (
127
127
  CONTROL_CHARS.test(role) ||
128
128
  role.includes('<') ||
@@ -130,24 +130,48 @@ export function validatePrincipal(p: Principal): Principal {
130
130
  role.includes(':') ||
131
131
  role.includes('#')
132
132
  ) {
133
- throw new AccessSpliceError(`invalid plugin name: ${JSON.stringify(p.role)}`);
133
+ throw new AccessSpliceError(`invalid role name: ${JSON.stringify(p.role)}`);
134
134
  }
135
135
  // `deny` is never a grantee — it's the denial prefix. `everyone` IS grantable
136
136
  // (the built-in public role); the access route restricts it to the read verb.
137
137
  if (canonical === 'deny') {
138
- throw new AccessSpliceError(`'${role}' is a reserved name and cannot be granted as a plugin`);
138
+ throw new AccessSpliceError(`'${role}' is a reserved name and cannot be granted as a role`);
139
139
  }
140
140
  const round = parseAccessEntry(role);
141
141
  if (!round.ok || round.entry.kind !== 'role' || round.entry.deny) {
142
- throw new AccessSpliceError(`plugin grant does not round-trip: ${JSON.stringify(p)}`);
142
+ throw new AccessSpliceError(`role grant does not round-trip: ${JSON.stringify(p)}`);
143
143
  }
144
144
  return { kind: 'role', role };
145
145
  }
146
146
 
147
- /** True when a parsed entry names the given principal (ignoring deny prefix). */
148
- function entryMatches(entry: ParsedEntry, p: Principal): boolean {
147
+ /**
148
+ * How a role-shaped principal's TOKEN is compared against a file entry:
149
+ *
150
+ * - `'exact'`: the entry must carry the SAME spelling — a bare token and a
151
+ * `role/<name>` token are DIFFERENT principals (bare = whoever owns the
152
+ * bare key under group-first precedence, `role/` = the role). Grants
153
+ * always use this: granting the group `X` while `role/X` is present (or
154
+ * vice versa) must NOT be treated as already-granted, or the other
155
+ * principal silently never receives the grant.
156
+ * - `'name'`: both spellings of the name match. Revokes use this ONLY when
157
+ * no group shadows the bare name — then both spellings resolve to the
158
+ * role, and revoking the role must also remove legacy bare spellings.
159
+ */
160
+ export type TokenMatch = 'exact' | 'name';
161
+
162
+ /**
163
+ * True when a parsed entry names the given principal (ignoring deny prefix).
164
+ * `tokenMatch` (role-shaped principals only) selects exact-spelling vs
165
+ * name-level matching — see {@link TokenMatch}.
166
+ */
167
+ function entryMatches(entry: ParsedEntry, p: Principal, tokenMatch: TokenMatch): boolean {
149
168
  if (p.kind === 'user') return entry.kind === 'user' && entry.email === canonicalEmail(p.email);
150
- return entry.kind === 'role' && entry.role === canonicalRoleName(p.role);
169
+ if (entry.kind !== 'role') return false;
170
+ const canonicalPrincipal = canonicalRoleName(p.role);
171
+ if (tokenMatch === 'exact') return entry.role === canonicalPrincipal;
172
+ const strip = (token: string): string =>
173
+ token.startsWith('role/') ? canonicalRoleName(token.slice('role/'.length)) : token;
174
+ return strip(entry.role) === strip(canonicalPrincipal);
151
175
  }
152
176
 
153
177
  // ---------------------------------------------------------------------------
@@ -325,10 +349,15 @@ function blockEntries(fm: string[], block: VerbBlock): { entry: ParsedEntry; lin
325
349
  // ---------------------------------------------------------------------------
326
350
 
327
351
  /**
328
- * Add `principal` under `verb`. Idempotent: if an equivalent entry already
329
- * exists with the SAME deny-ness (a grant when granting, a deny when denying),
330
- * returns `changed: false`. Creates the verb key (and the frontmatter fence,
331
- * for a fresh file) as needed. Preserves everything else byte-for-byte.
352
+ * Add `principal` under `verb`. Idempotent: if an entry with the EXACT same
353
+ * token spelling already exists with the SAME deny-ness (a grant when
354
+ * granting, a deny when denying), returns `changed: false`. Idempotency is
355
+ * deliberately exact-token, never name-level: a bare token and a `role/`
356
+ * token are different principals (group-first precedence gives the bare key
357
+ * to a same-named group), so a bare-`X` GROUP grant must go through even when
358
+ * `role/X` is present, and vice versa. Creates the verb key (and the
359
+ * frontmatter fence, for a fresh file) as needed. Preserves everything else
360
+ * byte-for-byte.
332
361
  *
333
362
  * `allowScalar` controls whether a fresh single-entry verb may be written in
334
363
  * the node-frontmatter scalar form (`owner: Name <email>`). access.md always
@@ -369,9 +398,10 @@ export function spliceGrant(
369
398
  const block = findVerbBlock(region.lines, verb);
370
399
  const existing = blockEntries(region.lines, block);
371
400
 
372
- // Idempotency: an entry for this principal with the same deny-ness is already
373
- // present (a grant when granting, a deny when denying).
374
- if (existing.some((e) => entryMatches(e.entry, principal) && e.entry.deny === deny)) {
401
+ // Idempotency: an entry for this EXACT token with the same deny-ness is
402
+ // already present (a grant when granting, a deny when denying). Exact-token
403
+ // on purpose — see the doc above.
404
+ if (existing.some((e) => entryMatches(e.entry, principal, 'exact') && e.entry.deny === deny)) {
375
405
  return { text, changed: false };
376
406
  }
377
407
 
@@ -413,14 +443,22 @@ export function spliceGrant(
413
443
  * last item empties the verb, the `verb:` key is collapsed to `verb: []` so the
414
444
  * file still parses cleanly (the resolver reads `[]` as an empty list).
415
445
  * Preserves everything else.
446
+ *
447
+ * Token matching for role-shaped principals is `tokenMatch` (default `'name'`:
448
+ * BOTH spellings — revoking the role removes legacy bare spellings too).
449
+ * Callers pass `'exact'` when a GROUP shadows the principal's bare name: the
450
+ * bare token then belongs to the group and the `role/` token to the role, so a
451
+ * revoke of one must never strip the other (see AccessMutationService, which
452
+ * derives the flag from the merged principal index).
416
453
  */
417
454
  export function spliceRevoke(
418
455
  text: string,
419
456
  verb: Verb,
420
457
  rawPrincipal: Principal,
421
- opts: { target?: SpliceTarget } = {},
458
+ opts: { target?: SpliceTarget; tokenMatch?: TokenMatch } = {},
422
459
  ): SpliceResult {
423
460
  const principal = validatePrincipal(rawPrincipal);
461
+ const tokenMatch = opts.tokenMatch ?? 'name';
424
462
  const f = splitFrontmatter(text);
425
463
  if (!f.hasFrontmatter) return { text, changed: false };
426
464
 
@@ -430,7 +468,7 @@ export function spliceRevoke(
430
468
 
431
469
  if (block.inlineScalarValue !== null) {
432
470
  const r = parseAccessEntry(block.inlineScalarValue);
433
- if (r.ok && entryMatches(r.entry, principal)) {
471
+ if (r.ok && entryMatches(r.entry, principal, tokenMatch)) {
434
472
  // Lone scalar entry removed -> collapse to empty list.
435
473
  region.lines[block.keyLine] = `${' '.repeat(block.keyIndent)}${verb}: []`;
436
474
  return { text: region.join(), changed: true };
@@ -439,7 +477,7 @@ export function spliceRevoke(
439
477
  }
440
478
 
441
479
  const entries = blockEntries(region.lines, block);
442
- const victims = entries.filter((e) => entryMatches(e.entry, principal)).map((e) => e.line);
480
+ const victims = entries.filter((e) => entryMatches(e.entry, principal, tokenMatch)).map((e) => e.line);
443
481
  if (victims.length === 0) return { text, changed: false };
444
482
 
445
483
  // Delete from the bottom up so earlier indices stay valid.