@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
@@ -6,7 +6,8 @@
6
6
  * markdown BODY below the frontmatter (the repo-root one is a whole README)
7
7
  * that a parse→emit round-trip would silently delete. `roles.yaml` has no body
8
8
  * and no frontmatter fence — it is a plain `roles:` mapping of display-name →
9
- * email list — and no real `roles.yaml` in the repo carries comments. So we
9
+ * member list (emails and `group:<name>` refs) — and no real `roles.yaml` in
10
+ * the repo carries comments. So we
10
11
  * parse the file into a model, apply the edit, and re-emit a canonical,
11
12
  * deterministic file. Re-emit is idempotent (re-emitting an unchanged model
12
13
  * yields byte-identical text), so a single edit moves only the lines it
@@ -23,8 +24,8 @@
23
24
  *
24
25
  * Every mutation returns `{ text, changed }`; `changed: false` means the edit
25
26
  * was a no-op (e.g. adding a member who already exists) and the caller should
26
- * skip the commit. Role ORDER is preserved as parsed; a newly created role is
27
- * appended last. Member emails within a role are canonicalised + de-duplicated.
27
+ * skip the commit. Role ORDER is preserved as parsed. Member entries within a
28
+ * role are canonicalised + de-duplicated.
28
29
  *
29
30
  * This module does NOT enforce the Admin-must-exist / no-self-lockout
30
31
  * invariants — those are policy and live in `roles-admin.service.ts`. It only
@@ -37,7 +38,7 @@ import {
37
38
  canonicalRoleName,
38
39
  canonicalEmail,
39
40
  EMAIL_REGEX,
40
- RESERVED_ROLE_NAMES,
41
+ GROUP_REF_PREFIX,
41
42
  } from './access-control.service.js';
42
43
 
43
44
  /** Bad-input failure when editing roles.yaml (invalid name/email, unknown role). */
@@ -50,7 +51,8 @@ export class RolesEditError extends Error {
50
51
  }
51
52
  }
52
53
 
53
- /** One role in parse order: display name + its de-duplicated member emails. */
54
+ /** One role in parse order: display name + its de-duplicated member entries
55
+ * (emails and `group:<canonical>` refs). */
54
56
  export interface RoleModel {
55
57
  displayName: string;
56
58
  /** canonicalised, de-duplicated, in first-seen order */
@@ -64,34 +66,6 @@ export interface EditResult {
64
66
  changed: boolean;
65
67
  }
66
68
 
67
- /**
68
- * Reject role display names whose characters would corrupt the emitted YAML.
69
- * `canonicalRoleName` only trims/lowercases/collapses spaces — it does NOT
70
- * reject structural characters, so a name like `Sales: West` or `#temp` would
71
- * round-trip into a broken file. This guard is the structural complement to the
72
- * reserved/duplicate checks the service layer applies.
73
- *
74
- * `:` mis-tokenises as a nested mapping key
75
- * `#` truncated as a comment by the subset parser's stripComment
76
- * `<` / `>` collide with the `Name <email>` user-reference shape
77
- * leading - tokenises as a list item
78
- * \x00-\x1f control chars / newlines break line structure outright
79
- */
80
- export function assertSafeRoleDisplayName(displayName: string): void {
81
- const trimmed = displayName.trim();
82
- if (!trimmed) throw new RolesEditError('role name must not be empty');
83
- if (/[:#<>]/.test(trimmed)) {
84
- throw new RolesEditError(`role name must not contain ':', '#', '<', or '>': ${JSON.stringify(displayName)}`);
85
- }
86
- // eslint-disable-next-line no-control-regex
87
- if (/[\x00-\x1f]/.test(trimmed)) {
88
- throw new RolesEditError(`role name must not contain control characters: ${JSON.stringify(displayName)}`);
89
- }
90
- if (trimmed.startsWith('-')) {
91
- throw new RolesEditError(`role name must not start with '-': ${JSON.stringify(displayName)}`);
92
- }
93
- }
94
-
95
69
  /**
96
70
  * Parse `roles.yaml` text into the ordered model. Tolerant of an empty/missing
97
71
  * file (yields `[]`). Throws RolesEditError on structurally-broken YAML — the
@@ -178,22 +152,13 @@ function reemit(original: string, model: RolesModel): EditResult {
178
152
  return { text, changed: text !== originalCanonical };
179
153
  }
180
154
 
181
- /** Create a new empty role. 422 on reserved/duplicate/structurally-unsafe name. */
182
- export function createRole(text: string, displayName: string): EditResult {
183
- assertSafeRoleDisplayName(displayName);
184
- const canonical = canonicalRoleName(displayName);
185
- if (RESERVED_ROLE_NAMES.has(canonical)) {
186
- throw new RolesEditError(`'${displayName.trim()}' is a reserved name and cannot be a role`);
187
- }
188
- const model = parseRolesModel(text);
189
- if (findRole(model, canonical)) {
190
- throw new RolesEditError(`a role named '${displayName.trim()}' already exists`);
191
- }
192
- model.push({ displayName: displayName.trim(), members: [] });
193
- return reemit(text, model);
194
- }
195
-
196
- /** Delete a role by canonical name. 404 if absent. */
155
+ /**
156
+ * Delete a role by canonical name. 404 if absent.
157
+ *
158
+ * NOTE: roles are app-defined capabilities now — there is no create/rename
159
+ * editor anymore. This deletion survives ONLY as the roles.yaml half of
160
+ * `convertRoleToGroup` (a LEGACY people-set role migrating to a group).
161
+ */
197
162
  export function deleteRole(text: string, canonical: string): EditResult {
198
163
  const model = parseRolesModel(text);
199
164
  const idx = model.findIndex((r) => canonicalRoleName(r.displayName) === canonical);
@@ -205,6 +170,14 @@ export function deleteRole(text: string, canonical: string): EditResult {
205
170
  /** Add a member email to a role. Idempotent. 422 bad email; 404 unknown role. */
206
171
  export function addMember(text: string, canonical: string, rawEmail: string): EditResult {
207
172
  const email = canonicalEmail(rawEmail);
173
+ // A `group:`-prefixed value is a GROUP REFERENCE, not an email — and
174
+ // `group:lee@x.io` would pass the email regex, landing a dead ref the
175
+ // resolver reads as an unknown group. Route the caller to the right editor.
176
+ if (email.startsWith(GROUP_REF_PREFIX)) {
177
+ throw new RolesEditError(
178
+ `member ${JSON.stringify(rawEmail)} starts with '${GROUP_REF_PREFIX}' — to assign this role to a group, use the group assignment (addRoleGroupRef / the role's Groups control), not a member email`,
179
+ );
180
+ }
208
181
  if (!EMAIL_REGEX.test(email)) throw new RolesEditError(`malformed email: ${JSON.stringify(rawEmail)}`);
209
182
  const model = parseRolesModel(text);
210
183
  const role = findRole(model, canonical);
@@ -227,22 +200,122 @@ export function removeMember(text: string, canonical: string, rawEmail: string):
227
200
  }
228
201
 
229
202
  /**
230
- * Rename a role's display name. The canonical name MAY change (the service
231
- * layer is responsible for the reference rewrite + Admin guard when it does).
232
- * 404 unknown source; 422 reserved/duplicate/unsafe target.
203
+ * A role member entry that assigns the role to a GROUP (`group:<name>`).
204
+ * Kept in `members` as the normalized `group:<canonical>` string — the
205
+ * parse/emit round-trip preserves it, so unrelated edits can never strip a
206
+ * group assignment.
233
207
  */
234
- export function renameRoleDisplay(text: string, canonical: string, newDisplayName: string): EditResult {
235
- assertSafeRoleDisplayName(newDisplayName);
236
- const newCanonical = canonicalRoleName(newDisplayName);
237
- if (RESERVED_ROLE_NAMES.has(newCanonical)) {
238
- throw new RolesEditError(`'${newDisplayName.trim()}' is a reserved name and cannot be a role`);
239
- }
208
+ export function isGroupRefMember(member: string): boolean {
209
+ return member.startsWith(GROUP_REF_PREFIX);
210
+ }
211
+
212
+ function groupRefFor(groupName: string): string {
213
+ const canonical = canonicalRoleName(groupName);
214
+ if (!canonical) throw new RolesEditError('group name must not be empty');
215
+ return `${GROUP_REF_PREFIX}${canonical}`;
216
+ }
217
+
218
+ /**
219
+ * The canonical group name a member entry references, or null for non-refs.
220
+ * Matching MUST go through this rather than string equality on the ref: a
221
+ * hand-edited file may carry an un-normalized ref (`group:GTM Team`) that
222
+ * the RESOLVER honors (it canonicalizes the suffix) — an equality match would
223
+ * then no-op the unassign/rename while the roster still shows the group.
224
+ */
225
+ function groupRefCanonical(member: string): string | null {
226
+ return isGroupRefMember(member)
227
+ ? canonicalRoleName(member.slice(GROUP_REF_PREFIX.length))
228
+ : null;
229
+ }
230
+
231
+ /**
232
+ * Assign a role to a group. Allowed on every role, Admin included — the
233
+ * resolver's parse-time invariant (Admin keeps at least one DIRECT email
234
+ * member) is what protects the rescue story, and the service validates every
235
+ * candidate through that parser before a byte lands. Idempotent; 404 unknown
236
+ * role.
237
+ */
238
+ export function addRoleGroupRef(text: string, canonical: string, groupName: string): EditResult {
239
+ const ref = groupRefFor(groupName);
240
+ const refCanonical = canonicalRoleName(groupName);
240
241
  const model = parseRolesModel(text);
241
242
  const role = findRole(model, canonical);
242
243
  if (!role) throw new RolesEditError(`role not found: ${canonical}`, 404);
243
- if (newCanonical !== canonical && findRole(model, newCanonical)) {
244
- throw new RolesEditError(`a role named '${newDisplayName.trim()}' already exists`);
244
+ if (role.members.some((m) => groupRefCanonical(m) === refCanonical)) {
245
+ return { text: emitRolesModel(model), changed: false };
246
+ }
247
+ role.members.push(ref);
248
+ return reemit(text, model);
249
+ }
250
+
251
+ /**
252
+ * Rewrite every role's `group:<oldCanonical>` assignment to name
253
+ * `group:<newCanonical>` — the roles.yaml half of a group rename. Refs are
254
+ * STORED canonical, so a canonical-changing group rename would otherwise
255
+ * strand them: `mergeGroupsIntoRoles` ignores an unknown ref with a warning,
256
+ * silently shrinking the role's membership. Already-present target refs
257
+ * dedupe rather than duplicate. No-op when nothing references the old name.
258
+ */
259
+ export function renameGroupRefs(text: string, oldCanonical: string, newCanonical: string): EditResult {
260
+ const newRef = `${GROUP_REF_PREFIX}${newCanonical}`;
261
+ const model = parseRolesModel(text);
262
+ let changed = false;
263
+ for (const role of model) {
264
+ // A role may carry SEVERAL differently-formatted refs to the same group
265
+ // (un-normalized hand edits) — rewrite them all, or the stragglers point
266
+ // at the renamed-away name and accumulate resolver warnings. Exactly one
267
+ // ref to the new name survives.
268
+ let hasNew = role.members.some((m) => groupRefCanonical(m) === newCanonical);
269
+ for (let i = role.members.length - 1; i >= 0; i--) {
270
+ if (groupRefCanonical(role.members[i]) !== oldCanonical) continue;
271
+ if (hasNew) role.members.splice(i, 1);
272
+ else {
273
+ role.members[i] = newRef;
274
+ hasNew = true;
275
+ }
276
+ changed = true;
277
+ }
245
278
  }
246
- role.displayName = newDisplayName.trim();
279
+ if (!changed) return { text: emitRolesModel(model), changed: false };
280
+ return reemit(text, model);
281
+ }
282
+
283
+ /**
284
+ * Remove every role's `group:<canonical>` assignment — the roles.yaml half of
285
+ * a group DELETION (mirror of {@link renameGroupRefs}): a deleted group's
286
+ * refs would otherwise dangle, silently shrinking each assigned role's
287
+ * membership with only a resolver log warning. Canonical-suffix matching, so
288
+ * un-normalized hand-edited refs are removed too. No-op when nothing
289
+ * references the group.
290
+ */
291
+ export function removeGroupRefsEverywhere(text: string, canonical: string): EditResult {
292
+ const model = parseRolesModel(text);
293
+ let changed = false;
294
+ for (const role of model) {
295
+ for (let i = role.members.length - 1; i >= 0; i--) {
296
+ if (groupRefCanonical(role.members[i]) !== canonical) continue;
297
+ role.members.splice(i, 1);
298
+ changed = true;
299
+ }
300
+ }
301
+ if (!changed) return { text: emitRolesModel(model), changed: false };
302
+ return reemit(text, model);
303
+ }
304
+
305
+ /** Remove a role's group assignment. 404 unknown role; no-op if not assigned.
306
+ * Removes EVERY member whose ref canonicalises to the group — a hand-edited
307
+ * file may carry several differently-formatted refs to the same group
308
+ * (`group:gtm team` + `group:GTM Team`), and stripping only the first would
309
+ * leave stragglers the resolver still honors, so the role silently keeps the
310
+ * group after an apparently-successful unassign. */
311
+ export function removeRoleGroupRef(text: string, canonical: string, groupName: string): EditResult {
312
+ const refCanonical = canonicalRoleName(groupName);
313
+ if (!refCanonical) throw new RolesEditError('group name must not be empty');
314
+ const model = parseRolesModel(text);
315
+ const role = findRole(model, canonical);
316
+ if (!role) throw new RolesEditError(`role not found: ${canonical}`, 404);
317
+ const kept = role.members.filter((m) => groupRefCanonical(m) !== refCanonical);
318
+ if (kept.length === role.members.length) return { text: emitRolesModel(model), changed: false };
319
+ role.members = kept;
247
320
  return reemit(text, model);
248
321
  }
@@ -0,0 +1,177 @@
1
+ import path from 'node:path';
2
+ import type { AuthUser, IWorkflowService } from '@bevel-software/platform-shared';
3
+ import type { WorkspaceService } from '../workspace/workspace.service.js';
4
+ import { workspaceIdForBranch } from '../workspace/workspace.service.js';
5
+ import type { AccessControlService } from './access-control.service.js';
6
+ import { SYNCED_GROUPS_YAML } from './group-files.js';
7
+ import type { WorkflowEventBus } from '../workflow/event-bus.js';
8
+ import { LockingFilesystem } from '../workflow/locking-filesystem.js';
9
+ import { PushNeedsAgentResolutionError, WorkflowDomainError } from '../workflow/workflow.errors.js';
10
+ import type { SyncedGroupsWriterDeps } from './synced-groups-writer.js';
11
+
12
+ /**
13
+ * The git half of the synced-groups materializer: reads/commits
14
+ * `synced-groups.yaml` on the DEFAULT branch through the same lock-aware
15
+ * pipeline every other in-app write uses, attributed to the directory-sync
16
+ * bot. Split from {@link SyncedGroupsWriter} so the writer's render/debounce
17
+ * logic stays testable against plain fakes.
18
+ *
19
+ * All three functions are resilient to an UNCONFIGURED deployment (no branch
20
+ * model yet): reads answer null and writes throw a descriptive error — the
21
+ * writer logs it; a SCIM push arriving before setup completes is a
22
+ * configuration race, not a crash.
23
+ */
24
+ export function createSyncedGroupsCommitter(deps: {
25
+ workspaceService: WorkspaceService;
26
+ workflowService: IWorkflowService;
27
+ accessControl: AccessControlService;
28
+ eventBus: WorkflowEventBus;
29
+ kbDirName: string;
30
+ bot: AuthUser;
31
+ /** Thunk — DEFAULT_BRANCH is a live binding that stays empty until setup. */
32
+ defaultBranchOf: () => string;
33
+ }): Pick<SyncedGroupsWriterDeps, 'readCurrent' | 'persist' | 'onWritten'> {
34
+ const {
35
+ workspaceService,
36
+ workflowService,
37
+ accessControl,
38
+ eventBus,
39
+ kbDirName,
40
+ bot,
41
+ defaultBranchOf,
42
+ } = deps;
43
+
44
+ const repoRelPath = SYNCED_GROUPS_YAML;
45
+ const wsRelPath = `${kbDirName}/${SYNCED_GROUPS_YAML}`;
46
+
47
+ return {
48
+ readCurrent: async () => {
49
+ const branch = defaultBranchOf();
50
+ if (!branch) return null;
51
+ await workspaceService.getOrCreateForBranch(branch);
52
+ try {
53
+ return await workspaceService.readFile(
54
+ workspaceIdForBranch(branch),
55
+ path.posix.join(kbDirName, repoRelPath),
56
+ );
57
+ } catch (err) {
58
+ const code = (err as NodeJS.ErrnoException | null)?.code;
59
+ if (code === 'ENOENT' || code === 'ENOTDIR') return null;
60
+ throw err;
61
+ }
62
+ },
63
+
64
+ persist: async (content: string) => {
65
+ const branch = defaultBranchOf();
66
+ if (!branch) {
67
+ throw new Error('deployment has no default branch configured yet — cannot materialize synced groups');
68
+ }
69
+ await workspaceService.getOrCreateForBranch(branch);
70
+ const workspaceId = workspaceIdForBranch(branch);
71
+ const basePath = await workspaceService.getWorkspacePath(workspaceId);
72
+ // Lock-aware write: acquires the file's lock, writes, and commits the
73
+ // batch as ONE change with a descriptive summary — same pipeline the
74
+ // roles admin's atomic writes use.
75
+ const fsys = new LockingFilesystem(
76
+ { basePath, contained: true },
77
+ { workflow: workflowService, workspaceId, branch, user: bot },
78
+ );
79
+ try {
80
+ await fsys.writeFiles(
81
+ [{ path: wsRelPath, content }],
82
+ 'Sync directory groups from the identity provider',
83
+ );
84
+ } catch (err) {
85
+ if (err instanceof PushNeedsAgentResolutionError) {
86
+ // POST-commit failure: the update IS committed locally — only the
87
+ // push needs help, and the pending-commits ladder retries it. But
88
+ // the ladder is only armed if a pending-commit row for this path
89
+ // actually EXISTS — the writeFiles push-retry release that enqueues
90
+ // it is best-effort (a failure is logged and swallowed), and
91
+ // without a row there is no retry vehicle: the next sync reads the
92
+ // committed bytes, sees a no-op, and the update stays unpublished
93
+ // forever. So PROVE the vehicle before reporting success:
94
+ // 1. Release our lock once more. Success means THIS release
95
+ // enqueued the row (releaseLock enqueues BEFORE dropping the
96
+ // row) → armed, lock freed.
97
+ // 2. Lock already gone → ambiguous: the earlier release either
98
+ // enqueued-then-dropped (armed) or died BEFORE its enqueue
99
+ // with the lock expired/stolen (NOT armed) — both leave the
100
+ // same lock-not-held answer. Only the queue itself can tell
101
+ // them apart: ask it directly for a live row on this path.
102
+ // 2b. No live row → one more possibility before giving up: the
103
+ // worker may have DRAINED the row (and pushed) between the
104
+ // first release and this probe. Nothing left unpushed on the
105
+ // branch proves exactly that — the update is already
106
+ // published, which is better than armed.
107
+ // 3. Anything else (or no proof either way) → no proven vehicle:
108
+ // rethrow, so the writer logs a real failure instead of a
109
+ // phantom success.
110
+ // Rejecting is still the LAST resort — a landed commit routed
111
+ // through the failure path stays locally live but unpublished until
112
+ // an operator (or any later write on the branch) pushes it.
113
+ let armed = false;
114
+ try {
115
+ await workflowService.releaseLock(workspaceId, branch, wsRelPath, bot);
116
+ armed = true;
117
+ } catch (armErr) {
118
+ const lockAlreadyReleased =
119
+ armErr instanceof WorkflowDomainError && armErr.payload?.kind === 'lock-not-held';
120
+ if (lockAlreadyReleased) {
121
+ try {
122
+ armed = await workflowService.hasQueuedCommit(workspaceId, branch, wsRelPath);
123
+ if (!armed) {
124
+ // The row may already be gone because the worker WON the
125
+ // race: drained it and pushed. A branch with nothing left
126
+ // unpushed cannot be holding our landed commit hostage.
127
+ armed = !(await workflowService.hasUnpushedCommits(workspaceId));
128
+ }
129
+ } catch (queueErr) {
130
+ console.warn(
131
+ '[directory-sync] could not verify the pending-commit queue:',
132
+ queueErr instanceof Error ? queueErr.message : queueErr,
133
+ );
134
+ }
135
+ } else {
136
+ console.warn(
137
+ '[directory-sync] the retry re-arm release failed:',
138
+ armErr instanceof Error ? armErr.message : armErr,
139
+ );
140
+ }
141
+ }
142
+ if (!armed) {
143
+ console.warn(
144
+ '[directory-sync] synced-groups commit landed, the push needs resolution, and no retry vehicle could be proven — surfacing the failure',
145
+ );
146
+ throw err;
147
+ }
148
+ console.warn(
149
+ '[directory-sync] synced-groups commit landed but the push needs resolution — publishing will be retried',
150
+ );
151
+ return;
152
+ }
153
+ throw err;
154
+ }
155
+ },
156
+
157
+ onWritten: () => {
158
+ const branch = defaultBranchOf();
159
+ if (!branch) return;
160
+ const workspaceId = workspaceIdForBranch(branch);
161
+ // A synced-groups change IS an access change: drop the resolver cache
162
+ // and nudge open clients to re-fetch + re-evaluate, mirroring
163
+ // RolesAdminService.emitWrites.
164
+ accessControl.invalidate(workspaceId);
165
+ eventBus.emit({
166
+ kind: 'file-changed',
167
+ workspaceId,
168
+ branch,
169
+ path: wsRelPath,
170
+ newSha: null,
171
+ byUserId: bot.id,
172
+ byUserName: bot.name,
173
+ });
174
+ eventBus.emit({ kind: 'fs-tree-changed', workspaceId, branch });
175
+ },
176
+ };
177
+ }