@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.
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +12 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +22 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +34 -1
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +54 -28
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +129 -14
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +435 -66
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/access-declarations.d.ts.map +1 -1
- package/dist/modules/access/access-declarations.js +5 -3
- package/dist/modules/access/access-declarations.js.map +1 -1
- package/dist/modules/access/access-mutation.service.d.ts +39 -6
- package/dist/modules/access/access-mutation.service.d.ts.map +1 -1
- package/dist/modules/access/access-mutation.service.js +78 -18
- package/dist/modules/access/access-mutation.service.js.map +1 -1
- package/dist/modules/access/access-splice.d.ts +31 -4
- package/dist/modules/access/access-splice.d.ts.map +1 -1
- package/dist/modules/access/access-splice.js +40 -16
- package/dist/modules/access/access-splice.js.map +1 -1
- package/dist/modules/access/access.routes.d.ts.map +1 -1
- package/dist/modules/access/access.routes.js +204 -82
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/access/admin-locked-commit.d.ts +134 -0
- package/dist/modules/access/admin-locked-commit.d.ts.map +1 -0
- package/dist/modules/access/admin-locked-commit.js +277 -0
- package/dist/modules/access/admin-locked-commit.js.map +1 -0
- package/dist/modules/access/admin-route-helpers.d.ts +32 -0
- package/dist/modules/access/admin-route-helpers.d.ts.map +1 -0
- package/dist/modules/access/admin-route-helpers.js +44 -0
- package/dist/modules/access/admin-route-helpers.js.map +1 -0
- package/dist/modules/access/capability-registry.d.ts +41 -0
- package/dist/modules/access/capability-registry.d.ts.map +1 -0
- package/dist/modules/access/capability-registry.js +46 -0
- package/dist/modules/access/capability-registry.js.map +1 -0
- package/dist/modules/access/directory-sync-bot.d.ts +13 -0
- package/dist/modules/access/directory-sync-bot.d.ts.map +1 -0
- package/dist/modules/access/directory-sync-bot.js +64 -0
- package/dist/modules/access/directory-sync-bot.js.map +1 -0
- package/dist/modules/access/group-files.d.ts +83 -0
- package/dist/modules/access/group-files.d.ts.map +1 -0
- package/dist/modules/access/group-files.js +167 -0
- package/dist/modules/access/group-files.js.map +1 -0
- package/dist/modules/access/groups-admin.routes.d.ts +19 -0
- package/dist/modules/access/groups-admin.routes.d.ts.map +1 -0
- package/dist/modules/access/groups-admin.routes.js +98 -0
- package/dist/modules/access/groups-admin.routes.js.map +1 -0
- package/dist/modules/access/groups-admin.service.d.ts +166 -0
- package/dist/modules/access/groups-admin.service.d.ts.map +1 -0
- package/dist/modules/access/groups-admin.service.js +442 -0
- package/dist/modules/access/groups-admin.service.js.map +1 -0
- package/dist/modules/access/groups-edit.d.ts +58 -0
- package/dist/modules/access/groups-edit.d.ts.map +1 -0
- package/dist/modules/access/groups-edit.js +162 -0
- package/dist/modules/access/groups-edit.js.map +1 -0
- package/dist/modules/access/reference-scan.d.ts +141 -0
- package/dist/modules/access/reference-scan.d.ts.map +1 -0
- package/dist/modules/access/reference-scan.js +440 -0
- package/dist/modules/access/reference-scan.js.map +1 -0
- package/dist/modules/access/roles-admin.service.d.ts +88 -119
- package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
- package/dist/modules/access/roles-admin.service.js +230 -384
- package/dist/modules/access/roles-admin.service.js.map +1 -1
- package/dist/modules/access/roles-edit.d.ts +51 -25
- package/dist/modules/access/roles-edit.d.ts.map +1 -1
- package/dist/modules/access/roles-edit.js +133 -59
- package/dist/modules/access/roles-edit.js.map +1 -1
- package/dist/modules/access/synced-groups-committer.d.ts +28 -0
- package/dist/modules/access/synced-groups-committer.d.ts.map +1 -0
- package/dist/modules/access/synced-groups-committer.js +139 -0
- package/dist/modules/access/synced-groups-committer.js.map +1 -0
- package/dist/modules/access/synced-groups-writer.d.ts +78 -0
- package/dist/modules/access/synced-groups-writer.d.ts.map +1 -0
- package/dist/modules/access/synced-groups-writer.js +219 -0
- package/dist/modules/access/synced-groups-writer.js.map +1 -0
- package/dist/modules/database/core-schema.d.ts +17 -0
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +9 -0
- package/dist/modules/database/core-schema.js.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.d.ts +13 -2
- package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.js +61 -2
- package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
- package/dist/modules/mcp/mcp.routes.d.ts +9 -3
- package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.routes.js +126 -2
- package/dist/modules/mcp/mcp.routes.js.map +1 -1
- package/dist/modules/mcp/mcp.service.d.ts +14 -0
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.service.js +6 -1
- package/dist/modules/mcp/mcp.service.js.map +1 -1
- package/dist/modules/workflow/file-lock.service.d.ts +11 -1
- package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
- package/dist/modules/workflow/file-lock.service.js +15 -1
- package/dist/modules/workflow/file-lock.service.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +34 -11
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +165 -37
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/locking-filesystem.d.ts +4 -0
- package/dist/modules/workflow/locking-filesystem.d.ts.map +1 -1
- package/dist/modules/workflow/locking-filesystem.js +181 -28
- package/dist/modules/workflow/locking-filesystem.js.map +1 -1
- package/dist/modules/workflow/pending-commits.service.d.ts +10 -0
- package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
- package/dist/modules/workflow/pending-commits.service.js +19 -1
- package/dist/modules/workflow/pending-commits.service.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +17 -2
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +113 -19
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/kb-template/AGENTS.md +4 -1
- package/migrations/0004_file_lock_mode.sql +2 -0
- package/migrations/meta/0004_snapshot.json +1564 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +3 -3
- package/src/core/create-core-server.ts +13 -0
- package/src/core/create-core-services.ts +68 -0
- package/src/index.ts +10 -0
- package/src/modules/access/__tests__/access-control.service.test.ts +9 -3
- package/src/modules/access/__tests__/access-groups.test.ts +427 -0
- package/src/modules/access/__tests__/access-mutation.service.test.ts +171 -5
- package/src/modules/access/__tests__/access-splice.test.ts +65 -0
- package/src/modules/access/__tests__/access.routes.group-grant.test.ts +337 -0
- package/src/modules/access/__tests__/access.routes.revoke.test.ts +48 -0
- package/src/modules/access/__tests__/admin-locked-commit.test.ts +221 -0
- package/src/modules/access/__tests__/admin-route-helpers.test.ts +61 -0
- package/src/modules/access/__tests__/directory-sync-bot.test.ts +106 -0
- package/src/modules/access/__tests__/grant-sources.test.ts +67 -0
- package/src/modules/access/__tests__/groups-admin.service.test.ts +440 -0
- package/src/modules/access/__tests__/reference-scan.test.ts +288 -0
- package/src/modules/access/__tests__/roles-admin.service.test.ts +161 -83
- package/src/modules/access/__tests__/roles-capabilities.test.ts +325 -0
- package/src/modules/access/__tests__/roles-edit.test.ts +104 -28
- package/src/modules/access/__tests__/roles.routes.test.ts +63 -40
- package/src/modules/access/__tests__/synced-groups-committer.test.ts +238 -0
- package/src/modules/access/__tests__/synced-groups-writer.test.ts +249 -0
- package/src/modules/access/access-control.interface.ts +66 -32
- package/src/modules/access/access-control.service.ts +536 -73
- package/src/modules/access/access-declarations.ts +5 -2
- package/src/modules/access/access-mutation.service.ts +88 -14
- package/src/modules/access/access-splice.ts +55 -17
- package/src/modules/access/access.routes.ts +227 -93
- package/src/modules/access/admin-locked-commit.ts +331 -0
- package/src/modules/access/admin-route-helpers.ts +55 -0
- package/src/modules/access/capability-registry.ts +74 -0
- package/src/modules/access/directory-sync-bot.ts +76 -0
- package/src/modules/access/group-files.ts +212 -0
- package/src/modules/access/groups-admin.routes.ts +113 -0
- package/src/modules/access/groups-admin.service.ts +551 -0
- package/src/modules/access/groups-edit.ts +187 -0
- package/src/modules/access/reference-scan.ts +513 -0
- package/src/modules/access/roles-admin.service.ts +290 -418
- package/src/modules/access/roles-edit.ts +134 -61
- package/src/modules/access/synced-groups-committer.ts +177 -0
- package/src/modules/access/synced-groups-writer.ts +303 -0
- package/src/modules/database/core-schema.ts +9 -0
- package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +116 -0
- package/src/modules/mcp/__tests__/mcp.e2e.test.ts +3 -1
- package/src/modules/mcp/__tests__/mcp.routes.delete.test.ts +6 -1
- package/src/modules/mcp/__tests__/mcp.routes.local-token.test.ts +301 -0
- package/src/modules/mcp/mcp-auth.middleware.ts +61 -1
- package/src/modules/mcp/mcp.routes.ts +137 -2
- package/src/modules/mcp/mcp.service.ts +6 -1
- package/src/modules/workflow/__tests__/locking-filesystem.test.ts +336 -0
- package/src/modules/workflow/__tests__/preserve-roles-yaml.test.ts +1 -1
- package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +32 -0
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +13 -2
- package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +139 -1
- package/src/modules/workflow/file-lock.service.ts +15 -0
- package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +0 -1
- package/src/modules/workflow/git/__tests__/git.service.commitChanges.test.ts +132 -0
- package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +0 -1
- package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +0 -1
- package/src/modules/workflow/git/git.service.ts +174 -35
- package/src/modules/workflow/locking-filesystem.ts +188 -26
- package/src/modules/workflow/pending-commits.service.ts +27 -1
- package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +0 -1
- package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +0 -1
- package/src/modules/workflow/workflow.service.ts +140 -20
|
@@ -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
|
-
*
|
|
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
|
|
27
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
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
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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 (
|
|
244
|
-
|
|
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
|
-
|
|
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
|
+
}
|