@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
@@ -1,9 +1,11 @@
1
1
  import { migrate } from 'drizzle-orm/node-postgres/migrator';
2
+ import { sql } from 'drizzle-orm';
2
3
  import { logger } from '../../shared/logging.js';
3
4
 
4
5
  const log = logger('database');
5
- import { DEFAULT_DB_SCHEMA, dbSchemaOf, type Database } from './connection.js';
6
+ import { DEFAULT_DB_SCHEMA, dbSchemaOf, piiKeysOf, type Database } from './connection.js';
6
7
  import { AdvisoryLock, withAdvisoryLock } from './advisory-lock.js';
8
+ import { PII_SEALED_SHAPE_SQL_REGEX, isEncryptedBlob, type PiiKeys } from '../../shared/column-crypto.js';
7
9
 
8
10
  /*
9
11
  * ── Per-tier migration folders ──────────────────────────────────────────────
@@ -69,6 +71,9 @@ export async function runCoreMigrations(db: Database, folder: string): Promise<v
69
71
  log.info('Running core database migrations...');
70
72
  await migrate(db, { migrationsFolder: folder, migrationsTable: '__drizzle_migrations_core', migrationsSchema });
71
73
  log.info('Core migrations complete.');
74
+ // Under the same lock, before anything reads or writes a PII column:
75
+ // the data half of migration 0016, which SQL cannot do (see below).
76
+ await runPiiEncryptionBackfill(db);
72
77
  },
73
78
  { tenantKey },
74
79
  );
@@ -96,3 +101,350 @@ export async function runEnterpriseMigrations(db: Database, folder: string): Pro
96
101
  { tenantKey },
97
102
  );
98
103
  }
104
+
105
+ /*
106
+ * ── PII column encryption backfill ──────────────────────────────────────────
107
+ *
108
+ * Migration 0016 adds the `*_bidx` columns; the DATA change — rewriting
109
+ * pre-existing plaintext PII to AES-256-GCM ciphertext and filling the blind
110
+ * indexes — happens here, programmatically, because it needs the key the
111
+ * handle holds (SQL migrations cannot encrypt). Runs on every start right after
112
+ * the core history, under the migrations lock, and is idempotent: sealed rows
113
+ * carry the `PII_CIPHERTEXT_PREFIX` marker and are excluded by the SELECT's
114
+ * own WHERE clause, so a completed backfill degenerates to one cheap,
115
+ * empty-result query per table.
116
+ *
117
+ * Concurrency: the migrations lock (keyed per tenant, like the history it
118
+ * guards) serialises two processes booting at once. Each UPDATE is also
119
+ * compare-and-swap — the WHERE clause pins every value the row was read with
120
+ * — so a writer that slips in between the scan and the write (an old-version
121
+ * instance in a mixed-version window) loses nothing: the CAS update matches
122
+ * zero rows and the next start's pass seals whatever that writer left
123
+ * behind. Deployments should still stop the old app before starting the new
124
+ * one (see UPGRADING.md); the lock and CAS make the failure mode of not doing
125
+ * so "unsealed until next start", never "silently overwritten".
126
+ *
127
+ * Once every row is sealed, the same transaction applies the constraints that
128
+ * could not ship in the SQL migration: SET NOT NULL on the bidx columns and
129
+ * the unique-index swaps (`users_email_unique` on the now-randomized
130
+ * ciphertext is meaningless, `users_email_bidx_unq` takes over; the same for
131
+ * the approvals and the join requests). The new unique index goes up BEFORE
132
+ * the old one is dropped so duplicate protection never has a gap. Legacy rows
133
+ * that collide under the normalized blind index are handled first: duplicate
134
+ * approval and join-request rows (the same logical row, a case-variant email)
135
+ * are collapsed; duplicate USERS are a genuine account conflict and abort the
136
+ * start with the row ids so an operator can merge them deliberately. All
137
+ * statements are IF-EXISTS-guarded — re-running is a no-op.
138
+ *
139
+ * Runs against whatever schema the handle searches first: a tenant's own, or
140
+ * `public`, under that handle's key. Every table name is unqualified for that
141
+ * reason.
142
+ *
143
+ * This is the one place that works on the STORED form. The handle's
144
+ * connection opens every sealed value of a result, which here would hide
145
+ * exactly what has to be told apart — so the columns are read as bytes
146
+ * ({@link asStored}), which it leaves alone, and written as ready-made
147
+ * ciphertext, which it passes through.
148
+ */
149
+
150
+ interface PiiBackfillTable {
151
+ table: string;
152
+ /** Columns that uniquely identify a row for the write-back UPDATE. */
153
+ key: string[];
154
+ /** Columns whose plaintext values get rewritten as ciphertext. */
155
+ encrypted: string[];
156
+ /** Blind-index column to fill from the plaintext of `source`. */
157
+ bidx?: { source: string; column: string };
158
+ }
159
+
160
+ const PII_BACKFILL_TABLES: PiiBackfillTable[] = [
161
+ { table: 'users', key: ['id'], encrypted: ['email', 'name', 'avatar_url'], bidx: { source: 'email', column: 'email_bidx' } },
162
+ { table: 'pr_file_approvals', key: ['id'], encrypted: ['approver_email', 'approver_name'], bidx: { source: 'approver_email', column: 'approver_email_bidx' } },
163
+ { table: 'pr_merge_log', key: ['id'], encrypted: ['triggered_by_email', 'triggered_by_name', 'error'], bidx: { source: 'triggered_by_email', column: 'triggered_by_email_bidx' } },
164
+ { table: 'pr_comments', key: ['id'], encrypted: ['author_email', 'author_name', 'body'], bidx: { source: 'author_email', column: 'author_email_bidx' } },
165
+ { table: 'change_requests', key: ['id'], encrypted: ['author_email', 'author_name', 'title', 'body', 'apply_failure_reason', 'apply_failed_by_name'], bidx: { source: 'author_email', column: 'author_email_bidx' } },
166
+ { table: 'pending_commits', key: ['id'], encrypted: ['author_email', 'author_name', 'last_error'], bidx: { source: 'author_email', column: 'author_email_bidx' } },
167
+ { table: 'file_locks', key: ['workspace_id', 'branch', 'path'], encrypted: ['holder_name'] },
168
+ { table: 'plugin_join_requests', key: ['id'], encrypted: ['requester_email', 'requester_name', 'failure_reason'], bidx: { source: 'requester_email', column: 'requester_email_bidx' } },
169
+ ];
170
+
171
+ const ident = (name: string) => sql.raw(`"${name}"`);
172
+
173
+ /** What the backfill needs from a drizzle client — the db or a transaction. */
174
+ type Executor = Pick<Database, 'execute'>;
175
+
176
+ /** A text column as it is stored: bytes, so the handle's connection does not open it. */
177
+ const asStored = (col: string) => sql`convert_to(${ident(col)}, 'UTF8') AS ${ident(col)}`;
178
+
179
+ /** What {@link asStored} selected, back as text; anything else as it came. */
180
+ const storedText = (value: unknown): unknown => (Buffer.isBuffer(value) ? value.toString('utf8') : value);
181
+
182
+ /**
183
+ * A column "needs sealing" when it holds non-empty text that isn't a blob.
184
+ * The shape regex is deliberately used as a NEGATIVE filter: anything failing
185
+ * it — including plaintext that merely BEGINS with the prefix — is selected
186
+ * and sealed, and `isEncryptedBlob` is the same pattern, so the two can never
187
+ * disagree about a value. Sealed rows all match, keeping the steady-state scan
188
+ * an empty-result query.
189
+ *
190
+ * `trustShape` is false on the FIRST backfill of a database (see
191
+ * {@link isFirstBackfill}): nothing has been sealed yet, so a value in this
192
+ * shape is plaintext somebody typed, and every non-empty value is selected.
193
+ */
194
+ function needsSealing(col: string, trustShape: boolean) {
195
+ if (!trustShape) return sql`(${ident(col)} IS NOT NULL AND ${ident(col)} <> '')`;
196
+ return sql`(${ident(col)} IS NOT NULL AND ${ident(col)} <> '' AND ${ident(col)} !~ ${PII_SEALED_SHAPE_SQL_REGEX})`;
197
+ }
198
+
199
+ /**
200
+ * Whether this database has never been through the backfill: the blind-index
201
+ * column of `users` is still nullable. The backfill, the duplicate collapse
202
+ * and the constraints run in ONE transaction, so a nullable column means no
203
+ * backfill has ever committed here — and therefore that nothing in it is
204
+ * sealed, whatever it looks like.
205
+ *
206
+ * That is what lets a first backfill trust no shape at all. A blob is
207
+ * recognised by its shape, and a title or a display name is text a person
208
+ * chose: one typed in that shape before the upgrade would be read as sealed,
209
+ * skipped, and left in clear for good — and, sampled by
210
+ * {@link assertKeyOpensSealedRows}, would refuse the start for a key that is
211
+ * perfectly right. Once the first backfill has committed, every write goes
212
+ * through a handle that seals it, so no such value can arrive again.
213
+ */
214
+ async function isFirstBackfill(tx: Executor): Promise<boolean> {
215
+ const result = await tx.execute(sql`
216
+ SELECT is_nullable FROM information_schema.columns
217
+ WHERE table_schema = current_schema() AND table_name = 'users' AND column_name = 'email_bidx'
218
+ `);
219
+ return (result.rows[0] as { is_nullable?: string } | undefined)?.is_nullable === 'YES';
220
+ }
221
+
222
+ /** How many rows one backfill query reads: a table is walked in batches, never loaded whole. */
223
+ const BACKFILL_BATCH_ROWS = 500;
224
+
225
+ /**
226
+ * The configured key must open what earlier starts sealed. A rotated or
227
+ * mistyped `SECRETS_ENC_KEY` would otherwise go unnoticed here — sealed rows
228
+ * are exactly the ones the scan skips — and surface only as every login
229
+ * failing and every lookup by email missing, because the blind indexes would
230
+ * be computed under the new key. One sealed value per table is enough: all
231
+ * rows of a deployment are sealed under one key. Refusing the start is the
232
+ * loud failure; re-keying a database is a deliberate operation, not a boot.
233
+ */
234
+ async function assertKeyOpensSealedRows(tx: Executor, keys: PiiKeys): Promise<void> {
235
+ for (const t of PII_BACKFILL_TABLES) {
236
+ const col = t.bidx?.source ?? t.encrypted[0]!;
237
+ const sample = await tx.execute(
238
+ sql`SELECT ${asStored(col)} FROM ${ident(t.table)} WHERE ${ident(col)} ~ ${PII_SEALED_SHAPE_SQL_REGEX} LIMIT 1`,
239
+ );
240
+ const value = storedText((sample.rows[0] as Record<string, unknown> | undefined)?.[col]);
241
+ if (typeof value === 'string' && !keys.open(value).ok) {
242
+ throw new Error(
243
+ `PII encryption: ${t.table}.${col} is sealed with a key the configured SECRETS_ENC_KEY ` +
244
+ '(for a tenant: the one derived from TENANT_MASTER_KEY) does not open — refusing to start. ' +
245
+ 'Restore the key that sealed it; changing the key is a re-keying of the database, not a configuration change.',
246
+ );
247
+ }
248
+ }
249
+ }
250
+
251
+ async function backfillTable(
252
+ tx: Executor,
253
+ keys: PiiKeys,
254
+ t: PiiBackfillTable,
255
+ trustShape: boolean,
256
+ ): Promise<number> {
257
+ // The key columns are read as text beside themselves, so the next batch can
258
+ // be asked for by value whatever their type is (a uuid, a serial, a path).
259
+ const keyText = (k: string) => `${k}__key`;
260
+ const cols = [
261
+ ...t.key.map(ident),
262
+ ...t.key.map((k) => sql`${ident(k)}::text AS ${ident(keyText(k))}`),
263
+ ...t.encrypted.map(asStored),
264
+ ...(t.bidx ? [ident(t.bidx.column)] : []),
265
+ ];
266
+ // Only rows with work left: the ciphertext prefix makes "unsealed" a plain
267
+ // SQL predicate, so a fully-sealed table costs one empty-result query.
268
+ const pending = [
269
+ ...t.encrypted.map((col) => needsSealing(col, trustShape)),
270
+ ...(t.bidx ? [sql`${ident(t.bidx.column)} IS NULL`] : []),
271
+ ];
272
+ const keyTuple = sql`(${sql.join(t.key.map((k) => sql`${ident(k)}::text`), sql`, `)})`;
273
+ // A blob, as far as this pass may believe one. On a first backfill nothing
274
+ // is sealed yet, so nothing is believed (see `isFirstBackfill`).
275
+ const sealedAlready = (value: string): boolean => trustShape && isEncryptedBlob(value);
276
+ let rewritten = 0;
277
+ /** The key of the last row read, as text: the next batch starts after it. */
278
+ let after: string[] | null = null;
279
+ // IN BATCHES, in key order. A table is never loaded whole: a deployment with
280
+ // years of change requests holds every title and body it ever had, and
281
+ // reading them into one result held the first start after the upgrade — and
282
+ // the memory of the process making it — for as long as that took. Walking by
283
+ // key rather than re-asking for "what is left" also ends: a row this pass
284
+ // leaves as it found it (a write overtook it) is passed, not met again.
285
+ for (;;) {
286
+ const past = after ? sql` AND ${keyTuple} > (${sql.join(after.map((v) => sql`${v}`), sql`, `)})` : sql``;
287
+ const result = await tx.execute(
288
+ sql`SELECT ${sql.join(cols, sql`, `)} FROM ${ident(t.table)} WHERE (${sql.join(pending, sql` OR `)})${past} ORDER BY ${keyTuple} LIMIT ${BACKFILL_BATCH_ROWS}`,
289
+ );
290
+ const batch = result.rows as Array<Record<string, unknown>>;
291
+ for (const read of batch) {
292
+ const row = Object.fromEntries(Object.entries(read).map(([col, value]) => [col, storedText(value)]));
293
+ const sets = [];
294
+ // Compare-and-swap: pin every value this row was read with, so a write
295
+ // that lands between scan and update makes this UPDATE match zero rows
296
+ // instead of clobbering the newer value.
297
+ const where = t.key.map((k) => sql`${ident(k)} = ${row[k]}`);
298
+ for (const col of t.encrypted) {
299
+ const value = row[col];
300
+ if (typeof value === 'string' && value !== '' && !sealedAlready(value)) {
301
+ sets.push(sql`${ident(col)} = ${keys.seal(value)}`);
302
+ where.push(sql`${ident(col)} = ${value}`);
303
+ }
304
+ }
305
+ if (t.bidx) {
306
+ // The blind index is derived from its source, so it is (re)computed
307
+ // whenever the source is being sealed in this pass — a legacy writer
308
+ // that put a NEW plaintext email on an already-indexed row left a stale
309
+ // index behind, and sealing the email alone would freeze that mismatch
310
+ // — and whenever it was never filled. The source may already be
311
+ // ciphertext (an index that was never filled beside a sealed address);
312
+ // the index is always computed over the plaintext, never over a blob
313
+ // the key cannot open.
314
+ const source = row[t.bidx.source];
315
+ const stored = row[t.bidx.column];
316
+ const text = typeof source === 'string' ? source : '';
317
+ const sealingSource = text !== '' && !sealedAlready(text);
318
+ if (sealingSource || stored == null) {
319
+ const opened = sealedAlready(text) ? keys.open(text) : ({ ok: true, plain: text } as const);
320
+ if (!opened.ok) {
321
+ throw new Error(
322
+ `PII encryption backfill: ${t.table}.${t.bidx.source} cannot be decrypted with the ` +
323
+ 'configured SECRETS_ENC_KEY — refusing to derive a blind index from ciphertext. ' +
324
+ 'Restore the key that sealed it, then restart.',
325
+ );
326
+ }
327
+ sets.push(sql`${ident(t.bidx.column)} = ${keys.index(opened.plain)}`);
328
+ // Pin the index AND its source: if a concurrent writer replaces the
329
+ // email between scan and write, the CAS must not attach the OLD
330
+ // email's blind index to the NEW value.
331
+ where.push(sql`${ident(t.bidx.column)} IS NOT DISTINCT FROM ${stored ?? null}`);
332
+ where.push(sql`${ident(t.bidx.source)} IS NOT DISTINCT FROM ${source ?? null}`);
333
+ }
334
+ }
335
+ if (sets.length === 0) continue;
336
+ const updated = await tx.execute(
337
+ sql`UPDATE ${ident(t.table)} SET ${sql.join(sets, sql`, `)} WHERE ${sql.join(where, sql` AND `)}`,
338
+ );
339
+ rewritten += updated.rowCount ?? 0;
340
+ }
341
+ if (batch.length < BACKFILL_BATCH_ROWS) return rewritten;
342
+ const last = batch[batch.length - 1]!;
343
+ after = t.key.map((k) => String(last[keyText(k)]));
344
+ }
345
+ }
346
+
347
+ /**
348
+ * The name on a refused apply that was recorded BEFORE this release: the
349
+ * address of whoever it names was never kept, so no blind index stands beside
350
+ * it and an account erasure has nothing to find it by.
351
+ *
352
+ * Matching it by display name instead got both mistakes at once: a person
353
+ * who had since changed their name kept the old one on the request for good,
354
+ * and somebody else of the same name had theirs taken away. So the name goes,
355
+ * once, here. The refusal itself stays, with its reason and its time; it
356
+ * reads "could not apply" without saying who. A refusal recorded from this
357
+ * release on carries its index and is never touched by this.
358
+ */
359
+ async function clearUnindexedRefusalNames(tx: Executor): Promise<number> {
360
+ const cleared = await tx.execute(sql.raw(`
361
+ UPDATE "change_requests" SET "apply_failed_by_name" = NULL
362
+ WHERE "apply_failed_by_name" IS NOT NULL AND "apply_failed_by_email_bidx" IS NULL
363
+ `));
364
+ return cleared.rowCount ?? 0;
365
+ }
366
+
367
+ /**
368
+ * Legacy uniqueness was on the RAW email, so rows differing only by case or
369
+ * whitespace could coexist; under the normalized blind index they collide and
370
+ * the unique-index creation below would abort the start. Approval rows and
371
+ * join-request rows are the same logical row — collapse them, keeping the
372
+ * earliest. Colliding USER rows are distinct accounts; refuse loudly with the
373
+ * ids so an operator resolves the conflict deliberately instead of the
374
+ * upgrade guessing.
375
+ */
376
+ async function resolveBidxCollisions(tx: Executor): Promise<void> {
377
+ await tx.execute(sql.raw(`
378
+ DELETE FROM "pr_file_approvals" a USING "pr_file_approvals" b
379
+ WHERE a."pr_number" = b."pr_number" AND a."path" = b."path"
380
+ AND a."approver_email_bidx" = b."approver_email_bidx" AND a."head_sha" = b."head_sha"
381
+ AND (a."approved_at" > b."approved_at" OR (a."approved_at" = b."approved_at" AND a."id" > b."id"))
382
+ `));
383
+ await tx.execute(sql.raw(`
384
+ DELETE FROM "plugin_join_requests" a USING "plugin_join_requests" b
385
+ WHERE a."plugin_key" = b."plugin_key" AND a."requester_email_bidx" = b."requester_email_bidx"
386
+ AND (a."created_at" > b."created_at" OR (a."created_at" = b."created_at" AND a."id" > b."id"))
387
+ `));
388
+ const dupes = await tx.execute(sql.raw(`
389
+ SELECT array_agg("id") AS ids FROM "users" GROUP BY "email_bidx" HAVING count(*) > 1
390
+ `));
391
+ if (dupes.rows.length > 0) {
392
+ const groups = (dupes.rows as Array<{ ids: string[] }>).map((r) => r.ids.join(', '));
393
+ throw new Error(
394
+ 'PII encryption backfill: multiple user rows share the same email after normalization ' +
395
+ '(case/whitespace variants of one address). Merge or delete the duplicates, then restart. ' +
396
+ `Conflicting user ids: [${groups.join('], [')}]`,
397
+ );
398
+ }
399
+ }
400
+
401
+ const PII_FINALIZE_STATEMENTS = [
402
+ 'ALTER TABLE "users" ALTER COLUMN "email_bidx" SET NOT NULL',
403
+ 'CREATE UNIQUE INDEX IF NOT EXISTS "users_email_bidx_unq" ON "users" ("email_bidx")',
404
+ 'ALTER TABLE "users" DROP CONSTRAINT IF EXISTS "users_email_unique"',
405
+ 'ALTER TABLE "pr_file_approvals" ALTER COLUMN "approver_email_bidx" SET NOT NULL',
406
+ 'CREATE UNIQUE INDEX IF NOT EXISTS "pr_file_approvals_bidx_unq" ON "pr_file_approvals" ("pr_number","path","approver_email_bidx","head_sha")',
407
+ 'DROP INDEX IF EXISTS "pr_file_approvals_unq"',
408
+ 'ALTER TABLE "pr_merge_log" ALTER COLUMN "triggered_by_email_bidx" SET NOT NULL',
409
+ 'ALTER TABLE "pr_comments" ALTER COLUMN "author_email_bidx" SET NOT NULL',
410
+ 'ALTER TABLE "change_requests" ALTER COLUMN "author_email_bidx" SET NOT NULL',
411
+ 'ALTER TABLE "pending_commits" ALTER COLUMN "author_email_bidx" SET NOT NULL',
412
+ 'ALTER TABLE "plugin_join_requests" ALTER COLUMN "requester_email_bidx" SET NOT NULL',
413
+ 'CREATE UNIQUE INDEX IF NOT EXISTS "plugin_join_requests_requester_bidx_plugin_unq" ON "plugin_join_requests" ("requester_email_bidx","plugin_key")',
414
+ 'DROP INDEX IF EXISTS "plugin_join_requests_requester_plugin_unq"',
415
+ ];
416
+
417
+ /**
418
+ * Encrypt pre-existing plaintext PII rows, fill the blind-index columns, and
419
+ * apply the constraints migration 0016 deferred. Idempotent; `runCoreMigrations`
420
+ * runs it under the migrations lock right after the history. The handle must
421
+ * hold the knowledge base's key (`createDb(url, { piiKey })`; the composition
422
+ * root's does): one that holds none is refused before anything is read.
423
+ */
424
+ export async function runPiiEncryptionBackfill(db: Database): Promise<void> {
425
+ const keys = piiKeysOf(db);
426
+ await db.transaction(async (tx) => {
427
+ const first = await isFirstBackfill(tx);
428
+ // Nothing is sealed before the first backfill, so there is no sealed row
429
+ // for the key to be checked against — and a value that only LOOKS sealed
430
+ // must not be taken for one (see `isFirstBackfill`).
431
+ if (!first) await assertKeyOpensSealedRows(tx, keys);
432
+ let rewritten = 0;
433
+ for (const t of PII_BACKFILL_TABLES) {
434
+ rewritten += await backfillTable(tx, keys, t, !first);
435
+ }
436
+ if (rewritten > 0) log.info(`PII encryption backfill: rewrote ${rewritten} row(s).`);
437
+ const cleared = await clearUnindexedRefusalNames(tx);
438
+ if (cleared > 0) {
439
+ log.info(`PII encryption backfill: took the name off ${cleared} refused apply(ies) recorded before this release.`);
440
+ }
441
+ // Only where the unique indexes are not up yet. Once they are, they are
442
+ // what keeps two rows from sharing an index, so there is nothing to
443
+ // collapse — and the collapse is two self-joins and an aggregate over
444
+ // whole tables, which every later start paid for nothing.
445
+ if (first) await resolveBidxCollisions(tx);
446
+ for (const statement of PII_FINALIZE_STATEMENTS) {
447
+ await tx.execute(sql.raw(statement));
448
+ }
449
+ });
450
+ }
@@ -67,6 +67,16 @@ describe('assertValidRelativePath', () => {
67
67
  ['nested parent', 'a/../b'],
68
68
  ['current segment', 'a/./b'],
69
69
  ['null byte', 'a\x00b'],
70
+ // A line break is not merely an unwritable name: git's `cat-file --batch`
71
+ // reads one `<ref>:<path>` object name per LINE, so a path carrying one
72
+ // is two specs and git answers about the first — under the gates that were
73
+ // asked about the whole string. Every other control character goes with
74
+ // them, matching what the shared `validateFilename` has always refused.
75
+ ['line feed', 'Docs/secret.md\nzzz'],
76
+ ['carriage return', 'Docs/secret.md\rzzz'],
77
+ ['tab', 'Docs/a\tb.md'],
78
+ ['escape', 'Docs/a\u001Bb.md'],
79
+ ['delete', 'Docs/a\u007Fb.md'],
70
80
  ['leading dash', '-afile'],
71
81
  ])('rejects %s (%s)', (_label, p) => {
72
82
  expect(() => assertValidRelativePath(p)).toThrow(WorkflowValidationError);
@@ -4,6 +4,7 @@ import {
4
4
  assertKbDirNameFree,
5
5
  assertRepoRootNameFree,
6
6
  assertRepoRootNameFreeArgs,
7
+ hasControlCharacter,
7
8
  isInsideRepo,
8
9
  normalizePathArgs,
9
10
  normalizeWorkspacePath,
@@ -47,6 +48,14 @@ describe('isInsideRepo', () => {
47
48
  expect(isInsideRepo('knowledge-base/KnowledgeBase/a\\b.md', KB)).toBe(false);
48
49
  });
49
50
 
51
+ it('refuses a control character anywhere: git reads a line break in a path as a separator', () => {
52
+ expect(isInsideRepo('knowledge-base/Docs/secret.md\nzzz', KB)).toBe(false);
53
+ expect(isInsideRepo('knowledge-base/Docs/secret.md\rzzz', KB)).toBe(false);
54
+ expect(isInsideRepo('knowledge-base/Docs/a\u0000b.md', KB)).toBe(false);
55
+ expect(isInsideRepo('knowledge-base/Docs/a\u001Bb.md', KB)).toBe(false);
56
+ expect(isInsideRepo('knowledge-base/Docs/a\u007Fb.md', KB)).toBe(false);
57
+ });
58
+
50
59
  it('tolerates a trailing slash on a directory path', () => {
51
60
  expect(isInsideRepo('knowledge-base/', KB)).toBe(true);
52
61
  expect(isInsideRepo('knowledge-base/KnowledgeBase/Projects/', KB)).toBe(true);
@@ -113,6 +122,120 @@ describe('assertInsideRepo', () => {
113
122
  });
114
123
  });
115
124
 
125
+ describe('a line break in a path — one name to the gates, two object names to git', () => {
126
+ // `git cat-file --batch` reads one `<ref>:<path>` spec per LINE. A path
127
+ // carrying a `\n` therefore means TWO things at once: the read gate, the
128
+ // download gate and the not-a-directory check are answered about the whole
129
+ // string — a name that exists nowhere, so the real file's own frontmatter
130
+ // rules are never the ones consulted — while git answers about the part
131
+ // before the break and hands over its bytes. In the access resolver's batch
132
+ // read the extra answer also shifts every result after it, so one directory
133
+ // is judged by another's `access.md`.
134
+ //
135
+ // Which is why the refusal is HERE, at the normaliser every accepted path
136
+ // passes through, and not at the route that happened to surface it: the next
137
+ // caller to build a git spec out of a path will not remember this.
138
+ const INJECTED = 'knowledge-base/Docs/secret.md\nzzz';
139
+
140
+ it('is refused by the normaliser, with a message that says what is wrong', () => {
141
+ let err: unknown;
142
+ try {
143
+ normalizeWorkspacePath(INJECTED, KB);
144
+ } catch (e) {
145
+ err = e;
146
+ }
147
+ expect(err).toBeInstanceOf(WorkflowValidationError);
148
+ const e = err as WorkflowValidationError;
149
+ expect(e.status).toBe(400);
150
+ expect(e.message).toMatch(/control character/);
151
+ // Never "use this instead": there is no corrected spelling of a name with
152
+ // an invisible byte in it.
153
+ expect(e.message).not.toMatch(/Use "/);
154
+ // The path is named, ESCAPED — `sanitizedPath`, the same spelling every
155
+ // other refusal uses — so the character that made the path invalid cannot
156
+ // forge a second line of the refusal either.
157
+ expect(e.payload).toMatchObject({
158
+ kind: 'path-control-character',
159
+ path: 'knowledge-base/Docs/secret.md\\nzzz',
160
+ });
161
+ expect(e.message).toContain('"knowledge-base/Docs/secret.md\\nzzz"');
162
+ expect(e.message).not.toContain('\n');
163
+ });
164
+
165
+ it('is refused the same way unprefixed, which is the form a route receives', () => {
166
+ expect(() => normalizeWorkspacePath('Docs/secret.md\nzzz', KB)).toThrow(/control character/);
167
+ });
168
+
169
+ it('is refused by the check AFTER the normaliser too', () => {
170
+ expect(() => assertInsideRepo(INJECTED, KB)).toThrow(/control character/);
171
+ });
172
+
173
+ it('refuses every control character, not only the line breaks', () => {
174
+ for (const ch of ['\u0000', '\u0001', '\t', '\n', '\u000B', '\r', '\u001F', '\u007F']) {
175
+ expect(() => normalizeWorkspacePath(`knowledge-base/Docs/a${ch}b.md`, KB), ch).toThrow(
176
+ WorkflowValidationError,
177
+ );
178
+ }
179
+ });
180
+
181
+ it('refuses a CHECKOUT FOLDER NAME carrying one, which the normaliser would otherwise emit', () => {
182
+ // The name is prefixed onto the result rather than read from it, so a
183
+ // clean path joined to `kb\nname` would come out carrying the break: the
184
+ // normaliser would emit exactly what it refuses from a caller, from inputs
185
+ // that were each individually fine.
186
+ let err: unknown;
187
+ try {
188
+ normalizeWorkspacePath('Docs/report.md', 'kb\nname');
189
+ } catch (e) {
190
+ err = e;
191
+ }
192
+ expect(err).toBeInstanceOf(WorkflowValidationError);
193
+ const e = err as WorkflowValidationError;
194
+ // Named as the CONFIGURATION error it is: an operator told their own path
195
+ // was invalid would have nothing to fix.
196
+ expect(e.message).toMatch(/checkout folder name/);
197
+ expect(e.message).toMatch(/KB_DIR_NAME/);
198
+ expect(e.message).toContain('"kb\\nname"');
199
+ expect(e.payload).toMatchObject({ kind: 'kb-dir-name-control-character', kbDirName: 'kb\\nname' });
200
+ // The guarantee the fence is for: no return value of this function carries
201
+ // a control character, whatever it was given.
202
+ for (const dir of ['kb\nname', 'kb\rname', 'kb\u0000name']) {
203
+ for (const p of ['Docs/report.md', `${dir}/Docs/report.md`, 'x']) {
204
+ let out: string | null = null;
205
+ try {
206
+ out = normalizeWorkspacePath(p, dir);
207
+ } catch {
208
+ out = null;
209
+ }
210
+ if (out !== null) expect(hasControlCharacter(out), `${dir} + ${p}`).toBe(false);
211
+ }
212
+ }
213
+ });
214
+
215
+ it('exposes the rule as a predicate, so the places that SET a name share it', () => {
216
+ expect(hasControlCharacter('knowledge-base')).toBe(false);
217
+ expect(hasControlCharacter('kb\nname')).toBe(true);
218
+ expect(hasControlCharacter('kb\u007Fname')).toBe(true);
219
+ // U+2028 is not in the range: nothing to git, and a name carrying one is
220
+ // creatable today — refusing it would make an existing file unreadable.
221
+ expect(hasControlCharacter('kb\u2028name')).toBe(false);
222
+ });
223
+
224
+ it('leaves ordinary names alone — the rule costs no real path', () => {
225
+ expect(normalizeWorkspacePath('knowledge-base/Docs/[Approved] Handbook_Order archive.docx', KB)).toBe(
226
+ 'knowledge-base/Docs/[Approved] Handbook_Order archive.docx',
227
+ );
228
+ expect(normalizeWorkspacePath('knowledge-base/Docs/Ünïcödé — naïve.md', KB)).toBe(
229
+ 'knowledge-base/Docs/Ünïcödé — naïve.md',
230
+ );
231
+ });
232
+
233
+ it('refuses it through normalizePathArgs, which is where the MCP tools meet the rule', () => {
234
+ expect(() => normalizePathArgs({ path: INJECTED }, KB)).toThrow(/control character/);
235
+ expect(() => normalizePathArgs({ files: [{ path: INJECTED }] }, KB)).toThrow(/control character/);
236
+ });
237
+ });
238
+
116
239
  describe('normalizeWorkspacePath — THE normaliser', () => {
117
240
  it('leaves a path that is already inside the repository alone', () => {
118
241
  expect(normalizeWorkspacePath('knowledge-base/KnowledgeBase/Foo.md', KB)).toBe('knowledge-base/KnowledgeBase/Foo.md');
@@ -49,7 +49,20 @@ export function assertValidBranchName(name: string): void {
49
49
  export function assertValidRelativePath(relativePath: string): void {
50
50
  if (!relativePath) throw new WorkflowValidationError('path is required');
51
51
  if (relativePath.length > 1024) throw new WorkflowValidationError('path too long');
52
- if (relativePath.includes('\0')) throw new WorkflowValidationError('path contains NUL');
52
+ // NUL, every other C0 control and DEL. A NUL is named on its own because it
53
+ // terminates a C string and git's `-z` output; the rest are refused with it
54
+ // because a LINE BREAK in a path is a separator in git's line-oriented stdin
55
+ // protocols — `cat-file --batch` reads one `<ref>:<path>` per line, so
56
+ // `Docs/secret.md\nzzz` is two object names while every gate upstream was
57
+ // asked about one string. See `CONTROL_CHARACTERS` in `repo-path.ts`, which
58
+ // refuses the same range at the normaliser; this is the same rule at the
59
+ // check every path handed to git as a pathspec passes through.
60
+ // eslint-disable-next-line no-control-regex
61
+ if (/[\x00-\x1F\x7F]/.test(relativePath)) {
62
+ throw new WorkflowValidationError(
63
+ relativePath.includes('\0') ? 'path contains NUL' : 'path contains a control character',
64
+ );
65
+ }
53
66
  if (relativePath.includes('\\')) {
54
67
  throw new WorkflowValidationError('path must use forward slashes only');
55
68
  }