@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,303 @@
1
+ import {
2
+ EMAIL_REGEX,
3
+ GROUP_REF_PREFIX,
4
+ canonicalEmail,
5
+ canonicalRoleName,
6
+ RESERVED_ROLE_NAMES,
7
+ } from './access-control.service.js';
8
+ import { unsafeNameReason } from './group-files.js';
9
+
10
+ /**
11
+ * Locale-independent code-unit comparator. `localeCompare` would make the
12
+ * "deterministic render" depend on the process's ambient locale/ICU build —
13
+ * two deployments could then disagree on byte order and ping-pong no-op
14
+ * detection.
15
+ */
16
+ function codeUnitCompare(a: string, b: string): number {
17
+ return a < b ? -1 : a > b ? 1 : 0;
18
+ }
19
+
20
+ /**
21
+ * Materializes an external directory's groups into `synced-groups.yaml` — the
22
+ * MACHINE-OWNED group file the access resolver reads in IdP mode.
23
+ *
24
+ * Why a file and not the DB: access resolution is file-based and evaluated at
25
+ * git refs (the merge/push gates depend on it), so group membership must live
26
+ * in the KB repo. Wholesale regeneration (never editing) is what makes the
27
+ * file conflict-free, and git history doubles as the membership audit log.
28
+ *
29
+ * The DIRECTORY ITSELF is not core's business: groups arrive through a
30
+ * {@link SyncedGroupsSource} an overlay provides (e.g. a SCIM mirror fed by
31
+ * Entra/Okta provisioning). Core owns the file format, the rendering rules,
32
+ * and the commit pipeline — the same module that parses the file on the read
33
+ * side (`group-files.ts`) governs what gets written into it.
34
+ *
35
+ * The renderer is a PURE function so its determinism is trivially testable;
36
+ * the writer wraps it with debounce (a provisioning cycle is a burst of many
37
+ * directory pushes — one commit per burst, not per mutation) and no-op
38
+ * detection (unchanged content never commits).
39
+ */
40
+
41
+ const FILE_HEADER = `# MACHINE-GENERATED from the identity provider's directory — do not edit.
42
+ # Membership is managed in the IdP; this file is regenerated wholesale on every
43
+ # provisioning push. Manual groups live in groups.yaml (ignored while this file
44
+ # exists — its presence IS what puts the deployment in IdP mode).
45
+ `;
46
+
47
+ /** A member of an externally-synced group, as the materializer needs it. */
48
+ export interface SyncedGroupMember {
49
+ /** Lowercased primary email — the identity join key; null when the IdP sent none. */
50
+ email: string | null;
51
+ /** Deactivated members stay mirrored upstream but are not materialized. */
52
+ active: boolean;
53
+ }
54
+
55
+ /** A group from the external directory, as the materializer needs it. */
56
+ export interface SyncedGroupRecord {
57
+ /** The IdP's own identifier, if it sent one — only used to break sort ties. */
58
+ externalId: string | null;
59
+ displayName: string;
60
+ members: SyncedGroupMember[];
61
+ }
62
+
63
+ /**
64
+ * The seam an overlay implements to feed the materializer: "the current
65
+ * groups in the external directory." Core never learns HOW they got there
66
+ * (SCIM push, API poll, …).
67
+ */
68
+ export interface SyncedGroupsSource {
69
+ listGroups(): Promise<SyncedGroupRecord[]>;
70
+ }
71
+
72
+ // Name safety is the SHARED predicate in `group-files.ts` (`unsafeNameReason`)
73
+ // — the same rules the manual group editor asserts, including the reserved
74
+ // `role/` prefix. Here the name arrives from the IdP, so an unsafe group is
75
+ // SKIPPED with a warning instead of erroring (fail-closed: an unrepresentable
76
+ // group grants nothing).
77
+
78
+ export interface RenderedSyncedGroups {
79
+ text: string;
80
+ /** Groups/members that could not be materialized, human-readable. */
81
+ warnings: string[];
82
+ /** Groups actually emitted (post-skip). */
83
+ groupCount: number;
84
+ }
85
+
86
+ /**
87
+ * Deterministic render: same directory state → byte-identical file. Groups
88
+ * sort by canonical name (ties by externalId), member emails sort lexically.
89
+ * Excluded from materialization (each with a warning): groups with
90
+ * YAML-unsafe or reserved names, canonical-name duplicates (first wins), and
91
+ * members that are inactive or carry no email (email is the identity join
92
+ * key — a member without one cannot be granted anything).
93
+ */
94
+ export function renderSyncedGroupsYaml(groups: SyncedGroupRecord[]): RenderedSyncedGroups {
95
+ const warnings: string[] = [];
96
+ const byCanonical = new Map<string, SyncedGroupRecord>();
97
+
98
+ const sorted = [...groups].sort((a, b) => {
99
+ const byName = codeUnitCompare(canonicalRoleName(a.displayName), canonicalRoleName(b.displayName));
100
+ if (byName !== 0) return byName;
101
+ const byExternalId = codeUnitCompare(a.externalId ?? '', b.externalId ?? '');
102
+ if (byExternalId !== 0) return byExternalId;
103
+ // Full-key duplicates: break the tie on membership, then on the RAW
104
+ // display name (same canonical can differ in case/spacing, and the winner
105
+ // of the first-wins dedup below decides the emitted name) — so the bytes
106
+ // never depend on source array order. The membership key is JSON — a
107
+ // plain delimiter join could collide (an email may legally contain the
108
+ // delimiter characters), and a collision between DIFFERENT member sets
109
+ // would push the tie-break back onto input order.
110
+ const memberKey = (members: SyncedGroupMember[]): string =>
111
+ JSON.stringify(
112
+ members.map((m) => `${JSON.stringify(m.email ?? '')}:${m.active}`).sort(codeUnitCompare),
113
+ );
114
+ const byMembers = codeUnitCompare(memberKey(a.members), memberKey(b.members));
115
+ if (byMembers !== 0) return byMembers;
116
+ return codeUnitCompare(a.displayName, b.displayName);
117
+ });
118
+
119
+ // Group names are UNTRUSTED IdP input and flow into warnings that land in
120
+ // logs — JSON.stringify them so a name carrying control characters (ANSI
121
+ // escapes, newlines) is rendered escaped instead of verbatim into the log
122
+ // stream (a newline-bearing name could otherwise forge whole log lines).
123
+ // JSON.stringify only escapes C0 controls (U+0000–U+001F) plus quote and
124
+ // backslash: C1 controls (U+007F–U+009F — including U+009B, the one-byte
125
+ // CSI that starts ANSI sequences on its own) and the JS line separators
126
+ // U+2028/U+2029 pass through raw, so escape those ourselves.
127
+ const printable = (name: string): string =>
128
+ JSON.stringify(name).replace(
129
+ /[\u007F-\u009F\u2028\u2029]/g,
130
+ (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`,
131
+ );
132
+ for (const group of sorted) {
133
+ const reason = unsafeNameReason(group.displayName);
134
+ if (reason) {
135
+ warnings.push(`group ${printable(group.displayName)} skipped: ${reason} — rename it in the IdP`);
136
+ continue;
137
+ }
138
+ const canonical = canonicalRoleName(group.displayName);
139
+ if (RESERVED_ROLE_NAMES.has(canonical)) {
140
+ warnings.push(
141
+ `group ${printable(group.displayName)} skipped: '${canonical}' is a reserved name — rename it in the IdP`,
142
+ );
143
+ continue;
144
+ }
145
+ const existing = byCanonical.get(canonical);
146
+ if (existing) {
147
+ warnings.push(
148
+ `group ${printable(group.displayName)} skipped: name collides with ${printable(existing.displayName)} — rename one in the IdP`,
149
+ );
150
+ continue;
151
+ }
152
+ byCanonical.set(canonical, group);
153
+ }
154
+
155
+ const lines: string[] = [FILE_HEADER.trimEnd(), 'groups:'];
156
+ for (const [, group] of byCanonical) {
157
+ const emails = new Set<string>();
158
+ let skippedMembers = 0;
159
+ let malformedMembers = 0;
160
+ for (const member of group.members) {
161
+ if (!member.active || !member.email) {
162
+ skippedMembers++;
163
+ continue;
164
+ }
165
+ // The directory is UNTRUSTED input: a "email" carrying a newline or
166
+ // entry-grammar characters would corrupt the emitted YAML or inject
167
+ // memberships. Canonicalize and validate before it may become a line.
168
+ // A leading '#' passes the regex but the emitted `- #…` reads back as a
169
+ // comment (stripComment) — the member would silently vanish on the next
170
+ // resolver read, so refuse it here where it at least gets a warning.
171
+ // The reserved `group:` prefix passes EMAIL_REGEX (`group:lee@x.io`)
172
+ // but the read-side parser skips it as a group reference — refuse it
173
+ // here so the file never carries an entry its own parser warns on.
174
+ const email = canonicalEmail(member.email);
175
+ if (!EMAIL_REGEX.test(email) || email.startsWith('#') || email.startsWith(GROUP_REF_PREFIX)) {
176
+ malformedMembers++;
177
+ continue;
178
+ }
179
+ emails.add(email);
180
+ }
181
+ if (skippedMembers > 0) {
182
+ warnings.push(
183
+ `group ${printable(group.displayName)}: ${skippedMembers} member${skippedMembers === 1 ? '' : 's'} not materialized (inactive or no email)`,
184
+ );
185
+ }
186
+ if (malformedMembers > 0) {
187
+ warnings.push(
188
+ `group ${printable(group.displayName)}: ${malformedMembers} member${malformedMembers === 1 ? '' : 's'} not materialized (malformed email)`,
189
+ );
190
+ }
191
+ const name = group.displayName.trim();
192
+ if (emails.size === 0) {
193
+ lines.push(` ${name}: []`);
194
+ } else {
195
+ lines.push(` ${name}:`);
196
+ for (const email of [...emails].sort(codeUnitCompare)) lines.push(` - ${email}`);
197
+ }
198
+ }
199
+
200
+ return { text: lines.join('\n') + '\n', warnings, groupCount: byCanonical.size };
201
+ }
202
+
203
+ export interface SyncedGroupsWriteResult {
204
+ /** False when the rendered content matched what is already committed. */
205
+ changed: boolean;
206
+ groupCount: number;
207
+ warnings: string[];
208
+ }
209
+
210
+ export interface SyncedGroupsWriterDeps {
211
+ source: SyncedGroupsSource;
212
+ /** Current committed file content, or null when it does not exist yet. */
213
+ readCurrent: () => Promise<string | null>;
214
+ /** Write + commit the new content (the composition root owns the git plumbing). */
215
+ persist: (content: string) => Promise<void>;
216
+ /** Post-commit hook: cache invalidation + change events. */
217
+ onWritten?: (result: SyncedGroupsWriteResult) => void;
218
+ /** Trailing-edge debounce for provisioning bursts. */
219
+ debounceMs?: number;
220
+ log?: (message: string) => void;
221
+ }
222
+
223
+ const DEFAULT_DEBOUNCE_MS = 10_000;
224
+
225
+ export class SyncedGroupsWriter {
226
+ private timer: NodeJS.Timeout | null = null;
227
+ /** Serializes writes: a mutation landing mid-write queues one follow-up. */
228
+ private inflight: Promise<SyncedGroupsWriteResult> | null = null;
229
+ private rerunWanted = false;
230
+
231
+ constructor(private readonly deps: SyncedGroupsWriterDeps) {}
232
+
233
+ /**
234
+ * Note a directory mutation; (re)arm the debounce timer. Called on every
235
+ * successful ingress/degress, so a provisioning burst keeps pushing the
236
+ * timer and the write lands once, after the burst goes quiet.
237
+ */
238
+ notifyMutation(): void {
239
+ if (this.timer) clearTimeout(this.timer);
240
+ this.timer = setTimeout(() => {
241
+ this.timer = null;
242
+ this.writeNow().catch((err) => {
243
+ this.deps.log?.(
244
+ `[directory-sync] deferred synced-groups write failed: ${err instanceof Error ? err.message : err}`,
245
+ );
246
+ });
247
+ }, this.deps.debounceMs ?? DEFAULT_DEBOUNCE_MS);
248
+ // Never hold the process open for a pending materialization.
249
+ this.timer.unref?.();
250
+ }
251
+
252
+ /** Render + commit immediately, skipping the debounce. Today only the
253
+ debounce timer calls this; it is public so a future manual "sync now"
254
+ surface can reuse it without changing the writer. */
255
+ async writeNow(): Promise<SyncedGroupsWriteResult> {
256
+ if (this.inflight) {
257
+ // A write is running against a snapshot that may predate this call —
258
+ // ask for one follow-up run and share ITS eventual result semantics by
259
+ // awaiting the current one first (callers only need "a write covering
260
+ // my mutation happened"; the follow-up covers it).
261
+ this.rerunWanted = true;
262
+ await this.inflight.catch(() => undefined);
263
+ if (this.inflight) return this.inflight;
264
+ }
265
+ this.inflight = this.runOnce();
266
+ try {
267
+ let result = await this.inflight;
268
+ while (this.rerunWanted) {
269
+ this.rerunWanted = false;
270
+ this.inflight = this.runOnce();
271
+ result = await this.inflight;
272
+ }
273
+ return result;
274
+ } finally {
275
+ this.inflight = null;
276
+ }
277
+ }
278
+
279
+ private async runOnce(): Promise<SyncedGroupsWriteResult> {
280
+ const groups = await this.deps.source.listGroups();
281
+ const rendered = renderSyncedGroupsYaml(groups);
282
+ for (const w of rendered.warnings) this.deps.log?.(`[directory-sync] ${w}`);
283
+
284
+ const current = await this.deps.readCurrent();
285
+ if (current === rendered.text) {
286
+ return { changed: false, groupCount: rendered.groupCount, warnings: rendered.warnings };
287
+ }
288
+ await this.deps.persist(rendered.text);
289
+ const result: SyncedGroupsWriteResult = {
290
+ changed: true,
291
+ groupCount: rendered.groupCount,
292
+ warnings: rendered.warnings,
293
+ };
294
+ this.deps.onWritten?.(result);
295
+ return result;
296
+ }
297
+
298
+ /** Cancel any pending debounce (shutdown/tests). */
299
+ dispose(): void {
300
+ if (this.timer) clearTimeout(this.timer);
301
+ this.timer = null;
302
+ }
303
+ }
@@ -211,6 +211,14 @@ export const fileLocks = pgTable('file_locks', {
211
211
  path: text('path').notNull(),
212
212
  holderUserId: uuid('holder_user_id').notNull().references(() => users.id),
213
213
  holderName: text('holder_name').notNull(),
214
+ // How the lock was acquired. 'edit' (the default) is a normal write hold —
215
+ // the holder is editing and the release publishes their bytes.
216
+ // 'coordination' is a pure-mutex hold (see IWorkflowService.acquireLock)
217
+ // that grants NO write authority. Persisted on the row — not inferred at
218
+ // release time — so the write/checkpoint/commit-enqueue paths can refuse
219
+ // to treat a coordination hold as write possession for as long as the row
220
+ // lives, across process restarts included.
221
+ mode: text('mode').notNull().default('edit'),
214
222
  acquiredAt: timestamp('acquired_at').defaultNow().notNull(),
215
223
  lastHeartbeatAt: timestamp('last_heartbeat_at').defaultNow().notNull(),
216
224
  expiresAt: timestamp('expires_at').notNull(),
@@ -220,6 +228,7 @@ export const fileLocks = pgTable('file_locks', {
220
228
  // table scan once the lock count grows.
221
229
  byExpiry: index('file_locks_by_expiry').on(t.expiresAt),
222
230
  byHolder: index('file_locks_by_holder').on(t.holderUserId),
231
+ modeCheck: check('file_locks_mode', sql`${t.mode} IN ('edit', 'coordination')`),
223
232
  }));
224
233
 
225
234
  /**
@@ -4,6 +4,7 @@ import { createMcpAuthMiddleware } from '../mcp-auth.middleware.js';
4
4
  import type { AuthService } from '../../auth/auth.service.js';
5
5
  import type { IExternalApiKeyService } from '../../tool-auth/external-api-key.interface.js';
6
6
  import type { BevelOAuthProvider } from '../oauth/bevel-oauth-provider.js';
7
+ import { InternalTokenService } from '../../tool-auth/internal-token.service.js';
7
8
 
8
9
  const RESOURCE_METADATA_URL = 'https://bevel.example/.well-known/oauth-protected-resource/api/mcp';
9
10
 
@@ -64,6 +65,7 @@ function makeMw(
64
65
  auth?: AuthService;
65
66
  keys?: IExternalApiKeyService;
66
67
  oauth?: BevelOAuthProvider;
68
+ internal?: InternalTokenService;
67
69
  } = {},
68
70
  ) {
69
71
  return createMcpAuthMiddleware(
@@ -71,6 +73,7 @@ function makeMw(
71
73
  overrides.keys ?? makeExternalApiKeyService(),
72
74
  overrides.oauth ?? makeOAuthProvider(),
73
75
  RESOURCE_METADATA_URL,
76
+ overrides.internal ?? new InternalTokenService({ secret: 'test-secret-32-bytes-long-enough!!' }),
74
77
  );
75
78
  }
76
79
 
@@ -246,4 +249,117 @@ describe('createMcpAuthMiddleware', () => {
246
249
  expect(status).toHaveBeenCalledWith(401);
247
250
  expect(next).not.toHaveBeenCalled();
248
251
  });
252
+
253
+
254
+ /**
255
+ * The internal-token branch: server-minted only (createSession's loopback
256
+ * bearer, the /mcp/local-token exchange). hexis-mcp's OAuth mode sends one
257
+ * here when it registers this endpoint as its remote manual — the exact hop
258
+ * that failed while this surface refused the shape.
259
+ */
260
+ describe('internal tokens', () => {
261
+ const internal = new InternalTokenService({ secret: 'test-secret-32-bytes-long-enough!!' });
262
+
263
+ it('accepts a live internal token and resolves the user', async () => {
264
+ const token = internal.mint({ userId: 'user-7', externalProxy: true }, 60_000);
265
+ const auth = makeAuthService();
266
+ (auth.getUserById as ReturnType<typeof vi.fn>) = vi.fn(async () => ({
267
+ id: 'user-7',
268
+ email: 'seven@example.com',
269
+ }));
270
+ const mw = makeMw({ internal, auth });
271
+ const { req, res, next } = makeReqRes(`Bearer ${token}`);
272
+ await mw(req, res, next);
273
+ expect(next).toHaveBeenCalled();
274
+ expect(req.userId).toBe('user-7');
275
+ expect(req.userEmail).toBe('seven@example.com');
276
+ });
277
+
278
+ it('401s an expired internal token', async () => {
279
+ const past = new InternalTokenService({
280
+ secret: 'test-secret-32-bytes-long-enough!!',
281
+ now: () => Date.now() - 120_000,
282
+ });
283
+ const token = past.mint({ userId: 'user-7', externalProxy: true }, 60_000);
284
+ const mw = makeMw({ internal });
285
+ const { req, res, next, status } = makeReqRes(`Bearer ${token}`);
286
+ await mw(req, res, next);
287
+ expect(next).not.toHaveBeenCalled();
288
+ expect(status).toHaveBeenCalledWith(401);
289
+ });
290
+
291
+ it('401s an internal token whose user no longer exists', async () => {
292
+ const token = internal.mint({ userId: 'ghost', externalProxy: true }, 60_000);
293
+ const auth = makeAuthService();
294
+ (auth.getUserById as ReturnType<typeof vi.fn>) = vi.fn(async () => null);
295
+ const mw = makeMw({ internal, auth });
296
+ const { req, res, next, status } = makeReqRes(`Bearer ${token}`);
297
+ await mw(req, res, next);
298
+ expect(next).not.toHaveBeenCalled();
299
+ expect(status).toHaveBeenCalledWith(401);
300
+ });
301
+
302
+ /**
303
+ * Only the externalProxy shape may be an MCP caller. A plain in-process
304
+ * internal token — the per-run credential the agent factory mints for its
305
+ * own code-mode client — is a loopback-surface credential, and admitting
306
+ * it here would let it open an MCP session (createSession would even mint
307
+ * it a fresh externalProxy bearer, upgrading it).
308
+ */
309
+ it('401s a VALID internal token that lacks the externalProxy claim', async () => {
310
+ const token = internal.mint({ userId: 'user-7' }, 60_000);
311
+ const auth = makeAuthService();
312
+ (auth.getUserById as ReturnType<typeof vi.fn>) = vi.fn(async () => ({
313
+ id: 'user-7',
314
+ email: 'seven@example.com',
315
+ }));
316
+ const mw = makeMw({ internal, auth });
317
+ const { req, res, next, status } = makeReqRes(`Bearer ${token}`);
318
+ await mw(req, res, next);
319
+ expect(next).not.toHaveBeenCalled();
320
+ expect(status).toHaveBeenCalledWith(401);
321
+ // Rejected before any user lookup — the shape alone disqualifies it.
322
+ expect(auth.getUserById).not.toHaveBeenCalled();
323
+ });
324
+
325
+ /**
326
+ * `verify` answers null for the invalid cases it can see coming, but a
327
+ * malformed token of plausible shape can still THROW from inside it
328
+ * (e.g. `timingSafeEqual` on same-length strings whose byte lengths
329
+ * differ). That is the caller's bad token — a clean 401, never a 500 or
330
+ * an unhandled throw.
331
+ */
332
+ it('401s — not 500s — when verify throws on a malformed token', async () => {
333
+ const throwing = {
334
+ looksLikeInternalToken: (t: string) => t.startsWith('bevel-int_'),
335
+ verify: () => {
336
+ throw new RangeError('Input buffers must have the same byte length');
337
+ },
338
+ } as unknown as InternalTokenService;
339
+ const mw = makeMw({ internal: throwing });
340
+ const { req, res, next, status } = makeReqRes('Bearer bevel-int_bödy.sïg');
341
+ await mw(req, res, next);
342
+ expect(next).not.toHaveBeenCalled();
343
+ expect(status).toHaveBeenCalledWith(401);
344
+ });
345
+
346
+ /**
347
+ * The real-token spelling of the throw above: a signature of the SAME
348
+ * string length whose non-ASCII characters change its BYTE length makes
349
+ * `timingSafeEqual` throw inside the real `verify` — proof the guard is
350
+ * needed against the genuine service, not only a mock.
351
+ */
352
+ it('401s a real token whose forged signature makes verify throw', async () => {
353
+ const token = internal.mint({ userId: 'user-7', externalProxy: true }, 60_000);
354
+ const dot = token.lastIndexOf('.');
355
+ const sig = token.slice(dot + 1);
356
+ // Same string LENGTH, different byte length: 'é' is two UTF-8 bytes.
357
+ const forged = `${token.slice(0, dot + 1)}é${sig.slice(1)}`;
358
+ const mw = makeMw({ internal });
359
+ const { req, res, next, status } = makeReqRes(`Bearer ${forged}`);
360
+ await mw(req, res, next);
361
+ expect(next).not.toHaveBeenCalled();
362
+ expect(status).toHaveBeenCalledWith(401);
363
+ });
364
+ });
249
365
  });
@@ -82,7 +82,9 @@ async function connectClient(): Promise<Client> {
82
82
  publicFrontendUrl: 'http://localhost:5173',
83
83
  });
84
84
  const stub = {} as never;
85
- app.use('/api', createMcpRoutes(mcpService, stub, fakeAuth, fakeAuth, stub));
85
+ // local-token deps (internal tokens / OAuth provider / metadata URL) are only
86
+ // dereferenced by that route's handler — stubs suffice here.
87
+ app.use('/api', createMcpRoutes(mcpService, stub, fakeAuth, fakeAuth, stub, stub, stub, ''));
86
88
  app.use('/api', createManualRoutes(registry, fakeAuth));
87
89
 
88
90
  const transport = new StreamableHTTPClientTransport(new URL(`${baseUrl}/api/mcp`), {
@@ -41,7 +41,12 @@ async function mountWithRemove(
41
41
 
42
42
  const app = express();
43
43
  app.use(express.json());
44
- app.use('/api', createMcpRoutes(mcpService, externalApiKeyService, fakeAuth, fakeAuth, stub));
44
+ // local-token deps (internal tokens / OAuth provider / metadata URL) are only
45
+ // dereferenced by that route's handler — stubs suffice here.
46
+ app.use(
47
+ '/api',
48
+ createMcpRoutes(mcpService, externalApiKeyService, fakeAuth, fakeAuth, stub, stub, stub, ''),
49
+ );
45
50
 
46
51
  httpServer = await new Promise<HttpServer>((resolve) => {
47
52
  const s = app.listen(0, () => resolve(s));