@bevel-software/platform-core-backend 0.21.0 → 0.23.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 (434) 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 +62 -13
  7. package/dist/core/create-core-server.js.map +1 -1
  8. package/dist/core/create-core-services.d.ts +22 -3
  9. package/dist/core/create-core-services.d.ts.map +1 -1
  10. package/dist/core/create-core-services.js +158 -23
  11. package/dist/core/create-core-services.js.map +1 -1
  12. package/dist/core/lifecycle.d.ts +34 -1
  13. package/dist/core/lifecycle.d.ts.map +1 -1
  14. package/dist/core/lifecycle.js +89 -13
  15. package/dist/core/lifecycle.js.map +1 -1
  16. package/dist/core-config.d.ts +0 -12
  17. package/dist/core-config.d.ts.map +1 -1
  18. package/dist/core-config.js +11 -13
  19. package/dist/core-config.js.map +1 -1
  20. package/dist/index.d.ts +3 -2
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +2 -1
  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-requests.contract.d.ts +75 -0
  31. package/dist/modules/access/access-requests.contract.d.ts.map +1 -0
  32. package/dist/modules/access/access-requests.contract.js +20 -0
  33. package/dist/modules/access/access-requests.contract.js.map +1 -0
  34. package/dist/modules/access/access-requests.routes.d.ts +35 -0
  35. package/dist/modules/access/access-requests.routes.d.ts.map +1 -0
  36. package/dist/modules/access/access-requests.routes.js +237 -0
  37. package/dist/modules/access/access-requests.routes.js.map +1 -0
  38. package/dist/modules/access/access-requests.service.d.ts +123 -0
  39. package/dist/modules/access/access-requests.service.d.ts.map +1 -0
  40. package/dist/modules/access/access-requests.service.js +337 -0
  41. package/dist/modules/access/access-requests.service.js.map +1 -0
  42. package/dist/modules/access/access.routes.d.ts.map +1 -1
  43. package/dist/modules/access/access.routes.js +13 -15
  44. package/dist/modules/access/access.routes.js.map +1 -1
  45. package/dist/modules/access-model/access-grammar.d.ts +12 -0
  46. package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
  47. package/dist/modules/access-model/access-grammar.js +24 -0
  48. package/dist/modules/access-model/access-grammar.js.map +1 -1
  49. package/dist/modules/auth/account-admission.d.ts +58 -7
  50. package/dist/modules/auth/account-admission.d.ts.map +1 -1
  51. package/dist/modules/auth/account-admission.js +44 -1
  52. package/dist/modules/auth/account-admission.js.map +1 -1
  53. package/dist/modules/auth/account.routes.d.ts +1 -1
  54. package/dist/modules/auth/account.routes.d.ts.map +1 -1
  55. package/dist/modules/auth/account.routes.js +61 -5
  56. package/dist/modules/auth/account.routes.js.map +1 -1
  57. package/dist/modules/auth/auth.middleware.d.ts +1 -1
  58. package/dist/modules/auth/auth.middleware.d.ts.map +1 -1
  59. package/dist/modules/auth/auth.middleware.js +18 -7
  60. package/dist/modules/auth/auth.middleware.js.map +1 -1
  61. package/dist/modules/auth/auth.routes.d.ts.map +1 -1
  62. package/dist/modules/auth/auth.routes.js +8 -0
  63. package/dist/modules/auth/auth.routes.js.map +1 -1
  64. package/dist/modules/auth/auth.service.d.ts +96 -3
  65. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  66. package/dist/modules/auth/auth.service.js +187 -9
  67. package/dist/modules/auth/auth.service.js.map +1 -1
  68. package/dist/modules/auth/oidc-auth-provider.d.ts.map +1 -1
  69. package/dist/modules/auth/oidc-auth-provider.js +7 -2
  70. package/dist/modules/auth/oidc-auth-provider.js.map +1 -1
  71. package/dist/modules/database/core-schema.d.ts +41 -24
  72. package/dist/modules/database/core-schema.d.ts.map +1 -1
  73. package/dist/modules/database/core-schema.js +35 -27
  74. package/dist/modules/database/core-schema.js.map +1 -1
  75. package/dist/modules/github-app/github-app.client.d.ts +101 -0
  76. package/dist/modules/github-app/github-app.client.d.ts.map +1 -0
  77. package/dist/modules/github-app/github-app.client.js +222 -0
  78. package/dist/modules/github-app/github-app.client.js.map +1 -0
  79. package/dist/modules/github-app/github-app.connection.d.ts +102 -0
  80. package/dist/modules/github-app/github-app.connection.d.ts.map +1 -0
  81. package/dist/modules/github-app/github-app.connection.js +185 -0
  82. package/dist/modules/github-app/github-app.connection.js.map +1 -0
  83. package/dist/modules/github-app/github-app.routes.d.ts +59 -0
  84. package/dist/modules/github-app/github-app.routes.d.ts.map +1 -0
  85. package/dist/modules/github-app/github-app.routes.js +323 -0
  86. package/dist/modules/github-app/github-app.routes.js.map +1 -0
  87. package/dist/modules/github-app/index.d.ts +4 -0
  88. package/dist/modules/github-app/index.d.ts.map +1 -0
  89. package/dist/modules/github-app/index.js +4 -0
  90. package/dist/modules/github-app/index.js.map +1 -0
  91. package/dist/modules/kb-fs/branch-name.d.ts.map +1 -1
  92. package/dist/modules/kb-fs/branch-name.js +12 -2
  93. package/dist/modules/kb-fs/branch-name.js.map +1 -1
  94. package/dist/modules/kb-fs/remote-url.d.ts +22 -0
  95. package/dist/modules/kb-fs/remote-url.d.ts.map +1 -0
  96. package/dist/modules/kb-fs/remote-url.js +35 -0
  97. package/dist/modules/kb-fs/remote-url.js.map +1 -0
  98. package/dist/modules/kb-fs/repo-path.d.ts +11 -16
  99. package/dist/modules/kb-fs/repo-path.d.ts.map +1 -1
  100. package/dist/modules/kb-fs/repo-path.js +79 -0
  101. package/dist/modules/kb-fs/repo-path.js.map +1 -1
  102. package/dist/modules/kb-sync/kb-sync.routes.d.ts +2 -2
  103. package/dist/modules/kb-sync/kb-sync.routes.d.ts.map +1 -1
  104. package/dist/modules/kb-sync/kb-sync.routes.js +14 -3
  105. package/dist/modules/kb-sync/kb-sync.routes.js.map +1 -1
  106. package/dist/modules/kb-sync/sync-auth.d.ts +3 -1
  107. package/dist/modules/kb-sync/sync-auth.d.ts.map +1 -1
  108. package/dist/modules/kb-sync/sync-auth.js +1 -1
  109. package/dist/modules/kb-sync/sync-auth.js.map +1 -1
  110. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  111. package/dist/modules/mcp/mcp-auth.middleware.js +21 -6
  112. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  113. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
  114. package/dist/modules/mcp/oauth/bevel-oauth-provider.js +3 -2
  115. package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
  116. package/dist/modules/plugins/join-proposals.d.ts +17 -5
  117. package/dist/modules/plugins/join-proposals.d.ts.map +1 -1
  118. package/dist/modules/plugins/join-proposals.js +76 -22
  119. package/dist/modules/plugins/join-proposals.js.map +1 -1
  120. package/dist/modules/plugins/join-requests.service.d.ts +111 -18
  121. package/dist/modules/plugins/join-requests.service.d.ts.map +1 -1
  122. package/dist/modules/plugins/join-requests.service.js +173 -29
  123. package/dist/modules/plugins/join-requests.service.js.map +1 -1
  124. package/dist/modules/plugins/plugins.routes.d.ts.map +1 -1
  125. package/dist/modules/plugins/plugins.routes.js +3 -2
  126. package/dist/modules/plugins/plugins.routes.js.map +1 -1
  127. package/dist/modules/settings/deployment-settings.service.d.ts +22 -1
  128. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
  129. package/dist/modules/settings/deployment-settings.service.js +101 -7
  130. package/dist/modules/settings/deployment-settings.service.js.map +1 -1
  131. package/dist/modules/settings/managed-repository.d.ts +43 -0
  132. package/dist/modules/settings/managed-repository.d.ts.map +1 -0
  133. package/dist/modules/settings/managed-repository.js +60 -0
  134. package/dist/modules/settings/managed-repository.js.map +1 -0
  135. package/dist/modules/settings/repository-source.d.ts +128 -0
  136. package/dist/modules/settings/repository-source.d.ts.map +1 -0
  137. package/dist/modules/settings/repository-source.js +150 -0
  138. package/dist/modules/settings/repository-source.js.map +1 -0
  139. package/dist/modules/settings/setup.routes.d.ts +78 -4
  140. package/dist/modules/settings/setup.routes.d.ts.map +1 -1
  141. package/dist/modules/settings/setup.routes.js +379 -17
  142. package/dist/modules/settings/setup.routes.js.map +1 -1
  143. package/dist/modules/skills/skill-access-requests.routes.d.ts +6 -8
  144. package/dist/modules/skills/skill-access-requests.routes.d.ts.map +1 -1
  145. package/dist/modules/skills/skill-access-requests.routes.js +24 -63
  146. package/dist/modules/skills/skill-access-requests.routes.js.map +1 -1
  147. package/dist/modules/skills/skills.contract.d.ts +90 -18
  148. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  149. package/dist/modules/skills/skills.contract.js +4 -1
  150. package/dist/modules/skills/skills.contract.js.map +1 -1
  151. package/dist/modules/skills/skills.service.d.ts +98 -9
  152. package/dist/modules/skills/skills.service.d.ts.map +1 -1
  153. package/dist/modules/skills/skills.service.js +239 -37
  154. package/dist/modules/skills/skills.service.js.map +1 -1
  155. package/dist/modules/skills/skills.tools.d.ts +7 -0
  156. package/dist/modules/skills/skills.tools.d.ts.map +1 -1
  157. package/dist/modules/skills/skills.tools.js +71 -7
  158. package/dist/modules/skills/skills.tools.js.map +1 -1
  159. package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
  160. package/dist/modules/tool-auth/external-api-key.service.js +4 -2
  161. package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
  162. package/dist/modules/tool-auth/internal-token.service.d.ts +3 -3
  163. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +16 -7
  164. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  165. package/dist/modules/tool-auth/tool-auth.middleware.js +34 -12
  166. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  167. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  168. package/dist/modules/tool-helpers/tool-context.js +15 -0
  169. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  170. package/dist/modules/tool-helpers/tool-handler.d.ts +2 -1
  171. package/dist/modules/tool-helpers/tool-handler.d.ts.map +1 -1
  172. package/dist/modules/tool-helpers/tool-handler.js +25 -4
  173. package/dist/modules/tool-helpers/tool-handler.js.map +1 -1
  174. package/dist/modules/tool-helpers/tool.contract.d.ts +3 -2
  175. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
  176. package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
  177. package/dist/modules/tool-helpers/validate-token.js +1 -1
  178. package/dist/modules/tool-helpers/validate-token.js.map +1 -1
  179. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +98 -0
  180. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -0
  181. package/dist/modules/workflow/agent-tools/change-request-summary.js +81 -0
  182. package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -0
  183. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  184. package/dist/modules/workflow/agent-tools/workflow.tools.js +113 -37
  185. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  186. package/dist/modules/workflow/file-lock.service.d.ts +24 -0
  187. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  188. package/dist/modules/workflow/file-lock.service.js +30 -0
  189. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  190. package/dist/modules/workflow/git/git.service.d.ts +72 -1
  191. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  192. package/dist/modules/workflow/git/git.service.js +170 -6
  193. package/dist/modules/workflow/git/git.service.js.map +1 -1
  194. package/dist/modules/workflow/git/node-git-runner.d.ts.map +1 -1
  195. package/dist/modules/workflow/git/node-git-runner.js +5 -0
  196. package/dist/modules/workflow/git/node-git-runner.js.map +1 -1
  197. package/dist/modules/workflow/git/pull-request.service.d.ts +45 -0
  198. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  199. package/dist/modules/workflow/git/pull-request.service.js +99 -0
  200. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  201. package/dist/modules/workflow/pending-commits.service.d.ts +38 -0
  202. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  203. package/dist/modules/workflow/pending-commits.service.js +55 -0
  204. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  205. package/dist/modules/workflow/workflow-hooks.d.ts +54 -32
  206. package/dist/modules/workflow/workflow-hooks.d.ts.map +1 -1
  207. package/dist/modules/workflow/workflow-hooks.js +16 -1
  208. package/dist/modules/workflow/workflow-hooks.js.map +1 -1
  209. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  210. package/dist/modules/workflow/workflow.routes.js +3 -1
  211. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  212. package/dist/modules/workflow/workflow.service.d.ts +94 -13
  213. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  214. package/dist/modules/workflow/workflow.service.js +258 -51
  215. package/dist/modules/workflow/workflow.service.js.map +1 -1
  216. package/dist/modules/workspace/agent-access.gate.d.ts +94 -0
  217. package/dist/modules/workspace/agent-access.gate.d.ts.map +1 -0
  218. package/dist/modules/workspace/agent-access.gate.js +123 -0
  219. package/dist/modules/workspace/agent-access.gate.js.map +1 -0
  220. package/dist/modules/workspace/routine-write-policy.d.ts +5 -6
  221. package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
  222. package/dist/modules/workspace/routine-write-policy.js +5 -6
  223. package/dist/modules/workspace/routine-write-policy.js.map +1 -1
  224. package/dist/modules/workspace/session-sink.d.ts +5 -5
  225. package/dist/modules/workspace/set-aside-clone.d.ts +46 -0
  226. package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -0
  227. package/dist/modules/workspace/set-aside-clone.js +92 -0
  228. package/dist/modules/workspace/set-aside-clone.js.map +1 -0
  229. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +117 -1
  230. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  231. package/dist/modules/workspace/startup/kb-startup-runner.js +190 -1
  232. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  233. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  234. package/dist/modules/workspace/workspace.routes.js +92 -3
  235. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  236. package/dist/modules/workspace/workspace.service.d.ts +115 -5
  237. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  238. package/dist/modules/workspace/workspace.service.js +272 -26
  239. package/dist/modules/workspace/workspace.service.js.map +1 -1
  240. package/dist/modules/workspace/workspace.tools.d.ts +2 -2
  241. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  242. package/dist/modules/workspace/workspace.tools.js +337 -117
  243. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  244. package/dist/modules/write-access/write-access.d.ts +60 -0
  245. package/dist/modules/write-access/write-access.d.ts.map +1 -0
  246. package/dist/modules/write-access/write-access.js +129 -0
  247. package/dist/modules/write-access/write-access.js.map +1 -0
  248. package/dist/shared/domain-errors.d.ts +64 -0
  249. package/dist/shared/domain-errors.d.ts.map +1 -1
  250. package/dist/shared/domain-errors.js +83 -0
  251. package/dist/shared/domain-errors.js.map +1 -1
  252. package/dist/shared/git.contract.d.ts +30 -0
  253. package/dist/shared/git.contract.d.ts.map +1 -1
  254. package/dist/shared/git.contract.js +26 -0
  255. package/dist/shared/git.contract.js.map +1 -1
  256. package/dist/tenancy/static-tenant-source.d.ts +0 -1
  257. package/dist/tenancy/static-tenant-source.d.ts.map +1 -1
  258. package/dist/tenancy/static-tenant-source.js +0 -2
  259. package/dist/tenancy/static-tenant-source.js.map +1 -1
  260. package/migrations/0014_change_request_closed_reason.sql +1 -0
  261. package/migrations/0015_account_deactivation.sql +3 -0
  262. package/migrations/meta/0014_snapshot.json +2265 -0
  263. package/migrations/meta/0015_snapshot.json +2271 -0
  264. package/migrations/meta/_journal.json +14 -0
  265. package/package.json +3 -3
  266. package/src/__tests__/kb-layout-config.test.ts +0 -2
  267. package/src/__tests__/retired-settings.test.ts +96 -0
  268. package/src/core/__tests__/gated-boot.test.ts +132 -0
  269. package/src/core/__tests__/lifecycle.test.ts +170 -1
  270. package/src/core/__tests__/set-aside-root-is-one-place.test.ts +63 -0
  271. package/src/core/core-ports.ts +11 -1
  272. package/src/core/create-core-server.ts +69 -15
  273. package/src/core/create-core-services.ts +190 -27
  274. package/src/core/lifecycle.ts +98 -13
  275. package/src/core-config.ts +13 -14
  276. package/src/index.ts +12 -1
  277. package/src/modules/access/__tests__/access-control.preview-relocation.test.ts +385 -0
  278. package/src/modules/access/__tests__/access-control.prospective.test.ts +94 -18
  279. package/src/modules/access/__tests__/access-requests.recut.test.ts +134 -0
  280. package/src/modules/access/__tests__/access-requests.routes.test.ts +610 -0
  281. package/src/modules/access/__tests__/access.routes.prospective.test.ts +6 -7
  282. package/src/modules/access/access-control.interface.ts +43 -12
  283. package/src/modules/access/access-control.service.ts +234 -36
  284. package/src/modules/access/access-requests.contract.ts +93 -0
  285. package/src/modules/access/access-requests.routes.ts +286 -0
  286. package/src/modules/access/access-requests.service.ts +420 -0
  287. package/src/modules/access/access.routes.ts +13 -15
  288. package/src/modules/access-model/access-grammar.ts +22 -0
  289. package/src/modules/agent-instructions/__tests__/agent-instructions.route.test.ts +3 -1
  290. package/src/modules/auth/__tests__/account-deactivation.test.ts +252 -0
  291. package/src/modules/auth/__tests__/account.routes.test.ts +81 -3
  292. package/src/modules/auth/__tests__/auth.middleware.test.ts +45 -22
  293. package/src/modules/auth/__tests__/auth.service.test.ts +39 -1
  294. package/src/modules/auth/account-admission.ts +78 -9
  295. package/src/modules/auth/account.routes.ts +63 -7
  296. package/src/modules/auth/auth.middleware.ts +19 -8
  297. package/src/modules/auth/auth.routes.ts +8 -0
  298. package/src/modules/auth/auth.service.ts +207 -7
  299. package/src/modules/auth/oidc-auth-provider.ts +7 -2
  300. package/src/modules/database/core-schema.ts +35 -27
  301. package/src/modules/github-app/__tests__/github-app.test.ts +848 -0
  302. package/src/modules/github-app/github-app.client.ts +268 -0
  303. package/src/modules/github-app/github-app.connection.ts +204 -0
  304. package/src/modules/github-app/github-app.routes.ts +359 -0
  305. package/src/modules/github-app/index.ts +19 -0
  306. package/src/modules/kb-fs/__tests__/branch-name.test.ts +10 -0
  307. package/src/modules/kb-fs/__tests__/remote-url.test.ts +38 -0
  308. package/src/modules/kb-fs/__tests__/repo-path.test.ts +123 -0
  309. package/src/modules/kb-fs/branch-name.ts +14 -1
  310. package/src/modules/kb-fs/remote-url.ts +35 -0
  311. package/src/modules/kb-fs/repo-path.ts +85 -0
  312. package/src/modules/kb-sync/__tests__/kb-sync.routes.test.ts +26 -1
  313. package/src/modules/kb-sync/kb-sync.routes.ts +14 -4
  314. package/src/modules/kb-sync/sync-auth.ts +2 -2
  315. package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +60 -3
  316. package/src/modules/mcp/__tests__/mcp.service.test.ts +68 -6
  317. package/src/modules/mcp/mcp-auth.middleware.ts +20 -6
  318. package/src/modules/mcp/oauth/bevel-oauth-provider.ts +3 -1
  319. package/src/modules/plugins/__tests__/join-proposals.test.ts +100 -19
  320. package/src/modules/plugins/__tests__/join-requests.service.test.ts +27 -11
  321. package/src/modules/plugins/__tests__/join-requests.settlement.test.ts +371 -0
  322. package/src/modules/plugins/__tests__/plugins.routes.test.ts +1 -1
  323. package/src/modules/plugins/__tests__/plugins.tools.test.ts +2 -1
  324. package/src/modules/plugins/join-proposals.ts +87 -19
  325. package/src/modules/plugins/join-requests.service.ts +199 -39
  326. package/src/modules/plugins/plugins.routes.ts +3 -2
  327. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +16 -0
  328. package/src/modules/settings/__tests__/repository-source.test.ts +191 -0
  329. package/src/modules/settings/__tests__/setup.routes.git-mode.test.ts +386 -0
  330. package/src/modules/settings/__tests__/setup.routes.github-app.test.ts +299 -0
  331. package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +236 -0
  332. package/src/modules/settings/__tests__/setup.routes.repository-change.test.ts +390 -0
  333. package/src/modules/settings/__tests__/setup.routes.test.ts +3 -0
  334. package/src/modules/settings/deployment-settings.service.ts +117 -6
  335. package/src/modules/settings/managed-repository.ts +68 -0
  336. package/src/modules/settings/repository-source.ts +197 -0
  337. package/src/modules/settings/setup.routes.ts +463 -15
  338. package/src/modules/skills/__tests__/allowed-tools-warn.tools.test.ts +2 -1
  339. package/src/modules/skills/__tests__/branch-skills.tools.test.ts +218 -0
  340. package/src/modules/skills/__tests__/skill-access-requests.routes.test.ts +5 -1
  341. package/src/modules/skills/__tests__/skills.service.test.ts +229 -5
  342. package/src/modules/skills/skill-access-requests.routes.ts +25 -75
  343. package/src/modules/skills/skills.contract.ts +91 -18
  344. package/src/modules/skills/skills.service.ts +278 -41
  345. package/src/modules/skills/skills.tools.ts +80 -8
  346. package/src/modules/tool-auth/__tests__/manual-auth.middleware.test.ts +14 -1
  347. package/src/modules/tool-auth/external-api-key.service.ts +4 -2
  348. package/src/modules/tool-auth/internal-token.service.ts +3 -3
  349. package/src/modules/tool-auth/tool-auth.middleware.ts +34 -11
  350. package/src/modules/tool-helpers/__tests__/agent-roles-write.test.ts +5 -3
  351. package/src/modules/tool-helpers/__tests__/phase4-tools.test.ts +3 -3
  352. package/src/modules/tool-helpers/__tests__/validate-token.test.ts +11 -1
  353. package/src/modules/tool-helpers/tool-context.ts +15 -0
  354. package/src/modules/tool-helpers/tool-handler.ts +24 -4
  355. package/src/modules/tool-helpers/tool.contract.ts +3 -2
  356. package/src/modules/tool-helpers/validate-token.ts +1 -1
  357. package/src/modules/workflow/__tests__/apply-failure.test.ts +9 -1
  358. package/src/modules/workflow/__tests__/pending-commits.repository-replaced.test.ts +111 -0
  359. package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +25 -0
  360. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +159 -8
  361. package/src/modules/workflow/__tests__/workflow.service.repository-replaced.test.ts +249 -0
  362. package/src/modules/workflow/__tests__/workflow.service.update-from-target.test.ts +146 -40
  363. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +405 -7
  364. package/src/modules/workflow/agent-tools/change-request-summary.ts +182 -0
  365. package/src/modules/workflow/agent-tools/workflow.tools.ts +135 -38
  366. package/src/modules/workflow/file-lock.service.ts +31 -0
  367. package/src/modules/workflow/git/__tests__/git.service.fileBytesAtCommit.test.ts +260 -0
  368. package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +121 -0
  369. package/src/modules/workflow/git/__tests__/pull-request.service.getPrDetail.test.ts +145 -2
  370. package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-delete.test.ts +166 -0
  371. package/src/modules/workflow/git/git.service.ts +193 -4
  372. package/src/modules/workflow/git/node-git-runner.ts +6 -0
  373. package/src/modules/workflow/git/pull-request.service.ts +119 -0
  374. package/src/modules/workflow/pending-commits.service.ts +59 -1
  375. package/src/modules/workflow/workflow-hooks.ts +64 -26
  376. package/src/modules/workflow/workflow.routes.ts +3 -1
  377. package/src/modules/workflow/workflow.service.ts +290 -55
  378. package/src/modules/workspace/__tests__/agent-access.gate.test.ts +208 -0
  379. package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +384 -0
  380. package/src/modules/workspace/__tests__/file-stat-access.test.ts +2 -1
  381. package/src/modules/workspace/__tests__/git-internals.security.test.ts +2 -1
  382. package/src/modules/workspace/__tests__/set-aside-clone.test.ts +52 -0
  383. package/src/modules/workspace/__tests__/workspace.routes.at-ref.test.ts +305 -0
  384. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +2 -0
  385. package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +147 -0
  386. package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +326 -0
  387. package/src/modules/workspace/__tests__/workspace.service.test.ts +20 -9
  388. package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +43 -0
  389. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +2 -3
  390. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +235 -10
  391. package/src/modules/workspace/__tests__/workspace.tools.test.ts +720 -17
  392. package/src/modules/workspace/agent-access.gate.ts +164 -0
  393. package/src/modules/workspace/routine-write-policy.ts +5 -6
  394. package/src/modules/workspace/session-sink.ts +5 -5
  395. package/src/modules/workspace/set-aside-clone.ts +96 -0
  396. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +297 -0
  397. package/src/modules/workspace/startup/kb-startup-runner.ts +242 -2
  398. package/src/modules/workspace/workspace.routes.ts +95 -3
  399. package/src/modules/workspace/workspace.service.ts +273 -25
  400. package/src/modules/workspace/workspace.tools.ts +373 -119
  401. package/src/modules/write-access/__tests__/write-access.test.ts +248 -0
  402. package/src/modules/write-access/write-access.ts +153 -0
  403. package/src/shared/domain-errors.ts +89 -0
  404. package/src/shared/git.contract.ts +37 -0
  405. package/src/tenancy/__tests__/static-tenant-source.test.ts +0 -1
  406. package/src/tenancy/static-tenant-source.ts +0 -3
  407. package/dist/modules/workflow/session-ontology.policy.d.ts +0 -52
  408. package/dist/modules/workflow/session-ontology.policy.d.ts.map +0 -1
  409. package/dist/modules/workflow/session-ontology.policy.js +0 -62
  410. package/dist/modules/workflow/session-ontology.policy.js.map +0 -1
  411. package/dist/modules/workflow/session-ontology.service.d.ts +0 -105
  412. package/dist/modules/workflow/session-ontology.service.d.ts.map +0 -1
  413. package/dist/modules/workflow/session-ontology.service.js +0 -147
  414. package/dist/modules/workflow/session-ontology.service.js.map +0 -1
  415. package/dist/modules/workspace/session-ontology.gate.d.ts +0 -114
  416. package/dist/modules/workspace/session-ontology.gate.d.ts.map +0 -1
  417. package/dist/modules/workspace/session-ontology.gate.js +0 -161
  418. package/dist/modules/workspace/session-ontology.gate.js.map +0 -1
  419. package/dist/shared/kb-layout.d.ts +0 -39
  420. package/dist/shared/kb-layout.d.ts.map +0 -1
  421. package/dist/shared/kb-layout.js +0 -103
  422. package/dist/shared/kb-layout.js.map +0 -1
  423. package/dist/shared/kb-layout.test.d.ts +0 -2
  424. package/dist/shared/kb-layout.test.d.ts.map +0 -1
  425. package/dist/shared/kb-layout.test.js +0 -75
  426. package/dist/shared/kb-layout.test.js.map +0 -1
  427. package/src/modules/workflow/__tests__/session-ontology.policy.test.ts +0 -62
  428. package/src/modules/workflow/__tests__/session-ontology.service.test.ts +0 -201
  429. package/src/modules/workflow/session-ontology.policy.ts +0 -70
  430. package/src/modules/workflow/session-ontology.service.ts +0 -183
  431. package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +0 -239
  432. package/src/modules/workspace/session-ontology.gate.ts +0 -191
  433. package/src/shared/kb-layout.test.ts +0 -98
  434. package/src/shared/kb-layout.ts +0 -102
@@ -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
+ }
@@ -167,6 +167,64 @@ export class PullRebaseConflictError extends WorkflowDomainError {
167
167
  }
168
168
  }
169
169
 
170
+ /**
171
+ * The one sentence a branch-less call is answered with. Names the input and
172
+ * what to pass, and carries NO stringified value of what was actually
173
+ * received: the whole point of this refusal is that the caller sent nothing,
174
+ * and echoing `undefined` back at them is how the branch "undefined" got
175
+ * invented in the first place.
176
+ */
177
+ export const BRANCH_REQUIRED_MESSAGE =
178
+ '`branch` is required: pass the branch (draft) you are working on.';
179
+
180
+ /**
181
+ * The caller named no branch — the input is missing, empty, not a string, or
182
+ * one of the stringified absent values (`"undefined"` / `"null"`) that a
183
+ * client produces by interpolating a variable it never set.
184
+ *
185
+ * Refused, never defaulted. A knowledge-base tool's workspace is NEVER implied
186
+ * by the credential (identity-only) — it always comes from this argument — so
187
+ * falling back to the deployment's default branch would make an omitted
188
+ * argument silently act on the protected branch. And it is refused HERE, at
189
+ * the boundary, because everything downstream treats the value as a real
190
+ * branch name: `workspaceIdForBranch` would turn it into a workspace directory
191
+ * literally named `undefined`, and the clone of that "branch" would fail with
192
+ * a story about a branch that never existed.
193
+ *
194
+ * 400 with kind `branch-required`, so a client tells it apart from the 404
195
+ * (no such branch) and the 410 (the branch was deleted) without reading prose.
196
+ */
197
+ export class BranchRequiredError extends WorkflowDomainError {
198
+ readonly kind = 'branch-required' as const;
199
+ constructor() {
200
+ super(BRANCH_REQUIRED_MESSAGE, 400, { kind: 'branch-required' });
201
+ this.name = 'BranchRequiredError';
202
+ }
203
+ }
204
+
205
+ /**
206
+ * The stringified absent values. Both are syntactically valid git branch
207
+ * names, so the shape validator (`assertValidBranchName`) accepts them
208
+ * happily — accepting one is exactly the bug this guard exists for. They are
209
+ * refused BY NAME, which is why this check cannot be folded into the shape
210
+ * check however tempting that looks.
211
+ */
212
+ const STRINGIFIED_ABSENT_VALUES = new Set(['undefined', 'null']);
213
+
214
+ /**
215
+ * Refuse a call that names no branch, before anything downstream can turn the
216
+ * missing value into a directory name, a clone attempt or a log line.
217
+ *
218
+ * Deliberately NOT a shape check: a well-formed name this platform has never
219
+ * seen is a 404 the caller can act on, and a malformed one is a
220
+ * `BranchNameError` from the canonical validator. This answers only "you sent
221
+ * nothing", which is the one case where naming the branch back is impossible.
222
+ */
223
+ export function assertBranchProvided(branch: unknown): asserts branch is string {
224
+ if (typeof branch !== 'string' || branch.length === 0) throw new BranchRequiredError();
225
+ if (STRINGIFIED_ABSENT_VALUES.has(branch)) throw new BranchRequiredError();
226
+ }
227
+
170
228
  /**
171
229
  * The platform has never heard of this branch: nothing it has cloned, and
172
230
  * nothing any listing of origin's branches has mentioned. Distinct from
@@ -319,6 +377,37 @@ export class PathNotFoundError extends WorkflowDomainError {
319
377
  }
320
378
  }
321
379
 
380
+ /**
381
+ * The one sentence every read of a past save answers when that save is not in
382
+ * the history of the branch being viewed.
383
+ *
384
+ * Written once, here, because four surfaces have to give the same answer: the
385
+ * bytes route (`?ref=`), the patch route (`show-file`), the before/after
386
+ * contents route (`file-at-change`), and anything later that serves a file at
387
+ * a commit. A save the history panel listed came off `git log` on this
388
+ * workspace's own branch, so it can never be refused this way — what this
389
+ * refuses is a sha from someone else's branch, or one that was invented.
390
+ */
391
+ export const VERSION_NOT_ON_BRANCH_MESSAGE =
392
+ "This version is not in this file's history on this branch.";
393
+
394
+ /**
395
+ * The caller named a save that is not an ancestor of the branch this
396
+ * workspace has checked out.
397
+ *
398
+ * 404, and DELIBERATELY the same 404 for a sha that exists on another branch
399
+ * as for one that exists nowhere: telling the two apart would turn the route
400
+ * into an oracle for "does this commit exist in the repository", which is
401
+ * exactly the reading a branch-scoped rule exists to prevent.
402
+ */
403
+ export class VersionNotOnBranchError extends WorkflowDomainError {
404
+ readonly kind = 'version-not-on-branch' as const;
405
+ constructor() {
406
+ super(VERSION_NOT_ON_BRANCH_MESSAGE, 404, { kind: 'version-not-on-branch' });
407
+ this.name = 'VersionNotOnBranchError';
408
+ }
409
+ }
410
+
322
411
  /**
323
412
  * An archive the caller uploaded that the zip reader cannot open. 422, not
324
413
  * 400: the request is well-formed and the path is fine — the BYTES are not a
@@ -62,6 +62,16 @@ export interface GitCredentials {
62
62
  username(): string;
63
63
  /** The token in effect, or null when the deployment has none configured. */
64
64
  token(): string | null;
65
+ /**
66
+ * Make sure {@link token} answers with a credential that is still good,
67
+ * for one that is not fixed: a token a host issues for an hour has to be
68
+ * renewed by asking the host, which `token` cannot do, being read where
69
+ * nothing can wait. The runner awaits this before every call it makes. A
70
+ * failure here is not the call's failure: git runs with the token in
71
+ * hand, and the host says what it thinks of it. Absent for a credential
72
+ * that does not expire.
73
+ */
74
+ prepare?(): Promise<void>;
65
75
  }
66
76
 
67
77
  /** The username git is given when a deployment names none. */
@@ -199,6 +209,33 @@ export class GitRunError extends Error {
199
209
  }
200
210
  }
201
211
 
212
+ /**
213
+ * Refuse a `cat-file --batch` spec that is not ONE line.
214
+ *
215
+ * `--batch` reads one object name per LINE, so a spec carrying a `\n` is two
216
+ * requests. For a single-spec read that means git answers about the part
217
+ * before the break — bytes the caller then presents as the whole path's,
218
+ * having had its access gates answered about a string that names nothing. For
219
+ * a batch it is worse: the extra answer shifts every result after it, and a
220
+ * walk that steps one header at a time hands each caller the object that
221
+ * belonged to the next one.
222
+ *
223
+ * A path is refused a control character long before this — by
224
+ * `assertValidRelativePath` and by `normalizeWorkspacePath`, which is where
225
+ * the rule belongs because it protects every route and tool at once. This is
226
+ * the fence AT the protocol, for a spec assembled from somewhere neither of
227
+ * those covers (a ref, a branch name, a path from a future caller). Nothing
228
+ * should ever trip it, so it throws rather than filtering: a request that
229
+ * cannot be expressed in this protocol has no correct truncation.
230
+ */
231
+ export function assertOneSpecPerLine(specs: readonly string[]): void {
232
+ for (const spec of specs) {
233
+ if (/[\r\n]/.test(spec)) {
234
+ throw new Error('git cat-file --batch spec contains a line break');
235
+ }
236
+ }
237
+ }
238
+
202
239
  /** Whether a failure came from the deadline rather than from git's own answer. */
203
240
  export function isGitTimeout(err: unknown): boolean {
204
241
  return err instanceof GitRunError && err.timedOut;
@@ -12,7 +12,6 @@ const settings: TenantHostSettings = {
12
12
  workspacesRoot: path.resolve('/srv/hexis/workspaces'),
13
13
  kbTemplateDir: '/srv/hexis/template',
14
14
  gitTimeoutMs: 120_000,
15
- ontologySessionBlock: true,
16
15
  updateCheckEnabled: false,
17
16
  masterKey: 'a-master-key-that-is-long-enough-to-count-as-random',
18
17
  };
@@ -23,7 +23,6 @@ export interface TenantHostSettings {
23
23
  readonly workspacesRoot: string;
24
24
  readonly kbTemplateDir: string;
25
25
  readonly gitTimeoutMs: number;
26
- readonly ontologySessionBlock: boolean;
27
26
  readonly updateCheckEnabled: boolean;
28
27
  /** See `tenant-secrets.ts`. */
29
28
  readonly masterKey: string;
@@ -182,7 +181,6 @@ export function tenantConfigFrom(record: TenantRecord, settings: TenantHostSetti
182
181
  gitUsername,
183
182
  gitToken: (record.gitToken ?? '').trim(),
184
183
  kbTemplateDir: settings.kbTemplateDir,
185
- ontologySessionBlock: settings.ontologySessionBlock,
186
184
  updateCheckEnabled: settings.updateCheckEnabled,
187
185
  allowedEmailDomains,
188
186
  trustProxy: settings.trustProxy,
@@ -289,7 +287,6 @@ export function tenantHostEnv(env: NodeJS.ProcessEnv = process.env): TenantHostE
289
287
  kbTemplateDir: env.KB_TEMPLATE_DIR || defaultKbTemplateDir(),
290
288
  gitTimeoutMs:
291
289
  Number.isFinite(gitTimeout) && gitTimeout > 0 && gitTimeout <= MAX_TIMER_MS ? gitTimeout : DEFAULT_GIT_TIMEOUT_MS,
292
- ontologySessionBlock: (env.ONTOLOGY_SESSION_BLOCK ?? 'true').trim().toLowerCase() !== 'false',
293
290
  updateCheckEnabled: (env.UPDATE_CHECK ?? 'true').trim().toLowerCase() !== 'false',
294
291
  idleMinutes: Number.isFinite(idle) && idle > 0 ? idle : 30,
295
292
  };
@@ -1,52 +0,0 @@
1
- /**
2
- * Ontology-session boundary — the PURE decision core (no IO, no DB).
3
- *
4
- * Model: each run/session accumulates the SET of named ontologies it has
5
- * touched (via any read or write). The rule is asymmetric:
6
- *
7
- * READS — always allowed. A read that touches a named ontology RECORDS it
8
- * into the session's touched set (so it can poison later writes).
9
- * WRITES — allowed only while the session is confined to a SINGLE named
10
- * ontology and the write targets that same ontology. The moment a
11
- * session has touched two or more named ontologies (typically by
12
- * reading across them), EVERY subsequent write is blocked — "read
13
- * across ontologies → you can't write anymore at all."
14
- *
15
- * Neutral touches (`ontologyOf` → null: root config, Skills, root-level
16
- * Knowledge, non-KB) never record and never block. See
17
- * `docs/rp4-ontology-boundary-plan-v2.md`.
18
- *
19
- * `SessionOntologyService` (Postgres-backed) persists the touched set and calls
20
- * these helpers; this module keeps the rule itself testable in isolation.
21
- */
22
- import type { Ontology } from '@bevel-software/platform-shared';
23
- /** Outcome of a write check against the session's touched-ontology set. */
24
- export type WriteDecision = {
25
- allow: true;
26
- } | {
27
- allow: false;
28
- reason: 'multi-ontology' | 'different-ontology';
29
- touched: string[];
30
- attempted: string;
31
- };
32
- /**
33
- * The set of NAMED ontologies a session has touched after this operation. A
34
- * neutral operation (`ontology === null`) adds nothing. Pure — returns a new
35
- * set, never mutates the input.
36
- */
37
- export declare function recordTouch(touched: ReadonlySet<string>, ontology: Ontology): Set<string>;
38
- /**
39
- * Decide whether a WRITE to `attempted` is allowed given the ontologies the
40
- * session has already touched (NOT counting this write yet).
41
- *
42
- * - A write to a neutral path (`attempted === null`) is always allowed.
43
- * - If the session has already touched two or more named ontologies, the
44
- * write is blocked (`multi-ontology`) — reading across ontologies poisoned
45
- * the session's write ability.
46
- * - If the session has touched exactly one named ontology and it differs from
47
- * the write target, the write is blocked (`different-ontology`).
48
- * - If the session is unpinned (touched none) or its single touched ontology
49
- * equals the target, the write is allowed.
50
- */
51
- export declare function decideWrite(touched: ReadonlySet<string>, attempted: Ontology): WriteDecision;
52
- //# sourceMappingURL=session-ontology.policy.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"session-ontology.policy.d.ts","sourceRoot":"","sources":["../../../src/modules/workflow/session-ontology.policy.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAC;AAEhE,2EAA2E;AAC3E,MAAM,MAAM,aAAa,GACrB;IAAE,KAAK,EAAE,IAAI,CAAA;CAAE,GACf;IAAE,KAAK,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,gBAAgB,GAAG,oBAAoB,CAAC;IAAC,OAAO,EAAE,MAAM,EAAE,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,CAAC;AAE5G;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,WAAW,CAAC,MAAM,CAAC,EAAE,QAAQ,EAAE,QAAQ,GAAG,GAAG,CAAC,MAAM,CAAC,CAIzF;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,WAAW,CAAC,MAAM,CAAC,EAAE,SAAS,EAAE,QAAQ,GAAG,aAAa,CAgB5F"}