@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
@@ -11,8 +11,22 @@ import type {
11
11
  GrantPrincipal,
12
12
  GrantSource,
13
13
  GrantSources,
14
+ ResolvedPrincipal,
14
15
  } from './access-control.interface.js';
15
16
  import { AccessConfigError } from './access-errors.js';
17
+ // NOTE: deliberate module cycle — group-files.ts imports this module's parsing
18
+ // primitives. Benign: both sides only dereference the other's exports inside
19
+ // function bodies, never at module-evaluation time.
20
+ import {
21
+ GROUPS_YAML,
22
+ SYNCED_GROUPS_YAML,
23
+ parseGroupsFile,
24
+ type GroupsIndex,
25
+ } from './group-files.js';
26
+ import {
27
+ DIRECTORY_SYNC_BOT_EMAIL,
28
+ DIRECTORY_SYNC_BOT_NAME,
29
+ } from './directory-sync-bot.js';
16
30
 
17
31
  const execFileAsync = promisify(execFile);
18
32
 
@@ -133,6 +147,24 @@ export function sourceVerbsFor(verb: Verb): Verb[] {
133
147
  }
134
148
  export const RESERVED_ROLE_NAMES = new Set(['deny', EVERYONE_CANONICAL]);
135
149
  export const DENY_PREFIX = 'deny ';
150
+ /**
151
+ * Member-entry prefix in roles.yaml that references a GROUP instead of an
152
+ * email: `- group:Engineering`. Explicit on purpose — membership kind is
153
+ * never guessed from string shape. Valid on EVERY role including Admin, with
154
+ * one kept invariant (see `parseRolesYaml`): Admin must always retain at
155
+ * least one direct email member, so a misconfigured or unreachable directory
156
+ * can never leave the deployment without a rescuable admin.
157
+ */
158
+ export const GROUP_REF_PREFIX = 'group:';
159
+ /**
160
+ * Explicit ROLE token prefix in access.md entries: `role/<Name>` resolves to
161
+ * the roles.yaml role only, never a group. A BARE name resolves GROUP-FIRST
162
+ * and falls back to the role — so `role/` is the escape hatch when a group
163
+ * shares the role's name. The prefix is reserved in the group name-safety
164
+ * rules (a group may never be named `role/...`), and every role is also
165
+ * registered in the principal index under its `role/<canonical>` alias.
166
+ */
167
+ export const ROLE_TOKEN_PREFIX = 'role/';
136
168
 
137
169
  export const USER_REF_REGEX = /^(.+?)\s+<\s*([^<>\s]+@[^<>\s]+)\s*>\s*$/;
138
170
  export const EMAIL_REGEX = /^[^<>\s@]+@[^<>\s@]+\.[^<>\s@]+$/;
@@ -180,14 +212,50 @@ export function isAccessMdPath(p: string): boolean {
180
212
  return p === 'access.md' || p.endsWith('/access.md');
181
213
  }
182
214
 
215
+ /**
216
+ * Extensions of node files whose OWN `---` frontmatter can carry access verbs
217
+ * the resolver enforces (`readOwnEntries` → `parseOwnAccessEntries`). The
218
+ * SINGLE source of truth for every surface that enumerates candidate files —
219
+ * the access-declarations scan and the shared `KbReferenceScanner` (which
220
+ * must scan/rewrite the same set, or a rename strands a live `.tool`
221
+ * frontmatter grant). `access.md` is covered by `.md`.
222
+ */
223
+ export const ACCESS_FRONTMATTER_EXTENSIONS = ['.md', '.tool'] as const;
224
+
225
+ /** True when `p` is a file the resolver reads access frontmatter from. */
226
+ export function hasAccessFrontmatterExtension(p: string): boolean {
227
+ return ACCESS_FRONTMATTER_EXTENSIONS.some((ext) => p.endsWith(ext));
228
+ }
229
+
230
+ /**
231
+ * The PRINCIPAL index — canonical name → member emails. Despite the name it
232
+ * holds both kinds of named principal after `mergeGroupsIntoRoles` runs:
233
+ * roles.yaml roles (`kind: 'role'`, the default) and the active group file's
234
+ * groups (`kind: 'group'`). Grant resolution treats them identically — a
235
+ * grant names a principal, the principal has member emails — which is what
236
+ * lets the whole closeness-first resolver work on groups without changes.
237
+ *
238
+ * `groupRefs` carries a role's `group:<Name>` member entries between parse
239
+ * and merge; the merge expands them into `emails`/`byEmail`.
240
+ */
183
241
  interface RolesIndex {
184
- byCanonical: Map<string, { displayName: string; emails: Set<string> }>;
242
+ byCanonical: Map<
243
+ string,
244
+ { displayName: string; emails: Set<string>; groupRefs?: Set<string>; kind?: 'role' | 'group' }
245
+ >;
185
246
  byEmail: Map<string, Set<string>>;
186
247
  }
187
248
 
188
249
  interface AccessModel {
189
250
  roles: RolesIndex;
190
251
  accessFilesByDir: Map<string, AccessFile>;
252
+ /**
253
+ * Whether the active groups source loaded cleanly — see {@link GroupsHealth}.
254
+ * A broken source degrades (groups contribute nothing) but is recorded here
255
+ * so admin surfaces can banner it instead of silently resolving without
256
+ * groups.
257
+ */
258
+ groupsHealth: GroupsHealth;
191
259
  /**
192
260
  * Canonical emails that count as Admin whatever `roles.yaml` says — the
193
261
  * deployment owner (`ADMIN_EMAIL`). Empty when none is configured.
@@ -230,7 +298,14 @@ interface YamlErr {
230
298
  error: string;
231
299
  }
232
300
 
233
- function stripComment(line: string): string {
301
+ /**
302
+ * Strip a trailing `# comment` the way the YAML-subset tokeniser reads a
303
+ * line: a `#` at the start (after only whitespace) or preceded by whitespace
304
+ * begins a comment. Exported so the reference scanner matches tokens against
305
+ * the SAME comment rule the resolver parses with (a `- GTM Team # sales`
306
+ * entry is the token `GTM Team`, never `GTM Team # sales`).
307
+ */
308
+ export function stripComment(line: string): string {
234
309
  let inWs = true;
235
310
  for (let i = 0; i < line.length; i++) {
236
311
  const ch = line[i];
@@ -250,9 +325,17 @@ interface Token {
250
325
  value: string;
251
326
  }
252
327
 
253
- function tokenise(text: string): YamlErr | { ok: true; tokens: Token[] } {
328
+ function tokenise(
329
+ text: string,
330
+ opts?: { tolerateEmptyKeys?: boolean },
331
+ ): YamlErr | { ok: true; tokens: Token[] } {
254
332
  const lines = text.split(/\r?\n/);
255
333
  const tokens: Token[] = [];
334
+ // Tolerated blank keys are uniquified as runs of spaces so a SECOND blank
335
+ // key doesn't trip the duplicate-key check (which would retire every valid
336
+ // group after it). All-whitespace keys still canonicalize to '' downstream,
337
+ // so the entry-level "empty group name — skipped" handling sees them all.
338
+ let blankKeySeq = 0;
256
339
  for (let i = 0; i < lines.length; i++) {
257
340
  const stripped = stripComment(lines[i]).replace(/\s+$/, '');
258
341
  if (!stripped.trim()) continue;
@@ -270,16 +353,28 @@ function tokenise(text: string): YamlErr | { ok: true; tokens: Token[] } {
270
353
  if (colonIdx < 0) {
271
354
  return { ok: false, error: `line ${lineNum}: expected 'key:' or '- value' but got '${content}'` };
272
355
  }
273
- const key = content.slice(0, colonIdx).trim();
356
+ let key = content.slice(0, colonIdx).trim();
274
357
  const valuePart = content.slice(colonIdx + 1).trim();
275
- if (!key) return { ok: false, error: `line ${lineNum}: empty mapping key` };
358
+ // An empty key is normally a hard error (roles.yaml/access.md want loud
359
+ // failures), but a parser with ENTRY-level forgiveness (the group files:
360
+ // one bad entry must not retire every other group) keeps it as a
361
+ // whitespace key for its own skip-with-warning handling.
362
+ if (!key) {
363
+ if (!opts?.tolerateEmptyKeys) {
364
+ return { ok: false, error: `line ${lineNum}: empty mapping key` };
365
+ }
366
+ key = ' '.repeat(++blankKeySeq);
367
+ }
276
368
  tokens.push({ lineNum, indent, kind: 'kv', key, value: valuePart });
277
369
  }
278
370
  return { ok: true, tokens };
279
371
  }
280
372
 
281
- export function parseYamlSubset(text: string): YamlOk | YamlErr {
282
- const tok = tokenise(text);
373
+ export function parseYamlSubset(
374
+ text: string,
375
+ opts?: { tolerateEmptyKeys?: boolean },
376
+ ): YamlOk | YamlErr {
377
+ const tok = tokenise(text, opts);
283
378
  if (!tok.ok) return tok;
284
379
  if (tok.tokens.length === 0) return { ok: true, value: {} };
285
380
 
@@ -426,8 +521,16 @@ export function parseAccessEntry(
426
521
  };
427
522
  }
428
523
 
429
- const role = canonicalRoleName(body);
524
+ let role = canonicalRoleName(body);
430
525
  if (!role) return { ok: false, error: `empty role name in entry '${raw}'` };
526
+ // Explicit role token: normalize `role/ <Name>` spacing so the canonical
527
+ // form is always `role/<canonicalName>` — the exact alias key the principal
528
+ // index registers for every role.
529
+ if (role.startsWith(ROLE_TOKEN_PREFIX)) {
530
+ const suffix = canonicalRoleName(role.slice(ROLE_TOKEN_PREFIX.length));
531
+ if (!suffix) return { ok: false, error: `entry '${body}' names no role after '${ROLE_TOKEN_PREFIX}'` };
532
+ role = `${ROLE_TOKEN_PREFIX}${suffix}`;
533
+ }
431
534
  return { ok: true, entry: { kind: 'role', role, displayRole: body, deny } };
432
535
  }
433
536
 
@@ -469,6 +572,12 @@ export function parseRolesYaml(
469
572
  );
470
573
  continue;
471
574
  }
575
+ if (canonical.startsWith(ROLE_TOKEN_PREFIX)) {
576
+ errors.push(
577
+ `roles.yaml: role '${displayName}' starts with the reserved '${ROLE_TOKEN_PREFIX}' prefix — that spelling is the explicit role token in access entries`,
578
+ );
579
+ continue;
580
+ }
472
581
  if (index.byCanonical.has(canonical)) {
473
582
  const prev = index.byCanonical.get(canonical)!.displayName;
474
583
  errors.push(
@@ -481,11 +590,26 @@ export function parseRolesYaml(
481
590
  continue;
482
591
  }
483
592
  const emails = new Set<string>();
593
+ const groupRefs = new Set<string>();
484
594
  for (const rawEmail of value) {
485
595
  if (typeof rawEmail !== 'string') {
486
596
  errors.push(`roles.yaml: role '${displayName}' has a non-string entry`);
487
597
  continue;
488
598
  }
599
+ // `- group:<Name>` assigns the role to a whole group (expanded against
600
+ // the active group source by `mergeGroupsIntoRoles`). Allowed on every
601
+ // role, Admin included — the Admin invariant below only demands at
602
+ // least one DIRECT email member so a broken directory can never leave
603
+ // the deployment adminless.
604
+ if (rawEmail.trim().toLowerCase().startsWith(GROUP_REF_PREFIX)) {
605
+ const refName = canonicalRoleName(rawEmail.trim().slice(GROUP_REF_PREFIX.length));
606
+ if (!refName) {
607
+ errors.push(`roles.yaml: role '${displayName}' has an empty group reference '${rawEmail}'`);
608
+ continue;
609
+ }
610
+ groupRefs.add(refName);
611
+ continue;
612
+ }
489
613
  const email = canonicalEmail(rawEmail);
490
614
  if (!EMAIL_REGEX.test(email)) {
491
615
  errors.push(`roles.yaml: role '${displayName}' has malformed email '${rawEmail}'`);
@@ -499,19 +623,182 @@ export function parseRolesYaml(
499
623
  }
500
624
  set.add(canonical);
501
625
  }
502
- index.byCanonical.set(canonical, { displayName: displayName.trim(), emails });
626
+ index.byCanonical.set(canonical, { displayName: displayName.trim(), emails, groupRefs });
503
627
  }
504
628
 
505
629
  if (!index.byCanonical.has(ADMIN_CANONICAL)) {
506
630
  errors.push(`roles.yaml: must declare at least one 'Admin' role`);
507
631
  } else if (index.byCanonical.get(ADMIN_CANONICAL)!.emails.size === 0) {
508
- errors.push(`roles.yaml: 'Admin' role has no emails`);
632
+ // The kept invariant: Admin may reference groups, but must ALWAYS retain
633
+ // at least one direct email member — the rescue story requires an admin
634
+ // whose membership does not depend on a reachable, well-configured
635
+ // directory. A group-only Admin is as hard an error as an adminless one.
636
+ errors.push(
637
+ `roles.yaml: 'Admin' role has no direct email members — Admin must keep at least one individual email (group references alone are not enough)`,
638
+ );
509
639
  }
510
640
 
511
641
  if (errors.length) return { ok: false, errors };
512
642
  return { ok: true, index };
513
643
  }
514
644
 
645
+ /**
646
+ * Merge the active group source into the principal index and expand role →
647
+ * group assignments. Mutates `index` in place; returns human-readable
648
+ * warnings (callers log them — nothing here ever throws, because group
649
+ * problems must degrade, not brick access resolution).
650
+ *
651
+ * Rules (the grant-grammar precedence):
652
+ * - Every role is ALSO registered under its explicit `role/<canonical>`
653
+ * alias — the token that always resolves to the role.
654
+ * - A BARE name resolves GROUP-FIRST: when a group's canonical name
655
+ * collides with a role's, the bare key resolves to the GROUP (warned);
656
+ * the role stays reachable via `role/<canonical>`.
657
+ * - Role `group:<Name>` refs — Admin's included — expand against the
658
+ * merged groups; an unknown ref contributes nothing (warned).
659
+ * - `byEmail` is rebuilt so each member holds exactly the tokens that
660
+ * resolve to a principal they belong to (bare + `role/` alias for roles,
661
+ * bare for groups).
662
+ */
663
+ export function mergeGroupsIntoRoles(
664
+ index: RolesIndex,
665
+ groups: GroupsIndex,
666
+ sourceFile: string,
667
+ ): string[] {
668
+ const warnings: string[] = [];
669
+ // Snapshot before any group lands: at this point the index holds roles only.
670
+ const roleRecords = new Map(index.byCanonical);
671
+
672
+ // 1. Expand role → group assignments (Admin included — its safety net is
673
+ // the parse-time "at least one direct email" invariant, not a merge skip).
674
+ for (const [, principal] of roleRecords) {
675
+ if (!principal.groupRefs?.size) continue;
676
+ for (const ref of principal.groupRefs) {
677
+ const group = groups.get(ref);
678
+ if (!group) {
679
+ warnings.push(
680
+ `roles.yaml: role '${principal.displayName}' references unknown group '${ref}' — reference ignored`,
681
+ );
682
+ continue;
683
+ }
684
+ for (const email of group.emails) principal.emails.add(email);
685
+ }
686
+ }
687
+
688
+ // 2. Bare-name precedence: groups win the bare token; the role keeps its
689
+ // `role/<canonical>` alias registered below.
690
+ for (const [canonical, def] of groups) {
691
+ if (roleRecords.has(canonical)) {
692
+ warnings.push(
693
+ `${sourceFile}: group '${def.displayName}' shares its name with a role — the bare name now resolves to the GROUP; use '${ROLE_TOKEN_PREFIX}${canonical}' to reference the role`,
694
+ );
695
+ }
696
+ index.byCanonical.set(canonical, {
697
+ displayName: def.displayName,
698
+ emails: new Set(def.emails),
699
+ kind: 'group',
700
+ });
701
+ }
702
+
703
+ // 3. Explicit `role/<canonical>` alias for every role (same record — the
704
+ // alias and the bare key, when the role still owns it, stay in lockstep).
705
+ for (const [canonical, principal] of roleRecords) {
706
+ principal.kind = 'role';
707
+ index.byCanonical.set(`${ROLE_TOKEN_PREFIX}${canonical}`, principal);
708
+ }
709
+
710
+ // 4. Rebuild the email → tokens map from the final index so membership
711
+ // reflects the post-precedence keys (a collided role's members no longer
712
+ // hold the bare token unless the group also contains them).
713
+ index.byEmail.clear();
714
+ for (const [key, principal] of index.byCanonical) {
715
+ for (const email of principal.emails) {
716
+ let set = index.byEmail.get(email);
717
+ if (!set) {
718
+ set = new Set();
719
+ index.byEmail.set(email, set);
720
+ }
721
+ set.add(key);
722
+ }
723
+ }
724
+
725
+ return warnings;
726
+ }
727
+
728
+ /**
729
+ * Health of the ACTIVE groups source as of the last load. `ok: false` means
730
+ * the source EXISTS but could not be read or parsed — groups contribute
731
+ * nothing, bare grant tokens fall through to roles, and group-backed denies
732
+ * drop (the owner's explicit degrade-loudly decision, NOT fail-closed). The
733
+ * marker is carried on the loaded model and surfaced by the groups admin
734
+ * endpoints so the Groups page can banner it.
735
+ */
736
+ export type GroupsHealth = { ok: true } | { ok: false; file: string; reason: string };
737
+
738
+ /** True for the errno codes that mean "the file genuinely is not there". */
739
+ function isAbsenceError(err: unknown): boolean {
740
+ const code = (err as NodeJS.ErrnoException | null)?.code;
741
+ return code === 'ENOENT' || code === 'ENOTDIR';
742
+ }
743
+
744
+ /**
745
+ * Load the ACTIVE group source through `read` (working tree or at-ref — the
746
+ * caller supplies the reader, so both model loaders share one mode rule):
747
+ * `synced-groups.yaml` existing → IdP mode (groups.yaml ignored entirely,
748
+ * even when the synced file is empty or malformed — falling back would
749
+ * resurrect retired manual groups); otherwise `groups.yaml` → manual mode.
750
+ *
751
+ * `read` returns null for a genuinely-absent file and may THROW for any other
752
+ * failure. Only ENOENT/ENOTDIR count as absent (the caller may also whitelist
753
+ * them into null); every other read error — notably a non-absence error on
754
+ * `synced-groups.yaml` — is treated as a BROKEN source: no groups, no
755
+ * fallback to the manual file (falling back would resurrect retired groups),
756
+ * and an `ok: false` health marker. Structural parse failures degrade the
757
+ * same way; this function never throws.
758
+ */
759
+ export async function loadActiveGroups(
760
+ read: (filename: string) => Promise<string | null>,
761
+ ): Promise<{ groups: GroupsIndex; sourceFile: string; warnings: string[]; health: GroupsHealth }> {
762
+ const broken = (file: string, reason: string) => ({
763
+ groups: new Map() as GroupsIndex,
764
+ sourceFile: file,
765
+ warnings: [],
766
+ health: { ok: false as const, file, reason },
767
+ });
768
+
769
+ let syncedText: string | null;
770
+ try {
771
+ syncedText = await read(SYNCED_GROUPS_YAML);
772
+ } catch (err) {
773
+ if (isAbsenceError(err)) {
774
+ syncedText = null;
775
+ } else {
776
+ // A non-absence read error on the SYNCED source must NOT fall back to
777
+ // groups.yaml — the synced file may exist and its manual predecessor is
778
+ // retired. Broken-groups instead.
779
+ return broken(SYNCED_GROUPS_YAML, err instanceof Error ? err.message : String(err));
780
+ }
781
+ }
782
+
783
+ const sourceFile = syncedText !== null ? SYNCED_GROUPS_YAML : GROUPS_YAML;
784
+ let text: string | null;
785
+ if (syncedText !== null) {
786
+ text = syncedText;
787
+ } else {
788
+ try {
789
+ text = await read(GROUPS_YAML);
790
+ } catch (err) {
791
+ if (isAbsenceError(err)) text = null;
792
+ else return broken(GROUPS_YAML, err instanceof Error ? err.message : String(err));
793
+ }
794
+ }
795
+ if (text === null) return { groups: new Map(), sourceFile, warnings: [], health: { ok: true } };
796
+
797
+ const parsed = parseGroupsFile(text, sourceFile);
798
+ if (!parsed.ok) return broken(sourceFile, parsed.errors.join('; '));
799
+ return { groups: parsed.groups, sourceFile, warnings: parsed.warnings, health: { ok: true } };
800
+ }
801
+
515
802
  /**
516
803
  * Does an `access.md` body declare access rules — i.e. is the file in the NEW
517
804
  * (body-governs-the-folder) format?
@@ -807,7 +1094,10 @@ function resolveAtPath(
807
1094
  function isAdminEmail(model: AccessModel, email: string): boolean {
808
1095
  if (model.deploymentOwners.has(email)) return true;
809
1096
  const roles = model.roles.byEmail.get(email);
810
- return !!roles && roles.has(ADMIN_CANONICAL);
1097
+ // Check the explicit `role/admin` alias, NOT the bare token: bare-name
1098
+ // precedence is group-first, so a group that happens to be named "Admin"
1099
+ // owns the bare key — and its members must never inherit the capability.
1100
+ return !!roles && roles.has(`${ROLE_TOKEN_PREFIX}${ADMIN_CANONICAL}`);
811
1101
  }
812
1102
 
813
1103
  /**
@@ -925,7 +1215,7 @@ function scopeToGrantSource(
925
1215
  * returning the WHOLE list of removable entries rather than just the winner.
926
1216
  *
927
1217
  * Only the principal's OWN named entry yields a source — a `user` by their email,
928
- * a `role` by its role token. A grant that reaches the user via a plugin they
1218
+ * a `role` by its role token. A grant that reaches the user via a group/role they
929
1219
  * belong to, the built-in `everyone`, or admin-rescue is NOT their entry, so it
930
1220
  * never adds a source (the group/role shows as its own row instead). The list is:
931
1221
  * - `[]` (verb omitted by the caller) when the principal effectively holds no
@@ -941,7 +1231,7 @@ function scopeToGrantSource(
941
1231
  * under closest-wins), and the revoke flow needs the inherited remainder that
942
1232
  * survives removing the direct entry. A group/everyone grant at some scope does
943
1233
  * not add a source AND does not hide a farther own-entry the principal is named
944
- * in (removing it is still meaningful if the plugin grant is later removed).
1234
+ * in (removing it is still meaningful if the group/role grant is later removed).
945
1235
  */
946
1236
  function resolveGrantSourcesForVerb(
947
1237
  model: AccessModel,
@@ -950,16 +1240,48 @@ function resolveGrantSourcesForVerb(
950
1240
  relativePath: string,
951
1241
  principal: GrantPrincipal,
952
1242
  fileOwn?: OwnEntries | null,
1243
+ tokenMatch?: 'exact' | 'name',
953
1244
  ): GrantSource[] {
954
1245
  const scopes = resolveScopes(model, verb, relativePath, fileOwn);
955
1246
  const out: GrantSource[] = [];
956
1247
 
957
1248
  if (principal.kind === 'role') {
958
- const role = canonicalRoleName(principal.role);
1249
+ // A named principal can be spelled two ways in a file: the bare token and
1250
+ // the explicit `role/<name>` token. WHICH spellings are THIS principal's
1251
+ // entries depends on who owns the bare key in the merged index:
1252
+ //
1253
+ // - UNSHADOWED (no group named `<bare>`): both spellings resolve to the
1254
+ // role, so both count — a grant under either adds a source; a deny
1255
+ // under either cuts off farther grants. (The revoke splice strips both
1256
+ // spellings in this case too, so classification and removal agree.)
1257
+ // - SHADOWED (a group owns the bare key): the spellings are DIFFERENT
1258
+ // principals. A bare-token principal is the GROUP — only bare entries
1259
+ // are its own; a `role/`-token principal is the ROLE — only `role/`
1260
+ // entries are its own. Counting the other spelling would attribute a
1261
+ // shadowed bare token to the role (hiding a real `role/<name>`
1262
+ // ancestor grant and making its revoke a false no-op), or vice versa.
1263
+ //
1264
+ // - PINNED EXACT (`tokenMatch: 'exact'`): only the literally-spelled
1265
+ // token is this principal's entry, shadowing notwithstanding. The
1266
+ // caller pinned the same identity into the splice it is checking —
1267
+ // a GROUP whose group has VANISHED reads "unshadowed" here, and the
1268
+ // alias-tolerant pair would then attribute a same-named role's
1269
+ // surviving `role/<name>` grant to the group.
1270
+ const canonical = canonicalRoleName(principal.role);
1271
+ const explicit = canonical.startsWith(ROLE_TOKEN_PREFIX);
1272
+ const bare = explicit ? canonical.slice(ROLE_TOKEN_PREFIX.length) : canonical;
1273
+ const shadowed = model.roles.byCanonical.get(bare)?.kind === 'group';
1274
+ const tokens =
1275
+ tokenMatch === 'exact' || shadowed
1276
+ ? [explicit ? `${ROLE_TOKEN_PREFIX}${bare}` : bare]
1277
+ : [bare, `${ROLE_TOKEN_PREFIX}${bare}`];
959
1278
  for (const scope of scopes) {
960
- const s = scope.byRole.get(role);
961
- if (s === 'denied') break; // a closer deny of this role cuts off farther grants
962
- if (s === 'grant') out.push(scopeToGrantSource(scope, kind, relativePath));
1279
+ const states = tokens.map((t) => scope.byRole.get(t));
1280
+ if (states.includes('grant')) {
1281
+ out.push(scopeToGrantSource(scope, kind, relativePath));
1282
+ continue;
1283
+ }
1284
+ if (states.includes('denied')) break; // a closer deny cuts off farther grants
963
1285
  }
964
1286
  return out;
965
1287
  }
@@ -1058,14 +1380,29 @@ function eligibleHoldersResolved(
1058
1380
  verb: Verb,
1059
1381
  relativePath: string,
1060
1382
  fileOwn?: OwnEntries | null,
1061
- ): { roles: string[]; users: { name: string; email: string }[] } {
1383
+ ): { principals: ResolvedPrincipal[]; roles: string[]; users: { name: string; email: string }[] } {
1062
1384
  const { byRole, byEmail } = resolveAtPath(model, verb, relativePath, fileOwn);
1063
1385
 
1064
- const roleSet = new Set<string>();
1386
+ // (kind, display name) → one entry. The `byRole` keys are the merged
1387
+ // index's canonical tokens exactly as granted (bare, or the
1388
+ // `role/<canonical>` alias); the `byCanonical` record a key hits carries
1389
+ // its kind — a `role/` alias always hits the role record, a bare key hits
1390
+ // whichever principal owns it under group-first precedence. A token with no
1391
+ // record (the built-in `everyone`, or a grant naming a since-vanished
1392
+ // principal) degrades to 'role', the pre-groups display. When one display
1393
+ // name is granted as BOTH (a role via its `role/` alias plus a same-named
1394
+ // group via the bare token) BOTH entries survive — they are DIFFERENT
1395
+ // principals, and collapsing them to one would hide the role's live
1396
+ // `role/<name>` grant from every consumer of the eligible list.
1397
+ const byIdentity = new Map<string, ResolvedPrincipal>();
1398
+ const addPrincipal = (name: string, kind: 'role' | 'group') => {
1399
+ const key = `${kind}\0${name.toLowerCase()}`;
1400
+ if (!byIdentity.has(key)) byIdentity.set(key, { name, kind });
1401
+ };
1065
1402
  for (const [canonical, state] of byRole) {
1066
1403
  if (state !== 'grant') continue;
1067
- const role = model.roles.byCanonical.get(canonical);
1068
- roleSet.add(role ? role.displayName : canonical);
1404
+ const record = model.roles.byCanonical.get(canonical);
1405
+ addPrincipal(record ? record.displayName : canonical, record?.kind ?? 'role');
1069
1406
  }
1070
1407
 
1071
1408
  // Mirror the admin overrides applied in `hasPermissionResolved`: write on
@@ -1077,11 +1414,20 @@ function eligibleHoldersResolved(
1077
1414
  verb === 'write' &&
1078
1415
  (relativePath === 'roles.yaml' || isAccessMdPath(relativePath))
1079
1416
  ) {
1080
- const adminRole = model.roles.byCanonical.get(ADMIN_CANONICAL);
1081
- roleSet.add(adminRole ? adminRole.displayName : ADMIN_CANONICAL);
1417
+ // Look the Admin ROLE up via its explicit alias — the bare key may be
1418
+ // owned by a same-named group under group-first precedence. The override
1419
+ // is the ROLE's capability, so the row's kind is 'role' regardless.
1420
+ const adminRole = model.roles.byCanonical.get(`${ROLE_TOKEN_PREFIX}${ADMIN_CANONICAL}`);
1421
+ addPrincipal(adminRole ? adminRole.displayName : ADMIN_CANONICAL, 'role');
1082
1422
  }
1083
1423
 
1084
- const roles = [...roleSet].sort();
1424
+ const principals: ResolvedPrincipal[] = [...byIdentity.values()].sort((a, b) =>
1425
+ a.name < b.name ? -1 : a.name > b.name ? 1 : a.kind < b.kind ? -1 : a.kind > b.kind ? 1 : 0,
1426
+ );
1427
+ // Legacy name-only list, kind erased — kept for the many consumers that
1428
+ // only ever render names (banners, contact lines, PR-routing messages).
1429
+ // De-duplicated: a name granted as both group and role appears once here.
1430
+ const roles = [...new Set(principals.map((p) => p.name))];
1085
1431
 
1086
1432
  const users: { name: string; email: string }[] = [];
1087
1433
  for (const [email, state] of byEmail) {
@@ -1090,7 +1436,7 @@ function eligibleHoldersResolved(
1090
1436
  }
1091
1437
  users.sort((a, b) => a.email.localeCompare(b.email));
1092
1438
 
1093
- return { roles, users };
1439
+ return { principals, roles, users };
1094
1440
  }
1095
1441
 
1096
1442
  /**
@@ -1254,35 +1600,13 @@ export class AccessControlService implements IAccessControl {
1254
1600
  return parsed.ok ? { ok: true } : { ok: false, errors: parsed.errors };
1255
1601
  }
1256
1602
 
1257
- /**
1258
- * Advisory scan of folder `access.md` files for references to a role (by
1259
- * canonical name). See `IAccessControl.referencesToRole` — undercounts node
1260
- * frontmatter by design; powers the delete warning only, never the rename
1261
- * gate.
1262
- */
1263
- async referencesToRole(
1264
- workspaceId: string,
1265
- canonicalRole: string,
1266
- ): Promise<{ path: string; verb: string }[]> {
1267
- const model = await this.loadModel(workspaceId);
1268
- const out: { path: string; verb: string }[] = [];
1269
- for (const file of model.accessFilesByDir.values()) {
1270
- for (const verb of KNOWN_VERBS) {
1271
- for (const entry of file.entries[verb]) {
1272
- if (entry.kind === 'role' && entry.role === canonicalRole) {
1273
- out.push({ path: file.path, verb });
1274
- }
1275
- }
1276
- }
1277
- }
1278
- return out;
1279
- }
1280
-
1281
1603
  async canWrite(
1282
1604
  workspaceId: string,
1283
1605
  userEmail: string,
1284
1606
  relativePath: string,
1285
1607
  ): Promise<boolean> {
1608
+ const machineOwned = this.machineOwnedWriteRule(userEmail, relativePath);
1609
+ if (machineOwned !== null) return machineOwned;
1286
1610
  const model = await this.loadModel(workspaceId);
1287
1611
  const own = await this.readOwnEntries(await this.repoDir(workspaceId), relativePath);
1288
1612
  return hasPermissionResolved(model, 'write', userEmail, relativePath, own);
@@ -1327,7 +1651,8 @@ export class AccessControlService implements IAccessControl {
1327
1651
  const owns = await Promise.all(relativePaths.map((p) => this.cachedOwnEntries(workspaceId, repoDir, p)));
1328
1652
  const result = new Map<string, boolean>();
1329
1653
  relativePaths.forEach((p, i) => {
1330
- result.set(p, hasPermissionResolved(model, 'write', userEmail, p, owns[i]));
1654
+ const machineOwned = this.machineOwnedWriteRule(userEmail, p);
1655
+ result.set(p, machineOwned ?? hasPermissionResolved(model, 'write', userEmail, p, owns[i]));
1331
1656
  });
1332
1657
  return result;
1333
1658
  }
@@ -1371,7 +1696,11 @@ export class AccessControlService implements IAccessControl {
1371
1696
  async eligibleOwners(
1372
1697
  workspaceId: string,
1373
1698
  relativePath: string,
1374
- ): Promise<{ roles: string[]; users: { name: string; email: string }[] }> {
1699
+ ): Promise<{
1700
+ principals: ResolvedPrincipal[];
1701
+ roles: string[];
1702
+ users: { name: string; email: string }[];
1703
+ }> {
1375
1704
  const model = await this.loadModel(workspaceId);
1376
1705
  const own = await this.readOwnEntries(await this.repoDir(workspaceId), relativePath);
1377
1706
  return eligibleHoldersResolved(model, 'owner', relativePath, own);
@@ -1380,7 +1709,11 @@ export class AccessControlService implements IAccessControl {
1380
1709
  async eligibleWriters(
1381
1710
  workspaceId: string,
1382
1711
  relativePath: string,
1383
- ): Promise<{ roles: string[]; users: { name: string; email: string }[] }> {
1712
+ ): Promise<{
1713
+ principals: ResolvedPrincipal[];
1714
+ roles: string[];
1715
+ users: { name: string; email: string }[];
1716
+ }> {
1384
1717
  const model = await this.loadModel(workspaceId);
1385
1718
  const own = await this.readOwnEntries(await this.repoDir(workspaceId), relativePath);
1386
1719
  return eligibleHoldersResolved(model, 'write', relativePath, own);
@@ -1389,23 +1722,32 @@ export class AccessControlService implements IAccessControl {
1389
1722
  async eligibleReaders(
1390
1723
  workspaceId: string,
1391
1724
  relativePath: string,
1392
- ): Promise<{ restricted: boolean; roles: string[]; users: { name: string; email: string }[] }> {
1725
+ ): Promise<{
1726
+ restricted: boolean;
1727
+ principals: ResolvedPrincipal[];
1728
+ roles: string[];
1729
+ users: { name: string; email: string }[];
1730
+ }> {
1393
1731
  const model = await this.loadModel(workspaceId);
1394
1732
  const own = await this.readOwnEntries(await this.repoDir(workspaceId), relativePath);
1395
1733
  // When `read: everyone` applies cleanly, the node is readable by all users
1396
1734
  // and the role/user lists are meaningless. Otherwise return the explicit
1397
1735
  // reader set; it may be empty for a default-denied path with no grants.
1398
1736
  if (canEveryoneReadResolved(model, relativePath, own)) {
1399
- return { restricted: false, roles: [], users: [] };
1737
+ return { restricted: false, principals: [], roles: [], users: [] };
1400
1738
  }
1401
- const { roles, users } = eligibleHoldersResolved(model, 'read', relativePath, own);
1402
- return { restricted: true, roles, users };
1739
+ const { principals, roles, users } = eligibleHoldersResolved(model, 'read', relativePath, own);
1740
+ return { restricted: true, principals, roles, users };
1403
1741
  }
1404
1742
 
1405
1743
  async eligibleDownloaders(
1406
1744
  workspaceId: string,
1407
1745
  relativePath: string,
1408
- ): Promise<{ roles: string[]; users: { name: string; email: string }[] }> {
1746
+ ): Promise<{
1747
+ principals: ResolvedPrincipal[];
1748
+ roles: string[];
1749
+ users: { name: string; email: string }[];
1750
+ }> {
1409
1751
  const model = await this.loadModel(workspaceId);
1410
1752
  const own = await this.readOwnEntries(await this.repoDir(workspaceId), relativePath);
1411
1753
  return eligibleHoldersResolved(model, 'download', relativePath, own);
@@ -1434,6 +1776,7 @@ export class AccessControlService implements IAccessControl {
1434
1776
  kind: AccessTargetKind,
1435
1777
  relativePath: string,
1436
1778
  principal: GrantPrincipal,
1779
+ opts?: { tokenMatch?: 'exact' | 'name' },
1437
1780
  ): Promise<GrantSources> {
1438
1781
  const model = await this.loadModel(workspaceId);
1439
1782
  // A file target consults its own frontmatter as the most-specific scope; a
@@ -1445,7 +1788,15 @@ export class AccessControlService implements IAccessControl {
1445
1788
  : null;
1446
1789
  const out: GrantSources = {};
1447
1790
  for (const verb of KNOWN_VERBS) {
1448
- const sources = resolveGrantSourcesForVerb(model, verb, kind, relativePath, principal, own);
1791
+ const sources = resolveGrantSourcesForVerb(
1792
+ model,
1793
+ verb,
1794
+ kind,
1795
+ relativePath,
1796
+ principal,
1797
+ own,
1798
+ opts?.tokenMatch,
1799
+ );
1449
1800
  if (sources.length > 0) out[verb] = sources;
1450
1801
  }
1451
1802
  return out;
@@ -1453,21 +1804,27 @@ export class AccessControlService implements IAccessControl {
1453
1804
 
1454
1805
  async kbPrincipals(
1455
1806
  workspaceId: string,
1456
- ): Promise<{ plugins: string[]; people: { name: string; email: string }[] }> {
1807
+ ): Promise<{ roles: string[]; groups: string[]; people: { name: string; email: string }[] }> {
1457
1808
  let model: AccessModel;
1458
1809
  try {
1459
1810
  model = await this.loadModel(workspaceId);
1460
1811
  } catch {
1461
- return { plugins: [], people: [] };
1812
+ return { roles: [], groups: [], people: [] };
1813
+ }
1814
+ // Roles = the built-in `everyone` role plus every declared role's display
1815
+ // name — ROLE principals only, never groups. Each role is enumerated via
1816
+ // its `role/<canonical>` alias key, which exists exactly once per role
1817
+ // (the bare key may be owned by a same-named group under group-first
1818
+ // precedence, and merged group entries must not appear here). `everyone`
1819
+ // is surfaced so the share UI can grant public read; the grant route
1820
+ // gates it to the `read` verb only (write/owner/download everyone stay a
1821
+ // direct-access.md edit).
1822
+ const roles = [EVERYONE_DISPLAY];
1823
+ const groups: string[] = [];
1824
+ for (const [key, principal] of model.roles.byCanonical) {
1825
+ if (key.startsWith(ROLE_TOKEN_PREFIX)) roles.push(principal.displayName);
1826
+ else if (principal.kind === 'group') groups.push(principal.displayName);
1462
1827
  }
1463
- // Plugins = the built-in `everyone` role plus every declared role's display
1464
- // name. `everyone` is surfaced so the share UI can grant public read; the
1465
- // grant route gates it to the `read` verb only (write/owner/download
1466
- // everyone stay a direct-access.md edit).
1467
- const plugins = [
1468
- EVERYONE_DISPLAY,
1469
- ...[...model.roles.byCanonical.values()].map((r) => r.displayName),
1470
- ];
1471
1828
  // People = roles.yaml member emails (name-less) ∪ access.md `Name <email>`
1472
1829
  // grants (named). The login-only users table is unioned in by the caller.
1473
1830
  const byEmail = new Map<string, string>(); // email -> display name ('' if unknown)
@@ -1487,7 +1844,7 @@ export class AccessControlService implements IAccessControl {
1487
1844
  name: name || email.split('@')[0],
1488
1845
  email,
1489
1846
  }));
1490
- return { plugins, people };
1847
+ return { roles, groups, people };
1491
1848
  }
1492
1849
 
1493
1850
  async findEmailByHash(
@@ -1530,12 +1887,28 @@ export class AccessControlService implements IAccessControl {
1530
1887
  return null;
1531
1888
  }
1532
1889
 
1890
+ /**
1891
+ * `synced-groups.yaml` is MACHINE-OWNED: regenerated wholesale from the
1892
+ * directory mirror and committed by the directory-sync bot. The bot is its
1893
+ * ONLY writer — role/grant resolution never applies to it. That cuts both
1894
+ * ways: the bot needs no role to write it (it isn't in roles.yaml, and on a
1895
+ * protected branch nothing else would make it eligible), and no HUMAN can
1896
+ * hand-edit it through the app (an edit would be silently overwritten by
1897
+ * the next provisioning push anyway).
1898
+ */
1899
+ private machineOwnedWriteRule(userEmail: string, relativePath: string): boolean | null {
1900
+ if (relativePath !== SYNCED_GROUPS_YAML) return null;
1901
+ return userEmail.trim().toLowerCase() === DIRECTORY_SYNC_BOT_EMAIL;
1902
+ }
1903
+
1533
1904
  async canWriteAtRef(
1534
1905
  workspaceId: string,
1535
1906
  ref: string,
1536
1907
  userEmail: string,
1537
1908
  relativePath: string,
1538
1909
  ): Promise<boolean | null> {
1910
+ const machineOwned = this.machineOwnedWriteRule(userEmail, relativePath);
1911
+ if (machineOwned !== null) return machineOwned;
1539
1912
  const loaded = await this.loadModelAtRef(workspaceId, ref);
1540
1913
  if (!loaded) return null;
1541
1914
  const repoDir = await this.repoDir(workspaceId);
@@ -1562,6 +1935,14 @@ export class AccessControlService implements IAccessControl {
1562
1935
  userEmail: string,
1563
1936
  relativePaths: string[],
1564
1937
  ): Promise<Map<string, boolean> | null> {
1938
+ // Machine-owned paths resolve without the model (see machineOwnedWriteRule)
1939
+ // — matching canWriteAtRef, including on a repo with no rules at the ref.
1940
+ const machineAnswers = new Map<string, boolean>();
1941
+ for (const p of relativePaths) {
1942
+ const machineOwned = this.machineOwnedWriteRule(userEmail, p);
1943
+ if (machineOwned !== null) machineAnswers.set(p, machineOwned);
1944
+ }
1945
+ if (machineAnswers.size === relativePaths.length) return machineAnswers;
1565
1946
  const loaded = await this.loadModelAtRef(workspaceId, ref);
1566
1947
  if (!loaded) return null;
1567
1948
  const repoDir = await this.repoDir(workspaceId);
@@ -1570,7 +1951,11 @@ export class AccessControlService implements IAccessControl {
1570
1951
  const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths);
1571
1952
  const result = new Map<string, boolean>();
1572
1953
  for (const p of relativePaths) {
1573
- result.set(p, hasPermissionResolved(loaded.model, 'write', userEmail, p, owns.get(p) ?? null));
1954
+ const machineOwned = machineAnswers.get(p);
1955
+ result.set(
1956
+ p,
1957
+ machineOwned ?? hasPermissionResolved(loaded.model, 'write', userEmail, p, owns.get(p) ?? null),
1958
+ );
1574
1959
  }
1575
1960
  return result;
1576
1961
  }
@@ -1580,6 +1965,13 @@ export class AccessControlService implements IAccessControl {
1580
1965
  ref: string,
1581
1966
  relativePath: string,
1582
1967
  ): Promise<{ roles: string[]; users: { name: string; email: string }[] } | null> {
1968
+ if (relativePath === SYNCED_GROUPS_YAML) {
1969
+ // Machine-owned — see machineOwnedWriteRule.
1970
+ return {
1971
+ roles: [],
1972
+ users: [{ name: DIRECTORY_SYNC_BOT_NAME, email: DIRECTORY_SYNC_BOT_EMAIL }],
1973
+ };
1974
+ }
1583
1975
  const loaded = await this.loadModelAtRef(workspaceId, ref);
1584
1976
  if (!loaded) return null;
1585
1977
  const repoDir = await this.repoDir(workspaceId);
@@ -1600,9 +1992,6 @@ export class AccessControlService implements IAccessControl {
1600
1992
  excludedEmails?: Set<string>;
1601
1993
  }
1602
1994
  > | null> {
1603
- const loaded = await this.loadModelAtRef(workspaceId, ref);
1604
- if (!loaded) return null;
1605
- const repoDir = await this.repoDir(workspaceId);
1606
1995
  const result = new Map<
1607
1996
  string,
1608
1997
  {
@@ -1612,9 +2001,30 @@ export class AccessControlService implements IAccessControl {
1612
2001
  excludedEmails?: Set<string>;
1613
2002
  }
1614
2003
  >();
2004
+ // Machine-owned paths resolve without the model — same answer
2005
+ // eligibleWritersAtRef gives (including at a ref with no usable
2006
+ // roles.yaml), so batched consumers (CR owner-routing, approval state)
2007
+ // agree with the single-path surface.
2008
+ for (const p of relativePaths) {
2009
+ if (p === SYNCED_GROUPS_YAML) {
2010
+ result.set(p, {
2011
+ roles: [],
2012
+ users: [{ name: DIRECTORY_SYNC_BOT_NAME, email: DIRECTORY_SYNC_BOT_EMAIL }],
2013
+ emails: new Set([DIRECTORY_SYNC_BOT_EMAIL]),
2014
+ });
2015
+ }
2016
+ }
2017
+ // Only short-circuit when there IS a machine-owned path covering the
2018
+ // whole request: an EMPTY request must still answer null for an
2019
+ // unresolvable ref, as documented.
2020
+ if (relativePaths.length > 0 && result.size === relativePaths.length) return result;
2021
+ const loaded = await this.loadModelAtRef(workspaceId, ref);
2022
+ if (!loaded) return null;
2023
+ const repoDir = await this.repoDir(workspaceId);
1615
2024
  // One `git cat-file --batch` for the whole path set — see canWriteBatchAtRef.
1616
2025
  const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths);
1617
2026
  for (const p of relativePaths) {
2027
+ if (result.has(p)) continue;
1618
2028
  const own = owns.get(p) ?? null;
1619
2029
  const display = eligibleHoldersResolved(loaded.model, 'write', p, own);
1620
2030
  const emails = new Set(eligibleHolderEmailsResolved(loaded.model, 'write', p, own).keys());
@@ -1646,6 +2056,35 @@ export class AccessControlService implements IAccessControl {
1646
2056
  const rolesParsed = parseRolesYaml(rolesYaml);
1647
2057
  if (!rolesParsed.ok) throw new AccessConfigError(rolesParsed.errors);
1648
2058
 
2059
+ // Groups — the other named-principal source. Loaded forgivingly (a broken
2060
+ // group file degrades to "contributes nothing"; only roles.yaml problems
2061
+ // may throw) and merged into the principal index, after which the resolver
2062
+ // below needs no group awareness at all. The reader whitelists ONLY
2063
+ // genuine absence (ENOENT/ENOTDIR → null, like synced-groups-committer);
2064
+ // any other read error propagates so loadActiveGroups records a
2065
+ // broken-groups marker instead of silently treating the file as missing.
2066
+ const activeGroups = await loadActiveGroups(async (filename) => {
2067
+ try {
2068
+ return await fs.readFile(path.join(repoDir, filename), 'utf-8');
2069
+ } catch (err) {
2070
+ const code = (err as NodeJS.ErrnoException | null)?.code;
2071
+ if (code === 'ENOENT' || code === 'ENOTDIR') return null;
2072
+ throw err;
2073
+ }
2074
+ });
2075
+ if (!activeGroups.health.ok) {
2076
+ console.error(
2077
+ `[access] groups source ${activeGroups.health.file} is broken (${activeGroups.health.reason}) — groups contribute nothing until it is fixed`,
2078
+ );
2079
+ }
2080
+ for (const w of activeGroups.warnings) console.warn(`[access] ${w}`);
2081
+ const mergeWarnings = mergeGroupsIntoRoles(
2082
+ rolesParsed.index,
2083
+ activeGroups.groups,
2084
+ activeGroups.sourceFile,
2085
+ );
2086
+ for (const w of mergeWarnings) console.warn(`[access] ${w}`);
2087
+
1649
2088
  const accessFiles = new Map<string, AccessFile>();
1650
2089
 
1651
2090
  // Walk the entire repo for `access.md` files. The access tree is
@@ -1684,6 +2123,7 @@ export class AccessControlService implements IAccessControl {
1684
2123
  const model: AccessModel = {
1685
2124
  roles: rolesParsed.index,
1686
2125
  accessFilesByDir: accessFiles,
2126
+ groupsHealth: activeGroups.health,
1687
2127
  deploymentOwners: this.deploymentOwners,
1688
2128
  };
1689
2129
  this.cache.set(workspaceId, { model, loadedAt: Date.now() });
@@ -1901,6 +2341,28 @@ export class AccessControlService implements IAccessControl {
1901
2341
  const rolesParsed = parseRolesYaml(rolesYaml);
1902
2342
  if (!rolesParsed.ok) return null;
1903
2343
 
2344
+ // Same group loading as the working-tree model, read AT THE REF — the
2345
+ // whole point of file-materialized groups is that the merge/push gates
2346
+ // can evaluate them at the commit they gate. (`showAtRef` folds every git
2347
+ // failure into null/absent — at-ref reads cannot distinguish a missing
2348
+ // path from a repo error, so the broken-groups marker here fires only on
2349
+ // parse failures.)
2350
+ const activeGroups = await loadActiveGroups((filename) =>
2351
+ this.showAtRef(repoDir, resolvedRef, filename),
2352
+ );
2353
+ if (!activeGroups.health.ok) {
2354
+ console.error(
2355
+ `[access@${resolvedRef}] groups source ${activeGroups.health.file} is broken (${activeGroups.health.reason}) — groups contribute nothing until it is fixed`,
2356
+ );
2357
+ }
2358
+ for (const w of activeGroups.warnings) console.warn(`[access@${resolvedRef}] ${w}`);
2359
+ const mergeWarnings = mergeGroupsIntoRoles(
2360
+ rolesParsed.index,
2361
+ activeGroups.groups,
2362
+ activeGroups.sourceFile,
2363
+ );
2364
+ for (const w of mergeWarnings) console.warn(`[access@${resolvedRef}] ${w}`);
2365
+
1904
2366
  const accessFiles = new Map<string, AccessFile>();
1905
2367
  const accessPaths = await this.listAccessFilesAtRef(repoDir, resolvedRef);
1906
2368
  for (const p of accessPaths) {
@@ -1928,6 +2390,7 @@ export class AccessControlService implements IAccessControl {
1928
2390
  model: {
1929
2391
  roles: rolesParsed.index,
1930
2392
  accessFilesByDir: accessFiles,
2393
+ groupsHealth: activeGroups.health,
1931
2394
  // Same owners as the working-tree model. The at-ref model backs the
1932
2395
  // PUSH gate, so omitting them here would let the deployment owner save
1933
2396
  // a roles.yaml repair locally and then be refused when it tries to