@bevel-software/platform-core-backend 0.22.0 → 0.24.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 (466) hide show
  1. package/dist/core/core-ports.d.ts +11 -1
  2. package/dist/core/core-ports.d.ts.map +1 -1
  3. package/dist/core/core-ports.js.map +1 -1
  4. package/dist/core/create-core-server.d.ts +3 -3
  5. package/dist/core/create-core-server.d.ts.map +1 -1
  6. package/dist/core/create-core-server.js +46 -13
  7. package/dist/core/create-core-server.js.map +1 -1
  8. package/dist/core/create-core-services.d.ts +12 -2
  9. package/dist/core/create-core-services.d.ts.map +1 -1
  10. package/dist/core/create-core-services.js +137 -24
  11. package/dist/core/create-core-services.js.map +1 -1
  12. package/dist/core/lifecycle.d.ts +46 -1
  13. package/dist/core/lifecycle.d.ts.map +1 -1
  14. package/dist/core/lifecycle.js +99 -13
  15. package/dist/core/lifecycle.js.map +1 -1
  16. package/dist/core-config.d.ts +16 -12
  17. package/dist/core-config.d.ts.map +1 -1
  18. package/dist/core-config.js +28 -13
  19. package/dist/core-config.js.map +1 -1
  20. package/dist/index.d.ts +6 -4
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +12 -3
  23. package/dist/index.js.map +1 -1
  24. package/dist/modules/access/access-control.interface.d.ts +42 -12
  25. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  26. package/dist/modules/access/access-control.service.d.ts +67 -10
  27. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  28. package/dist/modules/access/access-control.service.js +214 -35
  29. package/dist/modules/access/access-control.service.js.map +1 -1
  30. package/dist/modules/access/access.routes.d.ts.map +1 -1
  31. package/dist/modules/access/access.routes.js +17 -16
  32. package/dist/modules/access/access.routes.js.map +1 -1
  33. package/dist/modules/access/directory-sync-bot.d.ts.map +1 -1
  34. package/dist/modules/access/directory-sync-bot.js +7 -3
  35. package/dist/modules/access/directory-sync-bot.js.map +1 -1
  36. package/dist/modules/agent-instructions/agent-instructions.routes.d.ts +8 -1
  37. package/dist/modules/agent-instructions/agent-instructions.routes.d.ts.map +1 -1
  38. package/dist/modules/agent-instructions/agent-instructions.routes.js +8 -2
  39. package/dist/modules/agent-instructions/agent-instructions.routes.js.map +1 -1
  40. package/dist/modules/agent-instructions/compose.d.ts +38 -6
  41. package/dist/modules/agent-instructions/compose.d.ts.map +1 -1
  42. package/dist/modules/agent-instructions/compose.js +39 -6
  43. package/dist/modules/agent-instructions/compose.js.map +1 -1
  44. package/dist/modules/agent-instructions/index.d.ts +2 -1
  45. package/dist/modules/agent-instructions/index.d.ts.map +1 -1
  46. package/dist/modules/agent-instructions/index.js +2 -1
  47. package/dist/modules/agent-instructions/index.js.map +1 -1
  48. package/dist/modules/agent-instructions/shared-file-rules.d.ts +115 -0
  49. package/dist/modules/agent-instructions/shared-file-rules.d.ts.map +1 -0
  50. package/dist/modules/agent-instructions/shared-file-rules.js +272 -0
  51. package/dist/modules/agent-instructions/shared-file-rules.js.map +1 -0
  52. package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
  53. package/dist/modules/audit/agent-audit.service.js +4 -2
  54. package/dist/modules/audit/agent-audit.service.js.map +1 -1
  55. package/dist/modules/auth/account-admission.d.ts +58 -7
  56. package/dist/modules/auth/account-admission.d.ts.map +1 -1
  57. package/dist/modules/auth/account-admission.js +44 -1
  58. package/dist/modules/auth/account-admission.js.map +1 -1
  59. package/dist/modules/auth/account-erasure.service.d.ts.map +1 -1
  60. package/dist/modules/auth/account-erasure.service.js +57 -16
  61. package/dist/modules/auth/account-erasure.service.js.map +1 -1
  62. package/dist/modules/auth/account.routes.d.ts +1 -1
  63. package/dist/modules/auth/account.routes.d.ts.map +1 -1
  64. package/dist/modules/auth/account.routes.js +61 -5
  65. package/dist/modules/auth/account.routes.js.map +1 -1
  66. package/dist/modules/auth/auth.middleware.d.ts +1 -1
  67. package/dist/modules/auth/auth.middleware.d.ts.map +1 -1
  68. package/dist/modules/auth/auth.middleware.js +18 -7
  69. package/dist/modules/auth/auth.middleware.js.map +1 -1
  70. package/dist/modules/auth/auth.routes.d.ts.map +1 -1
  71. package/dist/modules/auth/auth.routes.js +8 -0
  72. package/dist/modules/auth/auth.routes.js.map +1 -1
  73. package/dist/modules/auth/auth.service.d.ts +70 -2
  74. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  75. package/dist/modules/auth/auth.service.js +197 -18
  76. package/dist/modules/auth/auth.service.js.map +1 -1
  77. package/dist/modules/auth/oidc-auth-provider.d.ts.map +1 -1
  78. package/dist/modules/auth/oidc-auth-provider.js +7 -2
  79. package/dist/modules/auth/oidc-auth-provider.js.map +1 -1
  80. package/dist/modules/code-mode/code-mode.tool.d.ts +20 -2
  81. package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -1
  82. package/dist/modules/code-mode/code-mode.tool.js +66 -35
  83. package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
  84. package/dist/modules/database/connection.d.ts +16 -0
  85. package/dist/modules/database/connection.d.ts.map +1 -1
  86. package/dist/modules/database/connection.js +117 -0
  87. package/dist/modules/database/connection.js.map +1 -1
  88. package/dist/modules/database/core-schema.d.ts +337 -120
  89. package/dist/modules/database/core-schema.d.ts.map +1 -1
  90. package/dist/modules/database/core-schema.js +116 -58
  91. package/dist/modules/database/core-schema.js.map +1 -1
  92. package/dist/modules/database/migrate.d.ts +8 -0
  93. package/dist/modules/database/migrate.d.ts.map +1 -1
  94. package/dist/modules/database/migrate.js +271 -1
  95. package/dist/modules/database/migrate.js.map +1 -1
  96. package/dist/modules/kb-fs/branch-name.d.ts.map +1 -1
  97. package/dist/modules/kb-fs/branch-name.js +12 -2
  98. package/dist/modules/kb-fs/branch-name.js.map +1 -1
  99. package/dist/modules/kb-fs/repo-path.d.ts +11 -16
  100. package/dist/modules/kb-fs/repo-path.d.ts.map +1 -1
  101. package/dist/modules/kb-fs/repo-path.js +79 -0
  102. package/dist/modules/kb-fs/repo-path.js.map +1 -1
  103. package/dist/modules/kb-sync/kb-sync.routes.d.ts +2 -2
  104. package/dist/modules/kb-sync/kb-sync.routes.d.ts.map +1 -1
  105. package/dist/modules/kb-sync/kb-sync.routes.js +14 -3
  106. package/dist/modules/kb-sync/kb-sync.routes.js.map +1 -1
  107. package/dist/modules/kb-sync/sync-auth.d.ts +3 -1
  108. package/dist/modules/kb-sync/sync-auth.d.ts.map +1 -1
  109. package/dist/modules/kb-sync/sync-auth.js +1 -1
  110. package/dist/modules/kb-sync/sync-auth.js.map +1 -1
  111. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  112. package/dist/modules/mcp/mcp-auth.middleware.js +21 -6
  113. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  114. package/dist/modules/mcp/mcp.service.d.ts +8 -0
  115. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  116. package/dist/modules/mcp/mcp.service.js +38 -8
  117. package/dist/modules/mcp/mcp.service.js.map +1 -1
  118. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
  119. package/dist/modules/mcp/oauth/bevel-oauth-provider.js +3 -2
  120. package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
  121. package/dist/modules/plugins/join-request-records.store.d.ts.map +1 -1
  122. package/dist/modules/plugins/join-request-records.store.js +7 -4
  123. package/dist/modules/plugins/join-request-records.store.js.map +1 -1
  124. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
  125. package/dist/modules/settings/deployment-settings.service.js +8 -3
  126. package/dist/modules/settings/deployment-settings.service.js.map +1 -1
  127. package/dist/modules/settings/setup.routes.d.ts +52 -1
  128. package/dist/modules/settings/setup.routes.d.ts.map +1 -1
  129. package/dist/modules/settings/setup.routes.js +207 -19
  130. package/dist/modules/settings/setup.routes.js.map +1 -1
  131. package/dist/modules/skills/skills.contract.d.ts +90 -18
  132. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  133. package/dist/modules/skills/skills.contract.js +4 -1
  134. package/dist/modules/skills/skills.contract.js.map +1 -1
  135. package/dist/modules/skills/skills.service.d.ts +98 -9
  136. package/dist/modules/skills/skills.service.d.ts.map +1 -1
  137. package/dist/modules/skills/skills.service.js +239 -37
  138. package/dist/modules/skills/skills.service.js.map +1 -1
  139. package/dist/modules/skills/skills.tools.d.ts +7 -0
  140. package/dist/modules/skills/skills.tools.d.ts.map +1 -1
  141. package/dist/modules/skills/skills.tools.js +71 -7
  142. package/dist/modules/skills/skills.tools.js.map +1 -1
  143. package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
  144. package/dist/modules/tool-auth/external-api-key.service.js +12 -4
  145. package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
  146. package/dist/modules/tool-auth/internal-token.service.d.ts +3 -3
  147. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +16 -7
  148. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  149. package/dist/modules/tool-auth/tool-auth.middleware.js +34 -12
  150. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  151. package/dist/modules/tool-helpers/tool-handler.d.ts +2 -1
  152. package/dist/modules/tool-helpers/tool-handler.d.ts.map +1 -1
  153. package/dist/modules/tool-helpers/tool-handler.js +25 -4
  154. package/dist/modules/tool-helpers/tool-handler.js.map +1 -1
  155. package/dist/modules/tool-helpers/tool.contract.d.ts +3 -2
  156. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
  157. package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
  158. package/dist/modules/tool-helpers/validate-token.js +1 -1
  159. package/dist/modules/tool-helpers/validate-token.js.map +1 -1
  160. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  161. package/dist/modules/tool-manuals/tool-manuals.tools.js +42 -31
  162. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  163. package/dist/modules/tool-registry/description-length.d.ts +80 -0
  164. package/dist/modules/tool-registry/description-length.d.ts.map +1 -0
  165. package/dist/modules/tool-registry/description-length.js +108 -0
  166. package/dist/modules/tool-registry/description-length.js.map +1 -0
  167. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +98 -0
  168. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -0
  169. package/dist/modules/workflow/agent-tools/change-request-summary.js +81 -0
  170. package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -0
  171. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  172. package/dist/modules/workflow/agent-tools/workflow.tools.js +75 -35
  173. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  174. package/dist/modules/workflow/file-lock.service.d.ts +24 -0
  175. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  176. package/dist/modules/workflow/file-lock.service.js +30 -0
  177. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  178. package/dist/modules/workflow/git/git.service.d.ts +94 -0
  179. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  180. package/dist/modules/workflow/git/git.service.js +193 -3
  181. package/dist/modules/workflow/git/git.service.js.map +1 -1
  182. package/dist/modules/workflow/pending-commits.service.d.ts +38 -0
  183. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  184. package/dist/modules/workflow/pending-commits.service.js +60 -1
  185. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  186. package/dist/modules/workflow/recovery-bot.d.ts.map +1 -1
  187. package/dist/modules/workflow/recovery-bot.js +7 -3
  188. package/dist/modules/workflow/recovery-bot.js.map +1 -1
  189. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
  190. package/dist/modules/workflow/review-workflow/review-workflow.service.js +12 -3
  191. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  192. package/dist/modules/workflow/workflow-hooks.d.ts +54 -32
  193. package/dist/modules/workflow/workflow-hooks.d.ts.map +1 -1
  194. package/dist/modules/workflow/workflow-hooks.js +16 -1
  195. package/dist/modules/workflow/workflow-hooks.js.map +1 -1
  196. package/dist/modules/workflow/workflow.service.d.ts +60 -6
  197. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  198. package/dist/modules/workflow/workflow.service.js +115 -5
  199. package/dist/modules/workflow/workflow.service.js.map +1 -1
  200. package/dist/modules/workspace/agent-access.gate.d.ts +94 -0
  201. package/dist/modules/workspace/agent-access.gate.d.ts.map +1 -0
  202. package/dist/modules/workspace/agent-access.gate.js +123 -0
  203. package/dist/modules/workspace/agent-access.gate.js.map +1 -0
  204. package/dist/modules/workspace/agent-upload.routes.d.ts +77 -0
  205. package/dist/modules/workspace/agent-upload.routes.d.ts.map +1 -0
  206. package/dist/modules/workspace/agent-upload.routes.js +210 -0
  207. package/dist/modules/workspace/agent-upload.routes.js.map +1 -0
  208. package/dist/modules/workspace/agent-upload.store.d.ts +284 -0
  209. package/dist/modules/workspace/agent-upload.store.d.ts.map +1 -0
  210. package/dist/modules/workspace/agent-upload.store.js +553 -0
  211. package/dist/modules/workspace/agent-upload.store.js.map +1 -0
  212. package/dist/modules/workspace/routine-write-policy.d.ts +5 -6
  213. package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
  214. package/dist/modules/workspace/routine-write-policy.js +5 -6
  215. package/dist/modules/workspace/routine-write-policy.js.map +1 -1
  216. package/dist/modules/workspace/session-sink.d.ts +5 -5
  217. package/dist/modules/workspace/set-aside-clone.d.ts +46 -0
  218. package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -0
  219. package/dist/modules/workspace/set-aside-clone.js +92 -0
  220. package/dist/modules/workspace/set-aside-clone.js.map +1 -0
  221. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +59 -9
  222. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  223. package/dist/modules/workspace/startup/kb-startup-runner.js +65 -24
  224. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  225. package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -1
  226. package/dist/modules/workspace/startup/steps/seed-tree.js +3 -3
  227. package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -1
  228. package/dist/modules/workspace/startup/steps/template-source.d.ts +40 -0
  229. package/dist/modules/workspace/startup/steps/template-source.d.ts.map +1 -1
  230. package/dist/modules/workspace/startup/steps/template-source.js +46 -4
  231. package/dist/modules/workspace/startup/steps/template-source.js.map +1 -1
  232. package/dist/modules/workspace/upload-limits.d.ts +13 -0
  233. package/dist/modules/workspace/upload-limits.d.ts.map +1 -0
  234. package/dist/modules/workspace/upload-limits.js +13 -0
  235. package/dist/modules/workspace/upload-limits.js.map +1 -0
  236. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  237. package/dist/modules/workspace/workspace.routes.js +93 -4
  238. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  239. package/dist/modules/workspace/workspace.service.d.ts +128 -5
  240. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  241. package/dist/modules/workspace/workspace.service.js +316 -58
  242. package/dist/modules/workspace/workspace.service.js.map +1 -1
  243. package/dist/modules/workspace/workspace.tools.d.ts +13 -11
  244. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  245. package/dist/modules/workspace/workspace.tools.js +791 -198
  246. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  247. package/dist/modules/workspace/write-denial.d.ts +0 -6
  248. package/dist/modules/workspace/write-denial.d.ts.map +1 -1
  249. package/dist/modules/workspace/write-denial.js +0 -6
  250. package/dist/modules/workspace/write-denial.js.map +1 -1
  251. package/dist/modules/workspace/zip-entry-rules.d.ts +114 -0
  252. package/dist/modules/workspace/zip-entry-rules.d.ts.map +1 -0
  253. package/dist/modules/workspace/zip-entry-rules.js +154 -0
  254. package/dist/modules/workspace/zip-entry-rules.js.map +1 -0
  255. package/dist/modules/write-access/write-access.d.ts +60 -0
  256. package/dist/modules/write-access/write-access.d.ts.map +1 -0
  257. package/dist/modules/write-access/write-access.js +129 -0
  258. package/dist/modules/write-access/write-access.js.map +1 -0
  259. package/dist/shared/column-crypto.d.ts +194 -0
  260. package/dist/shared/column-crypto.d.ts.map +1 -0
  261. package/dist/shared/column-crypto.js +144 -0
  262. package/dist/shared/column-crypto.js.map +1 -0
  263. package/dist/shared/domain-errors.d.ts +25 -0
  264. package/dist/shared/domain-errors.d.ts.map +1 -1
  265. package/dist/shared/domain-errors.js +28 -0
  266. package/dist/shared/domain-errors.js.map +1 -1
  267. package/dist/shared/git.contract.d.ts +20 -0
  268. package/dist/shared/git.contract.d.ts.map +1 -1
  269. package/dist/shared/git.contract.js +26 -0
  270. package/dist/shared/git.contract.js.map +1 -1
  271. package/dist/shared/token-crypto.d.ts.map +1 -1
  272. package/dist/shared/token-crypto.js +25 -1
  273. package/dist/shared/token-crypto.js.map +1 -1
  274. package/dist/tenancy/static-tenant-source.d.ts +0 -1
  275. package/dist/tenancy/static-tenant-source.d.ts.map +1 -1
  276. package/dist/tenancy/static-tenant-source.js +1 -2
  277. package/dist/tenancy/static-tenant-source.js.map +1 -1
  278. package/dist/tenancy/tenant-secrets.d.ts +5 -1
  279. package/dist/tenancy/tenant-secrets.d.ts.map +1 -1
  280. package/dist/tenancy/tenant-secrets.js +4 -0
  281. package/dist/tenancy/tenant-secrets.js.map +1 -1
  282. package/kb-template/AGENTS.md +2 -0
  283. package/migrations/0014_change_request_closed_reason.sql +1 -0
  284. package/migrations/0015_account_deactivation.sql +3 -0
  285. package/migrations/0016_pii_encryption.sql +20 -0
  286. package/migrations/meta/0014_snapshot.json +2265 -0
  287. package/migrations/meta/0015_snapshot.json +2271 -0
  288. package/migrations/meta/0016_snapshot.json +2327 -0
  289. package/migrations/meta/_journal.json +21 -0
  290. package/package.json +3 -3
  291. package/src/__tests__/kb-layout-config.test.ts +0 -2
  292. package/src/__tests__/retired-settings.test.ts +96 -0
  293. package/src/core/__tests__/gated-boot.test.ts +132 -0
  294. package/src/core/__tests__/lifecycle.test.ts +241 -5
  295. package/src/core/__tests__/set-aside-root-is-one-place.test.ts +63 -0
  296. package/src/core/core-ports.ts +11 -1
  297. package/src/core/create-core-server.ts +52 -16
  298. package/src/core/create-core-services.ts +156 -24
  299. package/src/core/lifecycle.ts +117 -13
  300. package/src/core-config.ts +31 -14
  301. package/src/index.ts +30 -1
  302. package/src/modules/access/__tests__/access-control.preview-relocation.test.ts +385 -0
  303. package/src/modules/access/__tests__/access-control.prospective.test.ts +94 -18
  304. package/src/modules/access/__tests__/access.routes.prospective.test.ts +6 -7
  305. package/src/modules/access/__tests__/users-db-double.ts +21 -12
  306. package/src/modules/access/access-control.interface.ts +43 -12
  307. package/src/modules/access/access-control.service.ts +234 -36
  308. package/src/modules/access/access.routes.ts +17 -16
  309. package/src/modules/access/directory-sync-bot.ts +7 -3
  310. package/src/modules/agent-instructions/__tests__/agent-instructions.route.test.ts +11 -5
  311. package/src/modules/agent-instructions/__tests__/compose.test.ts +19 -10
  312. package/src/modules/agent-instructions/__tests__/shared-file-rules.test.ts +238 -0
  313. package/src/modules/agent-instructions/agent-instructions.routes.ts +12 -2
  314. package/src/modules/agent-instructions/compose.ts +50 -7
  315. package/src/modules/agent-instructions/index.ts +12 -0
  316. package/src/modules/agent-instructions/shared-file-rules.ts +314 -0
  317. package/src/modules/audit/agent-audit.service.ts +4 -2
  318. package/src/modules/auth/__tests__/account-deactivation.test.ts +253 -0
  319. package/src/modules/auth/__tests__/account-erasure.approval-gate.test.ts +6 -2
  320. package/src/modules/auth/__tests__/account.routes.test.ts +87 -6
  321. package/src/modules/auth/__tests__/auth.middleware.test.ts +45 -22
  322. package/src/modules/auth/__tests__/auth.service.test.ts +4 -2
  323. package/src/modules/auth/account-admission.ts +78 -9
  324. package/src/modules/auth/account-erasure.service.ts +69 -18
  325. package/src/modules/auth/account.routes.ts +63 -7
  326. package/src/modules/auth/auth.middleware.ts +19 -8
  327. package/src/modules/auth/auth.routes.ts +8 -0
  328. package/src/modules/auth/auth.service.ts +206 -23
  329. package/src/modules/auth/oidc-auth-provider.ts +7 -2
  330. package/src/modules/code-mode/__tests__/chain-runtime.e2e.test.ts +335 -0
  331. package/src/modules/code-mode/__tests__/code-mode.tool.test.ts +46 -2
  332. package/src/modules/code-mode/code-mode.tool.ts +80 -34
  333. package/src/modules/database/__tests__/connection.test.ts +12 -0
  334. package/src/modules/database/__tests__/pii-backfill.pg.test.ts +561 -0
  335. package/src/modules/database/connection.ts +117 -0
  336. package/src/modules/database/core-schema.ts +116 -58
  337. package/src/modules/database/migrate.ts +353 -1
  338. package/src/modules/kb-fs/__tests__/branch-name.test.ts +10 -0
  339. package/src/modules/kb-fs/__tests__/repo-path.test.ts +123 -0
  340. package/src/modules/kb-fs/branch-name.ts +14 -1
  341. package/src/modules/kb-fs/repo-path.ts +85 -0
  342. package/src/modules/kb-sync/__tests__/kb-sync.routes.test.ts +26 -1
  343. package/src/modules/kb-sync/kb-sync.routes.ts +14 -4
  344. package/src/modules/kb-sync/sync-auth.ts +2 -2
  345. package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +60 -3
  346. package/src/modules/mcp/__tests__/mcp.e2e.test.ts +4 -3
  347. package/src/modules/mcp/__tests__/mcp.service.test.ts +115 -13
  348. package/src/modules/mcp/mcp-auth.middleware.ts +20 -6
  349. package/src/modules/mcp/mcp.service.ts +46 -7
  350. package/src/modules/mcp/oauth/bevel-oauth-provider.ts +3 -1
  351. package/src/modules/plugins/__tests__/plugins.tools.test.ts +2 -1
  352. package/src/modules/plugins/join-request-records.store.ts +7 -4
  353. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +16 -0
  354. package/src/modules/settings/__tests__/setup.routes.git-mode.test.ts +91 -58
  355. package/src/modules/settings/__tests__/setup.routes.github-app.test.ts +25 -8
  356. package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +14 -11
  357. package/src/modules/settings/__tests__/setup.routes.repository-change.test.ts +390 -0
  358. package/src/modules/settings/__tests__/setup.routes.test.ts +3 -0
  359. package/src/modules/settings/deployment-settings.service.ts +8 -3
  360. package/src/modules/settings/setup.routes.ts +263 -19
  361. package/src/modules/skills/__tests__/allowed-tools-warn.tools.test.ts +2 -1
  362. package/src/modules/skills/__tests__/branch-skills.tools.test.ts +218 -0
  363. package/src/modules/skills/__tests__/skills.service.test.ts +229 -5
  364. package/src/modules/skills/skills.contract.ts +91 -18
  365. package/src/modules/skills/skills.service.ts +278 -41
  366. package/src/modules/skills/skills.tools.ts +80 -8
  367. package/src/modules/tool-auth/__tests__/manual-auth.middleware.test.ts +14 -1
  368. package/src/modules/tool-auth/external-api-key.service.ts +12 -4
  369. package/src/modules/tool-auth/internal-token.service.ts +3 -3
  370. package/src/modules/tool-auth/tool-auth.middleware.ts +34 -11
  371. package/src/modules/tool-helpers/__tests__/agent-roles-write.test.ts +5 -3
  372. package/src/modules/tool-helpers/__tests__/phase4-tools.test.ts +3 -3
  373. package/src/modules/tool-helpers/__tests__/validate-token.test.ts +11 -1
  374. package/src/modules/tool-helpers/tool-handler.ts +24 -4
  375. package/src/modules/tool-helpers/tool.contract.ts +3 -2
  376. package/src/modules/tool-helpers/validate-token.ts +1 -1
  377. package/src/modules/tool-manuals/tool-manuals.tools.ts +44 -31
  378. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +378 -0
  379. package/src/modules/tool-registry/description-length.ts +111 -0
  380. package/src/modules/workflow/__tests__/pending-commits.repository-replaced.test.ts +111 -0
  381. package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +25 -0
  382. package/src/modules/workflow/__tests__/workflow.service.repository-replaced.test.ts +249 -0
  383. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +264 -6
  384. package/src/modules/workflow/agent-tools/change-request-summary.ts +182 -0
  385. package/src/modules/workflow/agent-tools/workflow.tools.ts +86 -35
  386. package/src/modules/workflow/file-lock.service.ts +31 -0
  387. package/src/modules/workflow/git/__tests__/git.service.fileBytesAtCommit.test.ts +260 -0
  388. package/src/modules/workflow/git/__tests__/git.service.prFetchFailure.test.ts +170 -0
  389. package/src/modules/workflow/git/git.service.ts +215 -1
  390. package/src/modules/workflow/pending-commits.service.ts +64 -2
  391. package/src/modules/workflow/recovery-bot.ts +7 -3
  392. package/src/modules/workflow/review-workflow/__tests__/carry-approvals-forward.test.ts +4 -0
  393. package/src/modules/workflow/review-workflow/__tests__/erase-approver.test.ts +25 -3
  394. package/src/modules/workflow/review-workflow/review-workflow.service.ts +12 -3
  395. package/src/modules/workflow/workflow-hooks.ts +64 -26
  396. package/src/modules/workflow/workflow.service.ts +125 -5
  397. package/src/modules/workspace/__tests__/agent-access.gate.test.ts +208 -0
  398. package/src/modules/workspace/__tests__/agent-uploads.test.ts +1604 -0
  399. package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +394 -0
  400. package/src/modules/workspace/__tests__/file-stat-access.test.ts +2 -1
  401. package/src/modules/workspace/__tests__/git-internals.security.test.ts +2 -1
  402. package/src/modules/workspace/__tests__/set-aside-clone.test.ts +52 -0
  403. package/src/modules/workspace/__tests__/workspace.routes.at-ref.test.ts +305 -0
  404. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +2 -0
  405. package/src/modules/workspace/__tests__/workspace.service.any-workspace-credentials.test.ts +156 -0
  406. package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +147 -0
  407. package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +402 -0
  408. package/src/modules/workspace/__tests__/workspace.service.test.ts +77 -9
  409. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +41 -39
  410. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +2 -3
  411. package/src/modules/workspace/__tests__/workspace.tools.test.ts +766 -56
  412. package/src/modules/workspace/agent-access.gate.ts +164 -0
  413. package/src/modules/workspace/agent-upload.routes.ts +214 -0
  414. package/src/modules/workspace/agent-upload.store.ts +668 -0
  415. package/src/modules/workspace/routine-write-policy.ts +5 -6
  416. package/src/modules/workspace/session-sink.ts +5 -5
  417. package/src/modules/workspace/set-aside-clone.ts +96 -0
  418. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +86 -0
  419. package/src/modules/workspace/startup/kb-startup-runner.ts +115 -34
  420. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +9 -9
  421. package/src/modules/workspace/startup/steps/seed-tree.ts +3 -6
  422. package/src/modules/workspace/startup/steps/template-source.ts +53 -5
  423. package/src/modules/workspace/upload-limits.ts +12 -0
  424. package/src/modules/workspace/workspace.routes.ts +96 -5
  425. package/src/modules/workspace/workspace.service.ts +319 -61
  426. package/src/modules/workspace/workspace.tools.ts +894 -214
  427. package/src/modules/workspace/write-denial.ts +0 -8
  428. package/src/modules/workspace/zip-entry-rules.ts +173 -0
  429. package/src/modules/write-access/__tests__/write-access.test.ts +248 -0
  430. package/src/modules/write-access/write-access.ts +153 -0
  431. package/src/shared/__tests__/column-crypto.test.ts +217 -0
  432. package/src/shared/column-crypto.ts +218 -0
  433. package/src/shared/domain-errors.ts +31 -0
  434. package/src/shared/git.contract.ts +27 -0
  435. package/src/shared/token-crypto.ts +28 -1
  436. package/src/tenancy/__tests__/static-tenant-source.test.ts +4 -1
  437. package/src/tenancy/static-tenant-source.ts +1 -3
  438. package/src/tenancy/tenant-secrets.ts +5 -1
  439. package/dist/modules/workflow/session-ontology.policy.d.ts +0 -52
  440. package/dist/modules/workflow/session-ontology.policy.d.ts.map +0 -1
  441. package/dist/modules/workflow/session-ontology.policy.js +0 -62
  442. package/dist/modules/workflow/session-ontology.policy.js.map +0 -1
  443. package/dist/modules/workflow/session-ontology.service.d.ts +0 -105
  444. package/dist/modules/workflow/session-ontology.service.d.ts.map +0 -1
  445. package/dist/modules/workflow/session-ontology.service.js +0 -147
  446. package/dist/modules/workflow/session-ontology.service.js.map +0 -1
  447. package/dist/modules/workspace/session-ontology.gate.d.ts +0 -114
  448. package/dist/modules/workspace/session-ontology.gate.d.ts.map +0 -1
  449. package/dist/modules/workspace/session-ontology.gate.js +0 -161
  450. package/dist/modules/workspace/session-ontology.gate.js.map +0 -1
  451. package/dist/shared/kb-layout.d.ts +0 -39
  452. package/dist/shared/kb-layout.d.ts.map +0 -1
  453. package/dist/shared/kb-layout.js +0 -103
  454. package/dist/shared/kb-layout.js.map +0 -1
  455. package/dist/shared/kb-layout.test.d.ts +0 -2
  456. package/dist/shared/kb-layout.test.d.ts.map +0 -1
  457. package/dist/shared/kb-layout.test.js +0 -75
  458. package/dist/shared/kb-layout.test.js.map +0 -1
  459. package/src/modules/workflow/__tests__/session-ontology.policy.test.ts +0 -62
  460. package/src/modules/workflow/__tests__/session-ontology.service.test.ts +0 -201
  461. package/src/modules/workflow/session-ontology.policy.ts +0 -70
  462. package/src/modules/workflow/session-ontology.service.ts +0 -183
  463. package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +0 -239
  464. package/src/modules/workspace/session-ontology.gate.ts +0 -191
  465. package/src/shared/kb-layout.test.ts +0 -98
  466. package/src/shared/kb-layout.ts +0 -102
@@ -7,14 +7,6 @@ import { toKbRelative } from '../access-model/kb-read-filter.js';
7
7
  import type { IAccessControl } from '../access/access-control.interface.js';
8
8
  import { workspaceIdForBranch } from '../../shared/workspace-id.js';
9
9
 
10
- /**
11
- * Appended to the description of every workspace tool whose refusal is mapped
12
- * by `writeDenial`, so an agent knows before it is refused that a refusal is
13
- * not necessarily the end of the road.
14
- */
15
- export const PROPOSAL_ROUTE_NOTE =
16
- ' If this is refused for permissions, the `write-denied` error says whether you may propose the change instead (create a branch from this one, repeat this call on it, then `open_change_request` into this branch) and lists those steps.';
17
-
18
10
  /** One step of the proposal route, named by the tool the agent calls. */
19
11
  export interface ProposalStep {
20
12
  tool: string;
@@ -0,0 +1,173 @@
1
+ import { inflateRawSync } from 'node:zlib';
2
+ import { validateFilename } from '@bevel-software/platform-shared';
3
+ import { GitInternalsError } from '../../shared/domain-errors.js';
4
+ import { hasGitInternalsSegment } from '../../shared/git-internals.js';
5
+
6
+ /**
7
+ * What an archive's entry NAMES are allowed to be, as one set of rules two
8
+ * surfaces ask: `unzip` (a .zip already in the workspace) and
9
+ * `apply_file_upload` (a .zip the agent sent to the upload route). Both land
10
+ * bytes an agent never typed at paths the archive chose, so both have to judge
11
+ * the same names the same way — and they did not, for as long as the rules
12
+ * lived inside `WorkspaceService.unzipFile` as three inline blocks.
13
+ *
14
+ * NAMES, and how an entry's BYTES are read ({@link readZipEntry}) — the two
15
+ * things decidable from the archive alone. Whether a target sits behind a
16
+ * symbolic link already on disk, whether the caller may write there, and what
17
+ * is already at the path are facts about a workspace, so each surface asks
18
+ * those where its own writes land.
19
+ */
20
+
21
+ /**
22
+ * The entries an archive from macOS carries that nobody asked for: the
23
+ * resource-fork sidecar tree and the Finder's own index. SILENTLY dropped
24
+ * rather than reported — they are not the caller's content and a list of
25
+ * refusals about them says nothing.
26
+ */
27
+ export function isZipNoiseEntry(rawName: string): boolean {
28
+ return (
29
+ rawName.startsWith('__MACOSX/') ||
30
+ rawName === '__MACOSX' ||
31
+ rawName.endsWith('/.DS_Store') ||
32
+ rawName === '.DS_Store' ||
33
+ /(^|\/)\._/.test(rawName)
34
+ );
35
+ }
36
+
37
+ /**
38
+ * An entry's name with separators in the one spelling the rules read. A zip
39
+ * written on Windows may use `\`, which every check below (and every path
40
+ * built from the result) would otherwise read as part of a single segment.
41
+ */
42
+ export function zipEntryName(entryName: string): string {
43
+ return entryName.replace(/\\/g, '/');
44
+ }
45
+
46
+ /** The path segments `rawName` names, with a trailing slash and empty parts dropped. */
47
+ export function zipEntrySegments(rawName: string): string[] {
48
+ return rawName
49
+ .replace(/\/+$/, '')
50
+ .split('/')
51
+ .filter((s) => s.length > 0);
52
+ }
53
+
54
+ /**
55
+ * Why `rawName` may not be landed at all — the reason a caller reports beside
56
+ * the entry — or null when the name is one a workspace path can be built from.
57
+ *
58
+ * Three rules, in the order that makes each refusal say the most useful thing:
59
+ * a path that climbs out or is anchored at the root is invalid whatever its
60
+ * segments are; the git folder is refused in every spelling, because an
61
+ * archive must not be a way to write what git reads as its own metadata; and
62
+ * then each segment has to be a name a filesystem on any of the three
63
+ * operating systems keeps intact (`validateFilename`).
64
+ */
65
+ export function zipEntryNameRefusal(rawName: string): string | null {
66
+ if (!rawName || rawName.startsWith('/') || /(^|\/)\.\.($|\/)/.test(rawName)) return 'Invalid path';
67
+ if (hasGitInternalsSegment(rawName)) return new GitInternalsError().message;
68
+ for (const segment of zipEntrySegments(rawName)) {
69
+ const reason = validateFilename(segment);
70
+ if (reason) return reason;
71
+ }
72
+ return null;
73
+ }
74
+
75
+ /**
76
+ * Whether a zip entry is a symbolic LINK rather than a file or a folder.
77
+ *
78
+ * A zip stores a link as an ordinary member whose bytes are the link's target
79
+ * text and whose unix mode (the high half of the external attributes) carries
80
+ * `S_IFLNK`. A reader that ignores the mode writes the target text out as a
81
+ * regular file — content nobody sent, under a name that was meant to point
82
+ * somewhere. `apply_file_upload` refuses such an entry outright; the entry is
83
+ * not a file, so there are no bytes of the caller's to land.
84
+ */
85
+ export function isSymlinkZipEntry(entry: {
86
+ header?: { attr?: number };
87
+ attr?: number;
88
+ }): boolean {
89
+ const attr = entry.header?.attr ?? entry.attr ?? 0;
90
+ if (!Number.isFinite(attr) || attr <= 0) return false;
91
+ // The external attributes' high 16 bits are the unix mode when the archive
92
+ // was written on a unix host; `S_IFMT & mode === S_IFLNK` is the link bit.
93
+ return ((attr >>> 16) & 0o170000) === 0o120000;
94
+ }
95
+
96
+ /** A zip's compression method for DEFLATE, the only one that can expand. */
97
+ const ZIP_METHOD_DEFLATED = 8;
98
+
99
+ /** What {@link readZipEntry} needs of an entry — the part of adm-zip's it reads. */
100
+ export interface ReadableZipEntry {
101
+ header: { size: number; compressedSize: number; method: number };
102
+ getData(): Buffer;
103
+ getCompressedData(): Buffer;
104
+ }
105
+
106
+ /** An entry's bytes, or why they were not read. */
107
+ export type ZipEntryRead =
108
+ | { ok: true; data: Buffer }
109
+ /** The entry is, or would expand to, more than the caller's budget allows. */
110
+ | { ok: false; reason: 'too_large' }
111
+ /** The entry cannot be read as what its header says it is. `detail` says how. */
112
+ | { ok: false; reason: 'unreadable'; detail: string };
113
+
114
+ /**
115
+ * Read one entry's bytes, inflating NO MORE than `budget` of them.
116
+ *
117
+ * The one read both surfaces use, because the bound is the whole point and it
118
+ * has a hole when each surface writes it itself. An entry's header DECLARES its
119
+ * uncompressed size, and the reader caps the inflation at that — but only when
120
+ * the size it declares is above zero. An entry that declares zero is inflated
121
+ * with no cap at all, so "check the declared size against the budget, then
122
+ * read" passes a 20 KB archive that expands to 20 MB, and a 50 MB one that
123
+ * expands to tens of gigabytes in a single call, before any check on the bytes
124
+ * that arrived has run. A thousandfold is what deflate does to a run of one
125
+ * byte; the header saying "empty" was the only thing standing in front of it.
126
+ *
127
+ * So the declared size is never the only bound:
128
+ *
129
+ * - an entry declaring more than `budget` is refused unread;
130
+ * - a DEFLATED entry declaring ZERO with a stream of its own is inflated here,
131
+ * capped at a single byte. An empty file compressed with deflate is exactly
132
+ * this shape (a two-byte stream that inflates to nothing) and is read as the
133
+ * empty file it is; anything that inflates to a byte or more contradicts its
134
+ * own header and is refused;
135
+ * - every other entry is read by the archive reader, which caps the inflation
136
+ * at the declared size this function has just held against the budget, and
137
+ * the bytes that arrive are measured again.
138
+ *
139
+ * A read that throws — a failed checksum, a stream longer than it declared, an
140
+ * unknown method — is that entry's refusal, not the whole archive's: one bad
141
+ * member must not cost the caller the others.
142
+ */
143
+ export function readZipEntry(entry: ReadableZipEntry, budget: number): ZipEntryRead {
144
+ const { size: declared, compressedSize, method } = entry.header;
145
+ if (declared > budget) return { ok: false, reason: 'too_large' };
146
+ if (declared === 0 && method === ZIP_METHOD_DEFLATED && compressedSize > 0) {
147
+ let inflated: Buffer;
148
+ try {
149
+ inflated = inflateRawSync(entry.getCompressedData(), { maxOutputLength: 1 });
150
+ } catch (err) {
151
+ // Past the one-byte cap, or not a deflate stream at all: either way it
152
+ // is not the empty file its header says it is.
153
+ return { ok: false, reason: 'unreadable', detail: declaresEmptyButIsNot(err) };
154
+ }
155
+ if (inflated.byteLength > 0) return { ok: false, reason: 'unreadable', detail: declaresEmptyButIsNot() };
156
+ return { ok: true, data: inflated };
157
+ }
158
+ let data: Buffer;
159
+ try {
160
+ data = entry.getData();
161
+ } catch (err) {
162
+ return { ok: false, reason: 'unreadable', detail: err instanceof Error ? err.message : String(err) };
163
+ }
164
+ if (data.byteLength > budget) return { ok: false, reason: 'too_large' };
165
+ return { ok: true, data };
166
+ }
167
+
168
+ function declaresEmptyButIsNot(err?: unknown): string {
169
+ const outputLimit = (err as { code?: string } | undefined)?.code === 'ERR_BUFFER_TOO_LARGE';
170
+ return err === undefined || outputLimit
171
+ ? 'its header declares an empty file, but it holds content'
172
+ : `its header declares an empty file, and its content could not be read (${err instanceof Error ? err.message : String(err)})`;
173
+ }
@@ -0,0 +1,248 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import express from 'express';
3
+ import type { Server } from 'node:http';
4
+ import type { AddressInfo } from 'node:net';
5
+ import type { Request, Response } from 'express';
6
+ import {
7
+ alwaysWritable,
8
+ createWriteAccessRoutes,
9
+ createWriteGateMiddleware,
10
+ isAlwaysWritable,
11
+ READ_ONLY_CODE,
12
+ type IWriteAccess,
13
+ } from '../write-access.js';
14
+ import { createToolHandlerFactory } from '../../tool-helpers/tool-handler.js';
15
+ import type { ResolveToolContext } from '../../tool-helpers/tool-context.js';
16
+
17
+ const READ_ONLY_MESSAGE = 'This workspace has 5 people switched on and room for 3.';
18
+ const readOnly: IWriteAccess = { canWrite: async () => ({ ok: false, message: READ_ONLY_MESSAGE }) };
19
+
20
+ const servers: Server[] = [];
21
+ afterEach(async () => {
22
+ await Promise.all(servers.splice(0).map((s) => new Promise<void>((resolve) => s.close(() => resolve()))));
23
+ });
24
+
25
+ async function listen(app: express.Express): Promise<string> {
26
+ const server = app.listen(0);
27
+ servers.push(server);
28
+ await new Promise<void>((resolve) => server.once('listening', () => resolve()));
29
+ const { port } = server.address() as AddressInfo;
30
+ return `http://127.0.0.1:${port}`;
31
+ }
32
+
33
+ describe('isAlwaysWritable', () => {
34
+ it.each([
35
+ ['GET', '/api/workspace/w1/file'],
36
+ ['HEAD', '/api/workspace/w1/file'],
37
+ ['POST', '/health'],
38
+ ['POST', '/api/auth/login'],
39
+ ['POST', '/api/mcp'],
40
+ ['POST', '/api/mcp/external-api-keys'],
41
+ ['POST', '/api/agent/tools/write_file'],
42
+ ['POST', '/api/admin/accounts'],
43
+ ['POST', '/api/admin/accounts/u1/deactivate'],
44
+ ['DELETE', '/api/admin/accounts/u1'],
45
+ ['POST', '/api/sync'],
46
+ ['POST', '/api/setup/settings'],
47
+ ['POST', '/api/workspace/w1/access/batch'],
48
+ ['POST', '/api/workspace/w1/workflow/locks/heartbeat'],
49
+ ['POST', '/api/events/s1/focus'],
50
+ ])('lets %s %s through', (method, path) => {
51
+ expect(isAlwaysWritable(method, path)).toBe(true);
52
+ });
53
+
54
+ it.each([
55
+ ['PUT', '/api/workspace/w1/file'],
56
+ ['DELETE', '/api/workspace/w1/file'],
57
+ ['POST', '/api/workspace/w1/workflow/changes'],
58
+ ['POST', '/api/workflow/change-requests/7/merge'],
59
+ ['POST', '/api/workspace/w1/workflow/locks'],
60
+ ['POST', '/api/plugins'],
61
+ ['POST', '/api/admin/groups'],
62
+ // A look-alike of an allowed path is not that path.
63
+ ['POST', '/api/admin/accountsx'],
64
+ ['POST', '/api/workspace/w1/access/batch/extra'],
65
+ // A route nobody listed is refused by default.
66
+ ['POST', '/api/some-new-route'],
67
+ // The API root is inside the gated namespace too.
68
+ ['POST', '/api'],
69
+ ])('holds %s %s for the port', (method, path) => {
70
+ expect(isAlwaysWritable(method, path)).toBe(false);
71
+ });
72
+
73
+ it("lets the host's own paths through", () => {
74
+ expect(isAlwaysWritable('PUT', '/api/cloud/workspace/billing/seats', ['/api/cloud/workspace/billing'])).toBe(true);
75
+ expect(isAlwaysWritable('PUT', '/api/cloud/workspace/sign-in/domains', ['/api/cloud/workspace/billing'])).toBe(false);
76
+ });
77
+ });
78
+
79
+ describe('createWriteGateMiddleware', () => {
80
+ function appWith(writeAccess: IWriteAccess) {
81
+ const app = express();
82
+ app.use(createWriteGateMiddleware(writeAccess));
83
+ app.all('/{*any}', (_req, res) => {
84
+ res.json({ reached: true });
85
+ });
86
+ return app;
87
+ }
88
+
89
+ it("refuses a change with the port's words and a code the app recognises", async () => {
90
+ const base = await listen(appWith(readOnly));
91
+ const res = await fetch(`${base}/api/workspace/w1/file`, { method: 'PUT' });
92
+ expect(res.status).toBe(403);
93
+ expect(await res.json()).toEqual({ error: READ_ONLY_MESSAGE, code: READ_ONLY_CODE });
94
+ });
95
+
96
+ /**
97
+ * The router matches a path whatever its letter case, so the gate has to
98
+ * read it the same way: here the route is mounted as the server mounts
99
+ * its own, under `/api`, and a spelling the gate did not recognise as
100
+ * that namespace would reach it.
101
+ */
102
+ it('refuses a change however the path is cased, since the router matches it either way', async () => {
103
+ const app = express();
104
+ app.use(createWriteGateMiddleware(readOnly));
105
+ const routes = express.Router();
106
+ const wrote = vi.fn();
107
+ routes.put('/workspace/:id/file', (_req, res) => {
108
+ wrote();
109
+ res.json({ wrote: true });
110
+ });
111
+ app.use('/api', routes);
112
+ const base = await listen(app);
113
+ for (const path of ['/api/workspace/w1/file', '/API/workspace/w1/file', '/Api/Workspace/w1/File']) {
114
+ const res = await fetch(`${base}${path}`, { method: 'PUT' });
115
+ expect({ path, status: res.status }).toEqual({ path, status: 403 });
116
+ }
117
+ expect(wrote).not.toHaveBeenCalled();
118
+ });
119
+
120
+ it('keeps an open route open however it is cased', async () => {
121
+ const base = await listen(appWith(readOnly));
122
+ expect((await fetch(`${base}/API/Auth/login`, { method: 'POST' })).status).toBe(200);
123
+ });
124
+
125
+ it('still serves reads, and the routes that sign people in and manage accounts', async () => {
126
+ const base = await listen(appWith(readOnly));
127
+ expect((await fetch(`${base}/api/workspace/w1/file`)).status).toBe(200);
128
+ expect((await fetch(`${base}/api/auth/login`, { method: 'POST' })).status).toBe(200);
129
+ expect((await fetch(`${base}/api/admin/accounts/u1/deactivate`, { method: 'POST' })).status).toBe(200);
130
+ });
131
+
132
+ it('does not ask the port about a request it lets through anyway', async () => {
133
+ const canWrite = vi.fn(readOnly.canWrite);
134
+ const base = await listen(appWith({ canWrite }));
135
+ await fetch(`${base}/api/workspace/w1/file`);
136
+ await fetch(`${base}/api/auth/login`, { method: 'POST' });
137
+ expect(canWrite).not.toHaveBeenCalled();
138
+ });
139
+
140
+ it('lets a change through while the port says yes', async () => {
141
+ const base = await listen(appWith({ canWrite: async () => ({ ok: true }) }));
142
+ expect((await fetch(`${base}/api/workspace/w1/file`, { method: 'PUT' })).status).toBe(200);
143
+ });
144
+
145
+ it('lets a change through when the port itself fails, rather than freezing the workspace', async () => {
146
+ const base = await listen(appWith({ canWrite: async () => Promise.reject(new Error('billing down')) }));
147
+ expect((await fetch(`${base}/api/workspace/w1/file`, { method: 'PUT' })).status).toBe(200);
148
+ });
149
+
150
+ it('is a no-op on core, which is always writable', async () => {
151
+ const base = await listen(appWith(alwaysWritable));
152
+ expect((await fetch(`${base}/api/workspace/w1/file`, { method: 'PUT' })).status).toBe(200);
153
+ });
154
+ });
155
+
156
+ describe('the tool layer', () => {
157
+ /**
158
+ * One tool, mounted the way the server mounts it and called over HTTP: a
159
+ * parsed body, then an auth step that does not wait for anything, then the
160
+ * handler. A request closes once its body has been read, so only a real
161
+ * request shows what the handler makes of that.
162
+ */
163
+ async function mounted(writeAccess: IWriteAccess, write: boolean) {
164
+ const resolve = (async () => ({})) as unknown as ResolveToolContext;
165
+ const handler = vi.fn(async () => ({ done: true }));
166
+ const closed = { count: 0 };
167
+ const app = express();
168
+ app.use(express.json());
169
+ app.use((req: Request, res: Response, next) => {
170
+ req.toolAuth = { source: 'external', userId: 'u1', scope: 'write' } as Request['toolAuth'];
171
+ res.on('close', () => {
172
+ closed.count += 1;
173
+ });
174
+ next();
175
+ });
176
+ app.post('/api/agent/tools/a_tool', createToolHandlerFactory(resolve, writeAccess)(handler as never, { write }));
177
+ const url = `${await listen(app)}/api/agent/tools/a_tool`;
178
+ const post = (signal?: AbortSignal) =>
179
+ fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}', signal });
180
+ return { post, handler, closed };
181
+ }
182
+
183
+ it('refuses a write tool while the deployment is read-only', async () => {
184
+ const { post, handler } = await mounted(readOnly, true);
185
+ const res = await post();
186
+ expect(res.status).toBe(403);
187
+ expect(await res.json()).toEqual({ error: READ_ONLY_MESSAGE, code: READ_ONLY_CODE });
188
+ expect(handler).not.toHaveBeenCalled();
189
+ });
190
+
191
+ /**
192
+ * The verdict is awaited, and by then the request has closed: its body was
193
+ * read. Taken for the client leaving, that left every write tool on a
194
+ * deployment with a port unanswered, for as long as the caller would wait.
195
+ */
196
+ it('answers a write tool the port allows, with the client still there', async () => {
197
+ const { post, handler } = await mounted({ canWrite: async () => ({ ok: true }) }, true);
198
+ const res = await post();
199
+ expect(res.status).toBe(200);
200
+ expect(await res.json()).toEqual({ done: true });
201
+ expect(handler).toHaveBeenCalledTimes(1);
202
+ });
203
+
204
+ it('does not run a write tool whose client left while the verdict was awaited', async () => {
205
+ const asked = { count: 0 };
206
+ let allow: () => void = () => undefined;
207
+ const { post, handler, closed } = await mounted(
208
+ {
209
+ canWrite: () => {
210
+ asked.count += 1;
211
+ return new Promise((resolve) => {
212
+ allow = () => resolve({ ok: true });
213
+ });
214
+ },
215
+ },
216
+ true,
217
+ );
218
+ const leaving = new AbortController();
219
+ const call = post(leaving.signal).catch(() => undefined);
220
+ await vi.waitFor(() => expect(asked.count).toBe(1));
221
+ leaving.abort();
222
+ await call;
223
+ await vi.waitFor(() => expect(closed.count).toBe(1));
224
+ allow();
225
+ await new Promise((resolve) => setTimeout(resolve, 20));
226
+ expect(handler).not.toHaveBeenCalled();
227
+ });
228
+
229
+ it('still runs a read tool', async () => {
230
+ const { post, handler } = await mounted(readOnly, false);
231
+ expect((await post()).status).toBe(200);
232
+ expect(handler).toHaveBeenCalledTimes(1);
233
+ });
234
+ });
235
+
236
+ describe('GET /write-access', () => {
237
+ it("says whether the deployment can be changed, and the port's words when not", async () => {
238
+ const app = express();
239
+ app.use('/api', createWriteAccessRoutes(readOnly));
240
+ const base = await listen(app);
241
+ expect(await (await fetch(`${base}/api/write-access`)).json()).toEqual({ writable: false, message: READ_ONLY_MESSAGE });
242
+
243
+ const open = express();
244
+ open.use('/api', createWriteAccessRoutes(alwaysWritable));
245
+ const openBase = await listen(open);
246
+ expect(await (await fetch(`${openBase}/api/write-access`)).json()).toEqual({ writable: true });
247
+ });
248
+ });
@@ -0,0 +1,153 @@
1
+ import express, { type RequestHandler } from 'express';
2
+ import { logger } from '../../shared/logging.js';
3
+
4
+ const log = logger('write-access');
5
+
6
+ /**
7
+ * Whether the deployment may be CHANGED right now. The seam a host that
8
+ * sells seats needs for a workspace that has more people on it than its plan
9
+ * allows: nobody is removed and everyone can still sign in and read, but
10
+ * nothing can be edited until an admin brings the count down or the plan up
11
+ * — the way Notion handles a lapsed workspace.
12
+ *
13
+ * Core is always writable. The port is asked on every request that would
14
+ * change something (a mutating HTTP route, a write tool), so a host answers
15
+ * from something cheap — its own cached view of the plan.
16
+ */
17
+ export type WriteAccessVerdict = { ok: true } | { ok: false; message: string };
18
+
19
+ export interface IWriteAccess {
20
+ canWrite(): Promise<WriteAccessVerdict>;
21
+ /**
22
+ * Paths of the host's own routes that must stay usable while the
23
+ * deployment is read-only — above all, the ones that END it (buying
24
+ * seats, the billing portal). Matched as prefixes of the request path.
25
+ */
26
+ readonly alwaysWritablePaths?: readonly string[];
27
+ }
28
+
29
+ /** Core's default: always writable. */
30
+ export const alwaysWritable: IWriteAccess = {
31
+ canWrite: async () => ({ ok: true }),
32
+ };
33
+
34
+ /** The `code` a refused write answers with, so a client can tell it from a permission error. */
35
+ export const READ_ONLY_CODE = 'workspace_read_only';
36
+
37
+ const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
38
+
39
+ /**
40
+ * The mutating routes that stay open while the deployment is read-only.
41
+ * EVERY OTHER mutating route under `/api` is refused, so a route added
42
+ * later is read-only until someone decides otherwise — the safe default for
43
+ * a list nobody re-reads when adding a route.
44
+ *
45
+ * What stays open, and why:
46
+ * - Signing in and out of everything: sessions, agent connections and
47
+ * their keys. A read-only workspace is still one people read.
48
+ * - Account administration: the way an admin brings the number of people
49
+ * down. Erasing an account with `removeFromAccess` commits to the
50
+ * knowledge base, and that commit is part of the same act.
51
+ * - The tool surface: every tool call is judged by the tool layer itself,
52
+ * which knows a read tool from a write tool (see `toolHandler`); the MCP
53
+ * endpoint only relays to it.
54
+ * - Deployment setup, tool secrets and pulling remote changes in: the
55
+ * deployment's configuration, not its content, and what an admin needs
56
+ * to keep it working.
57
+ * - Requests that are POST only because they carry a body: permission
58
+ * queries, previews, probes, focus and heartbeat signals, fetching
59
+ * remotes, accepting a review baseline. None of them changes content.
60
+ */
61
+ const ALWAYS_WRITABLE: readonly RegExp[] = [
62
+ /^\/api\/auth\//,
63
+ /^\/api\/mcp(\/|$)/,
64
+ /^\/api\/agent\//,
65
+ /^\/api\/admin\/accounts(\/|$)/,
66
+ /^\/api\/admin\/connection-keys\//,
67
+ /^\/api\/audit\//,
68
+ /^\/api\/sync(\/|$)/,
69
+ /^\/api\/setup\//,
70
+ /^\/api\/secrets\//,
71
+ /^\/api\/admin\/github-facade\//,
72
+ /^\/api\/workspace\/[^/]+\/access\/batch$/,
73
+ /^\/api\/workspace\/[^/]+\/review\/accept$/,
74
+ /^\/api\/workspace\/[^/]+\/flush$/,
75
+ /^\/api\/workspace\/[^/]+\/workflow\/refresh-remotes$/,
76
+ /^\/api\/workspace\/[^/]+\/workflow\/locks\/heartbeat$/,
77
+ /^\/api\/events\/[^/]+\/focus$/,
78
+ /^\/api\/tools\/preview$/,
79
+ ];
80
+
81
+ /**
82
+ * Whether a request may go ahead while the deployment is read-only, without asking.
83
+ *
84
+ * Judged on the path in LOWER CASE, because that is how the router reads
85
+ * it: routes match whatever the letter case, so `/API/workspace/…` reaches
86
+ * the same handler as `/api/workspace/…`. Judged as written, it would be
87
+ * outside the gated namespace and walk past the gate.
88
+ */
89
+ export function isAlwaysWritable(method: string, path: string, hostPaths: readonly string[] = []): boolean {
90
+ if (!MUTATING_METHODS.has(method.toUpperCase())) return true;
91
+ const asRouted = path.toLowerCase();
92
+ if (asRouted !== '/api' && !asRouted.startsWith('/api/')) return true;
93
+ return (
94
+ ALWAYS_WRITABLE.some((rule) => rule.test(asRouted)) ||
95
+ hostPaths.some((prefix) => asRouted.startsWith(prefix.toLowerCase()))
96
+ );
97
+ }
98
+
99
+ /**
100
+ * Refuse every change to the deployment while the port says it is
101
+ * read-only, with a 403 the app recognises by {@link READ_ONLY_CODE}.
102
+ * Mounted ahead of every route under `/api`; a request the port is not
103
+ * asked about costs nothing. A port that fails is treated as writable —
104
+ * a billing lookup that is down must not freeze every workspace.
105
+ */
106
+ export function createWriteGateMiddleware(writeAccess: IWriteAccess): RequestHandler {
107
+ if (writeAccess === alwaysWritable) return (_req, _res, next) => next();
108
+ return async (req, res, next) => {
109
+ if (isAlwaysWritable(req.method, req.path, writeAccess.alwaysWritablePaths)) return next();
110
+ let verdict: WriteAccessVerdict;
111
+ try {
112
+ verdict = await writeAccess.canWrite();
113
+ } catch (err) {
114
+ log.warn('the write-access port failed; letting the request through:', { err });
115
+ return next();
116
+ }
117
+ if (verdict.ok) return next();
118
+ res.status(403).json({ error: verdict.message, code: READ_ONLY_CODE });
119
+ };
120
+ }
121
+
122
+ /**
123
+ * The verdict for a write tool, the tool layer's half of the gate: `null`
124
+ * when it may run, the refusal otherwise. Same failure rule as the HTTP gate.
125
+ */
126
+ export async function refuseWriteTool(writeAccess: IWriteAccess): Promise<string | null> {
127
+ if (writeAccess === alwaysWritable) return null;
128
+ try {
129
+ const verdict = await writeAccess.canWrite();
130
+ return verdict.ok ? null : verdict.message;
131
+ } catch (err) {
132
+ log.warn('the write-access port failed; letting the tool run:', { err });
133
+ return null;
134
+ }
135
+ }
136
+
137
+ /**
138
+ * GET /api/write-access — whether the deployment can be changed right now,
139
+ * and if not, the host's words for why. The app reads it to say so once, in
140
+ * a banner, instead of letting every save fail on its own.
141
+ */
142
+ export function createWriteAccessRoutes(writeAccess: IWriteAccess): express.Router {
143
+ const router = express.Router();
144
+ router.get('/write-access', async (_req, res) => {
145
+ try {
146
+ const verdict = await writeAccess.canWrite();
147
+ res.json(verdict.ok ? { writable: true } : { writable: false, message: verdict.message });
148
+ } catch {
149
+ res.json({ writable: true });
150
+ }
151
+ });
152
+ return router;
153
+ }