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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/dist/core/create-core-server.d.ts.map +1 -1
  2. package/dist/core/create-core-server.js +12 -1
  3. package/dist/core/create-core-server.js.map +1 -1
  4. package/dist/core/create-core-services.d.ts +22 -0
  5. package/dist/core/create-core-services.d.ts.map +1 -1
  6. package/dist/core/create-core-services.js +34 -1
  7. package/dist/core/create-core-services.js.map +1 -1
  8. package/dist/index.d.ts +2 -0
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +4 -0
  11. package/dist/index.js.map +1 -1
  12. package/dist/modules/access/access-control.interface.d.ts +54 -28
  13. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  14. package/dist/modules/access/access-control.service.d.ts +129 -14
  15. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  16. package/dist/modules/access/access-control.service.js +435 -66
  17. package/dist/modules/access/access-control.service.js.map +1 -1
  18. package/dist/modules/access/access-declarations.d.ts.map +1 -1
  19. package/dist/modules/access/access-declarations.js +5 -3
  20. package/dist/modules/access/access-declarations.js.map +1 -1
  21. package/dist/modules/access/access-mutation.service.d.ts +39 -6
  22. package/dist/modules/access/access-mutation.service.d.ts.map +1 -1
  23. package/dist/modules/access/access-mutation.service.js +78 -18
  24. package/dist/modules/access/access-mutation.service.js.map +1 -1
  25. package/dist/modules/access/access-splice.d.ts +31 -4
  26. package/dist/modules/access/access-splice.d.ts.map +1 -1
  27. package/dist/modules/access/access-splice.js +40 -16
  28. package/dist/modules/access/access-splice.js.map +1 -1
  29. package/dist/modules/access/access.routes.d.ts.map +1 -1
  30. package/dist/modules/access/access.routes.js +204 -82
  31. package/dist/modules/access/access.routes.js.map +1 -1
  32. package/dist/modules/access/admin-locked-commit.d.ts +134 -0
  33. package/dist/modules/access/admin-locked-commit.d.ts.map +1 -0
  34. package/dist/modules/access/admin-locked-commit.js +277 -0
  35. package/dist/modules/access/admin-locked-commit.js.map +1 -0
  36. package/dist/modules/access/admin-route-helpers.d.ts +32 -0
  37. package/dist/modules/access/admin-route-helpers.d.ts.map +1 -0
  38. package/dist/modules/access/admin-route-helpers.js +44 -0
  39. package/dist/modules/access/admin-route-helpers.js.map +1 -0
  40. package/dist/modules/access/capability-registry.d.ts +41 -0
  41. package/dist/modules/access/capability-registry.d.ts.map +1 -0
  42. package/dist/modules/access/capability-registry.js +46 -0
  43. package/dist/modules/access/capability-registry.js.map +1 -0
  44. package/dist/modules/access/directory-sync-bot.d.ts +13 -0
  45. package/dist/modules/access/directory-sync-bot.d.ts.map +1 -0
  46. package/dist/modules/access/directory-sync-bot.js +64 -0
  47. package/dist/modules/access/directory-sync-bot.js.map +1 -0
  48. package/dist/modules/access/group-files.d.ts +83 -0
  49. package/dist/modules/access/group-files.d.ts.map +1 -0
  50. package/dist/modules/access/group-files.js +167 -0
  51. package/dist/modules/access/group-files.js.map +1 -0
  52. package/dist/modules/access/groups-admin.routes.d.ts +19 -0
  53. package/dist/modules/access/groups-admin.routes.d.ts.map +1 -0
  54. package/dist/modules/access/groups-admin.routes.js +98 -0
  55. package/dist/modules/access/groups-admin.routes.js.map +1 -0
  56. package/dist/modules/access/groups-admin.service.d.ts +166 -0
  57. package/dist/modules/access/groups-admin.service.d.ts.map +1 -0
  58. package/dist/modules/access/groups-admin.service.js +442 -0
  59. package/dist/modules/access/groups-admin.service.js.map +1 -0
  60. package/dist/modules/access/groups-edit.d.ts +58 -0
  61. package/dist/modules/access/groups-edit.d.ts.map +1 -0
  62. package/dist/modules/access/groups-edit.js +162 -0
  63. package/dist/modules/access/groups-edit.js.map +1 -0
  64. package/dist/modules/access/reference-scan.d.ts +141 -0
  65. package/dist/modules/access/reference-scan.d.ts.map +1 -0
  66. package/dist/modules/access/reference-scan.js +440 -0
  67. package/dist/modules/access/reference-scan.js.map +1 -0
  68. package/dist/modules/access/roles-admin.service.d.ts +88 -119
  69. package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
  70. package/dist/modules/access/roles-admin.service.js +230 -384
  71. package/dist/modules/access/roles-admin.service.js.map +1 -1
  72. package/dist/modules/access/roles-edit.d.ts +51 -25
  73. package/dist/modules/access/roles-edit.d.ts.map +1 -1
  74. package/dist/modules/access/roles-edit.js +133 -59
  75. package/dist/modules/access/roles-edit.js.map +1 -1
  76. package/dist/modules/access/synced-groups-committer.d.ts +28 -0
  77. package/dist/modules/access/synced-groups-committer.d.ts.map +1 -0
  78. package/dist/modules/access/synced-groups-committer.js +139 -0
  79. package/dist/modules/access/synced-groups-committer.js.map +1 -0
  80. package/dist/modules/access/synced-groups-writer.d.ts +78 -0
  81. package/dist/modules/access/synced-groups-writer.d.ts.map +1 -0
  82. package/dist/modules/access/synced-groups-writer.js +219 -0
  83. package/dist/modules/access/synced-groups-writer.js.map +1 -0
  84. package/dist/modules/database/core-schema.d.ts +17 -0
  85. package/dist/modules/database/core-schema.d.ts.map +1 -1
  86. package/dist/modules/database/core-schema.js +9 -0
  87. package/dist/modules/database/core-schema.js.map +1 -1
  88. package/dist/modules/mcp/mcp-auth.middleware.d.ts +13 -2
  89. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  90. package/dist/modules/mcp/mcp-auth.middleware.js +61 -2
  91. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  92. package/dist/modules/mcp/mcp.routes.d.ts +9 -3
  93. package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
  94. package/dist/modules/mcp/mcp.routes.js +126 -2
  95. package/dist/modules/mcp/mcp.routes.js.map +1 -1
  96. package/dist/modules/mcp/mcp.service.d.ts +14 -0
  97. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  98. package/dist/modules/mcp/mcp.service.js +6 -1
  99. package/dist/modules/mcp/mcp.service.js.map +1 -1
  100. package/dist/modules/workflow/file-lock.service.d.ts +11 -1
  101. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  102. package/dist/modules/workflow/file-lock.service.js +15 -1
  103. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  104. package/dist/modules/workflow/git/git.service.d.ts +34 -11
  105. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  106. package/dist/modules/workflow/git/git.service.js +165 -37
  107. package/dist/modules/workflow/git/git.service.js.map +1 -1
  108. package/dist/modules/workflow/locking-filesystem.d.ts +4 -0
  109. package/dist/modules/workflow/locking-filesystem.d.ts.map +1 -1
  110. package/dist/modules/workflow/locking-filesystem.js +181 -28
  111. package/dist/modules/workflow/locking-filesystem.js.map +1 -1
  112. package/dist/modules/workflow/pending-commits.service.d.ts +10 -0
  113. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  114. package/dist/modules/workflow/pending-commits.service.js +19 -1
  115. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  116. package/dist/modules/workflow/workflow.service.d.ts +17 -2
  117. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  118. package/dist/modules/workflow/workflow.service.js +113 -19
  119. package/dist/modules/workflow/workflow.service.js.map +1 -1
  120. package/kb-template/AGENTS.md +4 -1
  121. package/migrations/0004_file_lock_mode.sql +2 -0
  122. package/migrations/meta/0004_snapshot.json +1564 -0
  123. package/migrations/meta/_journal.json +7 -0
  124. package/package.json +3 -3
  125. package/src/core/create-core-server.ts +13 -0
  126. package/src/core/create-core-services.ts +68 -0
  127. package/src/index.ts +10 -0
  128. package/src/modules/access/__tests__/access-control.service.test.ts +9 -3
  129. package/src/modules/access/__tests__/access-groups.test.ts +427 -0
  130. package/src/modules/access/__tests__/access-mutation.service.test.ts +171 -5
  131. package/src/modules/access/__tests__/access-splice.test.ts +65 -0
  132. package/src/modules/access/__tests__/access.routes.group-grant.test.ts +337 -0
  133. package/src/modules/access/__tests__/access.routes.revoke.test.ts +48 -0
  134. package/src/modules/access/__tests__/admin-locked-commit.test.ts +221 -0
  135. package/src/modules/access/__tests__/admin-route-helpers.test.ts +61 -0
  136. package/src/modules/access/__tests__/directory-sync-bot.test.ts +106 -0
  137. package/src/modules/access/__tests__/grant-sources.test.ts +67 -0
  138. package/src/modules/access/__tests__/groups-admin.service.test.ts +440 -0
  139. package/src/modules/access/__tests__/reference-scan.test.ts +288 -0
  140. package/src/modules/access/__tests__/roles-admin.service.test.ts +161 -83
  141. package/src/modules/access/__tests__/roles-capabilities.test.ts +325 -0
  142. package/src/modules/access/__tests__/roles-edit.test.ts +104 -28
  143. package/src/modules/access/__tests__/roles.routes.test.ts +63 -40
  144. package/src/modules/access/__tests__/synced-groups-committer.test.ts +238 -0
  145. package/src/modules/access/__tests__/synced-groups-writer.test.ts +249 -0
  146. package/src/modules/access/access-control.interface.ts +66 -32
  147. package/src/modules/access/access-control.service.ts +536 -73
  148. package/src/modules/access/access-declarations.ts +5 -2
  149. package/src/modules/access/access-mutation.service.ts +88 -14
  150. package/src/modules/access/access-splice.ts +55 -17
  151. package/src/modules/access/access.routes.ts +227 -93
  152. package/src/modules/access/admin-locked-commit.ts +331 -0
  153. package/src/modules/access/admin-route-helpers.ts +55 -0
  154. package/src/modules/access/capability-registry.ts +74 -0
  155. package/src/modules/access/directory-sync-bot.ts +76 -0
  156. package/src/modules/access/group-files.ts +212 -0
  157. package/src/modules/access/groups-admin.routes.ts +113 -0
  158. package/src/modules/access/groups-admin.service.ts +551 -0
  159. package/src/modules/access/groups-edit.ts +187 -0
  160. package/src/modules/access/reference-scan.ts +513 -0
  161. package/src/modules/access/roles-admin.service.ts +290 -418
  162. package/src/modules/access/roles-edit.ts +134 -61
  163. package/src/modules/access/synced-groups-committer.ts +177 -0
  164. package/src/modules/access/synced-groups-writer.ts +303 -0
  165. package/src/modules/database/core-schema.ts +9 -0
  166. package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +116 -0
  167. package/src/modules/mcp/__tests__/mcp.e2e.test.ts +3 -1
  168. package/src/modules/mcp/__tests__/mcp.routes.delete.test.ts +6 -1
  169. package/src/modules/mcp/__tests__/mcp.routes.local-token.test.ts +301 -0
  170. package/src/modules/mcp/mcp-auth.middleware.ts +61 -1
  171. package/src/modules/mcp/mcp.routes.ts +137 -2
  172. package/src/modules/mcp/mcp.service.ts +6 -1
  173. package/src/modules/workflow/__tests__/locking-filesystem.test.ts +336 -0
  174. package/src/modules/workflow/__tests__/preserve-roles-yaml.test.ts +1 -1
  175. package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +32 -0
  176. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +13 -2
  177. package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +139 -1
  178. package/src/modules/workflow/file-lock.service.ts +15 -0
  179. package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +0 -1
  180. package/src/modules/workflow/git/__tests__/git.service.commitChanges.test.ts +132 -0
  181. package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +0 -1
  182. package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +0 -1
  183. package/src/modules/workflow/git/git.service.ts +174 -35
  184. package/src/modules/workflow/locking-filesystem.ts +188 -26
  185. package/src/modules/workflow/pending-commits.service.ts +27 -1
  186. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +0 -1
  187. package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +0 -1
  188. package/src/modules/workflow/workflow.service.ts +140 -20
@@ -0,0 +1,551 @@
1
+ /**
2
+ * Admin Groups service — CRUD on the default-branch `groups.yaml` (manual
3
+ * mode), the mode probe, and the connect-time retirement of manual groups.
4
+ *
5
+ * Shares `RolesAdminService`'s write pipeline via the SHARED
6
+ * `AdminLockedCommits` helper (one lock/commit implementation — the twin
7
+ * copies diverged once into a real bug) with the policy differences the
8
+ * roles/groups split defines:
9
+ *
10
+ * - MODE GATE: every mutation refuses in IdP mode (`synced-groups.yaml`
11
+ * exists on the default branch). Groups are managed in the identity
12
+ * provider then; a second write surface would fragment org management —
13
+ * which is the thing the mode model exists to prevent.
14
+ * - COLLISION GATE: create/rename refuse a name whose canonical form is a
15
+ * roles.yaml role. Bare-name precedence would let the group win, but a
16
+ * deliberately-created shadow of a capability role is far more likely a
17
+ * mistake than an intent — the IdP can still produce collisions, which
18
+ * precedence then resolves (group first, `role/<name>` for the role).
19
+ * - DEGRADE LOUDLY: a broken groups source never fails access resolution
20
+ * closed — groups contribute nothing and the model carries a
21
+ * `groupsHealth` marker, which the roster exposes so the Groups page can
22
+ * banner it. A broken MANUAL groups.yaml additionally makes the roster
23
+ * answer 422 with the parse message (the operator must see what to fix —
24
+ * never a dead Groups page, never a silent empty roster they might
25
+ * "repair" by re-creating groups over live bytes).
26
+ */
27
+
28
+ import { workspaceIdForBranch } from '../workspace/workspace.service.js';
29
+ import type { WorkflowEventBus } from '../workflow/event-bus.js';
30
+ import type { AuthUser, IWorkspaceService, IWorkflowService } from '@bevel-software/platform-shared';
31
+ import { WorkflowDomainError } from '../workflow/workflow.errors.js';
32
+ import type { IAccessControl } from './access-control.interface.js';
33
+ import {
34
+ GROUP_REF_PREFIX,
35
+ canonicalRoleName,
36
+ hasAccessFrontmatterExtension,
37
+ loadActiveGroups,
38
+ type GroupsHealth,
39
+ } from './access-control.service.js';
40
+ import { GROUPS_YAML, SYNCED_GROUPS_YAML, validateGroupsFile } from './group-files.js';
41
+ import { parseRolesModel, removeGroupRefsEverywhere, renameGroupRefs, isGroupRefMember } from './roles-edit.js';
42
+ import { makeRolesYamlWriteValidator } from './roles-yaml-guard.js';
43
+ import {
44
+ GroupsEditError,
45
+ parseGroupsModel,
46
+ createGroup as editCreateGroup,
47
+ deleteGroup as editDeleteGroup,
48
+ addGroupMember as editAddMember,
49
+ removeGroupMember as editRemoveMember,
50
+ renameGroupDisplay as editRenameDisplay,
51
+ type GroupsEditResult,
52
+ } from './groups-edit.js';
53
+ import { AdminLockedCommits, type LockedWrite } from './admin-locked-commit.js';
54
+ import { KbReferenceScanner } from './reference-scan.js';
55
+
56
+ /** Where role→group assignments live (`group:<canonical>` member entries). */
57
+ const ROLES_YAML = 'roles.yaml';
58
+
59
+ /** A group mutation that cannot proceed. `payload.kind` distinguishes the
60
+ * mode refusal (`'idp-mode'`) for the UI. */
61
+ export class GroupsAdminError extends WorkflowDomainError {
62
+ constructor(message: string, status = 422, payload?: Record<string, unknown>) {
63
+ super(message, status, payload);
64
+ this.name = 'GroupsAdminError';
65
+ }
66
+ }
67
+
68
+ export type GroupsMode = 'manual' | 'idp';
69
+
70
+ export interface GroupRosterEntry {
71
+ canonical: string;
72
+ displayName: string;
73
+ members: string[];
74
+ /** Every access rule referencing this group — same scan the rename uses. */
75
+ referencedBy: { path: string; verb: string }[];
76
+ /**
77
+ * Canonical names of roles carrying a `group:<canonical>` assignment to
78
+ * this group — so the delete confirm can warn that those roles will lose
79
+ * the members they inherit through it (deleteGroup unassigns them
80
+ * atomically).
81
+ */
82
+ assignedToRoles: string[];
83
+ }
84
+
85
+ export interface GroupsRoster {
86
+ mode: GroupsMode;
87
+ groups: GroupRosterEntry[];
88
+ /**
89
+ * Health of the active groups source — `ok: false` when the source exists
90
+ * but is unreadable/unparseable (resolution degrades: groups contribute
91
+ * nothing). Shipped for the Groups page banner (frontend increment).
92
+ */
93
+ groupsHealth: GroupsHealth;
94
+ }
95
+
96
+ export class GroupsAdminService {
97
+ /** Shared lock/commit plumbing — see module doc. */
98
+ private readonly locked: AdminLockedCommits;
99
+ /** Shared cached reference scanner. */
100
+ private readonly references: KbReferenceScanner;
101
+
102
+ constructor(
103
+ private readonly workspaceService: IWorkspaceService,
104
+ private readonly workflowService: IWorkflowService,
105
+ private readonly accessControl: IAccessControl,
106
+ private readonly kbDirName: string,
107
+ /** Live-binding thunk — see RolesAdminService's identical note. */
108
+ private readonly defaultBranchOf: () => string,
109
+ private readonly eventBus?: WorkflowEventBus,
110
+ ) {
111
+ this.locked = new AdminLockedCommits({
112
+ workspaceService,
113
+ workflowService,
114
+ kbDirName,
115
+ defaultBranchOf,
116
+ makeError: (message, status, payload) => new GroupsAdminError(message, status, payload),
117
+ logTag: 'groups-admin',
118
+ contendedSubject: 'Groups',
119
+ // The rename/delete batch may rewrite roles.yaml (group:<ref> members)
120
+ // — same pre-disk no-lockout gate the roles admin attaches.
121
+ validateWrite: makeRolesYamlWriteValidator(kbDirName),
122
+ });
123
+ // The event bus keeps the roster's `referencedBy` fresh: a share-dialog
124
+ // grant/revoke on any access.md happens outside this service, and the
125
+ // scanner invalidates its cache on those writes' events (TTL as backstop).
126
+ this.references = new KbReferenceScanner(workspaceService, kbDirName, eventBus);
127
+ }
128
+
129
+ private get defaultBranch(): string {
130
+ return this.defaultBranchOf();
131
+ }
132
+
133
+ private get workspaceId(): string {
134
+ return workspaceIdForBranch(this.defaultBranch);
135
+ }
136
+
137
+ private async ensureWorkspace(): Promise<string> {
138
+ await this.workspaceService.getOrCreateForBranch(this.defaultBranch);
139
+ return this.workspaceId;
140
+ }
141
+
142
+ /**
143
+ * IdP mode iff `synced-groups.yaml` exists — same rule the resolver applies.
144
+ *
145
+ * KNOWN GAP, deliberate: this cannot see the connect WINDOW — a directory
146
+ * connection that is configured but whose first provisioning push has not
147
+ * landed the synced file yet. Core's `SyncedGroupsSource` seam exposes only
148
+ * `listGroups()` (the overlay owns the connection), so "connected but not
149
+ * yet materialized" is not cheaply knowable server-side; the UI keeps its
150
+ * own suppression for that window.
151
+ */
152
+ async getMode(): Promise<GroupsMode> {
153
+ const workspaceId = await this.ensureWorkspace();
154
+ return (await this.locked.readKbFile(workspaceId, SYNCED_GROUPS_YAML)) !== null ? 'idp' : 'manual';
155
+ }
156
+
157
+ // ---- Read ---------------------------------------------------------------
158
+
159
+ /**
160
+ * Mode + roster + source health. Reads the ACTIVE source through the same
161
+ * `loadActiveGroups` rule the resolver applies, so mode, health, and the
162
+ * group set can never disagree with resolution:
163
+ *
164
+ * - IdP mode (synced file present, even broken): the roster IS the synced
165
+ * file — read-only in the UI; the manual file is retired and showing it
166
+ * would misreport who has access. A broken synced file yields an EMPTY
167
+ * roster with the `groupsHealth` marker (mode stays 'idp' — no
168
+ * fallback to manual, which would resurrect retired groups).
169
+ * - Manual mode, broken groups.yaml: 422 with the parse message — the
170
+ * repair path must show the operator what to fix, never a dead page.
171
+ */
172
+ async getRoster(): Promise<GroupsRoster> {
173
+ const workspaceId = await this.ensureWorkspace();
174
+ const active = await loadActiveGroups((f) => this.locked.readKbFile(workspaceId, f));
175
+ if (active.sourceFile === GROUPS_YAML && !active.health.ok) {
176
+ throw new GroupsAdminError(`groups.yaml cannot be read: ${active.health.reason}`, 422, {
177
+ kind: 'broken-groups',
178
+ file: active.health.file,
179
+ reason: active.health.reason,
180
+ });
181
+ }
182
+ const [referencesByToken, assignedByGroup] = await Promise.all([
183
+ this.references.scan(workspaceId),
184
+ this.rolesReferencingGroups(workspaceId),
185
+ ]);
186
+ const mode: GroupsMode = active.sourceFile === SYNCED_GROUPS_YAML ? 'idp' : 'manual';
187
+ const groups: GroupRosterEntry[] = [];
188
+ for (const [canonical, def] of active.groups) {
189
+ groups.push({
190
+ canonical,
191
+ displayName: def.displayName,
192
+ // IdP members render sorted (machine-generated file, no author order
193
+ // to honour); manual members keep file order (the editor's view).
194
+ members: mode === 'idp' ? [...def.emails].sort() : [...def.emails],
195
+ // A group's references are its BARE-token hits, unconditionally: this
196
+ // group exists in the active source, so under group-first precedence
197
+ // it owns the bare key — even when a role shares the name (the roles
198
+ // roster is the mirror image: it then skips the bare hits and counts
199
+ // only `role/<name>`). Explicit `role/<name>` hits are never a group
200
+ // reference.
201
+ referencedBy: referencesByToken.get(canonical) ?? [],
202
+ assignedToRoles: assignedByGroup.get(canonical) ?? [],
203
+ });
204
+ }
205
+ return { mode, groups, groupsHealth: active.health };
206
+ }
207
+
208
+ /**
209
+ * Display names from the ACTIVE group source (synced in IdP mode, manual
210
+ * otherwise). Malformed files degrade to an empty list, matching the
211
+ * resolver's degrade rule.
212
+ */
213
+ async listActiveGroupNames(): Promise<string[]> {
214
+ const workspaceId = await this.ensureWorkspace();
215
+ const active = await loadActiveGroups((f) => this.locked.readKbFile(workspaceId, f));
216
+ return [...active.groups.values()].map((g) => g.displayName);
217
+ }
218
+
219
+ /** Manual group display names — what the connect-time warning dialog lists. */
220
+ async listManualGroupNames(): Promise<string[]> {
221
+ const workspaceId = await this.ensureWorkspace();
222
+ const text = (await this.locked.readKbFile(workspaceId, GROUPS_YAML)) ?? '';
223
+ return parseGroupsModel(text).map((g) => g.displayName);
224
+ }
225
+
226
+ // ---- Mutations ----------------------------------------------------------
227
+
228
+ async createGroup(actor: AuthUser, displayName: string): Promise<GroupsRoster> {
229
+ // Mode gate FIRST: in IdP mode every mutation must answer the typed 409
230
+ // (the UI's cue to point at the identity provider) — a name-collision 422
231
+ // for a name like "Admin" would mislead about what is actually refused.
232
+ await this.assertManualMode(await this.ensureWorkspace());
233
+ await this.assertRoleNameFree(displayName);
234
+ await this.runEdit(actor, (text) => editCreateGroup(text, displayName));
235
+ return this.getRoster();
236
+ }
237
+
238
+ /**
239
+ * Delete a group AND unassign it from every role — the `group:<canonical>`
240
+ * members in roles.yaml — in the SAME atomic locked commit (mirror of the
241
+ * rename's ref rewrite): a delete that left the refs would silently shrink
242
+ * each assigned role's membership behind a mere log warning. Grant
243
+ * references in access.md are NOT touched — a dangling grant resolves to
244
+ * nothing by design (and the roster's `referencedBy` fed the confirm
245
+ * dialog's warning).
246
+ */
247
+ async deleteGroup(actor: AuthUser, canonical: string): Promise<GroupsRoster> {
248
+ const workspaceId = await this.ensureWorkspace();
249
+ await this.assertManualMode(workspaceId);
250
+ await this.locked.withFileLocks(workspaceId, actor, [GROUPS_YAML, ROLES_YAML], async () => {
251
+ const groupsOriginal = await this.locked.readKbFile(workspaceId, GROUPS_YAML);
252
+ const groupsEdit = this.guardEdit(() => editDeleteGroup(groupsOriginal ?? '', canonical));
253
+ this.assertLoadable(groupsEdit.text);
254
+ const files: LockedWrite[] = [
255
+ { repoRel: GROUPS_YAML, content: groupsEdit.text, original: groupsOriginal },
256
+ ];
257
+ const rolesText = await this.locked.readKbFile(workspaceId, ROLES_YAML);
258
+ if (rolesText !== null) {
259
+ let rolesEdit: { text: string; changed: boolean };
260
+ try {
261
+ rolesEdit = removeGroupRefsEverywhere(rolesText, canonical);
262
+ } catch (err) {
263
+ // Fail closed on a malformed roles.yaml: deleting the group without
264
+ // unassigning it would strand dangling refs the delete exists to clean.
265
+ throw new GroupsAdminError(
266
+ `Cannot rewrite role assignments in ${ROLES_YAML}; delete aborted with no changes`,
267
+ 422,
268
+ { cause: (err as Error)?.message },
269
+ );
270
+ }
271
+ if (rolesEdit.changed) {
272
+ // Same pre-disk no-lockout gate LockingFilesystem would have run.
273
+ makeRolesYamlWriteValidator(this.kbDirName)(`${this.kbDirName}/${ROLES_YAML}`, rolesEdit.text);
274
+ files.push({ repoRel: ROLES_YAML, content: rolesEdit.text, original: rolesText });
275
+ }
276
+ }
277
+ await this.locked.writeAndCommitLocked(
278
+ workspaceId,
279
+ actor,
280
+ files,
281
+ `Delete group ${canonical}`,
282
+ );
283
+ this.afterWrite(workspaceId, actor, files.map((f) => f.repoRel));
284
+ });
285
+ return this.getRoster();
286
+ }
287
+
288
+ async addMember(actor: AuthUser, canonical: string, email: string): Promise<GroupsRoster> {
289
+ await this.runEdit(actor, (text) => editAddMember(text, canonical, email));
290
+ return this.getRoster();
291
+ }
292
+
293
+ async removeMember(actor: AuthUser, canonical: string, email: string): Promise<GroupsRoster> {
294
+ await this.runEdit(actor, (text) => editRemoveMember(text, canonical, email));
295
+ return this.getRoster();
296
+ }
297
+
298
+ /**
299
+ * Rename — canonical-changing renames rewrite every grant reference in ONE
300
+ * atomic commit (same machinery, same reasoning as ref unassignment on
301
+ * delete: a partial rewrite is a silent access drop).
302
+ */
303
+ async renameGroup(actor: AuthUser, canonical: string, newDisplayName: string): Promise<GroupsRoster> {
304
+ const workspaceId = await this.ensureWorkspace();
305
+ await this.assertManualMode(workspaceId);
306
+ const newCanonical = canonicalRoleName(newDisplayName);
307
+ if (newCanonical !== canonical) await this.assertRoleNameFree(newDisplayName);
308
+
309
+ // Both roster files are read AND rewritten from snapshots — hold their
310
+ // locks across the whole build so a concurrent roster edit can't land in
311
+ // between and be overwritten by the batch.
312
+ await this.locked.withFileLocks(workspaceId, actor, [GROUPS_YAML, ROLES_YAML], () =>
313
+ this.renameGroupLocked(workspaceId, actor, canonical, newCanonical, newDisplayName),
314
+ );
315
+ return this.getRoster();
316
+ }
317
+
318
+ private async renameGroupLocked(
319
+ workspaceId: string,
320
+ actor: AuthUser,
321
+ canonical: string,
322
+ newCanonical: string,
323
+ newDisplayName: string,
324
+ ): Promise<void> {
325
+ const text = (await this.locked.readKbFile(workspaceId, GROUPS_YAML)) ?? '';
326
+ const groupsEdit = this.guardEdit(() => editRenameDisplay(text, canonical, newDisplayName));
327
+ const files: LockedWrite[] = [];
328
+ if (groupsEdit.changed) {
329
+ this.assertLoadable(groupsEdit.text);
330
+ files.push({ repoRel: GROUPS_YAML, content: groupsEdit.text, original: text });
331
+ }
332
+ if (newCanonical !== canonical) {
333
+ // Grant references (bare tokens in access.md + node frontmatter). The
334
+ // rewrite returns each file's ORIGINAL text alongside the new content,
335
+ // so the rollback snapshots reuse the read it already did — no second
336
+ // full-KB pass. Explicit `role/<name>` tokens are untouched by design:
337
+ // they reference the ROLE, not this group.
338
+ const refWrites = await this.references.rewriteReferences(
339
+ workspaceId,
340
+ canonical,
341
+ newDisplayName.trim(),
342
+ (message, cause) => new GroupsAdminError(message, 422, { cause }),
343
+ );
344
+ for (const w of refWrites) {
345
+ files.push({ repoRel: w.repoRelativePath, content: w.content, original: w.original });
346
+ }
347
+ // Role→group assignments live in roles.yaml as `group:<canonical>` —
348
+ // stored canonical, so they go stale on a canonical-changing rename and
349
+ // the role would silently stop expanding to the group's members. Rewrite
350
+ // them in the SAME atomic commit. Fail closed on a malformed roles.yaml:
351
+ // committing the rename without it would strand any refs it holds.
352
+ const rolesText = await this.locked.readKbFile(workspaceId, ROLES_YAML);
353
+ if (rolesText !== null) {
354
+ let rolesEdit: { text: string; changed: boolean };
355
+ try {
356
+ rolesEdit = renameGroupRefs(rolesText, canonical, newCanonical);
357
+ } catch (err) {
358
+ throw new GroupsAdminError(
359
+ `Cannot rewrite role assignments in ${ROLES_YAML}; rename aborted with no changes`,
360
+ 422,
361
+ { cause: (err as Error)?.message },
362
+ );
363
+ }
364
+ if (rolesEdit.changed) {
365
+ // Same pre-disk no-lockout gate LockingFilesystem would have run —
366
+ // the plain-write path must not lose it (a roles.yaml that fails
367
+ // the resolver's parser is an app-wide admin lockout).
368
+ makeRolesYamlWriteValidator(this.kbDirName)(`${this.kbDirName}/${ROLES_YAML}`, rolesEdit.text);
369
+ files.push({ repoRel: ROLES_YAML, content: rolesEdit.text, original: rolesText });
370
+ }
371
+ }
372
+ }
373
+ if (files.length === 0) return;
374
+
375
+ // Roster locks are already ours (withFileLocks; strict same-user acquire
376
+ // forbids LockingFilesystem here). Grant-reference candidates are written
377
+ // unlocked — same exposure every reference rewrite has always had.
378
+ await this.locked.writeAndCommitLocked(
379
+ workspaceId,
380
+ actor,
381
+ files,
382
+ `Rename group ${canonical} → ${newDisplayName.trim()}`,
383
+ );
384
+ this.afterWrite(workspaceId, actor, files.map((f) => f.repoRel));
385
+ }
386
+
387
+ /**
388
+ * Connect-time retirement: delete `groups.yaml` in one commit. Called by the
389
+ * SCIM connect flow AFTER the admin confirmed the warning dialog ("groups
390
+ * you don't recreate in the IdP will be lost"). Recovery is a git revert —
391
+ * the file's history keeps every retired group. No-op when the file is
392
+ * already absent.
393
+ */
394
+ async retireManualGroups(actor: AuthUser): Promise<boolean> {
395
+ const workspaceId = await this.ensureWorkspace();
396
+ let retired = false;
397
+ // Existence check AND delete under the SAME lock: checked outside it, a
398
+ // concurrent create could land groups.yaml right after a "not there"
399
+ // answer (leaving retired manual groups behind to resurrect if the synced
400
+ // file ever goes away), or a concurrent retire could delete it right
401
+ // after a "there" answer and fail this one on ENOENT.
402
+ await this.locked.withFileLocks(workspaceId, actor, [GROUPS_YAML], async () => {
403
+ const original = await this.locked.readKbFile(workspaceId, GROUPS_YAML);
404
+ if (original === null) return; // already absent — a no-op retire
405
+ await this.locked.writeAndCommitLocked(
406
+ workspaceId,
407
+ actor,
408
+ [{ repoRel: GROUPS_YAML, content: null, original }],
409
+ 'Retire manual groups — directory sync connected',
410
+ );
411
+ retired = true;
412
+ });
413
+ if (retired) this.afterWrite(workspaceId, actor, [GROUPS_YAML]);
414
+ return retired;
415
+ }
416
+
417
+ // ---- Internals ----------------------------------------------------------
418
+
419
+ private guardEdit(fn: () => GroupsEditResult): GroupsEditResult {
420
+ try {
421
+ return fn();
422
+ } catch (err) {
423
+ if (err instanceof GroupsEditError) throw new GroupsAdminError(err.message, err.status);
424
+ throw err;
425
+ }
426
+ }
427
+
428
+ private assertLoadable(candidate: string): void {
429
+ const v = validateGroupsFile(candidate, GROUPS_YAML);
430
+ if (!v.ok) {
431
+ throw new GroupsAdminError(`groups.yaml would be invalid: ${v.errors.join('; ')}`, 422);
432
+ }
433
+ }
434
+
435
+ private async assertManualMode(workspaceId: string): Promise<void> {
436
+ if ((await this.locked.readKbFile(workspaceId, SYNCED_GROUPS_YAML)) !== null) {
437
+ throw new GroupsAdminError(
438
+ 'Groups are synced from your identity provider — manage membership there.',
439
+ 409,
440
+ { kind: 'idp-mode' },
441
+ );
442
+ }
443
+ }
444
+
445
+ /**
446
+ * Refuse a group name that IS a role. Bare-name precedence (group-first)
447
+ * would make the group win resolution, so this is deliberate FRICTION, not
448
+ * a correctness need: an admin naming a group after a capability role is
449
+ * far more likely shadowing by accident than by intent. IdP-sourced
450
+ * collisions still happen (the sync writer doesn't consult roles.yaml) and
451
+ * precedence resolves them.
452
+ */
453
+ private async assertRoleNameFree(displayName: string): Promise<void> {
454
+ const workspaceId = await this.ensureWorkspace();
455
+ const canonical = canonicalRoleName(displayName);
456
+ const rolesText = (await this.locked.readKbFile(workspaceId, 'roles.yaml')) ?? '';
457
+ let roles;
458
+ try {
459
+ roles = parseRolesModel(rolesText);
460
+ } catch {
461
+ // A broken roles.yaml cannot vouch for the name being free — refuse
462
+ // rather than let a group land that may shadow a role once repaired.
463
+ throw new GroupsAdminError('roles.yaml is not readable — fix it before creating groups.', 409);
464
+ }
465
+ if (roles.some((r) => canonicalRoleName(r.displayName) === canonical)) {
466
+ throw new GroupsAdminError(
467
+ `'${displayName.trim()}' is a role name — name the group something else (a role can be referenced explicitly as 'role/${canonical}').`,
468
+ 422,
469
+ );
470
+ }
471
+ }
472
+
473
+ /**
474
+ * Which roles carry a `group:<canonical>` assignment, per group canonical.
475
+ * Advisory (feeds the roster's `assignedToRoles` warning field): a broken
476
+ * roles.yaml yields an empty map rather than failing the roster.
477
+ */
478
+ private async rolesReferencingGroups(workspaceId: string): Promise<Map<string, string[]>> {
479
+ const rolesText = (await this.locked.readKbFile(workspaceId, ROLES_YAML)) ?? '';
480
+ const byGroup = new Map<string, string[]>();
481
+ let roles;
482
+ try {
483
+ roles = parseRolesModel(rolesText);
484
+ } catch {
485
+ return byGroup;
486
+ }
487
+ for (const role of roles) {
488
+ const roleCanonical = canonicalRoleName(role.displayName);
489
+ for (const member of role.members) {
490
+ if (!isGroupRefMember(member)) continue;
491
+ const groupCanonical = canonicalRoleName(member.slice(GROUP_REF_PREFIX.length));
492
+ const list = byGroup.get(groupCanonical);
493
+ if (list) {
494
+ if (!list.includes(roleCanonical)) list.push(roleCanonical);
495
+ } else {
496
+ byGroup.set(groupCanonical, [roleCanonical]);
497
+ }
498
+ }
499
+ }
500
+ return byGroup;
501
+ }
502
+
503
+ private async runEdit(actor: AuthUser, pre: (text: string) => GroupsEditResult): Promise<void> {
504
+ const workspaceId = await this.ensureWorkspace();
505
+ await this.assertManualMode(workspaceId);
506
+ return this.locked.withFileLocks(workspaceId, actor, [GROUPS_YAML], () =>
507
+ this.runEditLocked(workspaceId, actor, pre),
508
+ );
509
+ }
510
+
511
+ private async runEditLocked(
512
+ workspaceId: string,
513
+ actor: AuthUser,
514
+ pre: (text: string) => GroupsEditResult,
515
+ ): Promise<void> {
516
+ // Keep absent (null) distinct from existing-but-empty ('') — rollback
517
+ // must DELETE a file it created, not truncate one that was already there.
518
+ const original = await this.locked.readKbFile(workspaceId, GROUPS_YAML);
519
+ const result = this.guardEdit(() => pre(original ?? ''));
520
+ if (!result.changed) return;
521
+ this.assertLoadable(result.text);
522
+ await this.locked.writeAndCommitLocked(
523
+ workspaceId,
524
+ actor,
525
+ [{ repoRel: GROUPS_YAML, content: result.text, original }],
526
+ `Update ${GROUPS_YAML}`,
527
+ );
528
+ this.afterWrite(workspaceId, actor, [GROUPS_YAML]);
529
+ }
530
+
531
+ private afterWrite(workspaceId: string, actor: AuthUser, repoRelPaths: string[]): void {
532
+ this.accessControl.invalidate(workspaceId);
533
+ // Any write in this batch may have moved grant references (scanned-file
534
+ // rewrites on rename — `.md` AND `.tool`); drop the cached scan so the
535
+ // next roster is fresh.
536
+ if (repoRelPaths.some(hasAccessFrontmatterExtension)) this.references.invalidate(workspaceId);
537
+ if (!this.eventBus) return;
538
+ for (const repoRel of repoRelPaths) {
539
+ this.eventBus.emit({
540
+ kind: 'file-changed',
541
+ workspaceId,
542
+ branch: this.defaultBranch,
543
+ path: `${this.kbDirName}/${repoRel}`,
544
+ newSha: null,
545
+ byUserId: actor.id,
546
+ byUserName: actor.name,
547
+ });
548
+ }
549
+ this.eventBus.emit({ kind: 'fs-tree-changed', workspaceId, branch: this.defaultBranch });
550
+ }
551
+ }