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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (325) 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 +30 -11
  7. package/dist/core/create-core-server.js.map +1 -1
  8. package/dist/core/create-core-services.d.ts +9 -2
  9. package/dist/core/create-core-services.d.ts.map +1 -1
  10. package/dist/core/create-core-services.js +112 -23
  11. package/dist/core/create-core-services.js.map +1 -1
  12. package/dist/core/lifecycle.d.ts +34 -1
  13. package/dist/core/lifecycle.d.ts.map +1 -1
  14. package/dist/core/lifecycle.js +89 -13
  15. package/dist/core/lifecycle.js.map +1 -1
  16. package/dist/core-config.d.ts +0 -12
  17. package/dist/core-config.d.ts.map +1 -1
  18. package/dist/core-config.js +11 -13
  19. package/dist/core-config.js.map +1 -1
  20. package/dist/index.d.ts +3 -2
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +2 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/modules/access/access-control.interface.d.ts +42 -12
  25. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  26. package/dist/modules/access/access-control.service.d.ts +67 -10
  27. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  28. package/dist/modules/access/access-control.service.js +214 -35
  29. package/dist/modules/access/access-control.service.js.map +1 -1
  30. package/dist/modules/access/access.routes.d.ts.map +1 -1
  31. package/dist/modules/access/access.routes.js +13 -15
  32. package/dist/modules/access/access.routes.js.map +1 -1
  33. package/dist/modules/auth/account-admission.d.ts +58 -7
  34. package/dist/modules/auth/account-admission.d.ts.map +1 -1
  35. package/dist/modules/auth/account-admission.js +44 -1
  36. package/dist/modules/auth/account-admission.js.map +1 -1
  37. package/dist/modules/auth/account.routes.d.ts +1 -1
  38. package/dist/modules/auth/account.routes.d.ts.map +1 -1
  39. package/dist/modules/auth/account.routes.js +61 -5
  40. package/dist/modules/auth/account.routes.js.map +1 -1
  41. package/dist/modules/auth/auth.middleware.d.ts +1 -1
  42. package/dist/modules/auth/auth.middleware.d.ts.map +1 -1
  43. package/dist/modules/auth/auth.middleware.js +18 -7
  44. package/dist/modules/auth/auth.middleware.js.map +1 -1
  45. package/dist/modules/auth/auth.routes.d.ts.map +1 -1
  46. package/dist/modules/auth/auth.routes.js +8 -0
  47. package/dist/modules/auth/auth.routes.js.map +1 -1
  48. package/dist/modules/auth/auth.service.d.ts +70 -2
  49. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  50. package/dist/modules/auth/auth.service.js +176 -7
  51. package/dist/modules/auth/auth.service.js.map +1 -1
  52. package/dist/modules/auth/oidc-auth-provider.d.ts.map +1 -1
  53. package/dist/modules/auth/oidc-auth-provider.js +7 -2
  54. package/dist/modules/auth/oidc-auth-provider.js.map +1 -1
  55. package/dist/modules/database/core-schema.d.ts +41 -24
  56. package/dist/modules/database/core-schema.d.ts.map +1 -1
  57. package/dist/modules/database/core-schema.js +35 -27
  58. package/dist/modules/database/core-schema.js.map +1 -1
  59. package/dist/modules/kb-fs/branch-name.d.ts.map +1 -1
  60. package/dist/modules/kb-fs/branch-name.js +12 -2
  61. package/dist/modules/kb-fs/branch-name.js.map +1 -1
  62. package/dist/modules/kb-fs/repo-path.d.ts +11 -16
  63. package/dist/modules/kb-fs/repo-path.d.ts.map +1 -1
  64. package/dist/modules/kb-fs/repo-path.js +79 -0
  65. package/dist/modules/kb-fs/repo-path.js.map +1 -1
  66. package/dist/modules/kb-sync/kb-sync.routes.d.ts +2 -2
  67. package/dist/modules/kb-sync/kb-sync.routes.d.ts.map +1 -1
  68. package/dist/modules/kb-sync/kb-sync.routes.js +14 -3
  69. package/dist/modules/kb-sync/kb-sync.routes.js.map +1 -1
  70. package/dist/modules/kb-sync/sync-auth.d.ts +3 -1
  71. package/dist/modules/kb-sync/sync-auth.d.ts.map +1 -1
  72. package/dist/modules/kb-sync/sync-auth.js +1 -1
  73. package/dist/modules/kb-sync/sync-auth.js.map +1 -1
  74. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  75. package/dist/modules/mcp/mcp-auth.middleware.js +21 -6
  76. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  77. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
  78. package/dist/modules/mcp/oauth/bevel-oauth-provider.js +3 -2
  79. package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
  80. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
  81. package/dist/modules/settings/deployment-settings.service.js +8 -3
  82. package/dist/modules/settings/deployment-settings.service.js.map +1 -1
  83. package/dist/modules/settings/setup.routes.d.ts +52 -1
  84. package/dist/modules/settings/setup.routes.d.ts.map +1 -1
  85. package/dist/modules/settings/setup.routes.js +207 -19
  86. package/dist/modules/settings/setup.routes.js.map +1 -1
  87. package/dist/modules/skills/skills.contract.d.ts +90 -18
  88. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  89. package/dist/modules/skills/skills.contract.js +4 -1
  90. package/dist/modules/skills/skills.contract.js.map +1 -1
  91. package/dist/modules/skills/skills.service.d.ts +98 -9
  92. package/dist/modules/skills/skills.service.d.ts.map +1 -1
  93. package/dist/modules/skills/skills.service.js +239 -37
  94. package/dist/modules/skills/skills.service.js.map +1 -1
  95. package/dist/modules/skills/skills.tools.d.ts +7 -0
  96. package/dist/modules/skills/skills.tools.d.ts.map +1 -1
  97. package/dist/modules/skills/skills.tools.js +71 -7
  98. package/dist/modules/skills/skills.tools.js.map +1 -1
  99. package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
  100. package/dist/modules/tool-auth/external-api-key.service.js +4 -2
  101. package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
  102. package/dist/modules/tool-auth/internal-token.service.d.ts +3 -3
  103. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +16 -7
  104. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  105. package/dist/modules/tool-auth/tool-auth.middleware.js +34 -12
  106. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  107. package/dist/modules/tool-helpers/tool-handler.d.ts +2 -1
  108. package/dist/modules/tool-helpers/tool-handler.d.ts.map +1 -1
  109. package/dist/modules/tool-helpers/tool-handler.js +25 -4
  110. package/dist/modules/tool-helpers/tool-handler.js.map +1 -1
  111. package/dist/modules/tool-helpers/tool.contract.d.ts +3 -2
  112. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
  113. package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
  114. package/dist/modules/tool-helpers/validate-token.js +1 -1
  115. package/dist/modules/tool-helpers/validate-token.js.map +1 -1
  116. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +98 -0
  117. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -0
  118. package/dist/modules/workflow/agent-tools/change-request-summary.js +81 -0
  119. package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -0
  120. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  121. package/dist/modules/workflow/agent-tools/workflow.tools.js +75 -35
  122. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  123. package/dist/modules/workflow/file-lock.service.d.ts +24 -0
  124. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  125. package/dist/modules/workflow/file-lock.service.js +30 -0
  126. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  127. package/dist/modules/workflow/git/git.service.d.ts +69 -0
  128. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  129. package/dist/modules/workflow/git/git.service.js +153 -2
  130. package/dist/modules/workflow/git/git.service.js.map +1 -1
  131. package/dist/modules/workflow/pending-commits.service.d.ts +38 -0
  132. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  133. package/dist/modules/workflow/pending-commits.service.js +55 -0
  134. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  135. package/dist/modules/workflow/workflow-hooks.d.ts +54 -32
  136. package/dist/modules/workflow/workflow-hooks.d.ts.map +1 -1
  137. package/dist/modules/workflow/workflow-hooks.js +16 -1
  138. package/dist/modules/workflow/workflow-hooks.js.map +1 -1
  139. package/dist/modules/workflow/workflow.service.d.ts +60 -6
  140. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  141. package/dist/modules/workflow/workflow.service.js +111 -4
  142. package/dist/modules/workflow/workflow.service.js.map +1 -1
  143. package/dist/modules/workspace/agent-access.gate.d.ts +94 -0
  144. package/dist/modules/workspace/agent-access.gate.d.ts.map +1 -0
  145. package/dist/modules/workspace/agent-access.gate.js +123 -0
  146. package/dist/modules/workspace/agent-access.gate.js.map +1 -0
  147. package/dist/modules/workspace/routine-write-policy.d.ts +5 -6
  148. package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
  149. package/dist/modules/workspace/routine-write-policy.js +5 -6
  150. package/dist/modules/workspace/routine-write-policy.js.map +1 -1
  151. package/dist/modules/workspace/session-sink.d.ts +5 -5
  152. package/dist/modules/workspace/set-aside-clone.d.ts +46 -0
  153. package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -0
  154. package/dist/modules/workspace/set-aside-clone.js +92 -0
  155. package/dist/modules/workspace/set-aside-clone.js.map +1 -0
  156. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +59 -9
  157. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  158. package/dist/modules/workspace/startup/kb-startup-runner.js +65 -24
  159. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  160. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  161. package/dist/modules/workspace/workspace.routes.js +92 -3
  162. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  163. package/dist/modules/workspace/workspace.service.d.ts +115 -5
  164. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  165. package/dist/modules/workspace/workspace.service.js +255 -25
  166. package/dist/modules/workspace/workspace.service.js.map +1 -1
  167. package/dist/modules/workspace/workspace.tools.d.ts +2 -2
  168. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  169. package/dist/modules/workspace/workspace.tools.js +309 -115
  170. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  171. package/dist/modules/write-access/write-access.d.ts +60 -0
  172. package/dist/modules/write-access/write-access.d.ts.map +1 -0
  173. package/dist/modules/write-access/write-access.js +129 -0
  174. package/dist/modules/write-access/write-access.js.map +1 -0
  175. package/dist/shared/domain-errors.d.ts +25 -0
  176. package/dist/shared/domain-errors.d.ts.map +1 -1
  177. package/dist/shared/domain-errors.js +28 -0
  178. package/dist/shared/domain-errors.js.map +1 -1
  179. package/dist/shared/git.contract.d.ts +20 -0
  180. package/dist/shared/git.contract.d.ts.map +1 -1
  181. package/dist/shared/git.contract.js +26 -0
  182. package/dist/shared/git.contract.js.map +1 -1
  183. package/dist/tenancy/static-tenant-source.d.ts +0 -1
  184. package/dist/tenancy/static-tenant-source.d.ts.map +1 -1
  185. package/dist/tenancy/static-tenant-source.js +0 -2
  186. package/dist/tenancy/static-tenant-source.js.map +1 -1
  187. package/migrations/0014_change_request_closed_reason.sql +1 -0
  188. package/migrations/0015_account_deactivation.sql +3 -0
  189. package/migrations/meta/0014_snapshot.json +2265 -0
  190. package/migrations/meta/0015_snapshot.json +2271 -0
  191. package/migrations/meta/_journal.json +14 -0
  192. package/package.json +3 -3
  193. package/src/__tests__/kb-layout-config.test.ts +0 -2
  194. package/src/__tests__/retired-settings.test.ts +96 -0
  195. package/src/core/__tests__/gated-boot.test.ts +132 -0
  196. package/src/core/__tests__/lifecycle.test.ts +170 -1
  197. package/src/core/__tests__/set-aside-root-is-one-place.test.ts +63 -0
  198. package/src/core/core-ports.ts +11 -1
  199. package/src/core/create-core-server.ts +32 -14
  200. package/src/core/create-core-services.ts +129 -23
  201. package/src/core/lifecycle.ts +98 -13
  202. package/src/core-config.ts +13 -14
  203. package/src/index.ts +12 -1
  204. package/src/modules/access/__tests__/access-control.preview-relocation.test.ts +385 -0
  205. package/src/modules/access/__tests__/access-control.prospective.test.ts +94 -18
  206. package/src/modules/access/__tests__/access.routes.prospective.test.ts +6 -7
  207. package/src/modules/access/access-control.interface.ts +43 -12
  208. package/src/modules/access/access-control.service.ts +234 -36
  209. package/src/modules/access/access.routes.ts +13 -15
  210. package/src/modules/agent-instructions/__tests__/agent-instructions.route.test.ts +3 -1
  211. package/src/modules/auth/__tests__/account-deactivation.test.ts +252 -0
  212. package/src/modules/auth/__tests__/account.routes.test.ts +81 -3
  213. package/src/modules/auth/__tests__/auth.middleware.test.ts +45 -22
  214. package/src/modules/auth/__tests__/auth.service.test.ts +1 -1
  215. package/src/modules/auth/account-admission.ts +78 -9
  216. package/src/modules/auth/account.routes.ts +63 -7
  217. package/src/modules/auth/auth.middleware.ts +19 -8
  218. package/src/modules/auth/auth.routes.ts +8 -0
  219. package/src/modules/auth/auth.service.ts +179 -6
  220. package/src/modules/auth/oidc-auth-provider.ts +7 -2
  221. package/src/modules/database/core-schema.ts +35 -27
  222. package/src/modules/kb-fs/__tests__/branch-name.test.ts +10 -0
  223. package/src/modules/kb-fs/__tests__/repo-path.test.ts +123 -0
  224. package/src/modules/kb-fs/branch-name.ts +14 -1
  225. package/src/modules/kb-fs/repo-path.ts +85 -0
  226. package/src/modules/kb-sync/__tests__/kb-sync.routes.test.ts +26 -1
  227. package/src/modules/kb-sync/kb-sync.routes.ts +14 -4
  228. package/src/modules/kb-sync/sync-auth.ts +2 -2
  229. package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +60 -3
  230. package/src/modules/mcp/__tests__/mcp.service.test.ts +68 -6
  231. package/src/modules/mcp/mcp-auth.middleware.ts +20 -6
  232. package/src/modules/mcp/oauth/bevel-oauth-provider.ts +3 -1
  233. package/src/modules/plugins/__tests__/plugins.tools.test.ts +2 -1
  234. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +16 -0
  235. package/src/modules/settings/__tests__/setup.routes.git-mode.test.ts +91 -58
  236. package/src/modules/settings/__tests__/setup.routes.github-app.test.ts +25 -8
  237. package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +14 -11
  238. package/src/modules/settings/__tests__/setup.routes.repository-change.test.ts +390 -0
  239. package/src/modules/settings/__tests__/setup.routes.test.ts +3 -0
  240. package/src/modules/settings/deployment-settings.service.ts +8 -3
  241. package/src/modules/settings/setup.routes.ts +263 -19
  242. package/src/modules/skills/__tests__/allowed-tools-warn.tools.test.ts +2 -1
  243. package/src/modules/skills/__tests__/branch-skills.tools.test.ts +218 -0
  244. package/src/modules/skills/__tests__/skills.service.test.ts +229 -5
  245. package/src/modules/skills/skills.contract.ts +91 -18
  246. package/src/modules/skills/skills.service.ts +278 -41
  247. package/src/modules/skills/skills.tools.ts +80 -8
  248. package/src/modules/tool-auth/__tests__/manual-auth.middleware.test.ts +14 -1
  249. package/src/modules/tool-auth/external-api-key.service.ts +4 -2
  250. package/src/modules/tool-auth/internal-token.service.ts +3 -3
  251. package/src/modules/tool-auth/tool-auth.middleware.ts +34 -11
  252. package/src/modules/tool-helpers/__tests__/agent-roles-write.test.ts +5 -3
  253. package/src/modules/tool-helpers/__tests__/phase4-tools.test.ts +3 -3
  254. package/src/modules/tool-helpers/__tests__/validate-token.test.ts +11 -1
  255. package/src/modules/tool-helpers/tool-handler.ts +24 -4
  256. package/src/modules/tool-helpers/tool.contract.ts +3 -2
  257. package/src/modules/tool-helpers/validate-token.ts +1 -1
  258. package/src/modules/workflow/__tests__/pending-commits.repository-replaced.test.ts +111 -0
  259. package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +25 -0
  260. package/src/modules/workflow/__tests__/workflow.service.repository-replaced.test.ts +249 -0
  261. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +264 -6
  262. package/src/modules/workflow/agent-tools/change-request-summary.ts +182 -0
  263. package/src/modules/workflow/agent-tools/workflow.tools.ts +86 -35
  264. package/src/modules/workflow/file-lock.service.ts +31 -0
  265. package/src/modules/workflow/git/__tests__/git.service.fileBytesAtCommit.test.ts +260 -0
  266. package/src/modules/workflow/git/git.service.ts +168 -0
  267. package/src/modules/workflow/pending-commits.service.ts +59 -1
  268. package/src/modules/workflow/workflow-hooks.ts +64 -26
  269. package/src/modules/workflow/workflow.service.ts +121 -4
  270. package/src/modules/workspace/__tests__/agent-access.gate.test.ts +208 -0
  271. package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +384 -0
  272. package/src/modules/workspace/__tests__/file-stat-access.test.ts +2 -1
  273. package/src/modules/workspace/__tests__/git-internals.security.test.ts +2 -1
  274. package/src/modules/workspace/__tests__/set-aside-clone.test.ts +52 -0
  275. package/src/modules/workspace/__tests__/workspace.routes.at-ref.test.ts +305 -0
  276. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +2 -0
  277. package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +147 -0
  278. package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +326 -0
  279. package/src/modules/workspace/__tests__/workspace.service.test.ts +20 -9
  280. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +2 -3
  281. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +2 -3
  282. package/src/modules/workspace/__tests__/workspace.tools.test.ts +685 -14
  283. package/src/modules/workspace/agent-access.gate.ts +164 -0
  284. package/src/modules/workspace/routine-write-policy.ts +5 -6
  285. package/src/modules/workspace/session-sink.ts +5 -5
  286. package/src/modules/workspace/set-aside-clone.ts +96 -0
  287. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +86 -0
  288. package/src/modules/workspace/startup/kb-startup-runner.ts +115 -34
  289. package/src/modules/workspace/workspace.routes.ts +95 -3
  290. package/src/modules/workspace/workspace.service.ts +256 -24
  291. package/src/modules/workspace/workspace.tools.ts +338 -119
  292. package/src/modules/write-access/__tests__/write-access.test.ts +248 -0
  293. package/src/modules/write-access/write-access.ts +153 -0
  294. package/src/shared/domain-errors.ts +31 -0
  295. package/src/shared/git.contract.ts +27 -0
  296. package/src/tenancy/__tests__/static-tenant-source.test.ts +0 -1
  297. package/src/tenancy/static-tenant-source.ts +0 -3
  298. package/dist/modules/workflow/session-ontology.policy.d.ts +0 -52
  299. package/dist/modules/workflow/session-ontology.policy.d.ts.map +0 -1
  300. package/dist/modules/workflow/session-ontology.policy.js +0 -62
  301. package/dist/modules/workflow/session-ontology.policy.js.map +0 -1
  302. package/dist/modules/workflow/session-ontology.service.d.ts +0 -105
  303. package/dist/modules/workflow/session-ontology.service.d.ts.map +0 -1
  304. package/dist/modules/workflow/session-ontology.service.js +0 -147
  305. package/dist/modules/workflow/session-ontology.service.js.map +0 -1
  306. package/dist/modules/workspace/session-ontology.gate.d.ts +0 -114
  307. package/dist/modules/workspace/session-ontology.gate.d.ts.map +0 -1
  308. package/dist/modules/workspace/session-ontology.gate.js +0 -161
  309. package/dist/modules/workspace/session-ontology.gate.js.map +0 -1
  310. package/dist/shared/kb-layout.d.ts +0 -39
  311. package/dist/shared/kb-layout.d.ts.map +0 -1
  312. package/dist/shared/kb-layout.js +0 -103
  313. package/dist/shared/kb-layout.js.map +0 -1
  314. package/dist/shared/kb-layout.test.d.ts +0 -2
  315. package/dist/shared/kb-layout.test.d.ts.map +0 -1
  316. package/dist/shared/kb-layout.test.js +0 -75
  317. package/dist/shared/kb-layout.test.js.map +0 -1
  318. package/src/modules/workflow/__tests__/session-ontology.policy.test.ts +0 -62
  319. package/src/modules/workflow/__tests__/session-ontology.service.test.ts +0 -201
  320. package/src/modules/workflow/session-ontology.policy.ts +0 -70
  321. package/src/modules/workflow/session-ontology.service.ts +0 -183
  322. package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +0 -239
  323. package/src/modules/workspace/session-ontology.gate.ts +0 -191
  324. package/src/shared/kb-layout.test.ts +0 -98
  325. package/src/shared/kb-layout.ts +0 -102
@@ -1,11 +1,14 @@
1
1
  import jwt from 'jsonwebtoken';
2
- import { eq } from 'drizzle-orm';
2
+ import { and, eq, isNotNull, isNull } from 'drizzle-orm';
3
3
  import type { Database } from '../database/connection.js';
4
4
  import { users } from '../database/schema.js';
5
5
  import type { AuthUser } from '@bevel-software/platform-shared';
6
6
  import { canonicalEmail, hashEmail } from '../../shared/email-identity.js';
7
7
  import {
8
8
  AccountAdmissionRefusedError,
9
+ AccountChangeRefusedError,
10
+ AccountDeactivatedError,
11
+ AuthBackendError,
9
12
  admitEveryone,
10
13
  type AccountProvisionReason,
11
14
  type IAccountAdmission,
@@ -16,9 +19,22 @@ import {
16
19
  timingSafeStringEqual,
17
20
  MIN_PASSWORD_LENGTH,
18
21
  } from './password-hash.js';
22
+ import { RECOVERY_BOT_EMAIL } from '../workflow/recovery-bot.js';
23
+ import { DIRECTORY_SYNC_BOT_EMAIL } from '../access/directory-sync-bot.js';
19
24
 
20
25
  const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
21
26
 
27
+ /**
28
+ * How long {@link AuthService.isActive} trusts what it last read. Every
29
+ * request carrying a credential asks, so the answer is kept briefly rather
30
+ * than read each time; switching an account off or on in THIS process
31
+ * forgets it at once, and another replica sees the change within this long.
32
+ */
33
+ const ACTIVE_CACHE_MS = 30_000;
34
+
35
+ /** The accounts core runs its own work as. Nobody signs in with them, and nobody switches them off. */
36
+ const SYSTEM_ACCOUNT_EMAILS: readonly string[] = [RECOVERY_BOT_EMAIL, DIRECTORY_SYNC_BOT_EMAIL];
37
+
22
38
  /**
23
39
  * What every caller is told when it tries to give the deployment admin a
24
40
  * stored password — the Account page's own change and an admin's "Set
@@ -129,7 +145,19 @@ export class AuthService {
129
145
  .limit(1);
130
146
  if (existing) return;
131
147
  const verdict = await this.admission.canProvision(normalizedEmail, reason);
132
- if (!verdict.ok) throw new AccountAdmissionRefusedError(verdict.message);
148
+ if (verdict.ok) return;
149
+ // A first sign-in the port would rather keep than turn away: the person
150
+ // is put on file, switched off, for an admin to switch on — and is still
151
+ // refused now, with the port's words. Only for single sign-on, the one
152
+ // provisioning nobody asked for (see `AccountAdmissionVerdict`).
153
+ if (verdict.waitForAdmin && reason === 'sso') {
154
+ await this.db
155
+ .insert(users)
156
+ .values({ email: normalizedEmail, name: normalizedEmail.split('@')[0] || normalizedEmail, deactivatedAt: new Date() })
157
+ .onConflictDoNothing({ target: users.email });
158
+ throw new AccountAdmissionRefusedError(verdict.message, { waitingForAdmin: true });
159
+ }
160
+ throw new AccountAdmissionRefusedError(verdict.message);
133
161
  }
134
162
 
135
163
  /**
@@ -200,6 +228,9 @@ export class AuthService {
200
228
  await verifyPassword(provided, await decoyHash());
201
229
  throw new Error('Invalid credentials');
202
230
  }
231
+ // Never refused for being switched off: the environment password is
232
+ // the deployment's way back in, and {@link deactivate} refuses this
233
+ // account for that reason.
203
234
  const defaultName = normalizedEmail.split('@')[0] || normalizedEmail;
204
235
  await this.assertAdmitted(normalizedEmail, 'bootstrap');
205
236
  const admin = await this.upsertUserByEmail(normalizedEmail, defaultName);
@@ -215,6 +246,9 @@ export class AuthService {
215
246
  if (!user?.passwordHash || !matches) {
216
247
  throw new Error('Invalid credentials');
217
248
  }
249
+ // Only now, with the password proven: telling a stranger that an address
250
+ // is switched off would tell them it has an account.
251
+ if (user.deactivatedAt) throw new AccountDeactivatedError();
218
252
  return { token: this.signToken(user.id, user.email), user: this.toClientUser(user) };
219
253
  }
220
254
 
@@ -246,6 +280,7 @@ export class AuthService {
246
280
  await this.assertAdmitted(normalizedEmail, 'sso');
247
281
  const displayName = (name ?? '').trim() || normalizedEmail.split('@')[0] || normalizedEmail;
248
282
  const user = await this.upsertUserByEmail(normalizedEmail, displayName);
283
+ if (user.deactivatedAt) throw new AccountDeactivatedError();
249
284
  return { token: this.signToken(user.id, user.email), user: this.toClientUser(user) };
250
285
  }
251
286
 
@@ -255,7 +290,13 @@ export class AuthService {
255
290
  * account (e.g. one that first arrived via SSO, or a reset for a locked-out
256
291
  * user) is deliberate admin behavior, not an error.
257
292
  *
258
- * The deployment admin is the one target this refuses, for the reason
293
+ * WITHOUT a password the account is made for single sign-on: the person
294
+ * signs in through the deployment's provider and finds their account
295
+ * waiting, already holding its place (a seat, on a host that sells them).
296
+ * Password sign-in refuses it until a password is set. For an address that
297
+ * already has an account it changes nothing but an explicitly given name.
298
+ *
299
+ * WITH a password, the deployment admin is the one target this refuses, for the reason
259
300
  * {@link changePassword} refuses it: that account's password is the
260
301
  * environment's, so a stored hash would not replace it but ADD a second
261
302
  * credential — one that keeps signing in after `ADMIN_PASSWORD` is rotated,
@@ -266,12 +307,26 @@ export class AuthService {
266
307
  async createAccount(
267
308
  email: string,
268
309
  name: string | undefined,
269
- password: string,
310
+ password?: string,
270
311
  ): Promise<AuthUser> {
271
312
  const normalizedEmail = canonicalEmail(email ?? '');
272
313
  if (!EMAIL_REGEX.test(normalizedEmail)) {
273
314
  throw new Error('Invalid email');
274
315
  }
316
+ const suppliedName = (name ?? '').trim();
317
+ const displayName = suppliedName || normalizedEmail.split('@')[0] || normalizedEmail;
318
+ if (!password) {
319
+ await this.assertAdmitted(normalizedEmail, 'admin-create');
320
+ const [created] = await this.db
321
+ .insert(users)
322
+ .values({ email: normalizedEmail, name: displayName })
323
+ .onConflictDoUpdate({
324
+ target: users.email,
325
+ set: suppliedName ? { name: suppliedName, updatedAt: new Date() } : { updatedAt: new Date() },
326
+ })
327
+ .returning();
328
+ return this.toClientUser(created);
329
+ }
275
330
  // Before the policy check, so the refusal names the real reason rather
276
331
  // than sending the admin off to pick a longer password first.
277
332
  if (this.isEnvAdminEmail(normalizedEmail)) {
@@ -279,8 +334,6 @@ export class AuthService {
279
334
  }
280
335
  this.assertPasswordPolicy(password);
281
336
  await this.assertAdmitted(normalizedEmail, 'admin-create');
282
- const suppliedName = (name ?? '').trim();
283
- const displayName = suppliedName || normalizedEmail.split('@')[0] || normalizedEmail;
284
337
  const passwordHash = await hashPassword(password);
285
338
  // One atomic upsert. On conflict (re-provisioning an existing account) an
286
339
  // EXPLICITLY supplied name is persisted too; a blank name keeps the
@@ -402,6 +455,10 @@ export class AuthService {
402
455
  name: string;
403
456
  hasPassword: boolean;
404
457
  isEnvAdmin: boolean;
458
+ /** When an admin switched the account off; null while it is on. */
459
+ deactivatedAt: Date | null;
460
+ /** One of the accounts the platform runs its own work as: never switched off, nobody signs in with it. */
461
+ isSystem: boolean;
405
462
  createdAt: Date;
406
463
  }>
407
464
  > {
@@ -412,6 +469,8 @@ export class AuthService {
412
469
  name: row.name,
413
470
  hasPassword: row.passwordHash != null,
414
471
  isEnvAdmin: this.reportsAsEnvAdmin(row.email),
472
+ deactivatedAt: row.deactivatedAt,
473
+ isSystem: SYSTEM_ACCOUNT_EMAILS.includes(row.email),
415
474
  createdAt: row.createdAt,
416
475
  }));
417
476
  }
@@ -443,6 +502,7 @@ export class AuthService {
443
502
  await this.assertAdmitted(normalizedEmail, 'embed');
444
503
  const displayName = (name ?? '').trim() || normalizedEmail.split('@')[0] || normalizedEmail;
445
504
  const user = await this.upsertUserByEmail(normalizedEmail, displayName);
505
+ if (user.deactivatedAt) throw new AccountDeactivatedError();
446
506
  return { id: user.id, email: user.email, name: user.name };
447
507
  }
448
508
 
@@ -455,6 +515,11 @@ export class AuthService {
455
515
  return user;
456
516
  }
457
517
 
518
+ /**
519
+ * The signature and expiry of a session token, and nothing else — whether
520
+ * its account is still on is {@link resolveSession}'s question. Every
521
+ * place a session is ACCEPTED goes through that one.
522
+ */
458
523
  verifyToken(token: string): { userId: string; email: string } {
459
524
  const decoded = jwt.verify(token, this.config.jwtSecret) as {
460
525
  userId: string;
@@ -463,6 +528,114 @@ export class AuthService {
463
528
  return { userId: decoded.userId, email: decoded.email };
464
529
  }
465
530
 
531
+ /**
532
+ * A session token that may still be used: signed by us, not expired, and
533
+ * its account still on. A token outlives a deactivation by up to seven
534
+ * days, so the signature alone no longer says the bearer may enter.
535
+ * Throws for an invalid token, {@link AccountDeactivatedError} for one
536
+ * whose account was switched off.
537
+ */
538
+ async resolveSession(token: string): Promise<{ userId: string; email: string }> {
539
+ const claim = this.verifyToken(token);
540
+ let active: boolean;
541
+ try {
542
+ active = await this.isActive(claim.userId);
543
+ } catch (err) {
544
+ // The token is fine; the database is not. Said apart from a refusal, so
545
+ // an outage is a 500 and not a sign-out (see `AuthBackendError`).
546
+ throw new AuthBackendError(err);
547
+ }
548
+ if (!active) throw new AccountDeactivatedError();
549
+ return claim;
550
+ }
551
+
552
+ private readonly activeCache = new Map<string, { active: boolean; at: number }>();
553
+
554
+ /**
555
+ * Whether the account behind a credential is on: it exists and no admin
556
+ * switched it off. Asked for every credential presented that does not
557
+ * already read the account (a session token, an internal token), so it is
558
+ * cached for {@link ACTIVE_CACHE_MS}; switching an account off or on in
559
+ * this process forgets its entry at once.
560
+ *
561
+ * A missing row is not on either — an erased account's tokens die with it.
562
+ *
563
+ * The deployment admin's account is always on, whatever its row says: its
564
+ * environment password signs it in regardless (see `loginWithPassword`),
565
+ * and a session that sign-in mints must then be usable too, or the way
566
+ * back into a deployment would lead nowhere. {@link deactivate} refuses
567
+ * the account, but a host may write the column itself.
568
+ */
569
+ async isActive(userId: string): Promise<boolean> {
570
+ const now = Date.now();
571
+ const hit = this.activeCache.get(userId);
572
+ if (hit && now - hit.at < ACTIVE_CACHE_MS) return hit.active;
573
+ const [row] = await this.db
574
+ .select({ email: users.email, deactivatedAt: users.deactivatedAt })
575
+ .from(users)
576
+ .where(eq(users.id, userId))
577
+ .limit(1);
578
+ const active = row !== undefined && (!row.deactivatedAt || this.isEnvAdminEmail(row.email ?? ''));
579
+ this.activeCache.set(userId, { active, at: now });
580
+ return active;
581
+ }
582
+
583
+ /**
584
+ * Switch an account off. Its row, history and place in roles and groups
585
+ * stay; every credential it holds stops being honoured (see
586
+ * {@link isActive}). Idempotent: an account already off keeps the moment
587
+ * it was first switched off.
588
+ *
589
+ * The deployment admin is refused: the environment password is the way
590
+ * back into a deployment whose every other admin is gone, and switching
591
+ * it off would close that door from inside. So are the machine accounts
592
+ * core runs its own work as. Refusing an admin's own account is the
593
+ * route's concern, since it knows who is asking.
594
+ *
595
+ * Returns false when there is no such account.
596
+ */
597
+ async deactivate(userId: string): Promise<boolean> {
598
+ const [row] = await this.db.select().from(users).where(eq(users.id, userId)).limit(1);
599
+ if (!row) return false;
600
+ if (this.isEnvAdminEmail(row.email)) {
601
+ throw new AccountChangeRefusedError('The deployment admin cannot be switched off: its password in the environment is the way back in.');
602
+ }
603
+ if (SYSTEM_ACCOUNT_EMAILS.includes(row.email)) {
604
+ throw new AccountChangeRefusedError('This account belongs to the platform itself and cannot be switched off.');
605
+ }
606
+ await this.db
607
+ .update(users)
608
+ .set({ deactivatedAt: new Date(), updatedAt: new Date() })
609
+ .where(and(eq(users.id, userId), isNull(users.deactivatedAt)));
610
+ this.activeCache.delete(userId);
611
+ return true;
612
+ }
613
+
614
+ /**
615
+ * Switch a deactivated account back on, if the deployment has room for it:
616
+ * the admission port is asked, as for a new account, because an account
617
+ * that is on takes the same place. A refusal throws
618
+ * {@link AccountAdmissionRefusedError} with the port's words. An account
619
+ * already on is not asked about.
620
+ *
621
+ * Returns false when there is no such account.
622
+ */
623
+ async reactivate(userId: string): Promise<boolean> {
624
+ const [row] = await this.db.select().from(users).where(eq(users.id, userId)).limit(1);
625
+ if (!row) return false;
626
+ if (!row.deactivatedAt) return true;
627
+ if (this.admission !== admitEveryone) {
628
+ const verdict = await this.admission.canProvision(row.email, 'reactivate');
629
+ if (!verdict.ok) throw new AccountAdmissionRefusedError(verdict.message);
630
+ }
631
+ await this.db
632
+ .update(users)
633
+ .set({ deactivatedAt: null, updatedAt: new Date() })
634
+ .where(and(eq(users.id, userId), isNotNull(users.deactivatedAt)));
635
+ this.activeCache.delete(userId);
636
+ return true;
637
+ }
638
+
466
639
  /**
467
640
  * Resolve a set of `authorId` hashes back to the app users that produced
468
641
  * them. Used by the PR list to display the human who triggered each PR
@@ -7,7 +7,7 @@ import type { AuthProviderPlugin } from './auth.routes.js';
7
7
  import { AUTH_COOKIE_MAX_AGE_S } from './auth.routes.js';
8
8
  import { AUTH_COOKIE_NAME } from './auth.middleware.js';
9
9
  import type { AuthService } from './auth.service.js';
10
- import { AccountAdmissionRefusedError } from './account-admission.js';
10
+ import { AccountAdmissionRefusedError, AccountDeactivatedError } from './account-admission.js';
11
11
  import { normalizeIssuerUrl } from '../settings/oidc-check.js';
12
12
 
13
13
  // Short-lived CSRF state + PKCE verifier for the OAuth round-trip: set before
@@ -325,7 +325,12 @@ export class OidcAuthProvider implements AuthProviderPlugin {
325
325
  // screen can say so instead of "try again".
326
326
  if (error instanceof AccountAdmissionRefusedError) {
327
327
  log.warn('OIDC sign-in refused by the account admission port:', { detail: error.message });
328
- fail('admission');
328
+ fail(error.waitingForAdmin ? 'waiting' : 'admission');
329
+ return;
330
+ }
331
+ if (error instanceof AccountDeactivatedError) {
332
+ log.warn('OIDC sign-in refused: the account is switched off');
333
+ fail('deactivated');
329
334
  return;
330
335
  }
331
336
  log.error('OIDC callback error:', { err: error });
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * CORE platform tables — the git-backed workspace/workflow, auth, access,
3
- * change requests, locks, the pending-commits queue, the ontology-session
4
- * touched-set, connection keys, MCP OAuth, and the Secrets Vault. A core-only
5
- * deployment migrates and runs on exactly these tables.
3
+ * change requests, locks, the pending-commits queue, connection keys, MCP
4
+ * OAuth, and the Secrets Vault. A core-only deployment migrates and runs on
5
+ * exactly these tables.
6
6
  *
7
7
  * Enterprise-only tables (chat, routines, watchlist, connectors, LLM config,
8
8
  * SharePoint/Atlassian links, feedback, upload, kb-revalidation) live in
@@ -37,6 +37,19 @@ export const users = pgTable('users', {
37
37
  * backfill would need, and being shown the setup once costs a click.
38
38
  */
39
39
  onboardingDone: boolean('onboarding_done').default(false).notNull(),
40
+ /**
41
+ * When an admin switched this account off; NULL while it is on. A
42
+ * deactivated account keeps its row, its history and its place in roles
43
+ * and groups, but nothing it holds is honoured: it cannot sign in, and its
44
+ * session, connection keys, agent tokens and internal tokens are refused
45
+ * the next time they are presented (see `AuthService.isActive`). Turning it
46
+ * back on restores all of them as they were.
47
+ *
48
+ * The off switch a host that sells seats needs — a seat is an account that
49
+ * is on — and the one an admin uses for someone who left without erasing
50
+ * what they did.
51
+ */
52
+ deactivatedAt: timestamp('deactivated_at'),
40
53
  createdAt: timestamp('created_at').defaultNow().notNull(),
41
54
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
42
55
  });
@@ -160,6 +173,18 @@ export const changeRequests = pgTable('change_requests', {
160
173
  createdAt: timestamp('created_at').defaultNow().notNull(),
161
174
  updatedAt: timestamp('updated_at'),
162
175
  closedAt: timestamp('closed_at'),
176
+ /**
177
+ * Why a closed request was closed, when something other than a person
178
+ * closed it. Today the one value is 'repository-replaced': the admin
179
+ * pointed the deployment at a different knowledge-base repository and chose
180
+ * to close the requests rather than keep them. Null for every request
181
+ * closed the ordinary way — by its author, or by the deleted-branch sweep —
182
+ * so the column reads as "nobody recorded a reason", not as a default.
183
+ *
184
+ * The row is never deleted: a replaced repository loses the branches, not
185
+ * the record of what people asked for.
186
+ */
187
+ closedReason: text('closed_reason'),
163
188
  }, (t) => ({
164
189
  numberUnq: uniqueIndex('change_requests_number_unq').on(t.number),
165
190
  // Enforce the "A→B blocks A→B while open" uniqueness rule at the DB level —
@@ -382,30 +407,6 @@ export const secrets = pgTable('secrets', {
382
407
  sharedKeyUnq: uniqueIndex('secrets_shared_key_unq').on(t.key).where(sql`${t.userId} is null`),
383
408
  }));
384
409
 
385
- /**
386
- * Ontology-session boundary — the per-session log of which named ontologies a
387
- * run has TOUCHED (one row per `(session_id, ontology)`). Each agent operation
388
- * that resolves to a named ontology records a row here (idempotent on the PK).
389
- *
390
- * The rule the gate enforces from this set:
391
- * - READS are always allowed; they just record the ontology they touched.
392
- * - WRITES are allowed only while the session's touched set is a SINGLE named
393
- * ontology equal to the write target. Once a session has touched two or
394
- * more ontologies (e.g. by reading across them), every write is blocked —
395
- * "read across ontologies → you can't write anymore at all."
396
- *
397
- * Postgres is the durable source of truth so the boundary holds across a
398
- * backend restart (an in-memory set would reset and silently un-poison a run).
399
- * A write-through in-process cache fronts this table for the chatty read path;
400
- * a cache miss falls back here.
401
- *
402
- * - PK is `(session_id, ontology)` — a set, idempotent on repeat touches.
403
- * - `ontology` is the resolved ontology id (e.g. `KnowledgeBase/Product`).
404
- * Neutral touches don't record, so they never write a row.
405
- * - `touched_at` drives the abandoned-run `sweepOlderThan` backstop; the
406
- * primary reclamation is an explicit delete of all of a session's rows at
407
- * run end.
408
- */
409
410
  /**
410
411
  * OAuth clients dynamically registered by MCP clients (RFC 7591 DCR). When an
411
412
  * MCP client (claude.ai, Claude Code) connects to `/api/mcp` without a
@@ -575,6 +576,13 @@ export const agentEvents = pgTable('agent_events', {
575
576
  ),
576
577
  }));
577
578
 
579
+ /**
580
+ * UNUSED IN THIS RELEASE. It backed the per-session boundary that a deployment
581
+ * now owns for itself, behind the agent read/write hooks; nothing in this
582
+ * package reads or writes it any more. The definition stays for ONE release so
583
+ * that no deployment runs a version in which neither side has the table, and
584
+ * the NEXT release drops it (see `TODOS.md`). Do not build on it.
585
+ */
578
586
  export const sessionOntologyTouches = pgTable('session_ontology_touches', {
579
587
  sessionId: text('session_id').notNull(),
580
588
  ontology: text('ontology').notNull(),
@@ -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
  }