@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
@@ -4,6 +4,11 @@ import { execFile, spawn } from 'node:child_process';
4
4
  import { promisify } from 'node:util';
5
5
  import { createHash } from 'node:crypto';
6
6
  import { AccessConfigError } from './access-errors.js';
7
+ // NOTE: deliberate module cycle — group-files.ts imports this module's parsing
8
+ // primitives. Benign: both sides only dereference the other's exports inside
9
+ // function bodies, never at module-evaluation time.
10
+ import { GROUPS_YAML, SYNCED_GROUPS_YAML, parseGroupsFile, } from './group-files.js';
11
+ import { DIRECTORY_SYNC_BOT_EMAIL, DIRECTORY_SYNC_BOT_NAME, } from './directory-sync-bot.js';
7
12
  const execFileAsync = promisify(execFile);
8
13
  /**
9
14
  * Read many objects from a git repo in ONE `git cat-file --batch` process.
@@ -119,6 +124,24 @@ export function sourceVerbsFor(verb) {
119
124
  }
120
125
  export const RESERVED_ROLE_NAMES = new Set(['deny', EVERYONE_CANONICAL]);
121
126
  export const DENY_PREFIX = 'deny ';
127
+ /**
128
+ * Member-entry prefix in roles.yaml that references a GROUP instead of an
129
+ * email: `- group:Engineering`. Explicit on purpose — membership kind is
130
+ * never guessed from string shape. Valid on EVERY role including Admin, with
131
+ * one kept invariant (see `parseRolesYaml`): Admin must always retain at
132
+ * least one direct email member, so a misconfigured or unreachable directory
133
+ * can never leave the deployment without a rescuable admin.
134
+ */
135
+ export const GROUP_REF_PREFIX = 'group:';
136
+ /**
137
+ * Explicit ROLE token prefix in access.md entries: `role/<Name>` resolves to
138
+ * the roles.yaml role only, never a group. A BARE name resolves GROUP-FIRST
139
+ * and falls back to the role — so `role/` is the escape hatch when a group
140
+ * shares the role's name. The prefix is reserved in the group name-safety
141
+ * rules (a group may never be named `role/...`), and every role is also
142
+ * registered in the principal index under its `role/<canonical>` alias.
143
+ */
144
+ export const ROLE_TOKEN_PREFIX = 'role/';
122
145
  export const USER_REF_REGEX = /^(.+?)\s+<\s*([^<>\s]+@[^<>\s]+)\s*>\s*$/;
123
146
  export const EMAIL_REGEX = /^[^<>\s@]+@[^<>\s@]+\.[^<>\s@]+$/;
124
147
  function emptyEntries() {
@@ -130,13 +153,33 @@ function emptyEntries() {
130
153
  export function isAccessMdPath(p) {
131
154
  return p === 'access.md' || p.endsWith('/access.md');
132
155
  }
156
+ /**
157
+ * Extensions of node files whose OWN `---` frontmatter can carry access verbs
158
+ * the resolver enforces (`readOwnEntries` → `parseOwnAccessEntries`). The
159
+ * SINGLE source of truth for every surface that enumerates candidate files —
160
+ * the access-declarations scan and the shared `KbReferenceScanner` (which
161
+ * must scan/rewrite the same set, or a rename strands a live `.tool`
162
+ * frontmatter grant). `access.md` is covered by `.md`.
163
+ */
164
+ export const ACCESS_FRONTMATTER_EXTENSIONS = ['.md', '.tool'];
165
+ /** True when `p` is a file the resolver reads access frontmatter from. */
166
+ export function hasAccessFrontmatterExtension(p) {
167
+ return ACCESS_FRONTMATTER_EXTENSIONS.some((ext) => p.endsWith(ext));
168
+ }
133
169
  function isBuiltInRole(canonicalRole) {
134
170
  return canonicalRole === EVERYONE_CANONICAL;
135
171
  }
136
172
  function roleKnown(roles, canonicalRole) {
137
173
  return roles.byCanonical.has(canonicalRole) || isBuiltInRole(canonicalRole);
138
174
  }
139
- function stripComment(line) {
175
+ /**
176
+ * Strip a trailing `# comment` the way the YAML-subset tokeniser reads a
177
+ * line: a `#` at the start (after only whitespace) or preceded by whitespace
178
+ * begins a comment. Exported so the reference scanner matches tokens against
179
+ * the SAME comment rule the resolver parses with (a `- GTM Team # sales`
180
+ * entry is the token `GTM Team`, never `GTM Team # sales`).
181
+ */
182
+ export function stripComment(line) {
140
183
  let inWs = true;
141
184
  for (let i = 0; i < line.length; i++) {
142
185
  const ch = line[i];
@@ -148,9 +191,14 @@ function stripComment(line) {
148
191
  }
149
192
  return line;
150
193
  }
151
- function tokenise(text) {
194
+ function tokenise(text, opts) {
152
195
  const lines = text.split(/\r?\n/);
153
196
  const tokens = [];
197
+ // Tolerated blank keys are uniquified as runs of spaces so a SECOND blank
198
+ // key doesn't trip the duplicate-key check (which would retire every valid
199
+ // group after it). All-whitespace keys still canonicalize to '' downstream,
200
+ // so the entry-level "empty group name — skipped" handling sees them all.
201
+ let blankKeySeq = 0;
154
202
  for (let i = 0; i < lines.length; i++) {
155
203
  const stripped = stripComment(lines[i]).replace(/\s+$/, '');
156
204
  if (!stripped.trim())
@@ -168,16 +216,24 @@ function tokenise(text) {
168
216
  if (colonIdx < 0) {
169
217
  return { ok: false, error: `line ${lineNum}: expected 'key:' or '- value' but got '${content}'` };
170
218
  }
171
- const key = content.slice(0, colonIdx).trim();
219
+ let key = content.slice(0, colonIdx).trim();
172
220
  const valuePart = content.slice(colonIdx + 1).trim();
173
- if (!key)
174
- return { ok: false, error: `line ${lineNum}: empty mapping key` };
221
+ // An empty key is normally a hard error (roles.yaml/access.md want loud
222
+ // failures), but a parser with ENTRY-level forgiveness (the group files:
223
+ // one bad entry must not retire every other group) keeps it as a
224
+ // whitespace key for its own skip-with-warning handling.
225
+ if (!key) {
226
+ if (!opts?.tolerateEmptyKeys) {
227
+ return { ok: false, error: `line ${lineNum}: empty mapping key` };
228
+ }
229
+ key = ' '.repeat(++blankKeySeq);
230
+ }
175
231
  tokens.push({ lineNum, indent, kind: 'kv', key, value: valuePart });
176
232
  }
177
233
  return { ok: true, tokens };
178
234
  }
179
- export function parseYamlSubset(text) {
180
- const tok = tokenise(text);
235
+ export function parseYamlSubset(text, opts) {
236
+ const tok = tokenise(text, opts);
181
237
  if (!tok.ok)
182
238
  return tok;
183
239
  if (tok.tokens.length === 0)
@@ -309,9 +365,18 @@ export function parseAccessEntry(raw) {
309
365
  error: `entry '${body}' looks like a user reference but doesn't match 'Name <email>' shape`,
310
366
  };
311
367
  }
312
- const role = canonicalRoleName(body);
368
+ let role = canonicalRoleName(body);
313
369
  if (!role)
314
370
  return { ok: false, error: `empty role name in entry '${raw}'` };
371
+ // Explicit role token: normalize `role/ <Name>` spacing so the canonical
372
+ // form is always `role/<canonicalName>` — the exact alias key the principal
373
+ // index registers for every role.
374
+ if (role.startsWith(ROLE_TOKEN_PREFIX)) {
375
+ const suffix = canonicalRoleName(role.slice(ROLE_TOKEN_PREFIX.length));
376
+ if (!suffix)
377
+ return { ok: false, error: `entry '${body}' names no role after '${ROLE_TOKEN_PREFIX}'` };
378
+ role = `${ROLE_TOKEN_PREFIX}${suffix}`;
379
+ }
315
380
  return { ok: true, entry: { kind: 'role', role, displayRole: body, deny } };
316
381
  }
317
382
  // ---------------------------------------------------------------------------
@@ -344,6 +409,10 @@ export function parseRolesYaml(text) {
344
409
  errors.push(`roles.yaml: role '${displayName}' uses reserved name '${canonical}' — this token has special meaning in access entries and cannot be a roles.yaml role`);
345
410
  continue;
346
411
  }
412
+ if (canonical.startsWith(ROLE_TOKEN_PREFIX)) {
413
+ errors.push(`roles.yaml: role '${displayName}' starts with the reserved '${ROLE_TOKEN_PREFIX}' prefix — that spelling is the explicit role token in access entries`);
414
+ continue;
415
+ }
347
416
  if (index.byCanonical.has(canonical)) {
348
417
  const prev = index.byCanonical.get(canonical).displayName;
349
418
  errors.push(`roles.yaml: role '${displayName}' canonicalises to '${canonical}', which is already declared as '${prev}'`);
@@ -354,11 +423,26 @@ export function parseRolesYaml(text) {
354
423
  continue;
355
424
  }
356
425
  const emails = new Set();
426
+ const groupRefs = new Set();
357
427
  for (const rawEmail of value) {
358
428
  if (typeof rawEmail !== 'string') {
359
429
  errors.push(`roles.yaml: role '${displayName}' has a non-string entry`);
360
430
  continue;
361
431
  }
432
+ // `- group:<Name>` assigns the role to a whole group (expanded against
433
+ // the active group source by `mergeGroupsIntoRoles`). Allowed on every
434
+ // role, Admin included — the Admin invariant below only demands at
435
+ // least one DIRECT email member so a broken directory can never leave
436
+ // the deployment adminless.
437
+ if (rawEmail.trim().toLowerCase().startsWith(GROUP_REF_PREFIX)) {
438
+ const refName = canonicalRoleName(rawEmail.trim().slice(GROUP_REF_PREFIX.length));
439
+ if (!refName) {
440
+ errors.push(`roles.yaml: role '${displayName}' has an empty group reference '${rawEmail}'`);
441
+ continue;
442
+ }
443
+ groupRefs.add(refName);
444
+ continue;
445
+ }
362
446
  const email = canonicalEmail(rawEmail);
363
447
  if (!EMAIL_REGEX.test(email)) {
364
448
  errors.push(`roles.yaml: role '${displayName}' has malformed email '${rawEmail}'`);
@@ -372,18 +456,158 @@ export function parseRolesYaml(text) {
372
456
  }
373
457
  set.add(canonical);
374
458
  }
375
- index.byCanonical.set(canonical, { displayName: displayName.trim(), emails });
459
+ index.byCanonical.set(canonical, { displayName: displayName.trim(), emails, groupRefs });
376
460
  }
377
461
  if (!index.byCanonical.has(ADMIN_CANONICAL)) {
378
462
  errors.push(`roles.yaml: must declare at least one 'Admin' role`);
379
463
  }
380
464
  else if (index.byCanonical.get(ADMIN_CANONICAL).emails.size === 0) {
381
- errors.push(`roles.yaml: 'Admin' role has no emails`);
465
+ // The kept invariant: Admin may reference groups, but must ALWAYS retain
466
+ // at least one direct email member — the rescue story requires an admin
467
+ // whose membership does not depend on a reachable, well-configured
468
+ // directory. A group-only Admin is as hard an error as an adminless one.
469
+ errors.push(`roles.yaml: 'Admin' role has no direct email members — Admin must keep at least one individual email (group references alone are not enough)`);
382
470
  }
383
471
  if (errors.length)
384
472
  return { ok: false, errors };
385
473
  return { ok: true, index };
386
474
  }
475
+ /**
476
+ * Merge the active group source into the principal index and expand role →
477
+ * group assignments. Mutates `index` in place; returns human-readable
478
+ * warnings (callers log them — nothing here ever throws, because group
479
+ * problems must degrade, not brick access resolution).
480
+ *
481
+ * Rules (the grant-grammar precedence):
482
+ * - Every role is ALSO registered under its explicit `role/<canonical>`
483
+ * alias — the token that always resolves to the role.
484
+ * - A BARE name resolves GROUP-FIRST: when a group's canonical name
485
+ * collides with a role's, the bare key resolves to the GROUP (warned);
486
+ * the role stays reachable via `role/<canonical>`.
487
+ * - Role `group:<Name>` refs — Admin's included — expand against the
488
+ * merged groups; an unknown ref contributes nothing (warned).
489
+ * - `byEmail` is rebuilt so each member holds exactly the tokens that
490
+ * resolve to a principal they belong to (bare + `role/` alias for roles,
491
+ * bare for groups).
492
+ */
493
+ export function mergeGroupsIntoRoles(index, groups, sourceFile) {
494
+ const warnings = [];
495
+ // Snapshot before any group lands: at this point the index holds roles only.
496
+ const roleRecords = new Map(index.byCanonical);
497
+ // 1. Expand role → group assignments (Admin included — its safety net is
498
+ // the parse-time "at least one direct email" invariant, not a merge skip).
499
+ for (const [, principal] of roleRecords) {
500
+ if (!principal.groupRefs?.size)
501
+ continue;
502
+ for (const ref of principal.groupRefs) {
503
+ const group = groups.get(ref);
504
+ if (!group) {
505
+ warnings.push(`roles.yaml: role '${principal.displayName}' references unknown group '${ref}' — reference ignored`);
506
+ continue;
507
+ }
508
+ for (const email of group.emails)
509
+ principal.emails.add(email);
510
+ }
511
+ }
512
+ // 2. Bare-name precedence: groups win the bare token; the role keeps its
513
+ // `role/<canonical>` alias registered below.
514
+ for (const [canonical, def] of groups) {
515
+ if (roleRecords.has(canonical)) {
516
+ warnings.push(`${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`);
517
+ }
518
+ index.byCanonical.set(canonical, {
519
+ displayName: def.displayName,
520
+ emails: new Set(def.emails),
521
+ kind: 'group',
522
+ });
523
+ }
524
+ // 3. Explicit `role/<canonical>` alias for every role (same record — the
525
+ // alias and the bare key, when the role still owns it, stay in lockstep).
526
+ for (const [canonical, principal] of roleRecords) {
527
+ principal.kind = 'role';
528
+ index.byCanonical.set(`${ROLE_TOKEN_PREFIX}${canonical}`, principal);
529
+ }
530
+ // 4. Rebuild the email → tokens map from the final index so membership
531
+ // reflects the post-precedence keys (a collided role's members no longer
532
+ // hold the bare token unless the group also contains them).
533
+ index.byEmail.clear();
534
+ for (const [key, principal] of index.byCanonical) {
535
+ for (const email of principal.emails) {
536
+ let set = index.byEmail.get(email);
537
+ if (!set) {
538
+ set = new Set();
539
+ index.byEmail.set(email, set);
540
+ }
541
+ set.add(key);
542
+ }
543
+ }
544
+ return warnings;
545
+ }
546
+ /** True for the errno codes that mean "the file genuinely is not there". */
547
+ function isAbsenceError(err) {
548
+ const code = err?.code;
549
+ return code === 'ENOENT' || code === 'ENOTDIR';
550
+ }
551
+ /**
552
+ * Load the ACTIVE group source through `read` (working tree or at-ref — the
553
+ * caller supplies the reader, so both model loaders share one mode rule):
554
+ * `synced-groups.yaml` existing → IdP mode (groups.yaml ignored entirely,
555
+ * even when the synced file is empty or malformed — falling back would
556
+ * resurrect retired manual groups); otherwise `groups.yaml` → manual mode.
557
+ *
558
+ * `read` returns null for a genuinely-absent file and may THROW for any other
559
+ * failure. Only ENOENT/ENOTDIR count as absent (the caller may also whitelist
560
+ * them into null); every other read error — notably a non-absence error on
561
+ * `synced-groups.yaml` — is treated as a BROKEN source: no groups, no
562
+ * fallback to the manual file (falling back would resurrect retired groups),
563
+ * and an `ok: false` health marker. Structural parse failures degrade the
564
+ * same way; this function never throws.
565
+ */
566
+ export async function loadActiveGroups(read) {
567
+ const broken = (file, reason) => ({
568
+ groups: new Map(),
569
+ sourceFile: file,
570
+ warnings: [],
571
+ health: { ok: false, file, reason },
572
+ });
573
+ let syncedText;
574
+ try {
575
+ syncedText = await read(SYNCED_GROUPS_YAML);
576
+ }
577
+ catch (err) {
578
+ if (isAbsenceError(err)) {
579
+ syncedText = null;
580
+ }
581
+ else {
582
+ // A non-absence read error on the SYNCED source must NOT fall back to
583
+ // groups.yaml — the synced file may exist and its manual predecessor is
584
+ // retired. Broken-groups instead.
585
+ return broken(SYNCED_GROUPS_YAML, err instanceof Error ? err.message : String(err));
586
+ }
587
+ }
588
+ const sourceFile = syncedText !== null ? SYNCED_GROUPS_YAML : GROUPS_YAML;
589
+ let text;
590
+ if (syncedText !== null) {
591
+ text = syncedText;
592
+ }
593
+ else {
594
+ try {
595
+ text = await read(GROUPS_YAML);
596
+ }
597
+ catch (err) {
598
+ if (isAbsenceError(err))
599
+ text = null;
600
+ else
601
+ return broken(GROUPS_YAML, err instanceof Error ? err.message : String(err));
602
+ }
603
+ }
604
+ if (text === null)
605
+ return { groups: new Map(), sourceFile, warnings: [], health: { ok: true } };
606
+ const parsed = parseGroupsFile(text, sourceFile);
607
+ if (!parsed.ok)
608
+ return broken(sourceFile, parsed.errors.join('; '));
609
+ return { groups: parsed.groups, sourceFile, warnings: parsed.warnings, health: { ok: true } };
610
+ }
387
611
  /**
388
612
  * Does an `access.md` body declare access rules — i.e. is the file in the NEW
389
613
  * (body-governs-the-folder) format?
@@ -630,7 +854,10 @@ function isAdminEmail(model, email) {
630
854
  if (model.deploymentOwners.has(email))
631
855
  return true;
632
856
  const roles = model.roles.byEmail.get(email);
633
- return !!roles && roles.has(ADMIN_CANONICAL);
857
+ // Check the explicit `role/admin` alias, NOT the bare token: bare-name
858
+ // precedence is group-first, so a group that happens to be named "Admin"
859
+ // owns the bare key — and its members must never inherit the capability.
860
+ return !!roles && roles.has(`${ROLE_TOKEN_PREFIX}${ADMIN_CANONICAL}`);
634
861
  }
635
862
  /**
636
863
  * Resolve whether `userEmail` has `verb` on `relativePath`.
@@ -737,7 +964,7 @@ function scopeToGrantSource(scope, kind, relativePath) {
737
964
  * returning the WHOLE list of removable entries rather than just the winner.
738
965
  *
739
966
  * Only the principal's OWN named entry yields a source — a `user` by their email,
740
- * a `role` by its role token. A grant that reaches the user via a plugin they
967
+ * a `role` by its role token. A grant that reaches the user via a group/role they
741
968
  * belong to, the built-in `everyone`, or admin-rescue is NOT their entry, so it
742
969
  * never adds a source (the group/role shows as its own row instead). The list is:
743
970
  * - `[]` (verb omitted by the caller) when the principal effectively holds no
@@ -753,19 +980,48 @@ function scopeToGrantSource(scope, kind, relativePath) {
753
980
  * under closest-wins), and the revoke flow needs the inherited remainder that
754
981
  * survives removing the direct entry. A group/everyone grant at some scope does
755
982
  * not add a source AND does not hide a farther own-entry the principal is named
756
- * in (removing it is still meaningful if the plugin grant is later removed).
983
+ * in (removing it is still meaningful if the group/role grant is later removed).
757
984
  */
758
- function resolveGrantSourcesForVerb(model, verb, kind, relativePath, principal, fileOwn) {
985
+ function resolveGrantSourcesForVerb(model, verb, kind, relativePath, principal, fileOwn, tokenMatch) {
759
986
  const scopes = resolveScopes(model, verb, relativePath, fileOwn);
760
987
  const out = [];
761
988
  if (principal.kind === 'role') {
762
- const role = canonicalRoleName(principal.role);
989
+ // A named principal can be spelled two ways in a file: the bare token and
990
+ // the explicit `role/<name>` token. WHICH spellings are THIS principal's
991
+ // entries depends on who owns the bare key in the merged index:
992
+ //
993
+ // - UNSHADOWED (no group named `<bare>`): both spellings resolve to the
994
+ // role, so both count — a grant under either adds a source; a deny
995
+ // under either cuts off farther grants. (The revoke splice strips both
996
+ // spellings in this case too, so classification and removal agree.)
997
+ // - SHADOWED (a group owns the bare key): the spellings are DIFFERENT
998
+ // principals. A bare-token principal is the GROUP — only bare entries
999
+ // are its own; a `role/`-token principal is the ROLE — only `role/`
1000
+ // entries are its own. Counting the other spelling would attribute a
1001
+ // shadowed bare token to the role (hiding a real `role/<name>`
1002
+ // ancestor grant and making its revoke a false no-op), or vice versa.
1003
+ //
1004
+ // - PINNED EXACT (`tokenMatch: 'exact'`): only the literally-spelled
1005
+ // token is this principal's entry, shadowing notwithstanding. The
1006
+ // caller pinned the same identity into the splice it is checking —
1007
+ // a GROUP whose group has VANISHED reads "unshadowed" here, and the
1008
+ // alias-tolerant pair would then attribute a same-named role's
1009
+ // surviving `role/<name>` grant to the group.
1010
+ const canonical = canonicalRoleName(principal.role);
1011
+ const explicit = canonical.startsWith(ROLE_TOKEN_PREFIX);
1012
+ const bare = explicit ? canonical.slice(ROLE_TOKEN_PREFIX.length) : canonical;
1013
+ const shadowed = model.roles.byCanonical.get(bare)?.kind === 'group';
1014
+ const tokens = tokenMatch === 'exact' || shadowed
1015
+ ? [explicit ? `${ROLE_TOKEN_PREFIX}${bare}` : bare]
1016
+ : [bare, `${ROLE_TOKEN_PREFIX}${bare}`];
763
1017
  for (const scope of scopes) {
764
- const s = scope.byRole.get(role);
765
- if (s === 'denied')
766
- break; // a closer deny of this role cuts off farther grants
767
- if (s === 'grant')
1018
+ const states = tokens.map((t) => scope.byRole.get(t));
1019
+ if (states.includes('grant')) {
768
1020
  out.push(scopeToGrantSource(scope, kind, relativePath));
1021
+ continue;
1022
+ }
1023
+ if (states.includes('denied'))
1024
+ break; // a closer deny cuts off farther grants
769
1025
  }
770
1026
  return out;
771
1027
  }
@@ -854,12 +1110,28 @@ function canReadResolved(model, userEmail, relativePath, fileOwn) {
854
1110
  }
855
1111
  function eligibleHoldersResolved(model, verb, relativePath, fileOwn) {
856
1112
  const { byRole, byEmail } = resolveAtPath(model, verb, relativePath, fileOwn);
857
- const roleSet = new Set();
1113
+ // (kind, display name) → one entry. The `byRole` keys are the merged
1114
+ // index's canonical tokens exactly as granted (bare, or the
1115
+ // `role/<canonical>` alias); the `byCanonical` record a key hits carries
1116
+ // its kind — a `role/` alias always hits the role record, a bare key hits
1117
+ // whichever principal owns it under group-first precedence. A token with no
1118
+ // record (the built-in `everyone`, or a grant naming a since-vanished
1119
+ // principal) degrades to 'role', the pre-groups display. When one display
1120
+ // name is granted as BOTH (a role via its `role/` alias plus a same-named
1121
+ // group via the bare token) BOTH entries survive — they are DIFFERENT
1122
+ // principals, and collapsing them to one would hide the role's live
1123
+ // `role/<name>` grant from every consumer of the eligible list.
1124
+ const byIdentity = new Map();
1125
+ const addPrincipal = (name, kind) => {
1126
+ const key = `${kind}\0${name.toLowerCase()}`;
1127
+ if (!byIdentity.has(key))
1128
+ byIdentity.set(key, { name, kind });
1129
+ };
858
1130
  for (const [canonical, state] of byRole) {
859
1131
  if (state !== 'grant')
860
1132
  continue;
861
- const role = model.roles.byCanonical.get(canonical);
862
- roleSet.add(role ? role.displayName : canonical);
1133
+ const record = model.roles.byCanonical.get(canonical);
1134
+ addPrincipal(record ? record.displayName : canonical, record?.kind ?? 'role');
863
1135
  }
864
1136
  // Mirror the admin overrides applied in `hasPermissionResolved`: write on
865
1137
  // `roles.yaml` and on any `access.md` is granted to Admin even if the
@@ -868,10 +1140,17 @@ function eligibleHoldersResolved(model, verb, relativePath, fileOwn) {
868
1140
  // access.md (it'd say "restricted to no one" when Admin is the answer).
869
1141
  if (verb === 'write' &&
870
1142
  (relativePath === 'roles.yaml' || isAccessMdPath(relativePath))) {
871
- const adminRole = model.roles.byCanonical.get(ADMIN_CANONICAL);
872
- roleSet.add(adminRole ? adminRole.displayName : ADMIN_CANONICAL);
873
- }
874
- const roles = [...roleSet].sort();
1143
+ // Look the Admin ROLE up via its explicit alias — the bare key may be
1144
+ // owned by a same-named group under group-first precedence. The override
1145
+ // is the ROLE's capability, so the row's kind is 'role' regardless.
1146
+ const adminRole = model.roles.byCanonical.get(`${ROLE_TOKEN_PREFIX}${ADMIN_CANONICAL}`);
1147
+ addPrincipal(adminRole ? adminRole.displayName : ADMIN_CANONICAL, 'role');
1148
+ }
1149
+ const principals = [...byIdentity.values()].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : a.kind < b.kind ? -1 : a.kind > b.kind ? 1 : 0);
1150
+ // Legacy name-only list, kind erased — kept for the many consumers that
1151
+ // only ever render names (banners, contact lines, PR-routing messages).
1152
+ // De-duplicated: a name granted as both group and role appears once here.
1153
+ const roles = [...new Set(principals.map((p) => p.name))];
875
1154
  const users = [];
876
1155
  for (const [email, state] of byEmail) {
877
1156
  if (state !== 'grant')
@@ -879,7 +1158,7 @@ function eligibleHoldersResolved(model, verb, relativePath, fileOwn) {
879
1158
  users.push({ name: '', email });
880
1159
  }
881
1160
  users.sort((a, b) => a.email.localeCompare(b.email));
882
- return { roles, users };
1161
+ return { principals, roles, users };
883
1162
  }
884
1163
  /**
885
1164
  * Expand the eligible-writer set to the underlying emails.
@@ -1017,27 +1296,10 @@ export class AccessControlService {
1017
1296
  const parsed = parseRolesYaml(text);
1018
1297
  return parsed.ok ? { ok: true } : { ok: false, errors: parsed.errors };
1019
1298
  }
1020
- /**
1021
- * Advisory scan of folder `access.md` files for references to a role (by
1022
- * canonical name). See `IAccessControl.referencesToRole` — undercounts node
1023
- * frontmatter by design; powers the delete warning only, never the rename
1024
- * gate.
1025
- */
1026
- async referencesToRole(workspaceId, canonicalRole) {
1027
- const model = await this.loadModel(workspaceId);
1028
- const out = [];
1029
- for (const file of model.accessFilesByDir.values()) {
1030
- for (const verb of KNOWN_VERBS) {
1031
- for (const entry of file.entries[verb]) {
1032
- if (entry.kind === 'role' && entry.role === canonicalRole) {
1033
- out.push({ path: file.path, verb });
1034
- }
1035
- }
1036
- }
1037
- }
1038
- return out;
1039
- }
1040
1299
  async canWrite(workspaceId, userEmail, relativePath) {
1300
+ const machineOwned = this.machineOwnedWriteRule(userEmail, relativePath);
1301
+ if (machineOwned !== null)
1302
+ return machineOwned;
1041
1303
  const model = await this.loadModel(workspaceId);
1042
1304
  const own = await this.readOwnEntries(await this.repoDir(workspaceId), relativePath);
1043
1305
  return hasPermissionResolved(model, 'write', userEmail, relativePath, own);
@@ -1067,7 +1329,8 @@ export class AccessControlService {
1067
1329
  const owns = await Promise.all(relativePaths.map((p) => this.cachedOwnEntries(workspaceId, repoDir, p)));
1068
1330
  const result = new Map();
1069
1331
  relativePaths.forEach((p, i) => {
1070
- result.set(p, hasPermissionResolved(model, 'write', userEmail, p, owns[i]));
1332
+ const machineOwned = this.machineOwnedWriteRule(userEmail, p);
1333
+ result.set(p, machineOwned ?? hasPermissionResolved(model, 'write', userEmail, p, owns[i]));
1071
1334
  });
1072
1335
  return result;
1073
1336
  }
@@ -1109,10 +1372,10 @@ export class AccessControlService {
1109
1372
  // and the role/user lists are meaningless. Otherwise return the explicit
1110
1373
  // reader set; it may be empty for a default-denied path with no grants.
1111
1374
  if (canEveryoneReadResolved(model, relativePath, own)) {
1112
- return { restricted: false, roles: [], users: [] };
1375
+ return { restricted: false, principals: [], roles: [], users: [] };
1113
1376
  }
1114
- const { roles, users } = eligibleHoldersResolved(model, 'read', relativePath, own);
1115
- return { restricted: true, roles, users };
1377
+ const { principals, roles, users } = eligibleHoldersResolved(model, 'read', relativePath, own);
1378
+ return { restricted: true, principals, roles, users };
1116
1379
  }
1117
1380
  async eligibleDownloaders(workspaceId, relativePath) {
1118
1381
  const model = await this.loadModel(workspaceId);
@@ -1129,7 +1392,7 @@ export class AccessControlService {
1129
1392
  const own = await this.readOwnEntries(await this.repoDir(workspaceId), relativePath);
1130
1393
  return eligibleHolderEmailsResolved(model, 'owner', relativePath, own);
1131
1394
  }
1132
- async grantSources(workspaceId, kind, relativePath, principal) {
1395
+ async grantSources(workspaceId, kind, relativePath, principal, opts) {
1133
1396
  const model = await this.loadModel(workspaceId);
1134
1397
  // A file target consults its own frontmatter as the most-specific scope; a
1135
1398
  // folder target's most-specific scope is its own access.md (in the dir
@@ -1139,7 +1402,7 @@ export class AccessControlService {
1139
1402
  : null;
1140
1403
  const out = {};
1141
1404
  for (const verb of KNOWN_VERBS) {
1142
- const sources = resolveGrantSourcesForVerb(model, verb, kind, relativePath, principal, own);
1405
+ const sources = resolveGrantSourcesForVerb(model, verb, kind, relativePath, principal, own, opts?.tokenMatch);
1143
1406
  if (sources.length > 0)
1144
1407
  out[verb] = sources;
1145
1408
  }
@@ -1151,16 +1414,24 @@ export class AccessControlService {
1151
1414
  model = await this.loadModel(workspaceId);
1152
1415
  }
1153
1416
  catch {
1154
- return { plugins: [], people: [] };
1155
- }
1156
- // Plugins = the built-in `everyone` role plus every declared role's display
1157
- // name. `everyone` is surfaced so the share UI can grant public read; the
1158
- // grant route gates it to the `read` verb only (write/owner/download
1159
- // everyone stay a direct-access.md edit).
1160
- const plugins = [
1161
- EVERYONE_DISPLAY,
1162
- ...[...model.roles.byCanonical.values()].map((r) => r.displayName),
1163
- ];
1417
+ return { roles: [], groups: [], people: [] };
1418
+ }
1419
+ // Roles = the built-in `everyone` role plus every declared role's display
1420
+ // name — ROLE principals only, never groups. Each role is enumerated via
1421
+ // its `role/<canonical>` alias key, which exists exactly once per role
1422
+ // (the bare key may be owned by a same-named group under group-first
1423
+ // precedence, and merged group entries must not appear here). `everyone`
1424
+ // is surfaced so the share UI can grant public read; the grant route
1425
+ // gates it to the `read` verb only (write/owner/download everyone stay a
1426
+ // direct-access.md edit).
1427
+ const roles = [EVERYONE_DISPLAY];
1428
+ const groups = [];
1429
+ for (const [key, principal] of model.roles.byCanonical) {
1430
+ if (key.startsWith(ROLE_TOKEN_PREFIX))
1431
+ roles.push(principal.displayName);
1432
+ else if (principal.kind === 'group')
1433
+ groups.push(principal.displayName);
1434
+ }
1164
1435
  // People = roles.yaml member emails (name-less) ∪ access.md `Name <email>`
1165
1436
  // grants (named). The login-only users table is unioned in by the caller.
1166
1437
  const byEmail = new Map(); // email -> display name ('' if unknown)
@@ -1181,7 +1452,7 @@ export class AccessControlService {
1181
1452
  name: name || email.split('@')[0],
1182
1453
  email,
1183
1454
  }));
1184
- return { plugins, people };
1455
+ return { roles, groups, people };
1185
1456
  }
1186
1457
  async findEmailByHash(workspaceId, hash) {
1187
1458
  let model;
@@ -1218,7 +1489,24 @@ export class AccessControlService {
1218
1489
  }
1219
1490
  return null;
1220
1491
  }
1492
+ /**
1493
+ * `synced-groups.yaml` is MACHINE-OWNED: regenerated wholesale from the
1494
+ * directory mirror and committed by the directory-sync bot. The bot is its
1495
+ * ONLY writer — role/grant resolution never applies to it. That cuts both
1496
+ * ways: the bot needs no role to write it (it isn't in roles.yaml, and on a
1497
+ * protected branch nothing else would make it eligible), and no HUMAN can
1498
+ * hand-edit it through the app (an edit would be silently overwritten by
1499
+ * the next provisioning push anyway).
1500
+ */
1501
+ machineOwnedWriteRule(userEmail, relativePath) {
1502
+ if (relativePath !== SYNCED_GROUPS_YAML)
1503
+ return null;
1504
+ return userEmail.trim().toLowerCase() === DIRECTORY_SYNC_BOT_EMAIL;
1505
+ }
1221
1506
  async canWriteAtRef(workspaceId, ref, userEmail, relativePath) {
1507
+ const machineOwned = this.machineOwnedWriteRule(userEmail, relativePath);
1508
+ if (machineOwned !== null)
1509
+ return machineOwned;
1222
1510
  const loaded = await this.loadModelAtRef(workspaceId, ref);
1223
1511
  if (!loaded)
1224
1512
  return null;
@@ -1235,6 +1523,16 @@ export class AccessControlService {
1235
1523
  return canReadResolved(loaded.model, userEmail, relativePath, own);
1236
1524
  }
1237
1525
  async canWriteBatchAtRef(workspaceId, ref, userEmail, relativePaths) {
1526
+ // Machine-owned paths resolve without the model (see machineOwnedWriteRule)
1527
+ // — matching canWriteAtRef, including on a repo with no rules at the ref.
1528
+ const machineAnswers = new Map();
1529
+ for (const p of relativePaths) {
1530
+ const machineOwned = this.machineOwnedWriteRule(userEmail, p);
1531
+ if (machineOwned !== null)
1532
+ machineAnswers.set(p, machineOwned);
1533
+ }
1534
+ if (machineAnswers.size === relativePaths.length)
1535
+ return machineAnswers;
1238
1536
  const loaded = await this.loadModelAtRef(workspaceId, ref);
1239
1537
  if (!loaded)
1240
1538
  return null;
@@ -1244,11 +1542,19 @@ export class AccessControlService {
1244
1542
  const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths);
1245
1543
  const result = new Map();
1246
1544
  for (const p of relativePaths) {
1247
- result.set(p, hasPermissionResolved(loaded.model, 'write', userEmail, p, owns.get(p) ?? null));
1545
+ const machineOwned = machineAnswers.get(p);
1546
+ result.set(p, machineOwned ?? hasPermissionResolved(loaded.model, 'write', userEmail, p, owns.get(p) ?? null));
1248
1547
  }
1249
1548
  return result;
1250
1549
  }
1251
1550
  async eligibleWritersAtRef(workspaceId, ref, relativePath) {
1551
+ if (relativePath === SYNCED_GROUPS_YAML) {
1552
+ // Machine-owned — see machineOwnedWriteRule.
1553
+ return {
1554
+ roles: [],
1555
+ users: [{ name: DIRECTORY_SYNC_BOT_NAME, email: DIRECTORY_SYNC_BOT_EMAIL }],
1556
+ };
1557
+ }
1252
1558
  const loaded = await this.loadModelAtRef(workspaceId, ref);
1253
1559
  if (!loaded)
1254
1560
  return null;
@@ -1257,14 +1563,34 @@ export class AccessControlService {
1257
1563
  return eligibleHoldersResolved(loaded.model, 'write', relativePath, own);
1258
1564
  }
1259
1565
  async eligibleWritersForPathsAtRef(workspaceId, ref, relativePaths) {
1566
+ const result = new Map();
1567
+ // Machine-owned paths resolve without the model — same answer
1568
+ // eligibleWritersAtRef gives (including at a ref with no usable
1569
+ // roles.yaml), so batched consumers (CR owner-routing, approval state)
1570
+ // agree with the single-path surface.
1571
+ for (const p of relativePaths) {
1572
+ if (p === SYNCED_GROUPS_YAML) {
1573
+ result.set(p, {
1574
+ roles: [],
1575
+ users: [{ name: DIRECTORY_SYNC_BOT_NAME, email: DIRECTORY_SYNC_BOT_EMAIL }],
1576
+ emails: new Set([DIRECTORY_SYNC_BOT_EMAIL]),
1577
+ });
1578
+ }
1579
+ }
1580
+ // Only short-circuit when there IS a machine-owned path covering the
1581
+ // whole request: an EMPTY request must still answer null for an
1582
+ // unresolvable ref, as documented.
1583
+ if (relativePaths.length > 0 && result.size === relativePaths.length)
1584
+ return result;
1260
1585
  const loaded = await this.loadModelAtRef(workspaceId, ref);
1261
1586
  if (!loaded)
1262
1587
  return null;
1263
1588
  const repoDir = await this.repoDir(workspaceId);
1264
- const result = new Map();
1265
1589
  // One `git cat-file --batch` for the whole path set — see canWriteBatchAtRef.
1266
1590
  const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths);
1267
1591
  for (const p of relativePaths) {
1592
+ if (result.has(p))
1593
+ continue;
1268
1594
  const own = owns.get(p) ?? null;
1269
1595
  const display = eligibleHoldersResolved(loaded.model, 'write', p, own);
1270
1596
  const emails = new Set(eligibleHolderEmailsResolved(loaded.model, 'write', p, own).keys());
@@ -1292,6 +1618,32 @@ export class AccessControlService {
1292
1618
  const rolesParsed = parseRolesYaml(rolesYaml);
1293
1619
  if (!rolesParsed.ok)
1294
1620
  throw new AccessConfigError(rolesParsed.errors);
1621
+ // Groups — the other named-principal source. Loaded forgivingly (a broken
1622
+ // group file degrades to "contributes nothing"; only roles.yaml problems
1623
+ // may throw) and merged into the principal index, after which the resolver
1624
+ // below needs no group awareness at all. The reader whitelists ONLY
1625
+ // genuine absence (ENOENT/ENOTDIR → null, like synced-groups-committer);
1626
+ // any other read error propagates so loadActiveGroups records a
1627
+ // broken-groups marker instead of silently treating the file as missing.
1628
+ const activeGroups = await loadActiveGroups(async (filename) => {
1629
+ try {
1630
+ return await fs.readFile(path.join(repoDir, filename), 'utf-8');
1631
+ }
1632
+ catch (err) {
1633
+ const code = err?.code;
1634
+ if (code === 'ENOENT' || code === 'ENOTDIR')
1635
+ return null;
1636
+ throw err;
1637
+ }
1638
+ });
1639
+ if (!activeGroups.health.ok) {
1640
+ console.error(`[access] groups source ${activeGroups.health.file} is broken (${activeGroups.health.reason}) — groups contribute nothing until it is fixed`);
1641
+ }
1642
+ for (const w of activeGroups.warnings)
1643
+ console.warn(`[access] ${w}`);
1644
+ const mergeWarnings = mergeGroupsIntoRoles(rolesParsed.index, activeGroups.groups, activeGroups.sourceFile);
1645
+ for (const w of mergeWarnings)
1646
+ console.warn(`[access] ${w}`);
1295
1647
  const accessFiles = new Map();
1296
1648
  // Walk the entire repo for `access.md` files. The access tree is
1297
1649
  // structure-agnostic — any `access.md` at any depth (including repo
@@ -1325,6 +1677,7 @@ export class AccessControlService {
1325
1677
  const model = {
1326
1678
  roles: rolesParsed.index,
1327
1679
  accessFilesByDir: accessFiles,
1680
+ groupsHealth: activeGroups.health,
1328
1681
  deploymentOwners: this.deploymentOwners,
1329
1682
  };
1330
1683
  this.cache.set(workspaceId, { model, loadedAt: Date.now() });
@@ -1525,6 +1878,21 @@ export class AccessControlService {
1525
1878
  const rolesParsed = parseRolesYaml(rolesYaml);
1526
1879
  if (!rolesParsed.ok)
1527
1880
  return null;
1881
+ // Same group loading as the working-tree model, read AT THE REF — the
1882
+ // whole point of file-materialized groups is that the merge/push gates
1883
+ // can evaluate them at the commit they gate. (`showAtRef` folds every git
1884
+ // failure into null/absent — at-ref reads cannot distinguish a missing
1885
+ // path from a repo error, so the broken-groups marker here fires only on
1886
+ // parse failures.)
1887
+ const activeGroups = await loadActiveGroups((filename) => this.showAtRef(repoDir, resolvedRef, filename));
1888
+ if (!activeGroups.health.ok) {
1889
+ console.error(`[access@${resolvedRef}] groups source ${activeGroups.health.file} is broken (${activeGroups.health.reason}) — groups contribute nothing until it is fixed`);
1890
+ }
1891
+ for (const w of activeGroups.warnings)
1892
+ console.warn(`[access@${resolvedRef}] ${w}`);
1893
+ const mergeWarnings = mergeGroupsIntoRoles(rolesParsed.index, activeGroups.groups, activeGroups.sourceFile);
1894
+ for (const w of mergeWarnings)
1895
+ console.warn(`[access@${resolvedRef}] ${w}`);
1528
1896
  const accessFiles = new Map();
1529
1897
  const accessPaths = await this.listAccessFilesAtRef(repoDir, resolvedRef);
1530
1898
  for (const p of accessPaths) {
@@ -1550,6 +1918,7 @@ export class AccessControlService {
1550
1918
  model: {
1551
1919
  roles: rolesParsed.index,
1552
1920
  accessFilesByDir: accessFiles,
1921
+ groupsHealth: activeGroups.health,
1553
1922
  // Same owners as the working-tree model. The at-ref model backs the
1554
1923
  // PUSH gate, so omitting them here would let the deployment owner save
1555
1924
  // a roles.yaml repair locally and then be refused when it tries to