@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.
- package/THIRD-PARTY-NOTICES.md +2 -2
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +16 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +25 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +45 -1
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core-config.d.ts +19 -1
- package/dist/core-config.d.ts.map +1 -1
- package/dist/core-config.js +44 -4
- package/dist/core-config.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/update-check/update-check.routes.d.ts +13 -0
- package/dist/modules/update-check/update-check.routes.d.ts.map +1 -0
- package/dist/modules/update-check/update-check.routes.js +21 -0
- package/dist/modules/update-check/update-check.routes.js.map +1 -0
- package/dist/modules/update-check/update-check.service.d.ts +57 -0
- package/dist/modules/update-check/update-check.service.d.ts.map +1 -0
- package/dist/modules/update-check/update-check.service.js +109 -0
- package/dist/modules/update-check/update-check.service.js.map +1 -0
- 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 +173 -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/dist/version.d.ts +14 -0
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +25 -0
- package/dist/version.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 +4 -4
- package/src/__tests__/core-config.domain.test.ts +92 -0
- package/src/core/create-core-server.ts +21 -0
- package/src/core/create-core-services.ts +82 -0
- package/src/core-config.ts +48 -4
- 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/update-check/__tests__/update-check.routes.test.ts +69 -0
- package/src/modules/update-check/__tests__/update-check.service.test.ts +170 -0
- package/src/modules/update-check/update-check.routes.ts +28 -0
- package/src/modules/update-check/update-check.service.ts +130 -0
- 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 +182 -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
- package/src/version.ts +28 -0
|
@@ -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
|
+
}
|