@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
@@ -16,8 +16,11 @@ import {
16
16
  failureOf,
17
17
  gitFailure,
18
18
  type GitFailure,
19
+ type GitFailureKind,
19
20
  } from '../../../shared/git-failure.js';
20
21
  import { redactSecret, urlQuerySecrets } from '../../../shared/redact-secret.js';
22
+ import { normalizeRepositoryAddress, sameRepository } from '../../kb-fs/remote-url.js';
23
+ import { setAsideClone, setAsideRootFor, setAsideStamp } from '../set-aside-clone.js';
21
24
 
22
25
  /**
23
26
  * The KB startup phase: run every registered {@link OnServerStart} step, in
@@ -28,7 +31,9 @@ import { redactSecret, urlQuerySecrets } from '../../../shared/redact-secret.js'
28
31
  *
29
32
  * Fully fail-closed: any failure this phase cannot DECLARE (an unhandled
30
33
  * step throw, an unreachable remote, a clone that will not come down, a
31
- * refused write) throws out of `runAll` and stops the boot. The container's
34
+ * refused write) throws out of `runAll`. The boot stops on it unless the
35
+ * host ANSWERED about the repository or the credentials, in which case it
36
+ * comes up gated instead — see `bootMaySurvive`. The container's
32
37
  * restart policy is the retry — each attempt at boot time on quiet trees —
33
38
  * so an environmental failure converges without a human the moment the
34
39
  * environment returns. The one carve-out: a push rejected because a
@@ -51,6 +56,15 @@ export interface KbStartupRunnerOptions {
51
56
  gitRunner: IGitRunner;
52
57
  kbRepoUrl: () => string;
53
58
  workspacesRoot: string;
59
+ /**
60
+ * Where a working copy of a repository that is no longer the configured one
61
+ * is moved to, under a folder named for the moment it happened — see
62
+ * `reconcileClonesWithConfiguredRepository`. THE SAME VALUE THE WORKSPACE
63
+ * SERVICE IS GIVEN.
64
+ *
65
+ * Why, in one place: `setAsideRootFor` in `set-aside-clone.ts`.
66
+ */
67
+ setAsideRoot?: string;
54
68
  kbDirName: string;
55
69
  templateDir: string;
56
70
  defaultBranch: () => string;
@@ -67,6 +81,47 @@ export interface KbStartupRunnerOptions {
67
81
  template `.gitignore` rule can never silently drop a required seed file
68
82
  from the commit. */
69
83
  buildSeedTree: (dir: string) => Promise<string[]>;
84
+ /**
85
+ * Called with the workspace id of each working copy the phase is ABOUT TO
86
+ * set aside for being a clone of another repository, and awaited first.
87
+ * Whatever is still queued to be committed into that copy was written
88
+ * against the repository that was left, so it must be held back before a
89
+ * fresh clone of another repository appears at the same path.
90
+ *
91
+ * A PRECONDITION, so a failure here STOPS THE PHASE with the copy still
92
+ * where it was. Carrying on would clone the replacement with the queue
93
+ * untouched, and the worker would then commit those bytes into a
94
+ * repository they were never meant for. Stopped before anything moved, the
95
+ * next run finds the same copy and asks again. Optional: a minimal graph
96
+ * has no queue.
97
+ */
98
+ beforeCloneSetAside?: (workspaceId: string) => Promise<void>;
99
+ /**
100
+ * Called with the workspace id of each working copy the phase SET ASIDE for
101
+ * being a clone of another repository. The workspace service keeps a
102
+ * branch→directory cache and adopts whatever is on disk, so on a RUNNING
103
+ * server (the save that moves the deployment to another repository) the
104
+ * copy would otherwise stay in that cache as a path to nothing. Optional:
105
+ * at boot nothing has been cached yet.
106
+ *
107
+ * Never throws into the phase: a listener that fails must not stop a boot.
108
+ */
109
+ onCloneDiscarded?: (workspaceId: string) => void | Promise<void>;
110
+ /**
111
+ * Called with the workspace id of each working copy the phase CLONED fresh.
112
+ * A clone has just downloaded every ref, which is a successful fetch by any
113
+ * measure — but the git layer keeps its own per-workspace record of the last
114
+ * fetch and whether it worked, and the one that drove the replacement is a
115
+ * FAILED fetch of the repository that is gone. Left standing, a strict
116
+ * branch listing inside that record's TTL refuses the new clone's refs as
117
+ * unproven, having never touched it.
118
+ *
119
+ * The ordinary path already says this (`WorkspaceService`'s cloned
120
+ * listener); the phase clones with git directly, so it has to say it itself.
121
+ *
122
+ * Never throws into the phase, for the same reason as above.
123
+ */
124
+ onCloneCreated?: (workspaceId: string) => void;
70
125
  }
71
126
 
72
127
  /**
@@ -97,6 +152,42 @@ export class KbRemoteUnreachableError extends ClassifiedFailure {
97
152
  }
98
153
  }
99
154
 
155
+ /**
156
+ * The failure kinds a boot may survive GATED rather than stop on: the remote
157
+ * ANSWERED (or could not be reached at all), and what it said is about the
158
+ * repository or the credentials — not about what this deployment would write.
159
+ *
160
+ * All four are fixed by an operator somewhere other than this process: the
161
+ * host comes back, the repository is created or the token is granted access
162
+ * to it, a rotated token is entered on the setup screen. Refusing to boot
163
+ * over any of them takes away the very screen the fix is entered on — which
164
+ * is what happened on 2026-09-28, when a replaced repository answered
165
+ * `not found` inside a step and the container crash-looped.
166
+ *
167
+ * Every OTHER failure still stops the boot: it means a step or the template
168
+ * would write something wrong, and booting over that is worse than not
169
+ * booting at all.
170
+ */
171
+ const GATED_BOOT_KINDS: ReadonlySet<GitFailureKind> = new Set([
172
+ 'unreachable',
173
+ 'not-found',
174
+ 'credentials-rejected',
175
+ 'write-refused',
176
+ ]);
177
+
178
+ /**
179
+ * Whether the boot may come up GATED on this failure instead of stopping.
180
+ *
181
+ * {@link KbRemoteUnreachableError} says so by its type — it is raised where
182
+ * the remote is first contacted, before any step. A failure raised INSIDE the
183
+ * phase says so by its classification: the same fetch that stops a boot when
184
+ * it is the first contact must not stop one when a step happened to make it.
185
+ */
186
+ export function bootMaySurvive(err: unknown): boolean {
187
+ if (err instanceof KbRemoteUnreachableError) return true;
188
+ return err instanceof ClassifiedFailure && GATED_BOOT_KINDS.has(err.failure.kind);
189
+ }
190
+
100
191
  export interface RetryOptions {
101
192
  /** First wait before trying again. Default 30s. */
102
193
  initialDelayMs?: number;
@@ -241,7 +332,7 @@ export class KbStartupRunner {
241
332
  // Stopped by either: a failure that is no longer the remote at all
242
333
  // (the knowledge base itself is wrong — asking again will not change
243
334
  // it), or a remote answer that a retry cannot change (see above).
244
- if (!(err instanceof KbRemoteUnreachableError) || !worthRetrying()) return stopOnStanding();
335
+ if (!bootMaySurvive(err) || !worthRetrying()) return stopOnStanding();
245
336
  delay = Math.min(delay * 2, max);
246
337
  log(`remote still unreachable; trying again in ${Math.round(delay / 1000)}s`);
247
338
  }
@@ -301,6 +392,10 @@ export class KbStartupRunner {
301
392
  );
302
393
  }
303
394
  const heads = await this.ensureRemote();
395
+ // AFTER the remote answered: the configured address is known to be a
396
+ // repository we can reach, so a clone of a different one is stale, not
397
+ // a casualty of a typo in the address.
398
+ await this.reconcileClonesWithConfiguredRepository();
304
399
  const ctx = this.buildContext(heads, handles);
305
400
 
306
401
  for (const step of this.opts.steps) {
@@ -501,6 +596,141 @@ export class KbStartupRunner {
501
596
  };
502
597
  }
503
598
 
599
+ /**
600
+ * Bring every working copy on disk into line with the configured
601
+ * repository, WITHOUT DELETING ANYTHING.
602
+ *
603
+ * A clone fetches and pushes through the address stored in its own
604
+ * `remote.origin.url`, which nothing updates when the configured address
605
+ * changes. After an operator replaced the repository (2026-09-28: the old
606
+ * one deleted, a new one saved on the setup screen) every surviving clone
607
+ * kept asking the old address: Save and Retry failed, and the restart
608
+ * stopped the boot on `repository … not found`, taking the setup screen
609
+ * with it.
610
+ *
611
+ * A clone whose address differs from the configured one is one of two
612
+ * things, and the histories say which:
613
+ *
614
+ * - THE SAME REPOSITORY AT A NEW ADDRESS — moved, renamed, mirrored. The
615
+ * one change the setup screen tells an admin to make ("only change this
616
+ * if the same repository was moved or renamed"). Its history is on the
617
+ * configured remote, so the clone is pointed at the new address and
618
+ * kept, unpushed commits included: they are pushed where they belonged
619
+ * all along.
620
+ * - ANOTHER REPOSITORY. Re-pointing it would push one repository's
621
+ * commits into the other, so it cannot stay where the workspace service
622
+ * would adopt it. It is MOVED to {@link KbStartupRunnerOptions.setAsideRoot}
623
+ * and the log says where. Never removed: when the old repository is gone
624
+ * — as it was that day — these clones are the last copy of the
625
+ * knowledge base that exists, and the unpushed work in them exists
626
+ * nowhere else at all.
627
+ *
628
+ * Every clone under the workspaces root, not only the branches this phase
629
+ * touches: the workspace service adopts whatever it finds there. A clone
630
+ * whose address cannot be read is left where it is — "could not tell" is
631
+ * no reason to move someone's work. One that cannot be set aside stops the
632
+ * boot with the reason: leaving it would fail the boot a step later with
633
+ * git's words about a repository nobody configured.
634
+ */
635
+ private async reconcileClonesWithConfiguredRepository(): Promise<void> {
636
+ const configured = this.opts.kbRepoUrl();
637
+ let entries: import('node:fs').Dirent[];
638
+ try {
639
+ entries = await fs.readdir(this.opts.workspacesRoot, { withFileTypes: true });
640
+ } catch {
641
+ return; // no workspaces yet
642
+ }
643
+ // One folder per run, so what was set aside together stays together.
644
+ const stamp = setAsideStamp();
645
+ const setAsideRoot = setAsideRootFor(this.opts.workspacesRoot, this.opts.setAsideRoot);
646
+ for (const entry of entries) {
647
+ if (!entry.isDirectory()) continue;
648
+ const repoDir = path.join(this.opts.workspacesRoot, entry.name, this.opts.kbDirName);
649
+ const hasGit = await fs.access(path.join(repoDir, '.git')).then(() => true, () => false);
650
+ if (!hasGit) continue;
651
+ let origin: string;
652
+ try {
653
+ origin = (await git(this.opts.gitRunner, repoDir, ['config', '--get', 'remote.origin.url'])).trim();
654
+ } catch {
655
+ continue;
656
+ }
657
+ if (origin === '' || sameRepository(origin, configured)) continue;
658
+ // The normalized form carries no userinfo, and the scrub covers the rest.
659
+ const was = this.redact(normalizeRepositoryAddress(origin));
660
+ if (await this.sharesHistoryWithConfigured(repoDir, configured)) {
661
+ await git(this.opts.gitRunner, repoDir, ['remote', 'set-url', 'origin', configured]);
662
+ startupLog.warn(
663
+ `working copy "${entry.name}" was cloned from ${was}, whose history the configured repository holds: ` +
664
+ 'the same repository at a new address. Pointed at the configured address and kept, unpushed work included.',
665
+ );
666
+ continue;
667
+ }
668
+ const kept = path.join(setAsideRoot, stamp, entry.name);
669
+ // First, and allowed to stop the phase: see `beforeCloneSetAside`. The
670
+ // directory name IS the workspace id (`workspaceIdForBranch`).
671
+ await this.opts.beforeCloneSetAside?.(entry.name);
672
+ await setAsideClone(repoDir, kept);
673
+ startupLog.warn(
674
+ `working copy "${entry.name}" is a clone of another repository (${was}). Set aside at ${kept}; nothing was ` +
675
+ 'deleted, and it will be cloned fresh from the configured one. Work that was never pushed is in that ' +
676
+ 'folder: `git log` there shows it, and `git push <address> <branch>` from there sends it to a repository.',
677
+ );
678
+ // Setting it aside already removed it from the workspaces root, so the
679
+ // cached handle the workspace service holds for it now points at
680
+ // nothing.
681
+ try {
682
+ await this.opts.onCloneDiscarded?.(entry.name);
683
+ } catch (err) {
684
+ startupLog.warn(`could not finish setting the working copy "${entry.name}" aside:`, {
685
+ detail: this.redact(err instanceof Error ? err.message : String(err)),
686
+ });
687
+ }
688
+ }
689
+ }
690
+
691
+ /**
692
+ * Whether the configured repository holds history this clone has too —
693
+ * asked of git, not guessed from the two addresses. The configured
694
+ * remote's branches are fetched under a namespace of their own and the
695
+ * clone's HEAD is tested for a common ancestor with any of them.
696
+ *
697
+ * "Could not tell" answers false: a fetch that fails, a clone with no
698
+ * commit to compare. False sets the clone aside, which loses nothing, where
699
+ * a wrong true would push a stranger's commits into the configured
700
+ * repository.
701
+ */
702
+ private async sharesHistoryWithConfigured(repoDir: string, configured: string): Promise<boolean> {
703
+ const probe = 'refs/hexis-probe';
704
+ let theirs: string[] = [];
705
+ try {
706
+ await git(this.opts.gitRunner, repoDir, ['fetch', '--no-tags', '--quiet', configured, `+refs/heads/*:${probe}/*`]);
707
+ theirs = (await git(this.opts.gitRunner, repoDir, ['for-each-ref', '--format=%(refname)', `${probe}/`]))
708
+ .split('\n')
709
+ .map((ref) => ref.trim())
710
+ .filter(Boolean);
711
+ if (theirs.length === 0) return false;
712
+ // One question for all of them: a common ancestor of HEAD and ANY of
713
+ // their branches is a common ancestor of HEAD and their merge. Capped,
714
+ // so a remote with thousands of branches cannot outgrow a command line.
715
+ await git(this.opts.gitRunner, repoDir, ['merge-base', 'HEAD', ...theirs.slice(0, 200)]);
716
+ return true;
717
+ } catch {
718
+ return false;
719
+ } finally {
720
+ // The probe leaves nothing behind in a clone that stays in use.
721
+ for (const ref of theirs) {
722
+ await git(this.opts.gitRunner, repoDir, ['update-ref', '-d', ref]).catch(() => undefined);
723
+ }
724
+ }
725
+ }
726
+
727
+ /**
728
+ * Move a working copy out of the workspaces root, whole. A rename where
729
+ * the two folders share a volume; a copy and then a removal where they do
730
+ * not (the backups root is a volume of its own in the shipped compose
731
+ * files), with the original removed only once the copy is complete.
732
+ */
733
+
504
734
  /**
505
735
  * The branch's working copy at the runtime layout
506
736
  * (`<workspacesRoot>/<id>/<kbDirName>`), so the workspace service finds it
@@ -518,6 +748,16 @@ export class KbStartupRunner {
518
748
  await fs.mkdir(workspaceDir, { recursive: true });
519
749
  await fs.rm(repoDir, { recursive: true, force: true });
520
750
  await git(this.opts.gitRunner, workspaceDir, ['clone', '-b', branch, this.opts.kbRepoUrl(), repoDir]);
751
+ // Said as soon as the clone is there, before its configuration: if a
752
+ // command below fails, the clone stays on disk, and the retry finds it
753
+ // and never comes back through here.
754
+ try {
755
+ this.opts.onCloneCreated?.(workspaceIdForBranch(branch));
756
+ } catch (err) {
757
+ startupLog.warn(`could not announce the fresh working copy for "${branch}":`, {
758
+ detail: this.redact(err instanceof Error ? err.message : String(err)),
759
+ });
760
+ }
521
761
  await git(this.opts.gitRunner, repoDir, ['config', 'core.longpaths', 'true']);
522
762
  await stampIdentity(this.opts.gitRunner, repoDir);
523
763
  return repoDir;
@@ -739,7 +739,7 @@ export function createWorkspaceRoutes(
739
739
  }
740
740
 
741
741
  /**
742
- * GET /workspace/:id/file/raw?path=<file>[&download=1][&v=<n>]
742
+ * GET /workspace/:id/file/raw?path=<file>[&download=1][&v=<n>][&ref=<sha>[&side=before]]
743
743
  *
744
744
  * The bytes of one workspace file: for the document renderers' fetches, the
745
745
  * file tree's Download, and the `<img>` tags the markdown pipeline emits for
@@ -750,7 +750,8 @@ export function createWorkspaceRoutes(
750
750
  * request ──▶ auth: Bearer, else the cookie
751
751
  * ──▶ read gate on the path 403 if the caller may not read it
752
752
  * ──▶ download gate, if ?download=1 403 without the download: verb
753
- * ──▶ readFileBinary 404 missing, 403 traversal
753
+ * ──▶ readFileBinary, or the bytes at 404 missing, 403 traversal
754
+ * ?ref when one is named
754
755
  * ──▶ Content-Type from the extension, nosniff, a CSP sandbox for
755
756
  * inline svg, attachment disposition for a download, and
756
757
  * Cache-Control: private, no-cache
@@ -760,6 +761,30 @@ export function createWorkspaceRoutes(
760
761
  *
761
762
  * `?v=` is not read here. The frontend bumps it when it learns an image
762
763
  * changed, so the browser asks for a URL it has not cached.
764
+ *
765
+ * `?ref=<sha>` serves the file AS IT WAS at that save instead of the working
766
+ * tree, so Version history can mount the very viewers this route already
767
+ * feeds. `&side=before` reads `<sha>^` — the version just before a save,
768
+ * which is the only thing the save that DELETED a file can show. Everything
769
+ * around the read is deliberately unchanged: the same read gate, the same
770
+ * download verb, the same content type, the same svg sandbox. What the ref
771
+ * changes is WHICH bytes, never WHO may have them:
772
+ *
773
+ * - both gates resolve against TODAY's tree, not the rules as they were at
774
+ * that save (hx-history-file-preview, decision 3): anyone who may read
775
+ * the file now may read any of its past saves, and anyone who may not is
776
+ * refused with the same sentence they get for the file itself;
777
+ * - the save must be in the history of the branch this workspace has
778
+ * checked out — `fileBytesAtChange` refuses anything else with a 404
779
+ * carrying `VERSION_NOT_ON_BRANCH_MESSAGE`;
780
+ * - a ref that is present but malformed is a 400, never a silent fall back
781
+ * to `readFileBinary`. Serving today's bytes for a request that asked for
782
+ * a past version would be the worst possible answer — it looks like a
783
+ * success — so the ref branch and the working-tree branch are exclusive;
784
+ * - and a read that fails for OUR reasons (a blob over the git runner's
785
+ * output ceiling, a timed-out `cat-file`) is a logged 500, not the
786
+ * blanket 404 the working-tree read answers with: "not found" about a
787
+ * save the reader can see listed is a lie about their own history.
763
788
  */
764
789
  router.get('/workspace/:id/file/raw', async (req, res) => {
765
790
  const id = authenticated(req, res);
@@ -771,6 +796,27 @@ export function createWorkspaceRoutes(
771
796
  }
772
797
  const filePath = inRepo(res, requested);
773
798
  if (filePath === null) return;
799
+ // A past save, asked for by sha. Validated BEFORE the gates so a
800
+ // mistyped ref is a 400 rather than a 200 carrying today's bytes.
801
+ const rawRef = req.query.ref;
802
+ if (rawRef !== undefined && (typeof rawRef !== 'string' || !/^[a-f0-9]{7,40}$/i.test(rawRef))) {
803
+ res.status(400).json({ error: 'ref must be a commit sha' });
804
+ return;
805
+ }
806
+ const ref = typeof rawRef === 'string' ? rawRef : null;
807
+ // Only the two sides exist; `side` without `ref` names nothing, and an
808
+ // unrecognised value is a spelling mistake in a security-relevant
809
+ // parameter, not a default to guess at.
810
+ const rawSide = req.query.side;
811
+ if (rawSide !== undefined && rawSide !== 'before' && rawSide !== 'after') {
812
+ res.status(400).json({ error: "side must be 'before' or 'after'" });
813
+ return;
814
+ }
815
+ if (rawSide !== undefined && ref === null) {
816
+ res.status(400).json({ error: 'side requires ref' });
817
+ return;
818
+ }
819
+ const side = rawSide === 'before' ? 'before' : 'after';
774
820
  // `?download=1` flips this from inline-serve (used by PdfRenderer and
775
821
  // the image renderers) to "save to disk" — and the save path is gated
776
822
  // on per-path `download:` rules in access.md. The inline path stays
@@ -785,7 +831,28 @@ export function createWorkspaceRoutes(
785
831
  if (!(await requireDownloadPermission(req, res, id, filePath))) return;
786
832
  }
787
833
  try {
788
- const buffer = await workspaceService.readFileBinary(id, filePath);
834
+ let buffer: Buffer;
835
+ if (ref === null) {
836
+ buffer = await workspaceService.readFileBinary(id, filePath);
837
+ } else {
838
+ const at = await workflowService.fileBytesAtChange(id, filePath, ref, side);
839
+ // Nothing at that path on that side of the save — for `before`, also
840
+ // how a root commit answers, since it has no parent to read.
841
+ if (at === null) {
842
+ res.status(404).json({ error: 'File not found' });
843
+ return;
844
+ }
845
+ // `IWorkflowService` is isomorphic, so the bytes arrive typed as a
846
+ // `Uint8Array`. `res.send` recognises a Buffer and JSON-encodes
847
+ // anything else, so a zero-copy view over the same memory is what it
848
+ // has to be handed — never a copy of a file that may be tens of MB.
849
+ buffer = Buffer.from(at.bytes.buffer, at.bytes.byteOffset, at.bytes.byteLength);
850
+ // The blob's object id IS the content hash, and a past version can
851
+ // never change, so this is the strongest ETag available and costs no
852
+ // second pass over the bytes. Set BEFORE `res.send`, which then skips
853
+ // its own weak ETag and answers a matching If-None-Match with a 304.
854
+ res.setHeader('ETag', `"${at.blobId}"`);
855
+ }
789
856
  const ext = filePath.slice(filePath.lastIndexOf('.')).toLowerCase();
790
857
  const mimeTypes: Record<string, string> = {
791
858
  '.png': 'image/png',
@@ -844,6 +911,31 @@ export function createWorkspaceRoutes(
844
911
  sendError(res, error);
845
912
  return;
846
913
  }
914
+ // A refusal the ref branch raised has to reach the caller AS ITSELF: a
915
+ // save from another branch is answered with
916
+ // `VERSION_NOT_ON_BRANCH_MESSAGE`, and flattening it into the generic
917
+ // "File not found" would tell a reader their file had vanished. Only
918
+ // the ref branch can raise one, so the working-tree read keeps the
919
+ // blanket 404 it has always answered with.
920
+ if (ref !== null && error instanceof WorkflowDomainError) {
921
+ sendError(res, error);
922
+ return;
923
+ }
924
+ // Anything else out of the ref branch is OUR failure, not a missing
925
+ // version: the read goes through the git runner, where a blob over
926
+ // `MAX_OUTPUT_BYTES` and a timed-out `cat-file` both arrive here as
927
+ // plain errors. A 404 would tell a reader the save they can see listed
928
+ // had vanished, and would do it silently — so it is a logged 500, and
929
+ // "Try again" in the pane is then a truthful offer.
930
+ if (ref !== null) {
931
+ log.error(
932
+ `could not read ${printable(filePath)} at ${printable(ref)}: ${printable(
933
+ error instanceof Error ? error.message : String(error),
934
+ )}`,
935
+ );
936
+ res.status(500).json({ error: 'Could not read this version of the file' });
937
+ return;
938
+ }
847
939
  res.status(404).json({ error: 'File not found' });
848
940
  }
849
941
  });