@bevel-software/platform-core-backend 0.9.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (212) hide show
  1. package/THIRD-PARTY-NOTICES.md +2 -2
  2. package/dist/core/create-core-server.d.ts.map +1 -1
  3. package/dist/core/create-core-server.js +16 -1
  4. package/dist/core/create-core-server.js.map +1 -1
  5. package/dist/core/create-core-services.d.ts +25 -0
  6. package/dist/core/create-core-services.d.ts.map +1 -1
  7. package/dist/core/create-core-services.js +45 -1
  8. package/dist/core/create-core-services.js.map +1 -1
  9. package/dist/core-config.d.ts +19 -1
  10. package/dist/core-config.d.ts.map +1 -1
  11. package/dist/core-config.js +44 -4
  12. package/dist/core-config.js.map +1 -1
  13. package/dist/index.d.ts +2 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +4 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/modules/access/access-control.interface.d.ts +54 -28
  18. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  19. package/dist/modules/access/access-control.service.d.ts +129 -14
  20. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  21. package/dist/modules/access/access-control.service.js +435 -66
  22. package/dist/modules/access/access-control.service.js.map +1 -1
  23. package/dist/modules/access/access-declarations.d.ts.map +1 -1
  24. package/dist/modules/access/access-declarations.js +5 -3
  25. package/dist/modules/access/access-declarations.js.map +1 -1
  26. package/dist/modules/access/access-mutation.service.d.ts +39 -6
  27. package/dist/modules/access/access-mutation.service.d.ts.map +1 -1
  28. package/dist/modules/access/access-mutation.service.js +78 -18
  29. package/dist/modules/access/access-mutation.service.js.map +1 -1
  30. package/dist/modules/access/access-splice.d.ts +31 -4
  31. package/dist/modules/access/access-splice.d.ts.map +1 -1
  32. package/dist/modules/access/access-splice.js +40 -16
  33. package/dist/modules/access/access-splice.js.map +1 -1
  34. package/dist/modules/access/access.routes.d.ts.map +1 -1
  35. package/dist/modules/access/access.routes.js +204 -82
  36. package/dist/modules/access/access.routes.js.map +1 -1
  37. package/dist/modules/access/admin-locked-commit.d.ts +134 -0
  38. package/dist/modules/access/admin-locked-commit.d.ts.map +1 -0
  39. package/dist/modules/access/admin-locked-commit.js +277 -0
  40. package/dist/modules/access/admin-locked-commit.js.map +1 -0
  41. package/dist/modules/access/admin-route-helpers.d.ts +32 -0
  42. package/dist/modules/access/admin-route-helpers.d.ts.map +1 -0
  43. package/dist/modules/access/admin-route-helpers.js +44 -0
  44. package/dist/modules/access/admin-route-helpers.js.map +1 -0
  45. package/dist/modules/access/capability-registry.d.ts +41 -0
  46. package/dist/modules/access/capability-registry.d.ts.map +1 -0
  47. package/dist/modules/access/capability-registry.js +46 -0
  48. package/dist/modules/access/capability-registry.js.map +1 -0
  49. package/dist/modules/access/directory-sync-bot.d.ts +13 -0
  50. package/dist/modules/access/directory-sync-bot.d.ts.map +1 -0
  51. package/dist/modules/access/directory-sync-bot.js +64 -0
  52. package/dist/modules/access/directory-sync-bot.js.map +1 -0
  53. package/dist/modules/access/group-files.d.ts +83 -0
  54. package/dist/modules/access/group-files.d.ts.map +1 -0
  55. package/dist/modules/access/group-files.js +167 -0
  56. package/dist/modules/access/group-files.js.map +1 -0
  57. package/dist/modules/access/groups-admin.routes.d.ts +19 -0
  58. package/dist/modules/access/groups-admin.routes.d.ts.map +1 -0
  59. package/dist/modules/access/groups-admin.routes.js +98 -0
  60. package/dist/modules/access/groups-admin.routes.js.map +1 -0
  61. package/dist/modules/access/groups-admin.service.d.ts +166 -0
  62. package/dist/modules/access/groups-admin.service.d.ts.map +1 -0
  63. package/dist/modules/access/groups-admin.service.js +442 -0
  64. package/dist/modules/access/groups-admin.service.js.map +1 -0
  65. package/dist/modules/access/groups-edit.d.ts +58 -0
  66. package/dist/modules/access/groups-edit.d.ts.map +1 -0
  67. package/dist/modules/access/groups-edit.js +162 -0
  68. package/dist/modules/access/groups-edit.js.map +1 -0
  69. package/dist/modules/access/reference-scan.d.ts +141 -0
  70. package/dist/modules/access/reference-scan.d.ts.map +1 -0
  71. package/dist/modules/access/reference-scan.js +440 -0
  72. package/dist/modules/access/reference-scan.js.map +1 -0
  73. package/dist/modules/access/roles-admin.service.d.ts +88 -119
  74. package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
  75. package/dist/modules/access/roles-admin.service.js +230 -384
  76. package/dist/modules/access/roles-admin.service.js.map +1 -1
  77. package/dist/modules/access/roles-edit.d.ts +51 -25
  78. package/dist/modules/access/roles-edit.d.ts.map +1 -1
  79. package/dist/modules/access/roles-edit.js +133 -59
  80. package/dist/modules/access/roles-edit.js.map +1 -1
  81. package/dist/modules/access/synced-groups-committer.d.ts +28 -0
  82. package/dist/modules/access/synced-groups-committer.d.ts.map +1 -0
  83. package/dist/modules/access/synced-groups-committer.js +139 -0
  84. package/dist/modules/access/synced-groups-committer.js.map +1 -0
  85. package/dist/modules/access/synced-groups-writer.d.ts +78 -0
  86. package/dist/modules/access/synced-groups-writer.d.ts.map +1 -0
  87. package/dist/modules/access/synced-groups-writer.js +219 -0
  88. package/dist/modules/access/synced-groups-writer.js.map +1 -0
  89. package/dist/modules/database/core-schema.d.ts +17 -0
  90. package/dist/modules/database/core-schema.d.ts.map +1 -1
  91. package/dist/modules/database/core-schema.js +9 -0
  92. package/dist/modules/database/core-schema.js.map +1 -1
  93. package/dist/modules/mcp/mcp-auth.middleware.d.ts +13 -2
  94. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  95. package/dist/modules/mcp/mcp-auth.middleware.js +61 -2
  96. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  97. package/dist/modules/mcp/mcp.routes.d.ts +9 -3
  98. package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
  99. package/dist/modules/mcp/mcp.routes.js +126 -2
  100. package/dist/modules/mcp/mcp.routes.js.map +1 -1
  101. package/dist/modules/mcp/mcp.service.d.ts +14 -0
  102. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  103. package/dist/modules/mcp/mcp.service.js +6 -1
  104. package/dist/modules/mcp/mcp.service.js.map +1 -1
  105. package/dist/modules/update-check/update-check.routes.d.ts +13 -0
  106. package/dist/modules/update-check/update-check.routes.d.ts.map +1 -0
  107. package/dist/modules/update-check/update-check.routes.js +21 -0
  108. package/dist/modules/update-check/update-check.routes.js.map +1 -0
  109. package/dist/modules/update-check/update-check.service.d.ts +57 -0
  110. package/dist/modules/update-check/update-check.service.d.ts.map +1 -0
  111. package/dist/modules/update-check/update-check.service.js +109 -0
  112. package/dist/modules/update-check/update-check.service.js.map +1 -0
  113. package/dist/modules/workflow/file-lock.service.d.ts +11 -1
  114. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  115. package/dist/modules/workflow/file-lock.service.js +15 -1
  116. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  117. package/dist/modules/workflow/git/git.service.d.ts +34 -11
  118. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  119. package/dist/modules/workflow/git/git.service.js +173 -37
  120. package/dist/modules/workflow/git/git.service.js.map +1 -1
  121. package/dist/modules/workflow/locking-filesystem.d.ts +4 -0
  122. package/dist/modules/workflow/locking-filesystem.d.ts.map +1 -1
  123. package/dist/modules/workflow/locking-filesystem.js +181 -28
  124. package/dist/modules/workflow/locking-filesystem.js.map +1 -1
  125. package/dist/modules/workflow/pending-commits.service.d.ts +10 -0
  126. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  127. package/dist/modules/workflow/pending-commits.service.js +19 -1
  128. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  129. package/dist/modules/workflow/workflow.service.d.ts +17 -2
  130. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  131. package/dist/modules/workflow/workflow.service.js +113 -19
  132. package/dist/modules/workflow/workflow.service.js.map +1 -1
  133. package/dist/version.d.ts +14 -0
  134. package/dist/version.d.ts.map +1 -1
  135. package/dist/version.js +25 -0
  136. package/dist/version.js.map +1 -1
  137. package/kb-template/AGENTS.md +4 -1
  138. package/migrations/0004_file_lock_mode.sql +2 -0
  139. package/migrations/meta/0004_snapshot.json +1564 -0
  140. package/migrations/meta/_journal.json +7 -0
  141. package/package.json +4 -4
  142. package/src/__tests__/core-config.domain.test.ts +92 -0
  143. package/src/core/create-core-server.ts +21 -0
  144. package/src/core/create-core-services.ts +82 -0
  145. package/src/core-config.ts +48 -4
  146. package/src/index.ts +10 -0
  147. package/src/modules/access/__tests__/access-control.service.test.ts +9 -3
  148. package/src/modules/access/__tests__/access-groups.test.ts +427 -0
  149. package/src/modules/access/__tests__/access-mutation.service.test.ts +171 -5
  150. package/src/modules/access/__tests__/access-splice.test.ts +65 -0
  151. package/src/modules/access/__tests__/access.routes.group-grant.test.ts +337 -0
  152. package/src/modules/access/__tests__/access.routes.revoke.test.ts +48 -0
  153. package/src/modules/access/__tests__/admin-locked-commit.test.ts +221 -0
  154. package/src/modules/access/__tests__/admin-route-helpers.test.ts +61 -0
  155. package/src/modules/access/__tests__/directory-sync-bot.test.ts +106 -0
  156. package/src/modules/access/__tests__/grant-sources.test.ts +67 -0
  157. package/src/modules/access/__tests__/groups-admin.service.test.ts +440 -0
  158. package/src/modules/access/__tests__/reference-scan.test.ts +288 -0
  159. package/src/modules/access/__tests__/roles-admin.service.test.ts +161 -83
  160. package/src/modules/access/__tests__/roles-capabilities.test.ts +325 -0
  161. package/src/modules/access/__tests__/roles-edit.test.ts +104 -28
  162. package/src/modules/access/__tests__/roles.routes.test.ts +63 -40
  163. package/src/modules/access/__tests__/synced-groups-committer.test.ts +238 -0
  164. package/src/modules/access/__tests__/synced-groups-writer.test.ts +249 -0
  165. package/src/modules/access/access-control.interface.ts +66 -32
  166. package/src/modules/access/access-control.service.ts +536 -73
  167. package/src/modules/access/access-declarations.ts +5 -2
  168. package/src/modules/access/access-mutation.service.ts +88 -14
  169. package/src/modules/access/access-splice.ts +55 -17
  170. package/src/modules/access/access.routes.ts +227 -93
  171. package/src/modules/access/admin-locked-commit.ts +331 -0
  172. package/src/modules/access/admin-route-helpers.ts +55 -0
  173. package/src/modules/access/capability-registry.ts +74 -0
  174. package/src/modules/access/directory-sync-bot.ts +76 -0
  175. package/src/modules/access/group-files.ts +212 -0
  176. package/src/modules/access/groups-admin.routes.ts +113 -0
  177. package/src/modules/access/groups-admin.service.ts +551 -0
  178. package/src/modules/access/groups-edit.ts +187 -0
  179. package/src/modules/access/reference-scan.ts +513 -0
  180. package/src/modules/access/roles-admin.service.ts +290 -418
  181. package/src/modules/access/roles-edit.ts +134 -61
  182. package/src/modules/access/synced-groups-committer.ts +177 -0
  183. package/src/modules/access/synced-groups-writer.ts +303 -0
  184. package/src/modules/database/core-schema.ts +9 -0
  185. package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +116 -0
  186. package/src/modules/mcp/__tests__/mcp.e2e.test.ts +3 -1
  187. package/src/modules/mcp/__tests__/mcp.routes.delete.test.ts +6 -1
  188. package/src/modules/mcp/__tests__/mcp.routes.local-token.test.ts +301 -0
  189. package/src/modules/mcp/mcp-auth.middleware.ts +61 -1
  190. package/src/modules/mcp/mcp.routes.ts +137 -2
  191. package/src/modules/mcp/mcp.service.ts +6 -1
  192. package/src/modules/update-check/__tests__/update-check.routes.test.ts +69 -0
  193. package/src/modules/update-check/__tests__/update-check.service.test.ts +170 -0
  194. package/src/modules/update-check/update-check.routes.ts +28 -0
  195. package/src/modules/update-check/update-check.service.ts +130 -0
  196. package/src/modules/workflow/__tests__/locking-filesystem.test.ts +336 -0
  197. package/src/modules/workflow/__tests__/preserve-roles-yaml.test.ts +1 -1
  198. package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +32 -0
  199. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +13 -2
  200. package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +139 -1
  201. package/src/modules/workflow/file-lock.service.ts +15 -0
  202. package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +0 -1
  203. package/src/modules/workflow/git/__tests__/git.service.commitChanges.test.ts +132 -0
  204. package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +0 -1
  205. package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +0 -1
  206. package/src/modules/workflow/git/git.service.ts +182 -35
  207. package/src/modules/workflow/locking-filesystem.ts +188 -26
  208. package/src/modules/workflow/pending-commits.service.ts +27 -1
  209. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +0 -1
  210. package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +0 -1
  211. package/src/modules/workflow/workflow.service.ts +140 -20
  212. package/src/version.ts +28 -0
@@ -0,0 +1,331 @@
1
+ /**
2
+ * SHARED transactional lock/commit helper for the admin roster services
3
+ * (roles + groups). The two services used to carry twin copies of this
4
+ * machinery, which diverged once into a real bug — this is the single
5
+ * implementation both delegate to, parameterized only by the domain-error
6
+ * constructor, log tag, and contention wording.
7
+ *
8
+ * The shape of every roster write:
9
+ *
10
+ * withFileLocks(paths, fn) — acquire every path's lock (sorted,
11
+ * all-or-nothing; strict even for the same user), run `fn`, release.
12
+ * HOW each lock is released depends on the outcome (see the finally
13
+ * block for the full reasoning):
14
+ * - `fn` succeeded → releaseLockNoCommit for every path the batch
15
+ * commit LANDED on (tracked by writeAndCommitLocked): the commit
16
+ * landed AND pushed, so the tree there is clean (the discard
17
+ * no-ops); releaseLock would instead ENQUEUE a commit before
18
+ * dropping the row, and an enqueue failure then strands the lock
19
+ * until TTL. A held path the batch never committed keeps
20
+ * commit-on-release — it may carry a PRIOR holder's still-queued
21
+ * work, which a discard would destroy.
22
+ * - `fn` threw PushNeedsAgentResolutionError → releaseLock. The commit
23
+ * landed but the push didn't; the enqueued release commit IS the
24
+ * retry (the worker sees the clean tree, notices unpushed commits,
25
+ * and re-runs the cooperative push ladder).
26
+ * - `fn` threw anything else → releaseLock (commit-on-release): `fn`
27
+ * restored its own bytes, so anything dirty at release time is
28
+ * either clean (queued commit no-ops) or someone else's still-queued
29
+ * work that must be preserved. The ONE exception: a path whose
30
+ * restore itself failed (`unrestoredPaths` on the thrown error)
31
+ * holds known-partial bytes → releaseLockNoCommit discards to HEAD.
32
+ * Every release is per-path best-effort: one failed release logs and
33
+ * moves on so it can never strand the REST of the held locks.
34
+ *
35
+ * writeAndCommitLocked(files) — the write half of a withFileLocks
36
+ * flow: plain-write each file (the lock is already ours — strict
37
+ * same-user acquire forbids LockingFilesystem here) and commit them as
38
+ * ONE path-scoped change. On a PRE-commit failure, best-effort restore of
39
+ * each file's original bytes (`original: null` = the file did not exist,
40
+ * so restoration DELETES it). A POST-commit failure — the push after a
41
+ * landed commit (`PushNeedsAgentResolutionError`, thrown with the commit
42
+ * intact) — must NOT restore: the edit is committed locally and restoring
43
+ * would publish a compensating revert of a landed change. It propagates
44
+ * as-is ("saved locally … will be resolved"), locks release normally, and
45
+ * the pending-commit ladder retries the share.
46
+ */
47
+
48
+ import path from 'node:path';
49
+
50
+ import { LockingFilesystem } from '../workflow/locking-filesystem.js';
51
+ import { PushNeedsAgentResolutionError, WorkflowDomainError } from '../workflow/workflow.errors.js';
52
+ import type { AuthUser, IWorkspaceService, IWorkflowService } from '@bevel-software/platform-shared';
53
+ import type { FileContent } from '@mastra/core/workspace';
54
+
55
+ export interface LockedCommitDeps {
56
+ workspaceService: IWorkspaceService;
57
+ workflowService: IWorkflowService;
58
+ kbDirName: string;
59
+ /** Live-binding thunk — DEFAULT_BRANCH stays empty until setup. */
60
+ defaultBranchOf: () => string;
61
+ /** Domain-error constructor (RolesAdminError / GroupsAdminError). */
62
+ makeError: (message: string, status: number, payload?: Record<string, unknown>) => WorkflowDomainError;
63
+ /** Log prefix, e.g. 'roles-admin' / 'groups-admin'. */
64
+ logTag: string;
65
+ /** Contention wording: what is "being edited by <holder>". */
66
+ contendedSubject: string;
67
+ /** Pre-disk write validator handed to LockingFilesystem writes. */
68
+ validateWrite?: (path: string, content: FileContent) => void;
69
+ }
70
+
71
+ export interface LockedWrite {
72
+ repoRel: string;
73
+ /** The new content; null = DELETE the file (restore writes `original` back). */
74
+ content: string | null;
75
+ /** null = the file did not exist before (restore DELETES it). */
76
+ original: string | null;
77
+ }
78
+
79
+ export class AdminLockedCommits {
80
+ /**
81
+ * Workspace-scoped ws-relative paths whose bytes a `writeAndCommitLocked`
82
+ * batch has committed (and pushed) while their locks are held — i.e. paths
83
+ * whose working tree is KNOWN clean. `withFileLocks` releases exactly these
84
+ * with `releaseLockNoCommit` on success (see the module doc) and clears
85
+ * each entry as it releases. Keyed `<workspaceId>\0<wsRelPath>` so two
86
+ * workspaces can't cross-talk.
87
+ */
88
+ private readonly committedCleanPaths = new Set<string>();
89
+
90
+ constructor(private readonly deps: LockedCommitDeps) {}
91
+
92
+ private cleanKey(workspaceId: string, wsRelPath: string): string {
93
+ return `${workspaceId}\0${wsRelPath}`;
94
+ }
95
+
96
+ private get defaultBranch(): string {
97
+ return this.deps.defaultBranchOf();
98
+ }
99
+
100
+ wsRel(repoRel: string): string {
101
+ return `${this.deps.kbDirName}/${repoRel}`;
102
+ }
103
+
104
+ async repoDir(workspaceId: string): Promise<string> {
105
+ const wsDir = await this.deps.workspaceService.getWorkspacePath(workspaceId);
106
+ return path.join(wsDir, this.deps.kbDirName);
107
+ }
108
+
109
+ /** Read a KB-root file; null when genuinely absent. */
110
+ async readKbFile(workspaceId: string, repoRel: string): Promise<string | null> {
111
+ try {
112
+ return await this.deps.workspaceService.readFile(
113
+ workspaceId,
114
+ path.posix.join(this.deps.kbDirName, repoRel),
115
+ );
116
+ } catch (err) {
117
+ const code = (err as NodeJS.ErrnoException | null)?.code;
118
+ if (code === 'ENOENT' || code === 'ENOTDIR') return null;
119
+ throw err;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * A lock-aware filesystem scoped to `actor` — writes auto-commit + push as
125
+ * the actor on release, through the same pipeline as the human editor and
126
+ * agent. Built per-op because the lock context captures the acting user.
127
+ * Only for flows that do NOT already hold the file locks (acquire is strict
128
+ * even same-user); a withFileLocks flow writes via writeAndCommitLocked.
129
+ */
130
+ async lockingFsForActor(workspaceId: string, actor: AuthUser): Promise<LockingFilesystem> {
131
+ const basePath = await this.deps.workspaceService.getWorkspacePath(workspaceId);
132
+ return new LockingFilesystem(
133
+ { basePath, contained: true },
134
+ {
135
+ workflow: this.deps.workflowService,
136
+ workspaceId,
137
+ branch: this.defaultBranch,
138
+ user: actor,
139
+ validateWrite: this.deps.validateWrite,
140
+ },
141
+ );
142
+ }
143
+
144
+ /**
145
+ * Map LockingFilesystem's status-less contention error ("Skipped editing …
146
+ * — locked by …") to the friendly 409. Callers pre-check the common case,
147
+ * but that is a TOCTOU check — this catches the race so contention always
148
+ * surfaces as a 409, never an unhandled 500.
149
+ */
150
+ async mapLockContention<T>(write: () => Promise<T>): Promise<T> {
151
+ try {
152
+ return await write();
153
+ } catch (err) {
154
+ if (err instanceof Error && /locked by /.test(err.message)) {
155
+ throw this.deps.makeError(
156
+ `${this.deps.contendedSubject} are being edited by another admin. Try again in a moment.`,
157
+ 409,
158
+ );
159
+ }
160
+ throw err;
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Run `fn` while HOLDING the named KB-file locks (sorted, all-or-nothing):
166
+ * read-modify-write flows must keep the lock across the READ too, or two
167
+ * concurrent edits (another tab, another admin, a conversion) both snapshot
168
+ * the same base and the second write silently discards the first. See the
169
+ * module doc for the release semantics.
170
+ *
171
+ * `opts.coordinationFiles` names files locked ONLY to serialize with their
172
+ * writer — `fn` never writes them. They're acquired with the workflow
173
+ * service's coordination flag (a pure mutex that skips the per-path write
174
+ * gate — required for machine-owned files like `synced-groups.yaml`, which
175
+ * a human actor may not, and need not, write) and always released with
176
+ * `releaseLockNoCommit`: there is never anything of ours to commit there,
177
+ * and enqueueing a release commit as an actor the path's write rule
178
+ * refuses would only feed the worker a doomed row. The discard inside that
179
+ * release no-ops: the machine writer commits synchronously under its own
180
+ * lock, so the path is clean whenever we could acquire it.
181
+ */
182
+ async withFileLocks<T>(
183
+ workspaceId: string,
184
+ actor: AuthUser,
185
+ repoRelFiles: string[],
186
+ fn: () => Promise<T>,
187
+ opts?: { coordinationFiles?: string[] },
188
+ ): Promise<T> {
189
+ const coordination = new Set((opts?.coordinationFiles ?? []).map((f) => this.wsRel(f)));
190
+ // One globally-sorted acquire order across BOTH kinds of path — a split
191
+ // order would reintroduce the deadlock the sort exists to prevent.
192
+ const paths = [...new Set([...repoRelFiles.map((f) => this.wsRel(f)), ...coordination])].sort();
193
+ const held: string[] = [];
194
+ let failure: unknown | null = null;
195
+ let unrestored: Set<string> | null = null;
196
+ try {
197
+ for (const p of paths) {
198
+ const res = await this.deps.workflowService.acquireLock(
199
+ workspaceId,
200
+ this.defaultBranch,
201
+ p,
202
+ actor,
203
+ coordination.has(p) ? { coordination: true } : undefined,
204
+ );
205
+ if (!res.acquired) {
206
+ throw this.deps.makeError(
207
+ `${this.deps.contendedSubject} are being edited by ${res.lock.holderName || 'another admin'}. Try again in a moment.`,
208
+ 409,
209
+ );
210
+ }
211
+ held.push(p);
212
+ }
213
+ return await fn();
214
+ } catch (err) {
215
+ failure = err;
216
+ unrestored = (err as { unrestoredPaths?: Set<string> } | null)?.unrestoredPaths ?? null;
217
+ throw err;
218
+ } finally {
219
+ // Outcome-dependent release — see the module doc. The key hazard this
220
+ // guards: `releaseLock` ENQUEUES the release commit BEFORE dropping the
221
+ // lock row (deliberately, so a crash can't orphan bytes), which means an
222
+ // enqueue failure leaves the row held until TTL. After a SUCCESSFUL
223
+ // batch commit there is nothing left to commit — writeAndCommitLocked
224
+ // committed and pushed these exact paths, and we hold their locks, so
225
+ // the tree there is clean — so releaseLockNoCommit (whose discard
226
+ // no-ops on a clean path) releases without ever touching the queue.
227
+ //
228
+ // EXCEPT on the push-retry path: PushNeedsAgentResolutionError means
229
+ // the commit landed but the push didn't, and the enqueued release
230
+ // commit is what retries the push (runPendingCommit: clean tree +
231
+ // unpushed commits → pushWithRecovery). That path MUST releaseLock.
232
+ // Failures there log + continue so one bad enqueue strands at most its
233
+ // own lock (TTL backstop), never the rest.
234
+ const pushRetry = failure instanceof PushNeedsAgentResolutionError;
235
+ for (const h of held) {
236
+ const committedClean =
237
+ this.committedCleanPaths.delete(this.cleanKey(workspaceId, h)) && !pushRetry;
238
+ try {
239
+ if (coordination.has(h) || committedClean || unrestored?.has(h)) {
240
+ // Coordination-only hold (see the method doc): `fn` never wrote
241
+ // it, so no outcome — push-retry included — has anything to
242
+ // commit there; the no-commit release just drops the row. Or:
243
+ // Batch-committed path (clean tree — discard no-ops) — even when
244
+ // `fn` threw AFTER the commit+push landed: the failure says
245
+ // nothing about the tree, and releaseLock would enqueue a
246
+ // pointless commit whose enqueue failure strands a known-clean
247
+ // lock until TTL. Or known-partial bytes (restore failed —
248
+ // discarding to HEAD beats committing them).
249
+ await this.deps.workflowService.releaseLockNoCommit(workspaceId, this.defaultBranch, h, actor);
250
+ } else {
251
+ // Push-retry (the enqueued commit retries the share), the
252
+ // restored-failure path, and any held path the batch never
253
+ // committed (commit-on-release: a prior holder's still-queued
254
+ // work must be preserved, never discarded).
255
+ await this.deps.workflowService.releaseLock(workspaceId, this.defaultBranch, h, actor);
256
+ }
257
+ } catch (releaseErr) {
258
+ console.warn(
259
+ `[${this.deps.logTag}] could not release lock on ${h}${pushRetry ? ' (push-retry release)' : ''}; ` +
260
+ 'it frees on TTL — continuing with the remaining locks:',
261
+ releaseErr instanceof Error ? releaseErr.message : releaseErr,
262
+ );
263
+ }
264
+ }
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Plain-write `files` and commit them as ONE path-scoped change — the write
270
+ * half of a `withFileLocks` flow. See the module doc for the pre- vs
271
+ * post-commit failure split; only PRE-commit failures restore.
272
+ */
273
+ async writeAndCommitLocked(
274
+ workspaceId: string,
275
+ actor: AuthUser,
276
+ files: LockedWrite[],
277
+ summary: string,
278
+ ): Promise<void> {
279
+ const { workspaceService, workflowService, logTag } = this.deps;
280
+ try {
281
+ for (const f of files) {
282
+ if (f.content === null) {
283
+ // A DELETE entry. Tolerate an already-absent file — that IS the
284
+ // desired state (and the caller checked existence under the lock).
285
+ try {
286
+ await workspaceService.deleteFile(workspaceId, this.wsRel(f.repoRel));
287
+ } catch (err) {
288
+ if ((err as NodeJS.ErrnoException | null)?.code !== 'ENOENT') throw err;
289
+ }
290
+ } else {
291
+ await workspaceService.writeFile(workspaceId, this.wsRel(f.repoRel), f.content);
292
+ }
293
+ }
294
+ await workflowService.commitChanges(workspaceId, actor, summary, files.map((f) => this.wsRel(f.repoRel)));
295
+ // Commit landed AND pushed — the tree at these paths is clean. Mark
296
+ // them so withFileLocks releases them WITHOUT enqueueing a release
297
+ // commit (see the module doc — an enqueue failure would strand the
298
+ // lock until TTL for a commit there is nothing left to make).
299
+ for (const f of files) this.committedCleanPaths.add(this.cleanKey(workspaceId, this.wsRel(f.repoRel)));
300
+ } catch (err) {
301
+ // POST-commit failure: the commit landed and only the PUSH needs help
302
+ // (thrown "with the commit intact"). The bytes on disk match the landed
303
+ // commit — restoring pre-edit bytes here would publish a compensating
304
+ // revert of a change that exists. No restore; release proceeds
305
+ // normally; the error itself tells the caller "saved locally, sharing
306
+ // will be resolved" and the pending-commit ladder retries the push.
307
+ if (err instanceof PushNeedsAgentResolutionError) {
308
+ console.warn(
309
+ `[${logTag}] commit landed but the push needs resolution — edit saved; publishing will be retried`,
310
+ );
311
+ throw err;
312
+ }
313
+ const unrestored = new Set<string>();
314
+ for (const f of files) {
315
+ try {
316
+ if (f.original === null) await workspaceService.deleteFile(workspaceId, this.wsRel(f.repoRel));
317
+ else await workspaceService.writeFile(workspaceId, this.wsRel(f.repoRel), f.original);
318
+ } catch (restoreErr) {
319
+ // Deleting an already-absent file IS the original state.
320
+ if (f.original === null && (restoreErr as NodeJS.ErrnoException | null)?.code === 'ENOENT') continue;
321
+ unrestored.add(this.wsRel(f.repoRel));
322
+ console.warn(`[${logTag}] could not restore ${f.repoRel} after a failed commit`);
323
+ }
324
+ }
325
+ if (unrestored.size > 0 && typeof err === 'object' && err !== null) {
326
+ (err as { unrestoredPaths?: Set<string> }).unrestoredPaths = unrestored;
327
+ }
328
+ throw err;
329
+ }
330
+ }
331
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * SHARED route plumbing for the access-family routers (access.routes,
3
+ * groups-admin.routes) — one error shape, one input-coercion rule, so the two
4
+ * surfaces can't drift apart again.
5
+ *
6
+ * Error shape: a sub-500 `WorkflowDomainError` renders as its own status +
7
+ * message + payload; everything else — a raw error OR a 500-status domain
8
+ * error (e.g. AccessMutationService wraps unexpected raw errors in a 500
9
+ * AccessMutationError, message included) — is an INTERNAL error: logged
10
+ * server-side in full, answered with a generic message. A raw `err.message`
11
+ * must never leak through a 500 (it can carry fs/db paths and internals the
12
+ * caller has no business seeing).
13
+ */
14
+
15
+ import type express from 'express';
16
+ import { WorkflowDomainError } from '../workflow/workflow.errors.js';
17
+
18
+ /** One error shape for every access-family route. */
19
+ export function toHttpError(
20
+ err: unknown,
21
+ logTag: string,
22
+ ): { status: number; body: Record<string, unknown> } {
23
+ if (err instanceof WorkflowDomainError && err.status < 500) {
24
+ // Spread the payload FIRST and assign `error` LAST — a payload key named
25
+ // `error` must never overwrite the message the client renders.
26
+ return { status: err.status, body: { ...(err.payload ?? {}), error: err.message } };
27
+ }
28
+ console.error(`[${logTag}] route failure:`, err instanceof Error ? err.stack ?? err.message : err);
29
+ const status = err instanceof WorkflowDomainError ? err.status : 500;
30
+ return { status, body: { error: 'Internal error.' } };
31
+ }
32
+
33
+ /** Answer `res` with the shared error shape. */
34
+ export function sendError(res: express.Response, err: unknown, logTag: string): void {
35
+ const { status, body } = toHttpError(err, logTag);
36
+ res.status(status).json(body);
37
+ }
38
+
39
+ /**
40
+ * Coerce a request-body field that must be a non-empty string. Rejects arrays,
41
+ * objects, numbers, null — anything `String()` would silently stringify into a
42
+ * bogus principal name / email (`[object Object]`, `"a,b"`) — with a 400
43
+ * instead of persisting garbage. Returns the raw string (untrimmed by default
44
+ * — services trim; pass `trim: true` where the route owns normalization).
45
+ */
46
+ export function requireNonEmptyString(
47
+ value: unknown,
48
+ field: string,
49
+ opts?: { trim?: boolean },
50
+ ): string {
51
+ if (typeof value !== 'string' || value.trim() === '') {
52
+ throw new WorkflowDomainError(`${field} must be a non-empty string`, 400);
53
+ }
54
+ return opts?.trim ? value.trim() : value;
55
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Capability registry — the "what you can do in the app" half of the
3
+ * roles/groups split.
4
+ *
5
+ * Roles are CODE-DEFINED capability bundles: the product decides which roles
6
+ * exist and what each unlocks; admins assign them (to individuals, and to
7
+ * groups via `group:` members in roles.yaml — every role, Admin included,
8
+ * kept safe by the parse-time at-least-one-direct-email invariant on Admin).
9
+ * Something like "Developer" is not a role; that's a group.
10
+ *
11
+ * Adding a future role (the planned Plugin Creator) is a registry entry plus
12
+ * capability gates at its feature surfaces — never a parser change: the
13
+ * roles.yaml grammar, expansion, and admin surfaces are already
14
+ * role-name-agnostic.
15
+ *
16
+ * A roles.yaml role NOT in this registry is a LEGACY people-set role from
17
+ * before the split (e.g. "Product") — still valid as a grant principal, but
18
+ * really a group; the roles admin surface flags it with a convert action.
19
+ */
20
+
21
+ import { ADMIN_CANONICAL, canonicalRoleName } from './access-control.service.js';
22
+
23
+ /** A capability an app surface gates on. Grow this union with the gates. */
24
+ export type Capability =
25
+ | 'manage-deployment'
26
+ | 'manage-accounts'
27
+ | 'manage-roles'
28
+ | 'manage-groups'
29
+ | 'manage-directory';
30
+
31
+ export interface CapabilityRole {
32
+ canonical: string;
33
+ displayName: string;
34
+ /** Admin-facing one-liner for the Roles page. */
35
+ description: string;
36
+ capabilities: readonly Capability[];
37
+ /**
38
+ * Whether the role may be assigned to groups. True for every role, Admin
39
+ * included — with the parse-time invariant that Admin ALWAYS keeps at least
40
+ * one direct email member (see `parseRolesYaml`), so a misconfigured or
41
+ * unreachable directory can never leave the deployment without a
42
+ * directory-independent admin.
43
+ */
44
+ groupAssignable: boolean;
45
+ }
46
+
47
+ export const CAPABILITY_ROLES: readonly CapabilityRole[] = [
48
+ {
49
+ canonical: ADMIN_CANONICAL,
50
+ displayName: 'Admin',
51
+ description:
52
+ 'Detailed configuration: deployment settings, user accounts, roles & groups, and the directory connection.',
53
+ capabilities: [
54
+ 'manage-deployment',
55
+ 'manage-accounts',
56
+ 'manage-roles',
57
+ 'manage-groups',
58
+ 'manage-directory',
59
+ ],
60
+ groupAssignable: true,
61
+ },
62
+ // Planned next (lands with its feature gates, not before):
63
+ // { canonical: 'plugin creator', displayName: 'Plugin Creator', … }
64
+ ];
65
+
66
+ export function capabilityRoleFor(roleName: string): CapabilityRole | null {
67
+ const canonical = canonicalRoleName(roleName);
68
+ return CAPABILITY_ROLES.find((r) => r.canonical === canonical) ?? null;
69
+ }
70
+
71
+ /** A roles.yaml role that is NOT a capability role — a pre-split people-set. */
72
+ export function isLegacyPeopleSetRole(roleName: string): boolean {
73
+ return capabilityRoleFor(roleName) === null;
74
+ }
@@ -0,0 +1,76 @@
1
+ import { eq } from 'drizzle-orm';
2
+ import type { AuthUser } from '@bevel-software/platform-shared';
3
+ import type { Database } from '../database/connection.js';
4
+ import { users } from '../database/core-schema.js';
5
+
6
+ /**
7
+ * Synthetic identity for the synced-groups materializer's commits: every
8
+ * `synced-groups.yaml` regeneration lands attributed to this bot, so the git
9
+ * history reads as "the system mirrored the IdP", never as any human.
10
+ * Same `<role>-bot@<host>` convention (and race-safe ensure shape) as the
11
+ * recovery bot.
12
+ */
13
+
14
+ // Trimmed BEFORE the empty-fallback so a whitespace-only override falls back,
15
+ // and so the constant always compares equal to a trimmed caller email (the
16
+ // machine-owned write rule trims the caller's side).
17
+ export const DIRECTORY_SYNC_BOT_EMAIL = (
18
+ process.env.DIRECTORY_SYNC_BOT_EMAIL?.trim() || 'directory-sync@bevel.local'
19
+ ).toLowerCase();
20
+
21
+ export const DIRECTORY_SYNC_BOT_NAME = 'Directory Sync Bot';
22
+
23
+ export async function ensureDirectorySyncBot(db: Database): Promise<AuthUser> {
24
+ const inserted = await db
25
+ .insert(users)
26
+ .values({ email: DIRECTORY_SYNC_BOT_EMAIL, name: DIRECTORY_SYNC_BOT_NAME })
27
+ .onConflictDoNothing({ target: users.email })
28
+ .returning();
29
+ if (inserted.length > 0) {
30
+ const row = inserted[0];
31
+ console.log(`[directory-sync] created sync-bot user id=${row.id} email=${row.email}`);
32
+ return { id: row.id, email: row.email, name: row.name, avatarUrl: row.avatarUrl ?? undefined };
33
+ }
34
+ const [row] = await db
35
+ .select()
36
+ .from(users)
37
+ .where(eq(users.email, DIRECTORY_SYNC_BOT_EMAIL))
38
+ .limit(1);
39
+ if (!row) {
40
+ throw new Error(
41
+ `directory-sync bot (${DIRECTORY_SYNC_BOT_EMAIL}) disappeared between insert-conflict and re-select`,
42
+ );
43
+ }
44
+ // The bot's IDENTITY is its email: the machine-owned write rule on
45
+ // synced-groups.yaml authorizes by DIRECTORY_SYNC_BOT_EMAIL alone. If the
46
+ // email already belongs to a pre-existing HUMAN account (a person signed up
47
+ // with it, or the override points at someone's address), silently binding
48
+ // the materializer to that row would attribute every sync commit to them —
49
+ // and, worse, hand their interactive session the bot's exclusive write
50
+ // authority. The users schema carries no dedicated bot/provenance column,
51
+ // so adoption gates on the strongest invariants the row can carry:
52
+ // 1. NO PASSWORD. The bot is only ever created by this function, without
53
+ // credentials, and never signs in — nothing legitimate ever provisions
54
+ // it a password. A row with a password hash is a login-capable account
55
+ // (a person, or a credential a person could use) no matter what its
56
+ // display name says, so the hash is the hard evidence a name can't be.
57
+ // 2. THE EXPECTED NAME. A display name is mutable and a human account
58
+ // could carry the bot's name (e.g. an SSO service account), so this is
59
+ // only a tripwire — it still catches password-less human rows created
60
+ // under their real names.
61
+ // A password-less SSO account that also mimics the bot's exact display name
62
+ // is indistinguishable at this layer; that shape only arises from the
63
+ // operator deliberately configuring it. Refuse loudly instead of adopting.
64
+ if (row.passwordHash !== null || row.name !== DIRECTORY_SYNC_BOT_NAME) {
65
+ const evidence =
66
+ row.passwordHash !== null
67
+ ? `it can sign in with a password (name '${row.name}')`
68
+ : `its name is '${row.name}'`;
69
+ throw new Error(
70
+ `directory-sync bot email ${DIRECTORY_SYNC_BOT_EMAIL} already belongs to an existing account ` +
71
+ `(id=${row.id}) that is not the sync bot — ${evidence}. Refusing to bind the materializer ` +
72
+ `to it. Set DIRECTORY_SYNC_BOT_EMAIL to an address no person uses.`,
73
+ );
74
+ }
75
+ return { id: row.id, email: row.email, name: row.name, avatarUrl: row.avatarUrl ?? undefined };
76
+ }