@bevel-software/platform-core-backend 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (466) hide show
  1. package/dist/core/core-ports.d.ts +11 -1
  2. package/dist/core/core-ports.d.ts.map +1 -1
  3. package/dist/core/core-ports.js.map +1 -1
  4. package/dist/core/create-core-server.d.ts +3 -3
  5. package/dist/core/create-core-server.d.ts.map +1 -1
  6. package/dist/core/create-core-server.js +46 -13
  7. package/dist/core/create-core-server.js.map +1 -1
  8. package/dist/core/create-core-services.d.ts +12 -2
  9. package/dist/core/create-core-services.d.ts.map +1 -1
  10. package/dist/core/create-core-services.js +137 -24
  11. package/dist/core/create-core-services.js.map +1 -1
  12. package/dist/core/lifecycle.d.ts +46 -1
  13. package/dist/core/lifecycle.d.ts.map +1 -1
  14. package/dist/core/lifecycle.js +99 -13
  15. package/dist/core/lifecycle.js.map +1 -1
  16. package/dist/core-config.d.ts +16 -12
  17. package/dist/core-config.d.ts.map +1 -1
  18. package/dist/core-config.js +28 -13
  19. package/dist/core-config.js.map +1 -1
  20. package/dist/index.d.ts +6 -4
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +12 -3
  23. package/dist/index.js.map +1 -1
  24. package/dist/modules/access/access-control.interface.d.ts +42 -12
  25. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  26. package/dist/modules/access/access-control.service.d.ts +67 -10
  27. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  28. package/dist/modules/access/access-control.service.js +214 -35
  29. package/dist/modules/access/access-control.service.js.map +1 -1
  30. package/dist/modules/access/access.routes.d.ts.map +1 -1
  31. package/dist/modules/access/access.routes.js +17 -16
  32. package/dist/modules/access/access.routes.js.map +1 -1
  33. package/dist/modules/access/directory-sync-bot.d.ts.map +1 -1
  34. package/dist/modules/access/directory-sync-bot.js +7 -3
  35. package/dist/modules/access/directory-sync-bot.js.map +1 -1
  36. package/dist/modules/agent-instructions/agent-instructions.routes.d.ts +8 -1
  37. package/dist/modules/agent-instructions/agent-instructions.routes.d.ts.map +1 -1
  38. package/dist/modules/agent-instructions/agent-instructions.routes.js +8 -2
  39. package/dist/modules/agent-instructions/agent-instructions.routes.js.map +1 -1
  40. package/dist/modules/agent-instructions/compose.d.ts +38 -6
  41. package/dist/modules/agent-instructions/compose.d.ts.map +1 -1
  42. package/dist/modules/agent-instructions/compose.js +39 -6
  43. package/dist/modules/agent-instructions/compose.js.map +1 -1
  44. package/dist/modules/agent-instructions/index.d.ts +2 -1
  45. package/dist/modules/agent-instructions/index.d.ts.map +1 -1
  46. package/dist/modules/agent-instructions/index.js +2 -1
  47. package/dist/modules/agent-instructions/index.js.map +1 -1
  48. package/dist/modules/agent-instructions/shared-file-rules.d.ts +115 -0
  49. package/dist/modules/agent-instructions/shared-file-rules.d.ts.map +1 -0
  50. package/dist/modules/agent-instructions/shared-file-rules.js +272 -0
  51. package/dist/modules/agent-instructions/shared-file-rules.js.map +1 -0
  52. package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
  53. package/dist/modules/audit/agent-audit.service.js +4 -2
  54. package/dist/modules/audit/agent-audit.service.js.map +1 -1
  55. package/dist/modules/auth/account-admission.d.ts +58 -7
  56. package/dist/modules/auth/account-admission.d.ts.map +1 -1
  57. package/dist/modules/auth/account-admission.js +44 -1
  58. package/dist/modules/auth/account-admission.js.map +1 -1
  59. package/dist/modules/auth/account-erasure.service.d.ts.map +1 -1
  60. package/dist/modules/auth/account-erasure.service.js +57 -16
  61. package/dist/modules/auth/account-erasure.service.js.map +1 -1
  62. package/dist/modules/auth/account.routes.d.ts +1 -1
  63. package/dist/modules/auth/account.routes.d.ts.map +1 -1
  64. package/dist/modules/auth/account.routes.js +61 -5
  65. package/dist/modules/auth/account.routes.js.map +1 -1
  66. package/dist/modules/auth/auth.middleware.d.ts +1 -1
  67. package/dist/modules/auth/auth.middleware.d.ts.map +1 -1
  68. package/dist/modules/auth/auth.middleware.js +18 -7
  69. package/dist/modules/auth/auth.middleware.js.map +1 -1
  70. package/dist/modules/auth/auth.routes.d.ts.map +1 -1
  71. package/dist/modules/auth/auth.routes.js +8 -0
  72. package/dist/modules/auth/auth.routes.js.map +1 -1
  73. package/dist/modules/auth/auth.service.d.ts +70 -2
  74. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  75. package/dist/modules/auth/auth.service.js +197 -18
  76. package/dist/modules/auth/auth.service.js.map +1 -1
  77. package/dist/modules/auth/oidc-auth-provider.d.ts.map +1 -1
  78. package/dist/modules/auth/oidc-auth-provider.js +7 -2
  79. package/dist/modules/auth/oidc-auth-provider.js.map +1 -1
  80. package/dist/modules/code-mode/code-mode.tool.d.ts +20 -2
  81. package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -1
  82. package/dist/modules/code-mode/code-mode.tool.js +66 -35
  83. package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
  84. package/dist/modules/database/connection.d.ts +16 -0
  85. package/dist/modules/database/connection.d.ts.map +1 -1
  86. package/dist/modules/database/connection.js +117 -0
  87. package/dist/modules/database/connection.js.map +1 -1
  88. package/dist/modules/database/core-schema.d.ts +337 -120
  89. package/dist/modules/database/core-schema.d.ts.map +1 -1
  90. package/dist/modules/database/core-schema.js +116 -58
  91. package/dist/modules/database/core-schema.js.map +1 -1
  92. package/dist/modules/database/migrate.d.ts +8 -0
  93. package/dist/modules/database/migrate.d.ts.map +1 -1
  94. package/dist/modules/database/migrate.js +271 -1
  95. package/dist/modules/database/migrate.js.map +1 -1
  96. package/dist/modules/kb-fs/branch-name.d.ts.map +1 -1
  97. package/dist/modules/kb-fs/branch-name.js +12 -2
  98. package/dist/modules/kb-fs/branch-name.js.map +1 -1
  99. package/dist/modules/kb-fs/repo-path.d.ts +11 -16
  100. package/dist/modules/kb-fs/repo-path.d.ts.map +1 -1
  101. package/dist/modules/kb-fs/repo-path.js +79 -0
  102. package/dist/modules/kb-fs/repo-path.js.map +1 -1
  103. package/dist/modules/kb-sync/kb-sync.routes.d.ts +2 -2
  104. package/dist/modules/kb-sync/kb-sync.routes.d.ts.map +1 -1
  105. package/dist/modules/kb-sync/kb-sync.routes.js +14 -3
  106. package/dist/modules/kb-sync/kb-sync.routes.js.map +1 -1
  107. package/dist/modules/kb-sync/sync-auth.d.ts +3 -1
  108. package/dist/modules/kb-sync/sync-auth.d.ts.map +1 -1
  109. package/dist/modules/kb-sync/sync-auth.js +1 -1
  110. package/dist/modules/kb-sync/sync-auth.js.map +1 -1
  111. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  112. package/dist/modules/mcp/mcp-auth.middleware.js +21 -6
  113. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  114. package/dist/modules/mcp/mcp.service.d.ts +8 -0
  115. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  116. package/dist/modules/mcp/mcp.service.js +38 -8
  117. package/dist/modules/mcp/mcp.service.js.map +1 -1
  118. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
  119. package/dist/modules/mcp/oauth/bevel-oauth-provider.js +3 -2
  120. package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
  121. package/dist/modules/plugins/join-request-records.store.d.ts.map +1 -1
  122. package/dist/modules/plugins/join-request-records.store.js +7 -4
  123. package/dist/modules/plugins/join-request-records.store.js.map +1 -1
  124. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
  125. package/dist/modules/settings/deployment-settings.service.js +8 -3
  126. package/dist/modules/settings/deployment-settings.service.js.map +1 -1
  127. package/dist/modules/settings/setup.routes.d.ts +52 -1
  128. package/dist/modules/settings/setup.routes.d.ts.map +1 -1
  129. package/dist/modules/settings/setup.routes.js +207 -19
  130. package/dist/modules/settings/setup.routes.js.map +1 -1
  131. package/dist/modules/skills/skills.contract.d.ts +90 -18
  132. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  133. package/dist/modules/skills/skills.contract.js +4 -1
  134. package/dist/modules/skills/skills.contract.js.map +1 -1
  135. package/dist/modules/skills/skills.service.d.ts +98 -9
  136. package/dist/modules/skills/skills.service.d.ts.map +1 -1
  137. package/dist/modules/skills/skills.service.js +239 -37
  138. package/dist/modules/skills/skills.service.js.map +1 -1
  139. package/dist/modules/skills/skills.tools.d.ts +7 -0
  140. package/dist/modules/skills/skills.tools.d.ts.map +1 -1
  141. package/dist/modules/skills/skills.tools.js +71 -7
  142. package/dist/modules/skills/skills.tools.js.map +1 -1
  143. package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
  144. package/dist/modules/tool-auth/external-api-key.service.js +12 -4
  145. package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
  146. package/dist/modules/tool-auth/internal-token.service.d.ts +3 -3
  147. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +16 -7
  148. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  149. package/dist/modules/tool-auth/tool-auth.middleware.js +34 -12
  150. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  151. package/dist/modules/tool-helpers/tool-handler.d.ts +2 -1
  152. package/dist/modules/tool-helpers/tool-handler.d.ts.map +1 -1
  153. package/dist/modules/tool-helpers/tool-handler.js +25 -4
  154. package/dist/modules/tool-helpers/tool-handler.js.map +1 -1
  155. package/dist/modules/tool-helpers/tool.contract.d.ts +3 -2
  156. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
  157. package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
  158. package/dist/modules/tool-helpers/validate-token.js +1 -1
  159. package/dist/modules/tool-helpers/validate-token.js.map +1 -1
  160. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  161. package/dist/modules/tool-manuals/tool-manuals.tools.js +42 -31
  162. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  163. package/dist/modules/tool-registry/description-length.d.ts +80 -0
  164. package/dist/modules/tool-registry/description-length.d.ts.map +1 -0
  165. package/dist/modules/tool-registry/description-length.js +108 -0
  166. package/dist/modules/tool-registry/description-length.js.map +1 -0
  167. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +98 -0
  168. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -0
  169. package/dist/modules/workflow/agent-tools/change-request-summary.js +81 -0
  170. package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -0
  171. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  172. package/dist/modules/workflow/agent-tools/workflow.tools.js +75 -35
  173. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  174. package/dist/modules/workflow/file-lock.service.d.ts +24 -0
  175. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  176. package/dist/modules/workflow/file-lock.service.js +30 -0
  177. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  178. package/dist/modules/workflow/git/git.service.d.ts +94 -0
  179. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  180. package/dist/modules/workflow/git/git.service.js +193 -3
  181. package/dist/modules/workflow/git/git.service.js.map +1 -1
  182. package/dist/modules/workflow/pending-commits.service.d.ts +38 -0
  183. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  184. package/dist/modules/workflow/pending-commits.service.js +60 -1
  185. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  186. package/dist/modules/workflow/recovery-bot.d.ts.map +1 -1
  187. package/dist/modules/workflow/recovery-bot.js +7 -3
  188. package/dist/modules/workflow/recovery-bot.js.map +1 -1
  189. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
  190. package/dist/modules/workflow/review-workflow/review-workflow.service.js +12 -3
  191. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  192. package/dist/modules/workflow/workflow-hooks.d.ts +54 -32
  193. package/dist/modules/workflow/workflow-hooks.d.ts.map +1 -1
  194. package/dist/modules/workflow/workflow-hooks.js +16 -1
  195. package/dist/modules/workflow/workflow-hooks.js.map +1 -1
  196. package/dist/modules/workflow/workflow.service.d.ts +60 -6
  197. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  198. package/dist/modules/workflow/workflow.service.js +115 -5
  199. package/dist/modules/workflow/workflow.service.js.map +1 -1
  200. package/dist/modules/workspace/agent-access.gate.d.ts +94 -0
  201. package/dist/modules/workspace/agent-access.gate.d.ts.map +1 -0
  202. package/dist/modules/workspace/agent-access.gate.js +123 -0
  203. package/dist/modules/workspace/agent-access.gate.js.map +1 -0
  204. package/dist/modules/workspace/agent-upload.routes.d.ts +77 -0
  205. package/dist/modules/workspace/agent-upload.routes.d.ts.map +1 -0
  206. package/dist/modules/workspace/agent-upload.routes.js +210 -0
  207. package/dist/modules/workspace/agent-upload.routes.js.map +1 -0
  208. package/dist/modules/workspace/agent-upload.store.d.ts +284 -0
  209. package/dist/modules/workspace/agent-upload.store.d.ts.map +1 -0
  210. package/dist/modules/workspace/agent-upload.store.js +553 -0
  211. package/dist/modules/workspace/agent-upload.store.js.map +1 -0
  212. package/dist/modules/workspace/routine-write-policy.d.ts +5 -6
  213. package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
  214. package/dist/modules/workspace/routine-write-policy.js +5 -6
  215. package/dist/modules/workspace/routine-write-policy.js.map +1 -1
  216. package/dist/modules/workspace/session-sink.d.ts +5 -5
  217. package/dist/modules/workspace/set-aside-clone.d.ts +46 -0
  218. package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -0
  219. package/dist/modules/workspace/set-aside-clone.js +92 -0
  220. package/dist/modules/workspace/set-aside-clone.js.map +1 -0
  221. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +59 -9
  222. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  223. package/dist/modules/workspace/startup/kb-startup-runner.js +65 -24
  224. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  225. package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -1
  226. package/dist/modules/workspace/startup/steps/seed-tree.js +3 -3
  227. package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -1
  228. package/dist/modules/workspace/startup/steps/template-source.d.ts +40 -0
  229. package/dist/modules/workspace/startup/steps/template-source.d.ts.map +1 -1
  230. package/dist/modules/workspace/startup/steps/template-source.js +46 -4
  231. package/dist/modules/workspace/startup/steps/template-source.js.map +1 -1
  232. package/dist/modules/workspace/upload-limits.d.ts +13 -0
  233. package/dist/modules/workspace/upload-limits.d.ts.map +1 -0
  234. package/dist/modules/workspace/upload-limits.js +13 -0
  235. package/dist/modules/workspace/upload-limits.js.map +1 -0
  236. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  237. package/dist/modules/workspace/workspace.routes.js +93 -4
  238. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  239. package/dist/modules/workspace/workspace.service.d.ts +128 -5
  240. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  241. package/dist/modules/workspace/workspace.service.js +316 -58
  242. package/dist/modules/workspace/workspace.service.js.map +1 -1
  243. package/dist/modules/workspace/workspace.tools.d.ts +13 -11
  244. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  245. package/dist/modules/workspace/workspace.tools.js +791 -198
  246. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  247. package/dist/modules/workspace/write-denial.d.ts +0 -6
  248. package/dist/modules/workspace/write-denial.d.ts.map +1 -1
  249. package/dist/modules/workspace/write-denial.js +0 -6
  250. package/dist/modules/workspace/write-denial.js.map +1 -1
  251. package/dist/modules/workspace/zip-entry-rules.d.ts +114 -0
  252. package/dist/modules/workspace/zip-entry-rules.d.ts.map +1 -0
  253. package/dist/modules/workspace/zip-entry-rules.js +154 -0
  254. package/dist/modules/workspace/zip-entry-rules.js.map +1 -0
  255. package/dist/modules/write-access/write-access.d.ts +60 -0
  256. package/dist/modules/write-access/write-access.d.ts.map +1 -0
  257. package/dist/modules/write-access/write-access.js +129 -0
  258. package/dist/modules/write-access/write-access.js.map +1 -0
  259. package/dist/shared/column-crypto.d.ts +194 -0
  260. package/dist/shared/column-crypto.d.ts.map +1 -0
  261. package/dist/shared/column-crypto.js +144 -0
  262. package/dist/shared/column-crypto.js.map +1 -0
  263. package/dist/shared/domain-errors.d.ts +25 -0
  264. package/dist/shared/domain-errors.d.ts.map +1 -1
  265. package/dist/shared/domain-errors.js +28 -0
  266. package/dist/shared/domain-errors.js.map +1 -1
  267. package/dist/shared/git.contract.d.ts +20 -0
  268. package/dist/shared/git.contract.d.ts.map +1 -1
  269. package/dist/shared/git.contract.js +26 -0
  270. package/dist/shared/git.contract.js.map +1 -1
  271. package/dist/shared/token-crypto.d.ts.map +1 -1
  272. package/dist/shared/token-crypto.js +25 -1
  273. package/dist/shared/token-crypto.js.map +1 -1
  274. package/dist/tenancy/static-tenant-source.d.ts +0 -1
  275. package/dist/tenancy/static-tenant-source.d.ts.map +1 -1
  276. package/dist/tenancy/static-tenant-source.js +1 -2
  277. package/dist/tenancy/static-tenant-source.js.map +1 -1
  278. package/dist/tenancy/tenant-secrets.d.ts +5 -1
  279. package/dist/tenancy/tenant-secrets.d.ts.map +1 -1
  280. package/dist/tenancy/tenant-secrets.js +4 -0
  281. package/dist/tenancy/tenant-secrets.js.map +1 -1
  282. package/kb-template/AGENTS.md +2 -0
  283. package/migrations/0014_change_request_closed_reason.sql +1 -0
  284. package/migrations/0015_account_deactivation.sql +3 -0
  285. package/migrations/0016_pii_encryption.sql +20 -0
  286. package/migrations/meta/0014_snapshot.json +2265 -0
  287. package/migrations/meta/0015_snapshot.json +2271 -0
  288. package/migrations/meta/0016_snapshot.json +2327 -0
  289. package/migrations/meta/_journal.json +21 -0
  290. package/package.json +3 -3
  291. package/src/__tests__/kb-layout-config.test.ts +0 -2
  292. package/src/__tests__/retired-settings.test.ts +96 -0
  293. package/src/core/__tests__/gated-boot.test.ts +132 -0
  294. package/src/core/__tests__/lifecycle.test.ts +241 -5
  295. package/src/core/__tests__/set-aside-root-is-one-place.test.ts +63 -0
  296. package/src/core/core-ports.ts +11 -1
  297. package/src/core/create-core-server.ts +52 -16
  298. package/src/core/create-core-services.ts +156 -24
  299. package/src/core/lifecycle.ts +117 -13
  300. package/src/core-config.ts +31 -14
  301. package/src/index.ts +30 -1
  302. package/src/modules/access/__tests__/access-control.preview-relocation.test.ts +385 -0
  303. package/src/modules/access/__tests__/access-control.prospective.test.ts +94 -18
  304. package/src/modules/access/__tests__/access.routes.prospective.test.ts +6 -7
  305. package/src/modules/access/__tests__/users-db-double.ts +21 -12
  306. package/src/modules/access/access-control.interface.ts +43 -12
  307. package/src/modules/access/access-control.service.ts +234 -36
  308. package/src/modules/access/access.routes.ts +17 -16
  309. package/src/modules/access/directory-sync-bot.ts +7 -3
  310. package/src/modules/agent-instructions/__tests__/agent-instructions.route.test.ts +11 -5
  311. package/src/modules/agent-instructions/__tests__/compose.test.ts +19 -10
  312. package/src/modules/agent-instructions/__tests__/shared-file-rules.test.ts +238 -0
  313. package/src/modules/agent-instructions/agent-instructions.routes.ts +12 -2
  314. package/src/modules/agent-instructions/compose.ts +50 -7
  315. package/src/modules/agent-instructions/index.ts +12 -0
  316. package/src/modules/agent-instructions/shared-file-rules.ts +314 -0
  317. package/src/modules/audit/agent-audit.service.ts +4 -2
  318. package/src/modules/auth/__tests__/account-deactivation.test.ts +253 -0
  319. package/src/modules/auth/__tests__/account-erasure.approval-gate.test.ts +6 -2
  320. package/src/modules/auth/__tests__/account.routes.test.ts +87 -6
  321. package/src/modules/auth/__tests__/auth.middleware.test.ts +45 -22
  322. package/src/modules/auth/__tests__/auth.service.test.ts +4 -2
  323. package/src/modules/auth/account-admission.ts +78 -9
  324. package/src/modules/auth/account-erasure.service.ts +69 -18
  325. package/src/modules/auth/account.routes.ts +63 -7
  326. package/src/modules/auth/auth.middleware.ts +19 -8
  327. package/src/modules/auth/auth.routes.ts +8 -0
  328. package/src/modules/auth/auth.service.ts +206 -23
  329. package/src/modules/auth/oidc-auth-provider.ts +7 -2
  330. package/src/modules/code-mode/__tests__/chain-runtime.e2e.test.ts +335 -0
  331. package/src/modules/code-mode/__tests__/code-mode.tool.test.ts +46 -2
  332. package/src/modules/code-mode/code-mode.tool.ts +80 -34
  333. package/src/modules/database/__tests__/connection.test.ts +12 -0
  334. package/src/modules/database/__tests__/pii-backfill.pg.test.ts +561 -0
  335. package/src/modules/database/connection.ts +117 -0
  336. package/src/modules/database/core-schema.ts +116 -58
  337. package/src/modules/database/migrate.ts +353 -1
  338. package/src/modules/kb-fs/__tests__/branch-name.test.ts +10 -0
  339. package/src/modules/kb-fs/__tests__/repo-path.test.ts +123 -0
  340. package/src/modules/kb-fs/branch-name.ts +14 -1
  341. package/src/modules/kb-fs/repo-path.ts +85 -0
  342. package/src/modules/kb-sync/__tests__/kb-sync.routes.test.ts +26 -1
  343. package/src/modules/kb-sync/kb-sync.routes.ts +14 -4
  344. package/src/modules/kb-sync/sync-auth.ts +2 -2
  345. package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +60 -3
  346. package/src/modules/mcp/__tests__/mcp.e2e.test.ts +4 -3
  347. package/src/modules/mcp/__tests__/mcp.service.test.ts +115 -13
  348. package/src/modules/mcp/mcp-auth.middleware.ts +20 -6
  349. package/src/modules/mcp/mcp.service.ts +46 -7
  350. package/src/modules/mcp/oauth/bevel-oauth-provider.ts +3 -1
  351. package/src/modules/plugins/__tests__/plugins.tools.test.ts +2 -1
  352. package/src/modules/plugins/join-request-records.store.ts +7 -4
  353. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +16 -0
  354. package/src/modules/settings/__tests__/setup.routes.git-mode.test.ts +91 -58
  355. package/src/modules/settings/__tests__/setup.routes.github-app.test.ts +25 -8
  356. package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +14 -11
  357. package/src/modules/settings/__tests__/setup.routes.repository-change.test.ts +390 -0
  358. package/src/modules/settings/__tests__/setup.routes.test.ts +3 -0
  359. package/src/modules/settings/deployment-settings.service.ts +8 -3
  360. package/src/modules/settings/setup.routes.ts +263 -19
  361. package/src/modules/skills/__tests__/allowed-tools-warn.tools.test.ts +2 -1
  362. package/src/modules/skills/__tests__/branch-skills.tools.test.ts +218 -0
  363. package/src/modules/skills/__tests__/skills.service.test.ts +229 -5
  364. package/src/modules/skills/skills.contract.ts +91 -18
  365. package/src/modules/skills/skills.service.ts +278 -41
  366. package/src/modules/skills/skills.tools.ts +80 -8
  367. package/src/modules/tool-auth/__tests__/manual-auth.middleware.test.ts +14 -1
  368. package/src/modules/tool-auth/external-api-key.service.ts +12 -4
  369. package/src/modules/tool-auth/internal-token.service.ts +3 -3
  370. package/src/modules/tool-auth/tool-auth.middleware.ts +34 -11
  371. package/src/modules/tool-helpers/__tests__/agent-roles-write.test.ts +5 -3
  372. package/src/modules/tool-helpers/__tests__/phase4-tools.test.ts +3 -3
  373. package/src/modules/tool-helpers/__tests__/validate-token.test.ts +11 -1
  374. package/src/modules/tool-helpers/tool-handler.ts +24 -4
  375. package/src/modules/tool-helpers/tool.contract.ts +3 -2
  376. package/src/modules/tool-helpers/validate-token.ts +1 -1
  377. package/src/modules/tool-manuals/tool-manuals.tools.ts +44 -31
  378. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +378 -0
  379. package/src/modules/tool-registry/description-length.ts +111 -0
  380. package/src/modules/workflow/__tests__/pending-commits.repository-replaced.test.ts +111 -0
  381. package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +25 -0
  382. package/src/modules/workflow/__tests__/workflow.service.repository-replaced.test.ts +249 -0
  383. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +264 -6
  384. package/src/modules/workflow/agent-tools/change-request-summary.ts +182 -0
  385. package/src/modules/workflow/agent-tools/workflow.tools.ts +86 -35
  386. package/src/modules/workflow/file-lock.service.ts +31 -0
  387. package/src/modules/workflow/git/__tests__/git.service.fileBytesAtCommit.test.ts +260 -0
  388. package/src/modules/workflow/git/__tests__/git.service.prFetchFailure.test.ts +170 -0
  389. package/src/modules/workflow/git/git.service.ts +215 -1
  390. package/src/modules/workflow/pending-commits.service.ts +64 -2
  391. package/src/modules/workflow/recovery-bot.ts +7 -3
  392. package/src/modules/workflow/review-workflow/__tests__/carry-approvals-forward.test.ts +4 -0
  393. package/src/modules/workflow/review-workflow/__tests__/erase-approver.test.ts +25 -3
  394. package/src/modules/workflow/review-workflow/review-workflow.service.ts +12 -3
  395. package/src/modules/workflow/workflow-hooks.ts +64 -26
  396. package/src/modules/workflow/workflow.service.ts +125 -5
  397. package/src/modules/workspace/__tests__/agent-access.gate.test.ts +208 -0
  398. package/src/modules/workspace/__tests__/agent-uploads.test.ts +1604 -0
  399. package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +394 -0
  400. package/src/modules/workspace/__tests__/file-stat-access.test.ts +2 -1
  401. package/src/modules/workspace/__tests__/git-internals.security.test.ts +2 -1
  402. package/src/modules/workspace/__tests__/set-aside-clone.test.ts +52 -0
  403. package/src/modules/workspace/__tests__/workspace.routes.at-ref.test.ts +305 -0
  404. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +2 -0
  405. package/src/modules/workspace/__tests__/workspace.service.any-workspace-credentials.test.ts +156 -0
  406. package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +147 -0
  407. package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +402 -0
  408. package/src/modules/workspace/__tests__/workspace.service.test.ts +77 -9
  409. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +41 -39
  410. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +2 -3
  411. package/src/modules/workspace/__tests__/workspace.tools.test.ts +766 -56
  412. package/src/modules/workspace/agent-access.gate.ts +164 -0
  413. package/src/modules/workspace/agent-upload.routes.ts +214 -0
  414. package/src/modules/workspace/agent-upload.store.ts +668 -0
  415. package/src/modules/workspace/routine-write-policy.ts +5 -6
  416. package/src/modules/workspace/session-sink.ts +5 -5
  417. package/src/modules/workspace/set-aside-clone.ts +96 -0
  418. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +86 -0
  419. package/src/modules/workspace/startup/kb-startup-runner.ts +115 -34
  420. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +9 -9
  421. package/src/modules/workspace/startup/steps/seed-tree.ts +3 -6
  422. package/src/modules/workspace/startup/steps/template-source.ts +53 -5
  423. package/src/modules/workspace/upload-limits.ts +12 -0
  424. package/src/modules/workspace/workspace.routes.ts +96 -5
  425. package/src/modules/workspace/workspace.service.ts +319 -61
  426. package/src/modules/workspace/workspace.tools.ts +894 -214
  427. package/src/modules/workspace/write-denial.ts +0 -8
  428. package/src/modules/workspace/zip-entry-rules.ts +173 -0
  429. package/src/modules/write-access/__tests__/write-access.test.ts +248 -0
  430. package/src/modules/write-access/write-access.ts +153 -0
  431. package/src/shared/__tests__/column-crypto.test.ts +217 -0
  432. package/src/shared/column-crypto.ts +218 -0
  433. package/src/shared/domain-errors.ts +31 -0
  434. package/src/shared/git.contract.ts +27 -0
  435. package/src/shared/token-crypto.ts +28 -1
  436. package/src/tenancy/__tests__/static-tenant-source.test.ts +4 -1
  437. package/src/tenancy/static-tenant-source.ts +1 -3
  438. package/src/tenancy/tenant-secrets.ts +5 -1
  439. package/dist/modules/workflow/session-ontology.policy.d.ts +0 -52
  440. package/dist/modules/workflow/session-ontology.policy.d.ts.map +0 -1
  441. package/dist/modules/workflow/session-ontology.policy.js +0 -62
  442. package/dist/modules/workflow/session-ontology.policy.js.map +0 -1
  443. package/dist/modules/workflow/session-ontology.service.d.ts +0 -105
  444. package/dist/modules/workflow/session-ontology.service.d.ts.map +0 -1
  445. package/dist/modules/workflow/session-ontology.service.js +0 -147
  446. package/dist/modules/workflow/session-ontology.service.js.map +0 -1
  447. package/dist/modules/workspace/session-ontology.gate.d.ts +0 -114
  448. package/dist/modules/workspace/session-ontology.gate.d.ts.map +0 -1
  449. package/dist/modules/workspace/session-ontology.gate.js +0 -161
  450. package/dist/modules/workspace/session-ontology.gate.js.map +0 -1
  451. package/dist/shared/kb-layout.d.ts +0 -39
  452. package/dist/shared/kb-layout.d.ts.map +0 -1
  453. package/dist/shared/kb-layout.js +0 -103
  454. package/dist/shared/kb-layout.js.map +0 -1
  455. package/dist/shared/kb-layout.test.d.ts +0 -2
  456. package/dist/shared/kb-layout.test.d.ts.map +0 -1
  457. package/dist/shared/kb-layout.test.js +0 -75
  458. package/dist/shared/kb-layout.test.js.map +0 -1
  459. package/src/modules/workflow/__tests__/session-ontology.policy.test.ts +0 -62
  460. package/src/modules/workflow/__tests__/session-ontology.service.test.ts +0 -201
  461. package/src/modules/workflow/session-ontology.policy.ts +0 -70
  462. package/src/modules/workflow/session-ontology.service.ts +0 -183
  463. package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +0 -239
  464. package/src/modules/workspace/session-ontology.gate.ts +0 -191
  465. package/src/shared/kb-layout.test.ts +0 -98
  466. package/src/shared/kb-layout.ts +0 -102
@@ -0,0 +1,217 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { PgDialect, pgTable, uuid } from 'drizzle-orm/pg-core';
4
+ import { eq, inArray } from 'drizzle-orm';
5
+ import {
6
+ IndexOnWrite,
7
+ PII_CIPHERTEXT_PREFIX,
8
+ PII_SEALED_SHAPE_SQL_REGEX,
9
+ SealOnWrite,
10
+ blindIndexText,
11
+ derivePiiKeys,
12
+ encryptedText,
13
+ isEncryptedBlob,
14
+ } from '../column-crypto.js';
15
+ import { TokenCrypto } from '../token-crypto.js';
16
+
17
+ const KEY = randomBytes(32).toString('base64');
18
+ const keys = derivePiiKeys(KEY);
19
+ const otherKeys = derivePiiKeys(randomBytes(32).toString('base64'));
20
+
21
+ describe('derivePiiKeys: seal / open / read', () => {
22
+ it('round-trips a value through ciphertext', () => {
23
+ const sealed = keys.seal('razvan@bevel.software');
24
+ expect(sealed).not.toContain('razvan');
25
+ expect(sealed.startsWith(PII_CIPHERTEXT_PREFIX)).toBe(true);
26
+ expect(isEncryptedBlob(sealed)).toBe(true);
27
+ expect(keys.read(sealed)).toBe('razvan@bevel.software');
28
+ });
29
+
30
+ it('stores the empty string as itself (no unparseable empty-ciphertext blob)', () => {
31
+ expect(keys.seal('')).toBe('');
32
+ expect(keys.read('')).toBe('');
33
+ expect(isEncryptedBlob('')).toBe(false);
34
+ });
35
+
36
+ it('is randomized — the same plaintext never encrypts to the same blob', () => {
37
+ expect(keys.seal('alice')).not.toBe(keys.seal('alice'));
38
+ });
39
+
40
+ it('passes legacy plaintext through unchanged (pre-backfill rows)', () => {
41
+ expect(keys.read('plain old email@example.com')).toBe('plain old email@example.com');
42
+ });
43
+
44
+ it('passes through plaintext that merely resembles ciphertext', () => {
45
+ // Blob-shaped but unprefixed (the legacy TokenCrypto shape) → plaintext.
46
+ const shapeOnly = new TokenCrypto(KEY).encrypt('not-a-pii-blob');
47
+ expect(isEncryptedBlob(shapeOnly)).toBe(false);
48
+ expect(keys.read(shapeOnly)).toBe(shapeOnly);
49
+ // Prefixed but malformed → still not a blob.
50
+ const impostor = `${PII_CIPHERTEXT_PREFIX}abc:def:ghi`;
51
+ expect(isEncryptedBlob(impostor)).toBe(false);
52
+ expect(keys.read(impostor)).toBe(impostor);
53
+ });
54
+
55
+ it('the SQL shape regex agrees with isEncryptedBlob on what a sealed value is', () => {
56
+ // The backfill trusts rows matching this regex as sealed and skips them;
57
+ // the app-side check is the authority. Pinned together here so a change
58
+ // to the prefix, IV or tag width in one place fails this test.
59
+ const regex = new RegExp(PII_SEALED_SHAPE_SQL_REGEX);
60
+ for (const plain of ['a@b.co', 'razvan@bevel.software', 'x'.repeat(500)]) {
61
+ const sealed = keys.seal(plain);
62
+ expect(isEncryptedBlob(sealed)).toBe(true);
63
+ expect(regex.test(sealed)).toBe(true);
64
+ }
65
+ // The same blob with its padding spelled otherwise decodes to the same
66
+ // bytes, and the two predicates used to disagree about it: the database
67
+ // called it unsealed and selected it, this process called it sealed and
68
+ // skipped it, on every start. One answer, from one pattern.
69
+ const [iv, tag, ct] = keys.seal('a@b.co').slice(PII_CIPHERTEXT_PREFIX.length).split(':') as [string, string, string];
70
+ const respelled = [
71
+ `${PII_CIPHERTEXT_PREFIX}${iv}:${tag.replace(/=+$/, '')}:${ct}`,
72
+ `${PII_CIPHERTEXT_PREFIX}${iv}==:${tag}:${ct}`,
73
+ `${PII_CIPHERTEXT_PREFIX}${iv}:${tag}:${ct}\n`,
74
+ ];
75
+ for (const unsealed of [
76
+ 'a@b.co',
77
+ '',
78
+ `${PII_CIPHERTEXT_PREFIX}abc:def:ghi`,
79
+ new TokenCrypto(KEY).encrypt('x'),
80
+ ...respelled,
81
+ ]) {
82
+ expect(isEncryptedBlob(unsealed), JSON.stringify(unsealed)).toBe(false);
83
+ expect(regex.test(unsealed), JSON.stringify(unsealed)).toBe(false);
84
+ }
85
+ });
86
+
87
+ it("another key cannot open it: the lenient read hands the blob back, open says so", () => {
88
+ const sealed = keys.seal('secret-person@example.com');
89
+ expect(otherKeys.read(sealed)).toBe(sealed);
90
+ expect(otherKeys.open(sealed)).toEqual({ ok: false });
91
+ expect(keys.open(sealed)).toEqual({ ok: true, plain: 'secret-person@example.com' });
92
+ });
93
+
94
+ it('open tells a blob the key does not open from a plaintext shaped like one', () => {
95
+ // A value whose PLAINTEXT is itself a well-formed blob: opened correctly,
96
+ // the result still looks sealed. Only the explicit outcome tells the two
97
+ // apart — the shape of what the lenient read returns cannot.
98
+ const inner = keys.seal('inner@example.com');
99
+ const outer = keys.seal(inner);
100
+ expect(keys.open(outer)).toEqual({ ok: true, plain: inner });
101
+ expect(keys.open('plain@example.com')).toEqual({ ok: true, plain: 'plain@example.com' });
102
+ expect(otherKeys.open(outer)).toEqual({ ok: false });
103
+ });
104
+
105
+ it('domain-separates from the raw secrets key via HKDF', () => {
106
+ // The column key is DERIVED from KEY — a TokenCrypto built from the raw
107
+ // KEY itself must not be able to open a PII blob's body.
108
+ const body = keys.seal('secret-person@example.com').slice(PII_CIPHERTEXT_PREFIX.length);
109
+ expect(() => new TokenCrypto(KEY).decrypt(body)).toThrow();
110
+ });
111
+
112
+ it('refuses a key that is not 32 bytes', () => {
113
+ expect(() => derivePiiKeys('too-short')).toThrow();
114
+ });
115
+
116
+ /**
117
+ * Node's base64 decoder is forgiving in ways a key must not be. Every value
118
+ * below DECODES TO 32 BYTES, so the length check passes each of them, and
119
+ * none of them is a way those 32 bytes are written: a mistyped key that
120
+ * became another key, sealing data the value an operator wrote down will
121
+ * never open. The property is one, whatever the mistake was — what is
122
+ * written must be a spelling of what it decodes to.
123
+ */
124
+ it('refuses whatever decodes to 32 bytes without being a spelling of them', () => {
125
+ const unpadded = KEY.replace(/=+$/, '');
126
+ const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
127
+ // The last character of 32 bytes carries four bits of the key and two
128
+ // that must be zero. Its neighbour in the alphabet sets one of those two.
129
+ const trailingBits = unpadded.slice(0, -1) + alphabet[alphabet.indexOf(unpadded.slice(-1)) + 1];
130
+ const mistakes = {
131
+ 'a character the alphabet does not have': `${KEY.slice(0, 10)}$${KEY.slice(10)}`,
132
+ 'a space in the middle': `${KEY.slice(0, 20)} ${KEY.slice(20)}`,
133
+ 'more padding than its length takes': `${unpadded}==`,
134
+ 'bits its last character should not carry': trailingBits,
135
+ };
136
+ for (const [mistake, written] of Object.entries(mistakes)) {
137
+ // The premise: only the round trip can tell this from a key.
138
+ expect(Buffer.from(written, 'base64'), mistake).toHaveLength(32);
139
+ expect(() => derivePiiKeys(written), mistake).toThrow(/SECRETS_ENC_KEY is not a clean hex or base64 spelling/);
140
+ }
141
+ // Padding in the MIDDLE ends the decoding there, so that one is short and
142
+ // the length check has always refused it.
143
+ expect(() => derivePiiKeys(`${unpadded.slice(0, 20)}=${unpadded.slice(20)}`)).toThrow(/must decode to 32 bytes/);
144
+ });
145
+
146
+ it('takes one key in any of its spellings: hex, base64, url-safe, unpadded, with whitespace around it', () => {
147
+ const raw = Buffer.from(KEY, 'base64');
148
+ const index = keys.index('a@b.co');
149
+ for (const spelled of [
150
+ raw.toString('hex'),
151
+ raw.toString('hex').toUpperCase(),
152
+ raw.toString('base64url'),
153
+ `${raw.toString('base64url')}=`,
154
+ KEY.replace(/=+$/, ''),
155
+ ` ${KEY}\n`,
156
+ ]) {
157
+ expect(derivePiiKeys(spelled).index('a@b.co'), spelled).toBe(index);
158
+ }
159
+ });
160
+ });
161
+
162
+ describe('derivePiiKeys: index', () => {
163
+ it('is deterministic and case/whitespace-insensitive', () => {
164
+ expect(keys.index('Alice@Example.com ')).toBe(keys.index('alice@example.com'));
165
+ expect(keys.index('alice@example.com')).toMatch(/^[0-9a-f]{64}$/);
166
+ });
167
+
168
+ it('differs across values', () => {
169
+ expect(keys.index('a@example.com')).not.toBe(keys.index('b@example.com'));
170
+ });
171
+
172
+ it('differs across keys, so one tenant’s index says nothing about another’s', () => {
173
+ expect(otherKeys.index('a@example.com')).not.toBe(keys.index('a@example.com'));
174
+ });
175
+ });
176
+
177
+ /**
178
+ * The column types hold no key: what they hand the driver is a mark for the
179
+ * database handle's connection to replace. Pinned through drizzle itself —
180
+ * the statement a service writes, rendered — because that is where the mark
181
+ * has to survive to.
182
+ */
183
+ describe('the column types mark values for the handle', () => {
184
+ const people = pgTable('people', {
185
+ id: uuid('id').primaryKey(),
186
+ email: encryptedText('email').notNull(),
187
+ emailBidx: blindIndexText('email_bidx').notNull(),
188
+ });
189
+ const paramsOf = (clause: unknown) => new PgDialect().sqlToQuery(clause as never).params;
190
+
191
+ it('a comparison on an index column binds the address, marked to be indexed', () => {
192
+ const [bound] = paramsOf(eq(people.emailBidx, 'Ada@Example.com'));
193
+ expect(bound).toBeInstanceOf(IndexOnWrite);
194
+ expect((bound as IndexOnWrite).stored(keys)).toBe(keys.index('ada@example.com'));
195
+ expect(paramsOf(inArray(people.emailBidx, ['a@x.co', 'b@x.co'])).every((p) => p instanceof IndexOnWrite)).toBe(true);
196
+ });
197
+
198
+ it('a value for an encrypted column is marked to be sealed; the empty string is stored as it is', () => {
199
+ const [bound] = paramsOf(eq(people.email, 'Ada@Example.com'));
200
+ expect(bound).toBeInstanceOf(SealOnWrite);
201
+ expect(keys.read((bound as SealOnWrite).stored(keys))).toBe('Ada@Example.com');
202
+ expect(paramsOf(eq(people.email, ''))).toEqual(['']);
203
+ });
204
+
205
+ it('a mark that reaches a connection holding no key refuses to be written', () => {
206
+ // `pg` serialises an object parameter through its `toPostgres`. Anything
207
+ // else here — JSON of the object, say — would store the plaintext.
208
+ const [bound] = paramsOf(eq(people.email, 'Ada@Example.com'));
209
+ expect(() => (bound as SealOnWrite).toPostgres()).toThrow(/holds no key/);
210
+ });
211
+
212
+ it('refuses a stored index written back into an index column', () => {
213
+ // Reading the column gives the stored index; writing that back would
214
+ // index the index and the row would answer to no address.
215
+ expect(() => paramsOf(eq(people.emailBidx, keys.index('ada@example.com')))).toThrow(/stored index/);
216
+ });
217
+ });
@@ -0,0 +1,218 @@
1
+ import { createHmac, hkdfSync } from 'node:crypto';
2
+ import { customType } from 'drizzle-orm/pg-core';
3
+ import { TokenCrypto, assertKeyDecodesTo32Bytes } from './token-crypto.js';
4
+
5
+ /**
6
+ * Application-layer encryption for PII columns. Personal data (emails, names,
7
+ * change-request text) is AES-256-GCM ciphertext in Postgres, so a leaked
8
+ * dump, an injected query, or a compromised DB credential yields no personal
9
+ * data — the key lives only in the app's environment. Disk-level encryption
10
+ * still carries the blanket at-rest claim (git refs, WAL, logs); this layer is
11
+ * the DB-specific control on top.
12
+ *
13
+ * THE KEY BELONGS TO THE DATABASE HANDLE, not to the process. A process that
14
+ * serves several knowledge bases holds one handle per tenant, each built with
15
+ * that tenant's own `SECRETS_ENC_KEY` (`createDb(url, { piiKey })`), so a
16
+ * tenant's rows are sealed under the same key as its stored credentials and
17
+ * its dump opens, whole, with that one key.
18
+ *
19
+ * A drizzle column type is a module-level object and is told nothing about
20
+ * the handle a statement runs on, so it cannot hold a key. It only MARKS a
21
+ * value ({@link SealOnWrite}, {@link IndexOnWrite}); the handle's own
22
+ * connection replaces the mark with ciphertext or a blind index on the way
23
+ * in, and opens every sealed value on the way out (see `connection.ts`). A
24
+ * mark that reaches a connection with no key refuses to be written.
25
+ *
26
+ * Both keys are HKDF-derived from the handle's key with distinct info
27
+ * strings, so PII ciphertext and blind indexes are domain-separated from the
28
+ * secrets-vault key without asking operators to provision a second variable.
29
+ */
30
+
31
+ /** What a database handle seals, opens and indexes personal data with. */
32
+ export interface PiiKeys {
33
+ /**
34
+ * Encrypt a value for storage (fresh random IV — NOT equality-comparable).
35
+ *
36
+ * The empty string is stored as itself: it carries no personal data, GCM of
37
+ * an empty plaintext would produce an empty ciphertext segment the blob
38
+ * parser cannot represent, and columns with a DB-level `DEFAULT ''` then
39
+ * hold exactly the same representation as an app-written empty value.
40
+ */
41
+ seal(plain: string): string;
42
+ /**
43
+ * Open a stored value, and SAY whether it opened: a value that is not a
44
+ * blob is its own plaintext; a blob this key does not open is `ok: false`.
45
+ * For the callers that must tell "the key does not open this" from a value
46
+ * — the shape of what {@link read} returns proves nothing, since a
47
+ * plaintext may itself be shaped like a blob.
48
+ */
49
+ open(value: string): { ok: true; plain: string } | { ok: false };
50
+ /**
51
+ * The lenient read every query result goes through: a blob this key does
52
+ * not open is handed back as it is, as is anything that is not a blob —
53
+ * rows written before the encryption release stay readable until the
54
+ * backfill at start rewrites them.
55
+ */
56
+ read(value: string): string;
57
+ /**
58
+ * Blind index for equality on an encrypted column: HMAC-SHA256 of the
59
+ * trimmed, lower-cased value, hex-encoded. Deterministic, so a `*_bidx`
60
+ * column can carry the unique constraints and lookups that randomized
61
+ * ciphertext cannot. Reveals only equality, never content.
62
+ */
63
+ index(value: string): string;
64
+ }
65
+
66
+ /** Derive the personal-data keys from a deployment's (or a tenant's) `SECRETS_ENC_KEY`. */
67
+ export function derivePiiKeys(secretsEncKey: string): PiiKeys {
68
+ const ikm = assertKeyDecodesTo32Bytes(secretsEncKey, 'SECRETS_ENC_KEY');
69
+ const columnKey = Buffer.from(hkdfSync('sha256', ikm, Buffer.alloc(0), 'bevel-pii-column-v1', 32));
70
+ const bidxKey = Buffer.from(hkdfSync('sha256', ikm, Buffer.alloc(0), 'bevel-pii-bidx-v1', 32));
71
+ const crypto = new TokenCrypto(columnKey.toString('base64'));
72
+ const open: PiiKeys['open'] = (value) => {
73
+ if (!isEncryptedBlob(value)) return { ok: true, plain: value };
74
+ try {
75
+ return { ok: true, plain: crypto.decrypt(value.slice(PII_CIPHERTEXT_PREFIX.length)) };
76
+ } catch {
77
+ return { ok: false };
78
+ }
79
+ };
80
+ return {
81
+ seal: (plain) => (plain === '' ? '' : PII_CIPHERTEXT_PREFIX + crypto.encrypt(plain)),
82
+ open,
83
+ read: (value) => {
84
+ const opened = open(value);
85
+ return opened.ok ? opened.plain : value;
86
+ },
87
+ index: (value) => createHmac('sha256', bidxKey).update(value.trim().toLowerCase()).digest('hex'),
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Every PII ciphertext starts with this marker. An explicit prefix — rather
93
+ * than recognising ciphertext by its `iv:tag:ct` shape — means legacy
94
+ * plaintext can never be mistaken for ciphertext (and silently skipped by the
95
+ * backfill), and lets the backfill find unsealed rows with a plain SQL
96
+ * predicate instead of scanning every row in the app. Bump the version
97
+ * segment if the format ever changes.
98
+ */
99
+ export const PII_CIPHERTEXT_PREFIX = 'pii:v1:';
100
+
101
+ /**
102
+ * The shape of a PII ciphertext blob, as ONE pattern both Postgres and this
103
+ * process read: the version prefix followed by base64 segments of the exact
104
+ * widths GCM produces (12-byte IV → 16 chars, 16-byte tag → 22 chars + `==`).
105
+ * Lets the backfill find unsealed rows with a `!~` predicate instead of
106
+ * scanning every row in the app.
107
+ *
108
+ * {@link isEncryptedBlob} is built FROM it rather than written beside it. The
109
+ * two used to be separate spellings of one idea, and they disagreed: the
110
+ * JavaScript one decoded each part and so took an unpadded tag for a 16-byte
111
+ * one, which this pattern does not. A value the two disagreed about was
112
+ * selected by the backfill's SQL as unsealed and then skipped by its
113
+ * JavaScript as sealed, on every start, and stayed in clear for good. The
114
+ * pattern uses nothing POSIX and JavaScript read differently.
115
+ */
116
+ export const PII_SEALED_SHAPE_SQL_REGEX = `^${PII_CIPHERTEXT_PREFIX}[A-Za-z0-9+/]{16}:[A-Za-z0-9+/]{22}==:[A-Za-z0-9+/]+={0,2}$`;
117
+
118
+ const PII_SEALED_SHAPE = new RegExp(PII_SEALED_SHAPE_SQL_REGEX);
119
+
120
+ /**
121
+ * Whether `value` has the shape of a PII ciphertext blob — exactly the values
122
+ * {@link PII_SEALED_SHAPE_SQL_REGEX} matches in the database, no more and no
123
+ * fewer. Shape only: whether the configured key OPENS it is `PiiKeys.open`'s
124
+ * answer, and a plaintext somebody typed in this shape passes here.
125
+ */
126
+ export function isEncryptedBlob(value: string): boolean {
127
+ return PII_SEALED_SHAPE.test(value);
128
+ }
129
+
130
+ /**
131
+ * A value on its way to the database that the handle's connection still has
132
+ * to turn into what is stored. It is never a storable value itself: `pg`
133
+ * asks an object how to serialise itself through `toPostgres`, and this one
134
+ * refuses, so a statement that reaches a connection which did not replace it
135
+ * — a pool built without `createDb`, or a handle built without a key — fails
136
+ * instead of writing the plaintext.
137
+ */
138
+ export abstract class PiiParam {
139
+ constructor(readonly plain: string) {}
140
+
141
+ /** What the handle's keys make of it. */
142
+ abstract stored(keys: PiiKeys): string;
143
+
144
+ toPostgres(): never {
145
+ throw new Error(
146
+ 'A personal-data value reached a database connection that holds no key for it. ' +
147
+ 'Build the handle with createDb/getDb and its piiKey (createCoreServices does).',
148
+ );
149
+ }
150
+ }
151
+
152
+ /** Plaintext of an {@link encryptedText} column: stored as ciphertext. */
153
+ export class SealOnWrite extends PiiParam {
154
+ stored(keys: PiiKeys): string {
155
+ return keys.seal(this.plain);
156
+ }
157
+ }
158
+
159
+ /** The address a {@link blindIndexText} column is written or compared with: stored as its blind index. */
160
+ export class IndexOnWrite extends PiiParam {
161
+ stored(keys: PiiKeys): string {
162
+ return keys.index(this.plain);
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Drizzle column type for encrypted PII text: services read and write
168
+ * plaintext, the database only ever sees ciphertext. NEVER use an
169
+ * `encryptedText` column in a WHERE clause or conflict target — the fresh IV
170
+ * per write means `eq(column, plaintext)` silently matches nothing. Equality
171
+ * goes through the column's `*_bidx` companion ({@link blindIndexText}).
172
+ */
173
+ export const encryptedText = customType<{ data: string; driverData: string }>({
174
+ dataType() {
175
+ return 'text';
176
+ },
177
+ toDriver(value: string): string {
178
+ // The mark, typed as the text it becomes on the handle's connection.
179
+ return (value === '' ? '' : new SealOnWrite(value)) as string;
180
+ },
181
+ fromDriver(value: string): string {
182
+ // Already opened: the handle's connection opens every sealed value of a
183
+ // result before drizzle maps it.
184
+ return value;
185
+ },
186
+ });
187
+
188
+ const STORED_INDEX = /^[0-9a-f]{64}$/;
189
+
190
+ /**
191
+ * Drizzle column type for the blind index beside an encrypted column. WRITE
192
+ * AND COMPARE IT WITH THE VALUE ITSELF — `emailBidx: email`,
193
+ * `eq(users.emailBidx, email)`, `inArray(users.emailBidx, emails)` — and the
194
+ * handle's connection stores or binds its index. Reading the column gives the
195
+ * stored index, which is good for nothing but SQL: writing it back would
196
+ * index the index, so that is refused. A row copied from another carries the
197
+ * value its index was made from, not the index.
198
+ *
199
+ * Only through drizzle's operators, which bind through the column: a value
200
+ * interpolated into a raw `sql` template is compared as it is and matches
201
+ * nothing.
202
+ */
203
+ export const blindIndexText = customType<{ data: string; driverData: string }>({
204
+ dataType() {
205
+ return 'text';
206
+ },
207
+ toDriver(value: string): string {
208
+ if (STORED_INDEX.test(value)) {
209
+ throw new Error(
210
+ 'A blind-index column was written with a stored index. Write it with the value the index is of (the address).',
211
+ );
212
+ }
213
+ return new IndexOnWrite(value) as unknown as string;
214
+ },
215
+ fromDriver(value: string): string {
216
+ return value;
217
+ },
218
+ });
@@ -377,6 +377,37 @@ export class PathNotFoundError extends WorkflowDomainError {
377
377
  }
378
378
  }
379
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
+
380
411
  /**
381
412
  * An archive the caller uploaded that the zip reader cannot open. 422, not
382
413
  * 400: the request is well-formed and the path is fine — the BYTES are not a
@@ -209,6 +209,33 @@ export class GitRunError extends Error {
209
209
  }
210
210
  }
211
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
+
212
239
  /** Whether a failure came from the deadline rather than from git's own answer. */
213
240
  export function isGitTimeout(err: unknown): boolean {
214
241
  return err instanceof GitRunError && err.timedOut;
@@ -61,12 +61,39 @@ function decodeKey(raw: string): Buffer {
61
61
  * is then safe, because a bad key never gets that far.
62
62
  */
63
63
  export function assertKeyDecodesTo32Bytes(rawKey: string, envVarName: string): Buffer {
64
- const key = decodeKey(rawKey);
64
+ // Whitespace around it is the environment's, not the key's.
65
+ const written = rawKey.trim();
66
+ const key = decodeKey(written);
65
67
  if (key.length !== 32) {
66
68
  throw new Error(
67
69
  `${envVarName} must decode to 32 bytes (got ${key.length}). ` +
68
70
  'Generate one with: `node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'base64\'))"`.',
69
71
  );
70
72
  }
73
+ // WHAT IS WRITTEN MUST BE A SPELLING OF WHAT IT DECODES TO. Node's decoder is
74
+ // forgiving in ways a key must not be: it skips a character it does not know,
75
+ // it stops at padding wherever padding stands, and it ignores bits a final
76
+ // character should not carry. Each of those turns a mistyped key into 32
77
+ // bytes — a DIFFERENT key, sealing data that the value an operator wrote
78
+ // down will never open. Asking which characters and which padding are
79
+ // acceptable is a list that is always one case short, so the question is put
80
+ // the other way round: encode the bytes again, and accept the value only
81
+ // when it is one of the ways those bytes are written.
82
+ if (!spellingsOf(key).includes(/^[0-9a-fA-F]{64}$/.test(written) ? written.toLowerCase() : written)) {
83
+ throw new Error(
84
+ `${envVarName} is not a clean hex or base64 spelling of a 32-byte key: it holds a character, a padding ` +
85
+ 'or trailing bits the encoding does not have, which the decoder skipped. The key in use so far is what ' +
86
+ 'it decoded to; print that key spelled properly with ' +
87
+ `\`node -e "console.log(Buffer.from(process.env.${envVarName}, 'base64').toString('base64'))"\` and set that.`,
88
+ );
89
+ }
71
90
  return key;
72
91
  }
92
+
93
+ /** Every way 32 bytes are written as a key: hex, and base64 in either alphabet, padded or not. */
94
+ function spellingsOf(key: Buffer): string[] {
95
+ const standard = key.toString('base64');
96
+ const urlSafe = key.toString('base64url');
97
+ const padding = standard.slice(standard.replace(/=+$/, '').length);
98
+ return [key.toString('hex'), standard, standard.replace(/=+$/, ''), urlSafe, urlSafe + padding];
99
+ }
@@ -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
  };
@@ -28,6 +27,10 @@ describe('tenantConfigFrom', () => {
28
27
  expect(config.workspacesRoot).toBe(path.resolve('/srv/hexis/workspaces/acme-2'));
29
28
  expect(config.backupsRoot).toBe(path.resolve('/srv/hexis/backups/acme-2'));
30
29
  expect(config.spillRoot).toBe(path.resolve('/srv/hexis/tool-chain-spills/acme-2'));
30
+ // Per tenant like the rest: a shared staging root would let one tenant's
31
+ // upload sweep delete another's bytes, and both would be writing ids into
32
+ // one directory.
33
+ expect(config.agentUploadsRoot).toBe(path.resolve('/srv/hexis/agent-uploads/acme-2'));
31
34
  expect(config.docExtractCacheRoot).toBe(path.resolve('/srv/hexis/doc-extract-cache/acme-2'));
32
35
  expect(config.loopbackBaseUrl).toBe('http://127.0.0.1:3001/_tenant/acme-2');
33
36
  });
@@ -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;
@@ -165,6 +164,7 @@ export function tenantConfigFrom(record: TenantRecord, settings: TenantHostSetti
165
164
  workspacesRoot: path.resolve(settings.workspacesRoot, slug),
166
165
  backupsRoot: besideWorkspaces('backups'),
167
166
  spillRoot: besideWorkspaces('tool-chain-spills'),
167
+ agentUploadsRoot: besideWorkspaces('agent-uploads'),
168
168
  docExtractCacheRoot: besideWorkspaces('doc-extract-cache'),
169
169
  jwtSecret: secrets.jwtSecret,
170
170
  secretsEncKey: secrets.secretsEncKey,
@@ -182,7 +182,6 @@ export function tenantConfigFrom(record: TenantRecord, settings: TenantHostSetti
182
182
  gitUsername,
183
183
  gitToken: (record.gitToken ?? '').trim(),
184
184
  kbTemplateDir: settings.kbTemplateDir,
185
- ontologySessionBlock: settings.ontologySessionBlock,
186
185
  updateCheckEnabled: settings.updateCheckEnabled,
187
186
  allowedEmailDomains,
188
187
  trustProxy: settings.trustProxy,
@@ -289,7 +288,6 @@ export function tenantHostEnv(env: NodeJS.ProcessEnv = process.env): TenantHostE
289
288
  kbTemplateDir: env.KB_TEMPLATE_DIR || defaultKbTemplateDir(),
290
289
  gitTimeoutMs:
291
290
  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
291
  updateCheckEnabled: (env.UPDATE_CHECK ?? 'true').trim().toLowerCase() !== 'false',
294
292
  idleMinutes: Number.isFinite(idle) && idle > 0 ? idle : 30,
295
293
  };
@@ -4,7 +4,7 @@ import { hkdfSync } from 'node:crypto';
4
4
  export interface TenantSecrets {
5
5
  /** Signs the tenant's login sessions and OAuth state. */
6
6
  jwtSecret: string;
7
- /** 32 bytes, base64: encrypts the tenant's vault values, MCP OAuth tokens and stored git credential. */
7
+ /** 32 bytes, base64: encrypts the tenant's vault values, MCP OAuth tokens, stored git credential and personal-data columns. */
8
8
  secretsEncKey: string;
9
9
  /** HMAC key for the tenant's internal (loopback) tool tokens. */
10
10
  internalTokenSecret: string;
@@ -25,6 +25,10 @@ const MIN_MASTER_KEY_LENGTH = 32;
25
25
  * export, and nothing per tenant has to be kept safe in the meantime. The
26
26
  * cloud app's registry calls this with the same inputs and gets the same
27
27
  * answer.
28
+ *
29
+ * The personal-data columns are sealed under `secretsEncKey` too (each
30
+ * tenant's database handle is built with it), so the same one key opens the
31
+ * whole of a tenant's dump.
28
32
  */
29
33
  export function deriveTenantSecrets(masterKey: string, slug: string): TenantSecrets {
30
34
  if (masterKey.length < MIN_MASTER_KEY_LENGTH) {