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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/dist/core/create-core-server.d.ts.map +1 -1
  2. package/dist/core/create-core-server.js +12 -1
  3. package/dist/core/create-core-server.js.map +1 -1
  4. package/dist/core/create-core-services.d.ts +22 -0
  5. package/dist/core/create-core-services.d.ts.map +1 -1
  6. package/dist/core/create-core-services.js +34 -1
  7. package/dist/core/create-core-services.js.map +1 -1
  8. package/dist/index.d.ts +2 -0
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +4 -0
  11. package/dist/index.js.map +1 -1
  12. package/dist/modules/access/access-control.interface.d.ts +54 -28
  13. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  14. package/dist/modules/access/access-control.service.d.ts +129 -14
  15. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  16. package/dist/modules/access/access-control.service.js +435 -66
  17. package/dist/modules/access/access-control.service.js.map +1 -1
  18. package/dist/modules/access/access-declarations.d.ts.map +1 -1
  19. package/dist/modules/access/access-declarations.js +5 -3
  20. package/dist/modules/access/access-declarations.js.map +1 -1
  21. package/dist/modules/access/access-mutation.service.d.ts +39 -6
  22. package/dist/modules/access/access-mutation.service.d.ts.map +1 -1
  23. package/dist/modules/access/access-mutation.service.js +78 -18
  24. package/dist/modules/access/access-mutation.service.js.map +1 -1
  25. package/dist/modules/access/access-splice.d.ts +31 -4
  26. package/dist/modules/access/access-splice.d.ts.map +1 -1
  27. package/dist/modules/access/access-splice.js +40 -16
  28. package/dist/modules/access/access-splice.js.map +1 -1
  29. package/dist/modules/access/access.routes.d.ts.map +1 -1
  30. package/dist/modules/access/access.routes.js +204 -82
  31. package/dist/modules/access/access.routes.js.map +1 -1
  32. package/dist/modules/access/admin-locked-commit.d.ts +134 -0
  33. package/dist/modules/access/admin-locked-commit.d.ts.map +1 -0
  34. package/dist/modules/access/admin-locked-commit.js +277 -0
  35. package/dist/modules/access/admin-locked-commit.js.map +1 -0
  36. package/dist/modules/access/admin-route-helpers.d.ts +32 -0
  37. package/dist/modules/access/admin-route-helpers.d.ts.map +1 -0
  38. package/dist/modules/access/admin-route-helpers.js +44 -0
  39. package/dist/modules/access/admin-route-helpers.js.map +1 -0
  40. package/dist/modules/access/capability-registry.d.ts +41 -0
  41. package/dist/modules/access/capability-registry.d.ts.map +1 -0
  42. package/dist/modules/access/capability-registry.js +46 -0
  43. package/dist/modules/access/capability-registry.js.map +1 -0
  44. package/dist/modules/access/directory-sync-bot.d.ts +13 -0
  45. package/dist/modules/access/directory-sync-bot.d.ts.map +1 -0
  46. package/dist/modules/access/directory-sync-bot.js +64 -0
  47. package/dist/modules/access/directory-sync-bot.js.map +1 -0
  48. package/dist/modules/access/group-files.d.ts +83 -0
  49. package/dist/modules/access/group-files.d.ts.map +1 -0
  50. package/dist/modules/access/group-files.js +167 -0
  51. package/dist/modules/access/group-files.js.map +1 -0
  52. package/dist/modules/access/groups-admin.routes.d.ts +19 -0
  53. package/dist/modules/access/groups-admin.routes.d.ts.map +1 -0
  54. package/dist/modules/access/groups-admin.routes.js +98 -0
  55. package/dist/modules/access/groups-admin.routes.js.map +1 -0
  56. package/dist/modules/access/groups-admin.service.d.ts +166 -0
  57. package/dist/modules/access/groups-admin.service.d.ts.map +1 -0
  58. package/dist/modules/access/groups-admin.service.js +442 -0
  59. package/dist/modules/access/groups-admin.service.js.map +1 -0
  60. package/dist/modules/access/groups-edit.d.ts +58 -0
  61. package/dist/modules/access/groups-edit.d.ts.map +1 -0
  62. package/dist/modules/access/groups-edit.js +162 -0
  63. package/dist/modules/access/groups-edit.js.map +1 -0
  64. package/dist/modules/access/reference-scan.d.ts +141 -0
  65. package/dist/modules/access/reference-scan.d.ts.map +1 -0
  66. package/dist/modules/access/reference-scan.js +440 -0
  67. package/dist/modules/access/reference-scan.js.map +1 -0
  68. package/dist/modules/access/roles-admin.service.d.ts +88 -119
  69. package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
  70. package/dist/modules/access/roles-admin.service.js +230 -384
  71. package/dist/modules/access/roles-admin.service.js.map +1 -1
  72. package/dist/modules/access/roles-edit.d.ts +51 -25
  73. package/dist/modules/access/roles-edit.d.ts.map +1 -1
  74. package/dist/modules/access/roles-edit.js +133 -59
  75. package/dist/modules/access/roles-edit.js.map +1 -1
  76. package/dist/modules/access/synced-groups-committer.d.ts +28 -0
  77. package/dist/modules/access/synced-groups-committer.d.ts.map +1 -0
  78. package/dist/modules/access/synced-groups-committer.js +139 -0
  79. package/dist/modules/access/synced-groups-committer.js.map +1 -0
  80. package/dist/modules/access/synced-groups-writer.d.ts +78 -0
  81. package/dist/modules/access/synced-groups-writer.d.ts.map +1 -0
  82. package/dist/modules/access/synced-groups-writer.js +219 -0
  83. package/dist/modules/access/synced-groups-writer.js.map +1 -0
  84. package/dist/modules/database/core-schema.d.ts +17 -0
  85. package/dist/modules/database/core-schema.d.ts.map +1 -1
  86. package/dist/modules/database/core-schema.js +9 -0
  87. package/dist/modules/database/core-schema.js.map +1 -1
  88. package/dist/modules/mcp/mcp-auth.middleware.d.ts +13 -2
  89. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  90. package/dist/modules/mcp/mcp-auth.middleware.js +61 -2
  91. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  92. package/dist/modules/mcp/mcp.routes.d.ts +9 -3
  93. package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
  94. package/dist/modules/mcp/mcp.routes.js +126 -2
  95. package/dist/modules/mcp/mcp.routes.js.map +1 -1
  96. package/dist/modules/mcp/mcp.service.d.ts +14 -0
  97. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  98. package/dist/modules/mcp/mcp.service.js +6 -1
  99. package/dist/modules/mcp/mcp.service.js.map +1 -1
  100. package/dist/modules/workflow/file-lock.service.d.ts +11 -1
  101. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  102. package/dist/modules/workflow/file-lock.service.js +15 -1
  103. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  104. package/dist/modules/workflow/git/git.service.d.ts +34 -11
  105. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  106. package/dist/modules/workflow/git/git.service.js +165 -37
  107. package/dist/modules/workflow/git/git.service.js.map +1 -1
  108. package/dist/modules/workflow/locking-filesystem.d.ts +4 -0
  109. package/dist/modules/workflow/locking-filesystem.d.ts.map +1 -1
  110. package/dist/modules/workflow/locking-filesystem.js +181 -28
  111. package/dist/modules/workflow/locking-filesystem.js.map +1 -1
  112. package/dist/modules/workflow/pending-commits.service.d.ts +10 -0
  113. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  114. package/dist/modules/workflow/pending-commits.service.js +19 -1
  115. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  116. package/dist/modules/workflow/workflow.service.d.ts +17 -2
  117. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  118. package/dist/modules/workflow/workflow.service.js +113 -19
  119. package/dist/modules/workflow/workflow.service.js.map +1 -1
  120. package/kb-template/AGENTS.md +4 -1
  121. package/migrations/0004_file_lock_mode.sql +2 -0
  122. package/migrations/meta/0004_snapshot.json +1564 -0
  123. package/migrations/meta/_journal.json +7 -0
  124. package/package.json +3 -3
  125. package/src/core/create-core-server.ts +13 -0
  126. package/src/core/create-core-services.ts +68 -0
  127. package/src/index.ts +10 -0
  128. package/src/modules/access/__tests__/access-control.service.test.ts +9 -3
  129. package/src/modules/access/__tests__/access-groups.test.ts +427 -0
  130. package/src/modules/access/__tests__/access-mutation.service.test.ts +171 -5
  131. package/src/modules/access/__tests__/access-splice.test.ts +65 -0
  132. package/src/modules/access/__tests__/access.routes.group-grant.test.ts +337 -0
  133. package/src/modules/access/__tests__/access.routes.revoke.test.ts +48 -0
  134. package/src/modules/access/__tests__/admin-locked-commit.test.ts +221 -0
  135. package/src/modules/access/__tests__/admin-route-helpers.test.ts +61 -0
  136. package/src/modules/access/__tests__/directory-sync-bot.test.ts +106 -0
  137. package/src/modules/access/__tests__/grant-sources.test.ts +67 -0
  138. package/src/modules/access/__tests__/groups-admin.service.test.ts +440 -0
  139. package/src/modules/access/__tests__/reference-scan.test.ts +288 -0
  140. package/src/modules/access/__tests__/roles-admin.service.test.ts +161 -83
  141. package/src/modules/access/__tests__/roles-capabilities.test.ts +325 -0
  142. package/src/modules/access/__tests__/roles-edit.test.ts +104 -28
  143. package/src/modules/access/__tests__/roles.routes.test.ts +63 -40
  144. package/src/modules/access/__tests__/synced-groups-committer.test.ts +238 -0
  145. package/src/modules/access/__tests__/synced-groups-writer.test.ts +249 -0
  146. package/src/modules/access/access-control.interface.ts +66 -32
  147. package/src/modules/access/access-control.service.ts +536 -73
  148. package/src/modules/access/access-declarations.ts +5 -2
  149. package/src/modules/access/access-mutation.service.ts +88 -14
  150. package/src/modules/access/access-splice.ts +55 -17
  151. package/src/modules/access/access.routes.ts +227 -93
  152. package/src/modules/access/admin-locked-commit.ts +331 -0
  153. package/src/modules/access/admin-route-helpers.ts +55 -0
  154. package/src/modules/access/capability-registry.ts +74 -0
  155. package/src/modules/access/directory-sync-bot.ts +76 -0
  156. package/src/modules/access/group-files.ts +212 -0
  157. package/src/modules/access/groups-admin.routes.ts +113 -0
  158. package/src/modules/access/groups-admin.service.ts +551 -0
  159. package/src/modules/access/groups-edit.ts +187 -0
  160. package/src/modules/access/reference-scan.ts +513 -0
  161. package/src/modules/access/roles-admin.service.ts +290 -418
  162. package/src/modules/access/roles-edit.ts +134 -61
  163. package/src/modules/access/synced-groups-committer.ts +177 -0
  164. package/src/modules/access/synced-groups-writer.ts +303 -0
  165. package/src/modules/database/core-schema.ts +9 -0
  166. package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +116 -0
  167. package/src/modules/mcp/__tests__/mcp.e2e.test.ts +3 -1
  168. package/src/modules/mcp/__tests__/mcp.routes.delete.test.ts +6 -1
  169. package/src/modules/mcp/__tests__/mcp.routes.local-token.test.ts +301 -0
  170. package/src/modules/mcp/mcp-auth.middleware.ts +61 -1
  171. package/src/modules/mcp/mcp.routes.ts +137 -2
  172. package/src/modules/mcp/mcp.service.ts +6 -1
  173. package/src/modules/workflow/__tests__/locking-filesystem.test.ts +336 -0
  174. package/src/modules/workflow/__tests__/preserve-roles-yaml.test.ts +1 -1
  175. package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +32 -0
  176. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +13 -2
  177. package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +139 -1
  178. package/src/modules/workflow/file-lock.service.ts +15 -0
  179. package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +0 -1
  180. package/src/modules/workflow/git/__tests__/git.service.commitChanges.test.ts +132 -0
  181. package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +0 -1
  182. package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +0 -1
  183. package/src/modules/workflow/git/git.service.ts +174 -35
  184. package/src/modules/workflow/locking-filesystem.ts +188 -26
  185. package/src/modules/workflow/pending-commits.service.ts +27 -1
  186. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +0 -1
  187. package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +0 -1
  188. package/src/modules/workflow/workflow.service.ts +140 -20
@@ -0,0 +1,134 @@
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
+ import { LockingFilesystem } from '../workflow/locking-filesystem.js';
48
+ import { WorkflowDomainError } from '../workflow/workflow.errors.js';
49
+ import type { AuthUser, IWorkspaceService, IWorkflowService } from '@bevel-software/platform-shared';
50
+ import type { FileContent } from '@mastra/core/workspace';
51
+ export interface LockedCommitDeps {
52
+ workspaceService: IWorkspaceService;
53
+ workflowService: IWorkflowService;
54
+ kbDirName: string;
55
+ /** Live-binding thunk — DEFAULT_BRANCH stays empty until setup. */
56
+ defaultBranchOf: () => string;
57
+ /** Domain-error constructor (RolesAdminError / GroupsAdminError). */
58
+ makeError: (message: string, status: number, payload?: Record<string, unknown>) => WorkflowDomainError;
59
+ /** Log prefix, e.g. 'roles-admin' / 'groups-admin'. */
60
+ logTag: string;
61
+ /** Contention wording: what is "being edited by <holder>". */
62
+ contendedSubject: string;
63
+ /** Pre-disk write validator handed to LockingFilesystem writes. */
64
+ validateWrite?: (path: string, content: FileContent) => void;
65
+ }
66
+ export interface LockedWrite {
67
+ repoRel: string;
68
+ /** The new content; null = DELETE the file (restore writes `original` back). */
69
+ content: string | null;
70
+ /** null = the file did not exist before (restore DELETES it). */
71
+ original: string | null;
72
+ }
73
+ export declare class AdminLockedCommits {
74
+ private readonly deps;
75
+ /**
76
+ * Workspace-scoped ws-relative paths whose bytes a `writeAndCommitLocked`
77
+ * batch has committed (and pushed) while their locks are held — i.e. paths
78
+ * whose working tree is KNOWN clean. `withFileLocks` releases exactly these
79
+ * with `releaseLockNoCommit` on success (see the module doc) and clears
80
+ * each entry as it releases. Keyed `<workspaceId>\0<wsRelPath>` so two
81
+ * workspaces can't cross-talk.
82
+ */
83
+ private readonly committedCleanPaths;
84
+ constructor(deps: LockedCommitDeps);
85
+ private cleanKey;
86
+ private get defaultBranch();
87
+ wsRel(repoRel: string): string;
88
+ repoDir(workspaceId: string): Promise<string>;
89
+ /** Read a KB-root file; null when genuinely absent. */
90
+ readKbFile(workspaceId: string, repoRel: string): Promise<string | null>;
91
+ /**
92
+ * A lock-aware filesystem scoped to `actor` — writes auto-commit + push as
93
+ * the actor on release, through the same pipeline as the human editor and
94
+ * agent. Built per-op because the lock context captures the acting user.
95
+ * Only for flows that do NOT already hold the file locks (acquire is strict
96
+ * even same-user); a withFileLocks flow writes via writeAndCommitLocked.
97
+ */
98
+ lockingFsForActor(workspaceId: string, actor: AuthUser): Promise<LockingFilesystem>;
99
+ /**
100
+ * Map LockingFilesystem's status-less contention error ("Skipped editing …
101
+ * — locked by …") to the friendly 409. Callers pre-check the common case,
102
+ * but that is a TOCTOU check — this catches the race so contention always
103
+ * surfaces as a 409, never an unhandled 500.
104
+ */
105
+ mapLockContention<T>(write: () => Promise<T>): Promise<T>;
106
+ /**
107
+ * Run `fn` while HOLDING the named KB-file locks (sorted, all-or-nothing):
108
+ * read-modify-write flows must keep the lock across the READ too, or two
109
+ * concurrent edits (another tab, another admin, a conversion) both snapshot
110
+ * the same base and the second write silently discards the first. See the
111
+ * module doc for the release semantics.
112
+ *
113
+ * `opts.coordinationFiles` names files locked ONLY to serialize with their
114
+ * writer — `fn` never writes them. They're acquired with the workflow
115
+ * service's coordination flag (a pure mutex that skips the per-path write
116
+ * gate — required for machine-owned files like `synced-groups.yaml`, which
117
+ * a human actor may not, and need not, write) and always released with
118
+ * `releaseLockNoCommit`: there is never anything of ours to commit there,
119
+ * and enqueueing a release commit as an actor the path's write rule
120
+ * refuses would only feed the worker a doomed row. The discard inside that
121
+ * release no-ops: the machine writer commits synchronously under its own
122
+ * lock, so the path is clean whenever we could acquire it.
123
+ */
124
+ withFileLocks<T>(workspaceId: string, actor: AuthUser, repoRelFiles: string[], fn: () => Promise<T>, opts?: {
125
+ coordinationFiles?: string[];
126
+ }): Promise<T>;
127
+ /**
128
+ * Plain-write `files` and commit them as ONE path-scoped change — the write
129
+ * half of a `withFileLocks` flow. See the module doc for the pre- vs
130
+ * post-commit failure split; only PRE-commit failures restore.
131
+ */
132
+ writeAndCommitLocked(workspaceId: string, actor: AuthUser, files: LockedWrite[], summary: string): Promise<void>;
133
+ }
134
+ //# sourceMappingURL=admin-locked-commit.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"admin-locked-commit.d.ts","sourceRoot":"","sources":["../../../src/modules/access/admin-locked-commit.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6CG;AAIH,OAAO,EAAE,iBAAiB,EAAE,MAAM,mCAAmC,CAAC;AACtE,OAAO,EAAiC,mBAAmB,EAAE,MAAM,gCAAgC,CAAC;AACpG,OAAO,KAAK,EAAE,QAAQ,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,MAAM,iCAAiC,CAAC;AACrG,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,wBAAwB,CAAC;AAE1D,MAAM,WAAW,gBAAgB;IAC/B,gBAAgB,EAAE,iBAAiB,CAAC;IACpC,eAAe,EAAE,gBAAgB,CAAC;IAClC,SAAS,EAAE,MAAM,CAAC;IAClB,mEAAmE;IACnE,eAAe,EAAE,MAAM,MAAM,CAAC;IAC9B,qEAAqE;IACrE,SAAS,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,mBAAmB,CAAC;IACvG,uDAAuD;IACvD,MAAM,EAAE,MAAM,CAAC;IACf,8DAA8D;IAC9D,gBAAgB,EAAE,MAAM,CAAC;IACzB,mEAAmE;IACnE,aAAa,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,KAAK,IAAI,CAAC;CAC9D;AAED,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,gFAAgF;IAChF,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,iEAAiE;IACjE,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB;AAED,qBAAa,kBAAkB;IAWjB,OAAO,CAAC,QAAQ,CAAC,IAAI;IAVjC;;;;;;;OAOG;IACH,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAAqB;gBAE5B,IAAI,EAAE,gBAAgB;IAEnD,OAAO,CAAC,QAAQ;IAIhB,OAAO,KAAK,aAAa,GAExB;IAED,KAAK,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM;IAIxB,OAAO,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAKnD,uDAAuD;IACjD,UAAU,CAAC,WAAW,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IAa9E;;;;;;OAMG;IACG,iBAAiB,CAAC,WAAW,EAAE,MAAM,EAAE,KAAK,EAAE,QAAQ,GAAG,OAAO,CAAC,iBAAiB,CAAC;IAczF;;;;;OAKG;IACG,iBAAiB,CAAC,CAAC,EAAE,KAAK,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC;IAc/D;;;;;;;;;;;;;;;;;OAiBG;IACG,aAAa,CAAC,CAAC,EACnB,WAAW,EAAE,MAAM,EACnB,KAAK,EAAE,QAAQ,EACf,YAAY,EAAE,MAAM,EAAE,EACtB,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EACpB,IAAI,CAAC,EAAE;QAAE,iBAAiB,CAAC,EAAE,MAAM,EAAE,CAAA;KAAE,GACtC,OAAO,CAAC,CAAC,CAAC;IAgFb;;;;OAIG;IACG,oBAAoB,CACxB,WAAW,EAAE,MAAM,EACnB,KAAK,EAAE,QAAQ,EACf,KAAK,EAAE,WAAW,EAAE,EACpB,OAAO,EAAE,MAAM,GACd,OAAO,CAAC,IAAI,CAAC;CAqDjB"}
@@ -0,0 +1,277 @@
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
+ import path from 'node:path';
48
+ import { LockingFilesystem } from '../workflow/locking-filesystem.js';
49
+ import { PushNeedsAgentResolutionError } from '../workflow/workflow.errors.js';
50
+ export class AdminLockedCommits {
51
+ deps;
52
+ /**
53
+ * Workspace-scoped ws-relative paths whose bytes a `writeAndCommitLocked`
54
+ * batch has committed (and pushed) while their locks are held — i.e. paths
55
+ * whose working tree is KNOWN clean. `withFileLocks` releases exactly these
56
+ * with `releaseLockNoCommit` on success (see the module doc) and clears
57
+ * each entry as it releases. Keyed `<workspaceId>\0<wsRelPath>` so two
58
+ * workspaces can't cross-talk.
59
+ */
60
+ committedCleanPaths = new Set();
61
+ constructor(deps) {
62
+ this.deps = deps;
63
+ }
64
+ cleanKey(workspaceId, wsRelPath) {
65
+ return `${workspaceId}\0${wsRelPath}`;
66
+ }
67
+ get defaultBranch() {
68
+ return this.deps.defaultBranchOf();
69
+ }
70
+ wsRel(repoRel) {
71
+ return `${this.deps.kbDirName}/${repoRel}`;
72
+ }
73
+ async repoDir(workspaceId) {
74
+ const wsDir = await this.deps.workspaceService.getWorkspacePath(workspaceId);
75
+ return path.join(wsDir, this.deps.kbDirName);
76
+ }
77
+ /** Read a KB-root file; null when genuinely absent. */
78
+ async readKbFile(workspaceId, repoRel) {
79
+ try {
80
+ return await this.deps.workspaceService.readFile(workspaceId, path.posix.join(this.deps.kbDirName, repoRel));
81
+ }
82
+ catch (err) {
83
+ const code = err?.code;
84
+ if (code === 'ENOENT' || code === 'ENOTDIR')
85
+ return null;
86
+ throw err;
87
+ }
88
+ }
89
+ /**
90
+ * A lock-aware filesystem scoped to `actor` — writes auto-commit + push as
91
+ * the actor on release, through the same pipeline as the human editor and
92
+ * agent. Built per-op because the lock context captures the acting user.
93
+ * Only for flows that do NOT already hold the file locks (acquire is strict
94
+ * even same-user); a withFileLocks flow writes via writeAndCommitLocked.
95
+ */
96
+ async lockingFsForActor(workspaceId, actor) {
97
+ const basePath = await this.deps.workspaceService.getWorkspacePath(workspaceId);
98
+ return new LockingFilesystem({ basePath, contained: true }, {
99
+ workflow: this.deps.workflowService,
100
+ workspaceId,
101
+ branch: this.defaultBranch,
102
+ user: actor,
103
+ validateWrite: this.deps.validateWrite,
104
+ });
105
+ }
106
+ /**
107
+ * Map LockingFilesystem's status-less contention error ("Skipped editing …
108
+ * — locked by …") to the friendly 409. Callers pre-check the common case,
109
+ * but that is a TOCTOU check — this catches the race so contention always
110
+ * surfaces as a 409, never an unhandled 500.
111
+ */
112
+ async mapLockContention(write) {
113
+ try {
114
+ return await write();
115
+ }
116
+ catch (err) {
117
+ if (err instanceof Error && /locked by /.test(err.message)) {
118
+ throw this.deps.makeError(`${this.deps.contendedSubject} are being edited by another admin. Try again in a moment.`, 409);
119
+ }
120
+ throw err;
121
+ }
122
+ }
123
+ /**
124
+ * Run `fn` while HOLDING the named KB-file locks (sorted, all-or-nothing):
125
+ * read-modify-write flows must keep the lock across the READ too, or two
126
+ * concurrent edits (another tab, another admin, a conversion) both snapshot
127
+ * the same base and the second write silently discards the first. See the
128
+ * module doc for the release semantics.
129
+ *
130
+ * `opts.coordinationFiles` names files locked ONLY to serialize with their
131
+ * writer — `fn` never writes them. They're acquired with the workflow
132
+ * service's coordination flag (a pure mutex that skips the per-path write
133
+ * gate — required for machine-owned files like `synced-groups.yaml`, which
134
+ * a human actor may not, and need not, write) and always released with
135
+ * `releaseLockNoCommit`: there is never anything of ours to commit there,
136
+ * and enqueueing a release commit as an actor the path's write rule
137
+ * refuses would only feed the worker a doomed row. The discard inside that
138
+ * release no-ops: the machine writer commits synchronously under its own
139
+ * lock, so the path is clean whenever we could acquire it.
140
+ */
141
+ async withFileLocks(workspaceId, actor, repoRelFiles, fn, opts) {
142
+ const coordination = new Set((opts?.coordinationFiles ?? []).map((f) => this.wsRel(f)));
143
+ // One globally-sorted acquire order across BOTH kinds of path — a split
144
+ // order would reintroduce the deadlock the sort exists to prevent.
145
+ const paths = [...new Set([...repoRelFiles.map((f) => this.wsRel(f)), ...coordination])].sort();
146
+ const held = [];
147
+ let failure = null;
148
+ let unrestored = null;
149
+ try {
150
+ for (const p of paths) {
151
+ const res = await this.deps.workflowService.acquireLock(workspaceId, this.defaultBranch, p, actor, coordination.has(p) ? { coordination: true } : undefined);
152
+ if (!res.acquired) {
153
+ throw this.deps.makeError(`${this.deps.contendedSubject} are being edited by ${res.lock.holderName || 'another admin'}. Try again in a moment.`, 409);
154
+ }
155
+ held.push(p);
156
+ }
157
+ return await fn();
158
+ }
159
+ catch (err) {
160
+ failure = err;
161
+ unrestored = err?.unrestoredPaths ?? null;
162
+ throw err;
163
+ }
164
+ finally {
165
+ // Outcome-dependent release — see the module doc. The key hazard this
166
+ // guards: `releaseLock` ENQUEUES the release commit BEFORE dropping the
167
+ // lock row (deliberately, so a crash can't orphan bytes), which means an
168
+ // enqueue failure leaves the row held until TTL. After a SUCCESSFUL
169
+ // batch commit there is nothing left to commit — writeAndCommitLocked
170
+ // committed and pushed these exact paths, and we hold their locks, so
171
+ // the tree there is clean — so releaseLockNoCommit (whose discard
172
+ // no-ops on a clean path) releases without ever touching the queue.
173
+ //
174
+ // EXCEPT on the push-retry path: PushNeedsAgentResolutionError means
175
+ // the commit landed but the push didn't, and the enqueued release
176
+ // commit is what retries the push (runPendingCommit: clean tree +
177
+ // unpushed commits → pushWithRecovery). That path MUST releaseLock.
178
+ // Failures there log + continue so one bad enqueue strands at most its
179
+ // own lock (TTL backstop), never the rest.
180
+ const pushRetry = failure instanceof PushNeedsAgentResolutionError;
181
+ for (const h of held) {
182
+ const committedClean = this.committedCleanPaths.delete(this.cleanKey(workspaceId, h)) && !pushRetry;
183
+ try {
184
+ if (coordination.has(h) || committedClean || unrestored?.has(h)) {
185
+ // Coordination-only hold (see the method doc): `fn` never wrote
186
+ // it, so no outcome — push-retry included — has anything to
187
+ // commit there; the no-commit release just drops the row. Or:
188
+ // Batch-committed path (clean tree — discard no-ops) — even when
189
+ // `fn` threw AFTER the commit+push landed: the failure says
190
+ // nothing about the tree, and releaseLock would enqueue a
191
+ // pointless commit whose enqueue failure strands a known-clean
192
+ // lock until TTL. Or known-partial bytes (restore failed —
193
+ // discarding to HEAD beats committing them).
194
+ await this.deps.workflowService.releaseLockNoCommit(workspaceId, this.defaultBranch, h, actor);
195
+ }
196
+ else {
197
+ // Push-retry (the enqueued commit retries the share), the
198
+ // restored-failure path, and any held path the batch never
199
+ // committed (commit-on-release: a prior holder's still-queued
200
+ // work must be preserved, never discarded).
201
+ await this.deps.workflowService.releaseLock(workspaceId, this.defaultBranch, h, actor);
202
+ }
203
+ }
204
+ catch (releaseErr) {
205
+ console.warn(`[${this.deps.logTag}] could not release lock on ${h}${pushRetry ? ' (push-retry release)' : ''}; ` +
206
+ 'it frees on TTL — continuing with the remaining locks:', releaseErr instanceof Error ? releaseErr.message : releaseErr);
207
+ }
208
+ }
209
+ }
210
+ }
211
+ /**
212
+ * Plain-write `files` and commit them as ONE path-scoped change — the write
213
+ * half of a `withFileLocks` flow. See the module doc for the pre- vs
214
+ * post-commit failure split; only PRE-commit failures restore.
215
+ */
216
+ async writeAndCommitLocked(workspaceId, actor, files, summary) {
217
+ const { workspaceService, workflowService, logTag } = this.deps;
218
+ try {
219
+ for (const f of files) {
220
+ if (f.content === null) {
221
+ // A DELETE entry. Tolerate an already-absent file — that IS the
222
+ // desired state (and the caller checked existence under the lock).
223
+ try {
224
+ await workspaceService.deleteFile(workspaceId, this.wsRel(f.repoRel));
225
+ }
226
+ catch (err) {
227
+ if (err?.code !== 'ENOENT')
228
+ throw err;
229
+ }
230
+ }
231
+ else {
232
+ await workspaceService.writeFile(workspaceId, this.wsRel(f.repoRel), f.content);
233
+ }
234
+ }
235
+ await workflowService.commitChanges(workspaceId, actor, summary, files.map((f) => this.wsRel(f.repoRel)));
236
+ // Commit landed AND pushed — the tree at these paths is clean. Mark
237
+ // them so withFileLocks releases them WITHOUT enqueueing a release
238
+ // commit (see the module doc — an enqueue failure would strand the
239
+ // lock until TTL for a commit there is nothing left to make).
240
+ for (const f of files)
241
+ this.committedCleanPaths.add(this.cleanKey(workspaceId, this.wsRel(f.repoRel)));
242
+ }
243
+ catch (err) {
244
+ // POST-commit failure: the commit landed and only the PUSH needs help
245
+ // (thrown "with the commit intact"). The bytes on disk match the landed
246
+ // commit — restoring pre-edit bytes here would publish a compensating
247
+ // revert of a change that exists. No restore; release proceeds
248
+ // normally; the error itself tells the caller "saved locally, sharing
249
+ // will be resolved" and the pending-commit ladder retries the push.
250
+ if (err instanceof PushNeedsAgentResolutionError) {
251
+ console.warn(`[${logTag}] commit landed but the push needs resolution — edit saved; publishing will be retried`);
252
+ throw err;
253
+ }
254
+ const unrestored = new Set();
255
+ for (const f of files) {
256
+ try {
257
+ if (f.original === null)
258
+ await workspaceService.deleteFile(workspaceId, this.wsRel(f.repoRel));
259
+ else
260
+ await workspaceService.writeFile(workspaceId, this.wsRel(f.repoRel), f.original);
261
+ }
262
+ catch (restoreErr) {
263
+ // Deleting an already-absent file IS the original state.
264
+ if (f.original === null && restoreErr?.code === 'ENOENT')
265
+ continue;
266
+ unrestored.add(this.wsRel(f.repoRel));
267
+ console.warn(`[${logTag}] could not restore ${f.repoRel} after a failed commit`);
268
+ }
269
+ }
270
+ if (unrestored.size > 0 && typeof err === 'object' && err !== null) {
271
+ err.unrestoredPaths = unrestored;
272
+ }
273
+ throw err;
274
+ }
275
+ }
276
+ }
277
+ //# sourceMappingURL=admin-locked-commit.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"admin-locked-commit.js","sourceRoot":"","sources":["../../../src/modules/access/admin-locked-commit.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,32 @@
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
+ import type express from 'express';
15
+ /** One error shape for every access-family route. */
16
+ export declare function toHttpError(err: unknown, logTag: string): {
17
+ status: number;
18
+ body: Record<string, unknown>;
19
+ };
20
+ /** Answer `res` with the shared error shape. */
21
+ export declare function sendError(res: express.Response, err: unknown, logTag: string): void;
22
+ /**
23
+ * Coerce a request-body field that must be a non-empty string. Rejects arrays,
24
+ * objects, numbers, null — anything `String()` would silently stringify into a
25
+ * bogus principal name / email (`[object Object]`, `"a,b"`) — with a 400
26
+ * instead of persisting garbage. Returns the raw string (untrimmed by default
27
+ * — services trim; pass `trim: true` where the route owns normalization).
28
+ */
29
+ export declare function requireNonEmptyString(value: unknown, field: string, opts?: {
30
+ trim?: boolean;
31
+ }): string;
32
+ //# sourceMappingURL=admin-route-helpers.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"admin-route-helpers.d.ts","sourceRoot":"","sources":["../../../src/modules/access/admin-route-helpers.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,OAAO,MAAM,SAAS,CAAC;AAGnC,qDAAqD;AACrD,wBAAgB,WAAW,CACzB,GAAG,EAAE,OAAO,EACZ,MAAM,EAAE,MAAM,GACb;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,CASnD;AAED,gDAAgD;AAChD,wBAAgB,SAAS,CAAC,GAAG,EAAE,OAAO,CAAC,QAAQ,EAAE,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAGnF;AAED;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CACnC,KAAK,EAAE,OAAO,EACd,KAAK,EAAE,MAAM,EACb,IAAI,CAAC,EAAE;IAAE,IAAI,CAAC,EAAE,OAAO,CAAA;CAAE,GACxB,MAAM,CAKR"}
@@ -0,0 +1,44 @@
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
+ import { WorkflowDomainError } from '../workflow/workflow.errors.js';
15
+ /** One error shape for every access-family route. */
16
+ export function toHttpError(err, logTag) {
17
+ if (err instanceof WorkflowDomainError && err.status < 500) {
18
+ // Spread the payload FIRST and assign `error` LAST — a payload key named
19
+ // `error` must never overwrite the message the client renders.
20
+ return { status: err.status, body: { ...(err.payload ?? {}), error: err.message } };
21
+ }
22
+ console.error(`[${logTag}] route failure:`, err instanceof Error ? err.stack ?? err.message : err);
23
+ const status = err instanceof WorkflowDomainError ? err.status : 500;
24
+ return { status, body: { error: 'Internal error.' } };
25
+ }
26
+ /** Answer `res` with the shared error shape. */
27
+ export function sendError(res, err, logTag) {
28
+ const { status, body } = toHttpError(err, logTag);
29
+ res.status(status).json(body);
30
+ }
31
+ /**
32
+ * Coerce a request-body field that must be a non-empty string. Rejects arrays,
33
+ * objects, numbers, null — anything `String()` would silently stringify into a
34
+ * bogus principal name / email (`[object Object]`, `"a,b"`) — with a 400
35
+ * instead of persisting garbage. Returns the raw string (untrimmed by default
36
+ * — services trim; pass `trim: true` where the route owns normalization).
37
+ */
38
+ export function requireNonEmptyString(value, field, opts) {
39
+ if (typeof value !== 'string' || value.trim() === '') {
40
+ throw new WorkflowDomainError(`${field} must be a non-empty string`, 400);
41
+ }
42
+ return opts?.trim ? value.trim() : value;
43
+ }
44
+ //# sourceMappingURL=admin-route-helpers.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"admin-route-helpers.js","sourceRoot":"","sources":["../../../src/modules/access/admin-route-helpers.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAGH,OAAO,EAAE,mBAAmB,EAAE,MAAM,gCAAgC,CAAC;AAErE,qDAAqD;AACrD,MAAM,UAAU,WAAW,CACzB,GAAY,EACZ,MAAc;IAEd,IAAI,GAAG,YAAY,mBAAmB,IAAI,GAAG,CAAC,MAAM,GAAG,GAAG,EAAE,CAAC;QAC3D,yEAAyE;QACzE,+DAA+D;QAC/D,OAAO,EAAE,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,IAAI,EAAE,EAAE,GAAG,CAAC,GAAG,CAAC,OAAO,IAAI,EAAE,CAAC,EAAE,KAAK,EAAE,GAAG,CAAC,OAAO,EAAE,EAAE,CAAC;IACtF,CAAC;IACD,OAAO,CAAC,KAAK,CAAC,IAAI,MAAM,kBAAkB,EAAE,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,KAAK,IAAI,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC;IACnG,MAAM,MAAM,GAAG,GAAG,YAAY,mBAAmB,CAAC,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC;IACrE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,iBAAiB,EAAE,EAAE,CAAC;AACxD,CAAC;AAED,gDAAgD;AAChD,MAAM,UAAU,SAAS,CAAC,GAAqB,EAAE,GAAY,EAAE,MAAc;IAC3E,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,GAAG,WAAW,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;IAClD,GAAG,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAChC,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,qBAAqB,CACnC,KAAc,EACd,KAAa,EACb,IAAyB;IAEzB,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC;QACrD,MAAM,IAAI,mBAAmB,CAAC,GAAG,KAAK,6BAA6B,EAAE,GAAG,CAAC,CAAC;IAC5E,CAAC;IACD,OAAO,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC;AAC3C,CAAC"}
@@ -0,0 +1,41 @@
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
+ /** A capability an app surface gates on. Grow this union with the gates. */
21
+ export type Capability = 'manage-deployment' | 'manage-accounts' | 'manage-roles' | 'manage-groups' | 'manage-directory';
22
+ export interface CapabilityRole {
23
+ canonical: string;
24
+ displayName: string;
25
+ /** Admin-facing one-liner for the Roles page. */
26
+ description: string;
27
+ capabilities: readonly Capability[];
28
+ /**
29
+ * Whether the role may be assigned to groups. True for every role, Admin
30
+ * included — with the parse-time invariant that Admin ALWAYS keeps at least
31
+ * one direct email member (see `parseRolesYaml`), so a misconfigured or
32
+ * unreachable directory can never leave the deployment without a
33
+ * directory-independent admin.
34
+ */
35
+ groupAssignable: boolean;
36
+ }
37
+ export declare const CAPABILITY_ROLES: readonly CapabilityRole[];
38
+ export declare function capabilityRoleFor(roleName: string): CapabilityRole | null;
39
+ /** A roles.yaml role that is NOT a capability role — a pre-split people-set. */
40
+ export declare function isLegacyPeopleSetRole(roleName: string): boolean;
41
+ //# sourceMappingURL=capability-registry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"capability-registry.d.ts","sourceRoot":"","sources":["../../../src/modules/access/capability-registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAIH,4EAA4E;AAC5E,MAAM,MAAM,UAAU,GAClB,mBAAmB,GACnB,iBAAiB,GACjB,cAAc,GACd,eAAe,GACf,kBAAkB,CAAC;AAEvB,MAAM,WAAW,cAAc;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,iDAAiD;IACjD,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,SAAS,UAAU,EAAE,CAAC;IACpC;;;;;;OAMG;IACH,eAAe,EAAE,OAAO,CAAC;CAC1B;AAED,eAAO,MAAM,gBAAgB,EAAE,SAAS,cAAc,EAiBrD,CAAC;AAEF,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,GAAG,cAAc,GAAG,IAAI,CAGzE;AAED,gFAAgF;AAChF,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAE/D"}
@@ -0,0 +1,46 @@
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
+ import { ADMIN_CANONICAL, canonicalRoleName } from './access-control.service.js';
21
+ export const CAPABILITY_ROLES = [
22
+ {
23
+ canonical: ADMIN_CANONICAL,
24
+ displayName: 'Admin',
25
+ description: 'Detailed configuration: deployment settings, user accounts, roles & groups, and the directory connection.',
26
+ capabilities: [
27
+ 'manage-deployment',
28
+ 'manage-accounts',
29
+ 'manage-roles',
30
+ 'manage-groups',
31
+ 'manage-directory',
32
+ ],
33
+ groupAssignable: true,
34
+ },
35
+ // Planned next (lands with its feature gates, not before):
36
+ // { canonical: 'plugin creator', displayName: 'Plugin Creator', … }
37
+ ];
38
+ export function capabilityRoleFor(roleName) {
39
+ const canonical = canonicalRoleName(roleName);
40
+ return CAPABILITY_ROLES.find((r) => r.canonical === canonical) ?? null;
41
+ }
42
+ /** A roles.yaml role that is NOT a capability role — a pre-split people-set. */
43
+ export function isLegacyPeopleSetRole(roleName) {
44
+ return capabilityRoleFor(roleName) === null;
45
+ }
46
+ //# sourceMappingURL=capability-registry.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"capability-registry.js","sourceRoot":"","sources":["../../../src/modules/access/capability-registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,6BAA6B,CAAC;AA0BjF,MAAM,CAAC,MAAM,gBAAgB,GAA8B;IACzD;QACE,SAAS,EAAE,eAAe;QAC1B,WAAW,EAAE,OAAO;QACpB,WAAW,EACT,2GAA2G;QAC7G,YAAY,EAAE;YACZ,mBAAmB;YACnB,iBAAiB;YACjB,cAAc;YACd,eAAe;YACf,kBAAkB;SACnB;QACD,eAAe,EAAE,IAAI;KACtB;IACD,2DAA2D;IAC3D,oEAAoE;CACrE,CAAC;AAEF,MAAM,UAAU,iBAAiB,CAAC,QAAgB;IAChD,MAAM,SAAS,GAAG,iBAAiB,CAAC,QAAQ,CAAC,CAAC;IAC9C,OAAO,gBAAgB,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,SAAS,KAAK,SAAS,CAAC,IAAI,IAAI,CAAC;AACzE,CAAC;AAED,gFAAgF;AAChF,MAAM,UAAU,qBAAqB,CAAC,QAAgB;IACpD,OAAO,iBAAiB,CAAC,QAAQ,CAAC,KAAK,IAAI,CAAC;AAC9C,CAAC"}
@@ -0,0 +1,13 @@
1
+ import type { AuthUser } from '@bevel-software/platform-shared';
2
+ import type { Database } from '../database/connection.js';
3
+ /**
4
+ * Synthetic identity for the synced-groups materializer's commits: every
5
+ * `synced-groups.yaml` regeneration lands attributed to this bot, so the git
6
+ * history reads as "the system mirrored the IdP", never as any human.
7
+ * Same `<role>-bot@<host>` convention (and race-safe ensure shape) as the
8
+ * recovery bot.
9
+ */
10
+ export declare const DIRECTORY_SYNC_BOT_EMAIL: string;
11
+ export declare const DIRECTORY_SYNC_BOT_NAME = "Directory Sync Bot";
12
+ export declare function ensureDirectorySyncBot(db: Database): Promise<AuthUser>;
13
+ //# sourceMappingURL=directory-sync-bot.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"directory-sync-bot.d.ts","sourceRoot":"","sources":["../../../src/modules/access/directory-sync-bot.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAC;AAChE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,2BAA2B,CAAC;AAG1D;;;;;;GAMG;AAKH,eAAO,MAAM,wBAAwB,QAEtB,CAAC;AAEhB,eAAO,MAAM,uBAAuB,uBAAuB,CAAC;AAE5D,wBAAsB,sBAAsB,CAAC,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAqD5E"}