@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,5 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import { WorkflowValidationError } from '../../shared/domain-errors.js';
3
+ import { sanitizedPath } from '../../shared/printable.js';
3
4
 
4
5
  /**
5
6
  * Workspace-relative paths and the repository folder.
@@ -20,6 +21,51 @@ import { WorkflowValidationError } from '../../shared/domain-errors.js';
20
21
  * rule were allowed to exist.
21
22
  */
22
23
 
24
+ /**
25
+ * NUL, the C0 controls (a line break among them) and DEL — refused in a
26
+ * workspace path wherever one enters.
27
+ *
28
+ * Not merely hygiene about names nobody can type. A `\n` is a SEPARATOR in
29
+ * git's own line-oriented stdin protocols: `cat-file --batch` reads one
30
+ * `<ref>:<path>` object name per LINE, so `Docs/secret.md\nzzz` is TWO specs.
31
+ * Everything upstream — the read gate, the download gate, the not-a-directory
32
+ * check — is then asked about the whole string, which names nothing, while git
33
+ * answers about `Docs/secret.md` and hands over its bytes. A file denied by
34
+ * its own frontmatter inside a folder its reader may list would be readable
35
+ * that way, and in a batch of several specs the extra answer shifts every
36
+ * result after it, so the access resolver reads one directory's `access.md` as
37
+ * another's.
38
+ *
39
+ * Refused HERE, at the normaliser every accepted path goes through, rather
40
+ * than at each route: the fence has to be at the mechanism, because the next
41
+ * caller to assemble a git spec from a path will not remember this. The
42
+ * shared `validateFilename` has always refused the same range per segment, so
43
+ * no name any surface can create is lost — this closes the gap between it and
44
+ * the paths that reach git. U+2028/U+2029 are NOT in it: they are nothing to
45
+ * git's protocols (which read bytes, and a line break there is 0x0A), a name
46
+ * carrying one is creatable today, and refusing it here would make an existing
47
+ * file unreadable. They are escaped for display instead, which is what
48
+ * `sanitizedPath` is for.
49
+ */
50
+ // eslint-disable-next-line no-control-regex
51
+ const CONTROL_CHARACTERS = /[\x00-\x1F\x7F]/;
52
+
53
+ /**
54
+ * Whether `value` carries one of them — THE predicate, so the places that
55
+ * accept a NAME enforce the same rule as the normaliser that joins it into a
56
+ * path. The checkout folder name is the one that matters: it is not a caller's
57
+ * path, it is deployment configuration (`KB_DIR_NAME`, and the `kbDirName`
58
+ * setting), and it is PREFIXED onto every workspace-relative path. A name with
59
+ * a line break in it would therefore make the normaliser emit exactly the path
60
+ * this rule exists to exclude, from inputs that were each clean. Refused where
61
+ * the name is set — at boot in `core-config` and in the settings validator —
62
+ * so the deployment fails loudly instead of serving a path nothing downstream
63
+ * can safely read.
64
+ */
65
+ export function hasControlCharacter(value: string): boolean {
66
+ return CONTROL_CHARACTERS.test(value);
67
+ }
68
+
23
69
  /**
24
70
  * True when `wsPath` is the repository folder or lies under it.
25
71
  *
@@ -33,6 +79,10 @@ import { WorkflowValidationError } from '../../shared/domain-errors.js';
33
79
  */
34
80
  export function isInsideRepo(wsPath: string, kbDirName: string): boolean {
35
81
  if (typeof wsPath !== 'string' || wsPath.includes('\\')) return false;
82
+ // A control character is not a spelling of any path under the repository —
83
+ // see {@link CONTROL_CHARACTERS}. Refused here too, so the check AFTER the
84
+ // normaliser cannot pass one that somehow skipped it.
85
+ if (CONTROL_CHARACTERS.test(wsPath)) return false;
36
86
  const segments = wsPath.replace(/\/$/, '').split('/');
37
87
  if (segments[0] !== kbDirName) return false;
38
88
  return segments.every((segment) => segment !== '' && segment !== '.' && segment !== '..');
@@ -52,6 +102,22 @@ export function isInsideRepo(wsPath: string, kbDirName: string): boolean {
52
102
  */
53
103
  export function assertInsideRepo(wsPath: string, kbDirName: string): void {
54
104
  if (isInsideRepo(wsPath, kbDirName)) return;
105
+ // Said as what it is, before the suggestion below: a control character is
106
+ // not a path that landed in the wrong place, and a "use this instead"
107
+ // carrying an invisible character back to the caller would be nonsense.
108
+ if (typeof wsPath === 'string' && CONTROL_CHARACTERS.test(wsPath)) {
109
+ // Through `sanitizedPath`, like every other refusal that names a path: the
110
+ // character that makes this path invalid is also the character that would
111
+ // forge a second line of the answer, in the message and in the `path` a
112
+ // JSON consumer reads.
113
+ const shown = sanitizedPath(wsPath);
114
+ throw new WorkflowValidationError(
115
+ `"${shown}" contains a control character: a line break, a NUL or another invisible byte is not part of any ` +
116
+ 'file name, and git reads a line break in a path as the end of one object name and the start of another. ' +
117
+ 'Remove it and send the name as it is written.',
118
+ { kind: 'path-control-character', path: shown, kbDirName },
119
+ );
120
+ }
55
121
  // The suggestion collapses what made the path wrong: backslashes, leading
56
122
  // `./` or `/`, and any `..` climbing back out (a bare `..` included, so the
57
123
  // suggestion is never itself a climb). The agent gets a path it can use.
@@ -103,6 +169,25 @@ export function normalizeWorkspacePath(wsPath: string, kbDirName: string): strin
103
169
  // `\` separates segments on Windows, so `foo\..\..` is a climb this cannot
104
170
  // read. Refused before anything is collapsed, never rewritten to `/`.
105
171
  if (wsPath.includes('\\')) refuse();
172
+ // A line break (or any other control character) is a SEPARATOR to git, not
173
+ // part of a name — see {@link CONTROL_CHARACTERS}. Refused before the path
174
+ // is normalised, so no gate downstream is ever asked about a string that
175
+ // means one thing to it and two things to git.
176
+ if (CONTROL_CHARACTERS.test(wsPath)) refuse();
177
+ // …and the FOLDER NAME, which is prefixed onto the result rather than read
178
+ // from it: a clean path joined to `kb\nname` comes out carrying the line
179
+ // break, so the normaliser would itself emit what the check above refuses.
180
+ // Said as the configuration error it is — the caller's path is fine, and an
181
+ // operator reading a refusal about their own path would have nothing to fix.
182
+ // `core-config` and the `kbDirName` setting validator both refuse this at
183
+ // the point it is SET, so reaching here means the two drifted.
184
+ if (CONTROL_CHARACTERS.test(kbDirName)) {
185
+ throw new WorkflowValidationError(
186
+ `the configured checkout folder name (kbDirName / KB_DIR_NAME) contains a control character: "${sanitizedPath(kbDirName)}". ` +
187
+ 'Every workspace path is prefixed with it, so no path can be resolved until the setting is corrected.',
188
+ { kind: 'kb-dir-name-control-character', kbDirName: sanitizedPath(kbDirName) },
189
+ );
190
+ }
106
191
  // The root-anchored form, and ONLY for the repository folder: `/<kbDirName>/…`
107
192
  // is what the app's Copy path gives (it is the form a Markdown link resolves
108
193
  // from) and names the same workspace path. `/tmp/x` and `//x` are not that
@@ -4,6 +4,7 @@ import express from 'express';
4
4
  import { afterEach, describe, expect, it, vi } from 'vitest';
5
5
  import { createKbSyncRoutes, isSyncRawBodyPath } from '../kb-sync.routes.js';
6
6
  import type { IKbSyncService, SyncResult } from '../kb-sync.interface.js';
7
+ import { AuthBackendError } from '../../auth/account-admission.js';
7
8
 
8
9
  const SECRET = 'a-very-long-and-random-sync-secret';
9
10
 
@@ -23,6 +24,8 @@ async function mount(opts: {
23
24
  secret?: string;
24
25
  result?: SyncResult;
25
26
  admin?: boolean;
27
+ /** No account can be looked up: every session check fails as the database being down. */
28
+ outage?: boolean;
26
29
  /** Install the production-shaped global JSON parser in front of the router. */
27
30
  globalJson?: boolean;
28
31
  }): Promise<{ base: string; sync: ReturnType<typeof vi.fn> }> {
@@ -44,7 +47,8 @@ async function mount(opts: {
44
47
  kbSync,
45
48
  syncSecret: () => opts.secret ?? SECRET,
46
49
  authService: {
47
- verifyToken: (token: string) => {
50
+ resolveSession: async (token: string) => {
51
+ if (opts.outage) throw new AuthBackendError(new Error('the database is down'));
48
52
  if (token !== 'jwt-ok') throw new Error('bad token');
49
53
  return { userId: 'u1', email: 'person@example.com' };
50
54
  },
@@ -94,6 +98,27 @@ describe('POST /api/sync — credentials', () => {
94
98
  expect((await post(person.base, { headers: { authorization: 'Bearer jwt-ok' } })).status).toBe(401);
95
99
  });
96
100
 
101
+ it('500, not 401, when the session’s account cannot be looked up', async () => {
102
+ const { base, sync } = await mount({ admin: true, outage: true });
103
+ const res = await post(base, { headers: { authorization: 'Bearer jwt-ok' } });
104
+ // An outage is not a wrong credential: a 401 would send the admin to sign in again.
105
+ expect(res.status).toBe(500);
106
+ expect(res.headers.get('www-authenticate')).toBeNull();
107
+ expect(sync).not.toHaveBeenCalled();
108
+ });
109
+
110
+ it('the shared secret still works while accounts cannot be looked up', async () => {
111
+ const { base, sync } = await mount({ outage: true });
112
+ expect((await post(base, { headers: { authorization: `Bearer ${SECRET}` } })).status).toBe(200);
113
+ expect(sync).toHaveBeenCalledTimes(1);
114
+ });
115
+
116
+ it('a request with no credential is still a 401 during an outage', async () => {
117
+ const { base } = await mount({ outage: true });
118
+ // No bearer at all: nothing was looked up, so nothing failed.
119
+ expect((await post(base)).status).toBe(401);
120
+ });
121
+
97
122
  it('accepts the GitLab token header', async () => {
98
123
  const { base } = await mount({});
99
124
  const res = await post(base, { headers: { 'x-gitlab-token': SECRET } });
@@ -7,6 +7,7 @@ import { assertValidBranchName } from '../kb-fs/branch-name.js';
7
7
  import type { IKbSyncService, SyncResult } from './kb-sync.interface.js';
8
8
  import { parseSyncPayload, type SyncPayloadSource } from './sync-payload.js';
9
9
  import { verifySyncCredential } from './sync-auth.js';
10
+ import { AuthBackendError } from '../auth/account-admission.js';
10
11
 
11
12
  /**
12
13
  * Remote sync — a git host or a pipeline tells Hexis the repository changed.
@@ -45,7 +46,7 @@ export interface KbSyncRouteDeps {
45
46
  kbSync: IKbSyncService;
46
47
  /** The configured secret, read per request so a setup-screen save applies at once. */
47
48
  syncSecret: () => string;
48
- authService: { verifyToken(token: string): { userId: string; email: string } };
49
+ authService: { resolveSession(token: string): Promise<{ userId: string; email: string }> };
49
50
  adminAccess: IAdminAccessService;
50
51
  }
51
52
 
@@ -107,6 +108,7 @@ export function createKbSyncRoutes(deps: KbSyncRouteDeps): express.Router {
107
108
  ): Promise<void> {
108
109
  res.setHeader(SYNC_RESPONSE_HEADER, SYNC_RESPONSE_MARKER);
109
110
  const raw: Buffer | undefined = Buffer.isBuffer(req.body) ? req.body : undefined;
111
+ let lookupFailed: AuthBackendError | null = null as AuthBackendError | null;
110
112
 
111
113
  const auth = await verifySyncCredential(
112
114
  {
@@ -117,10 +119,11 @@ export function createKbSyncRoutes(deps: KbSyncRouteDeps): express.Router {
117
119
  rawBody: raw,
118
120
  },
119
121
  {
120
- verifyJwt: (token) => {
122
+ verifyJwt: async (token) => {
121
123
  try {
122
- return { email: deps.authService.verifyToken(token).email };
123
- } catch {
124
+ return { email: (await deps.authService.resolveSession(token)).email };
125
+ } catch (err) {
126
+ if (err instanceof AuthBackendError) lookupFailed = err;
124
127
  return null;
125
128
  }
126
129
  },
@@ -128,6 +131,13 @@ export function createKbSyncRoutes(deps: KbSyncRouteDeps): express.Router {
128
131
  },
129
132
  );
130
133
  if (!auth.ok) {
134
+ // The session's account could not be looked up: an outage, not a wrong
135
+ // credential, and saying 401 would send an admin to sign in again.
136
+ if (lookupFailed) {
137
+ log.error('session account lookup failed:', { err: lookupFailed.cause });
138
+ res.status(500).json({ error: 'Authentication backend unavailable' });
139
+ return;
140
+ }
131
141
  if (auth.status === 401) res.setHeader('WWW-Authenticate', 'Bearer realm="hexis-sync"');
132
142
  res.status(auth.status).json({ error: auth.message });
133
143
  return;
@@ -42,7 +42,7 @@ export interface SyncAuthInput {
42
42
 
43
43
  export interface SyncSessionVerifier {
44
44
  /** Resolve a browser JWT to its email, or null when it is not one. */
45
- verifyJwt(token: string): { email: string } | null;
45
+ verifyJwt(token: string): { email: string } | null | Promise<{ email: string } | null>;
46
46
  isAdmin(email: string): Promise<boolean>;
47
47
  }
48
48
 
@@ -98,7 +98,7 @@ export async function verifySyncCredential(
98
98
  // An admin's own session, carried as the bearer. Checked after the secret
99
99
  // so a secret that happens to parse as a JWT is never mistaken for one.
100
100
  if (bearer) {
101
- const claim = session.verifyJwt(bearer);
101
+ const claim = await session.verifyJwt(bearer);
102
102
  if (claim && (await session.isAdmin(claim.email))) {
103
103
  return { ok: true, credential: { kind: 'admin-session', email: claim.email } };
104
104
  }
@@ -5,6 +5,7 @@ import type { AuthService } from '../../auth/auth.service.js';
5
5
  import type { IExternalApiKeyService } from '../../tool-auth/external-api-key.interface.js';
6
6
  import type { BevelOAuthProvider } from '../oauth/bevel-oauth-provider.js';
7
7
  import { InternalTokenService } from '../../tool-auth/internal-token.service.js';
8
+ import { AccountDeactivatedError, AuthBackendError } from '../../auth/account-admission.js';
8
9
 
9
10
  const RESOURCE_METADATA_URL = 'https://bevel.example/.well-known/oauth-protected-resource/api/mcp';
10
11
 
@@ -19,10 +20,14 @@ function makeReqRes(authorization?: string) {
19
20
  }
20
21
 
21
22
  function makeAuthService(verify?: (t: string) => { userId: string; email: string }) {
23
+ const verifyToken = vi.fn(verify ?? (() => {
24
+ throw new Error('invalid');
25
+ }));
22
26
  return {
23
- verifyToken: vi.fn(verify ?? (() => {
24
- throw new Error('invalid');
25
- })),
27
+ verifyToken,
28
+ // The session path checks the account is still on; every account here is.
29
+ resolveSession: vi.fn(async (t: string) => verifyToken(t)),
30
+ isActive: vi.fn(async () => true),
26
31
  } as unknown as AuthService;
27
32
  }
28
33
 
@@ -424,3 +429,55 @@ describe('createMcpAuthMiddleware', () => {
424
429
  });
425
430
  });
426
431
  });
432
+
433
+ describe('createMcpAuthMiddleware — a switched-off account', () => {
434
+ const internal = new InternalTokenService({ secret: 'test-secret-32-bytes-long-enough!!' });
435
+
436
+ it('401s a session whose account was switched off, and says why', async () => {
437
+ const auth = makeAuthService();
438
+ (auth.resolveSession as ReturnType<typeof vi.fn>) = vi.fn(async () => {
439
+ throw new AccountDeactivatedError();
440
+ });
441
+ const { req, res, next, status, json } = makeReqRes('Bearer some.jwt.token');
442
+ await makeMw({ auth })(req, res, next);
443
+ expect(next).not.toHaveBeenCalled();
444
+ expect(status).toHaveBeenCalledWith(401);
445
+ expect(json.mock.calls[0][0].error).toContain('switched off');
446
+ });
447
+
448
+ it('500s — not 401s — when the session account cannot be looked up', async () => {
449
+ const auth = makeAuthService();
450
+ (auth.resolveSession as ReturnType<typeof vi.fn>) = vi.fn(async () => {
451
+ throw new AuthBackendError(new Error('db down'));
452
+ });
453
+ const { req, res, next, status } = makeReqRes('Bearer some.jwt.token');
454
+ await makeMw({ auth })(req, res, next);
455
+ expect(next).not.toHaveBeenCalled();
456
+ expect(status).toHaveBeenCalledWith(500);
457
+ });
458
+
459
+ it('401s an internal token whose account was switched off, and says why', async () => {
460
+ const token = internal.mint({ userId: 'user-7', externalProxy: true }, 60_000);
461
+ const auth = makeAuthService();
462
+ (auth.getUserById as ReturnType<typeof vi.fn>) = vi.fn(async () => ({ id: 'user-7', email: 'seven@example.com' }));
463
+ (auth.isActive as ReturnType<typeof vi.fn>) = vi.fn(async () => false);
464
+ const { req, res, next, status, json } = makeReqRes(`Bearer ${token}`);
465
+ await makeMw({ internal, auth })(req, res, next);
466
+ expect(next).not.toHaveBeenCalled();
467
+ expect(status).toHaveBeenCalledWith(401);
468
+ expect(json.mock.calls[0][0].error).toContain('switched off');
469
+ });
470
+
471
+ it('500s — not 401s — when an internal token\'s account cannot be looked up', async () => {
472
+ const token = internal.mint({ userId: 'user-7', externalProxy: true }, 60_000);
473
+ const auth = makeAuthService();
474
+ (auth.getUserById as ReturnType<typeof vi.fn>) = vi.fn(async () => ({ id: 'user-7', email: 'seven@example.com' }));
475
+ (auth.isActive as ReturnType<typeof vi.fn>) = vi.fn(async () => {
476
+ throw new Error('db down');
477
+ });
478
+ const { req, res, next, status } = makeReqRes(`Bearer ${token}`);
479
+ await makeMw({ internal, auth })(req, res, next);
480
+ expect(next).not.toHaveBeenCalled();
481
+ expect(status).toHaveBeenCalledWith(500);
482
+ });
483
+ });
@@ -24,6 +24,8 @@ import type { AgentEventInput, IAgentEventRecorder } from '../../audit/audit.con
24
24
 
25
25
  let httpServer: HttpServer | undefined;
26
26
  const cleanups: Array<() => Promise<void>> = [];
27
+ /** Every loopback call the skill routes saw this test, in order — with its body. */
28
+ let skillRequests: { tool: string; body: Record<string, unknown> }[] = [];
27
29
 
28
30
  // Module-hosted tool endpoints + their registered defs: a stand-in echo `ask`
29
31
  // (mirrors the real `{text, sessionId}` contract) and an erroring `boom`.
@@ -49,6 +51,7 @@ async function setup(deps?: {
49
51
  /** The agent connection an OAuth caller arrived through (with `tokenId: null`). */
50
52
  connectionId?: string | null;
51
53
  }) {
54
+ skillRequests = [];
52
55
  const registry = new ToolRegistry();
53
56
  registry.registerExternalTool(
54
57
  toolDef({
@@ -99,9 +102,47 @@ async function setup(deps?: {
99
102
  // The two the Audit log's skill classification reads through: a catalog of
100
103
  // one skill, and a file read that answers for any path (registered as
101
104
  // tools only by the tests that need them, via `extraTools`).
102
- app.post('/api/agent/tools/list_skills', (_req, res) =>
103
- res.json({ skills: [{ name: 'rfi', description: 'RFI answers', path: 'Plugins/Sales/rfi' }] }),
104
- );
105
+ //
106
+ // Both skill routes answer like the real ones: a `branch` in the body reads
107
+ // that draft (an extra, unmerged skill), no `branch` reads the released
108
+ // catalog. Every call is recorded, so a test can assert which of the two the
109
+ // prompt surface asked for.
110
+ const RFI_BODY = '# RFI\n\nAnswer the RFI.';
111
+ app.post('/api/agent/tools/list_skills', (req, res) => {
112
+ const b = (req.body ?? {}) as Record<string, unknown>;
113
+ skillRequests.push({ tool: 'list_skills', body: b });
114
+ const released = [{ name: 'rfi', description: 'RFI answers', path: 'Plugins/Sales/rfi' }];
115
+ res.json({
116
+ skills:
117
+ typeof b.branch === 'string'
118
+ ? [
119
+ ...released,
120
+ { name: 'make-deck', description: 'Decks.', path: 'Plugins/make-deck', unmerged: true, branch: b.branch },
121
+ ]
122
+ : released,
123
+ });
124
+ });
125
+ app.post('/api/agent/tools/get_skill', (req, res) => {
126
+ const b = (req.body ?? {}) as Record<string, unknown>;
127
+ skillRequests.push({ tool: 'get_skill', body: b });
128
+ if (b.name !== 'rfi' && typeof b.branch !== 'string') {
129
+ res.json({ ok: false, error: 'not_found' });
130
+ return;
131
+ }
132
+ const unmergedLine =
133
+ typeof b.branch === 'string' ? `This skill is read from the unmerged branch "${b.branch}" and is not approved.\n\n` : '';
134
+ res.json({
135
+ ok: true,
136
+ kind: 'skill',
137
+ skill: {
138
+ name: b.name,
139
+ description: 'RFI answers',
140
+ path: 'Plugins/Sales/rfi',
141
+ body: `${unmergedLine}${RFI_BODY}`,
142
+ files: [],
143
+ },
144
+ });
145
+ });
105
146
  app.post('/api/agent/tools/read_file', (req, res) => {
106
147
  const b = (req.body ?? {}) as { path?: string };
107
148
  res.json({ content: `contents of ${b.path}` });
@@ -773,10 +814,31 @@ describe('McpService — the Audit log records what an agent calls', () => {
773
814
  it('records a prompt read as a skill, with an error outcome when the skill is unknown', async () => {
774
815
  const recorder = spyRecorder();
775
816
  const client = await setup({ auditRecorder: recorder });
776
- // The harness serves no get_skill endpoint, so every prompt is unknown here.
777
- await expect(client.getPrompt({ name: 'rfi' })).rejects.toThrow(/Unknown skill/);
817
+ // A name the harness's catalog does not have: get_skill answers not_found.
818
+ await expect(client.getPrompt({ name: 'nope' })).rejects.toThrow(/Unknown skill/);
778
819
  await settle();
779
- expect(recorder.events[0]).toMatchObject({ kind: 'skill', name: 'rfi', outcome: 'error' });
820
+ expect(recorder.events[0]).toMatchObject({ kind: 'skill', name: 'nope', outcome: 'error' });
821
+ });
822
+
823
+ /**
824
+ * Prompts ARE skills, and a skill offered as a prompt is one the
825
+ * organisation released: the two prompt handlers name no branch, so a draft's
826
+ * skills never reach a client's slash-command menu.
827
+ */
828
+ it('serves prompts from the default branch only, never a draft', async () => {
829
+ const client = await setup();
830
+ const { prompts } = await client.listPrompts();
831
+ expect(prompts.map((p) => p.name)).toEqual(['rfi']);
832
+
833
+ const got = await client.getPrompt({ name: 'rfi' });
834
+ const text = got.messages[0].content.text as string;
835
+ expect(text).toContain('Answer the RFI.');
836
+ expect(text).not.toContain('unmerged');
837
+
838
+ // The loopback bodies prove it: had either handler passed a branch, the
839
+ // harness would have answered with the draft's skill and its notice.
840
+ expect(skillRequests.map((r) => r.tool)).toEqual(['list_skills', 'get_skill']);
841
+ expect(skillRequests.every((r) => r.body.branch === undefined)).toBe(true);
780
842
  });
781
843
 
782
844
  it('records a call refused for a missing sign-in as denied, without running the tool', async () => {
@@ -4,6 +4,7 @@ import { logger } from '../../shared/logging.js';
4
4
  const log = logger('mcp-auth');
5
5
  import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
6
6
  import type { AuthService } from '../auth/auth.service.js';
7
+ import { ACCOUNT_DEACTIVATED_MESSAGE, AccountDeactivatedError, AuthBackendError } from '../auth/account-admission.js';
7
8
  import type { IExternalApiKeyService } from '../tool-auth/external-api-key.interface.js';
8
9
  import type { InternalTokenService } from '../tool-auth/internal-token.service.js';
9
10
  import { rejectConnectionKey } from '../tool-auth/connection-key-rejection.js';
@@ -170,8 +171,10 @@ export function createMcpAuthMiddleware(
170
171
  return;
171
172
  }
172
173
  let user;
174
+ let active = false;
173
175
  try {
174
176
  user = await authService.getUserById(claim.userId);
177
+ if (user) active = await authService.isActive(user.id);
175
178
  } catch (err) {
176
179
  log.error('internal-token user lookup failed:', { err });
177
180
  res.status(500).json({ error: 'Authentication backend unavailable' });
@@ -181,6 +184,10 @@ export function createMcpAuthMiddleware(
181
184
  unauthorized(res, 'Invalid or expired internal token');
182
185
  return;
183
186
  }
187
+ if (!active) {
188
+ unauthorized(res, ACCOUNT_DEACTIVATED_MESSAGE);
189
+ return;
190
+ }
184
191
  req.userId = user.id;
185
192
  req.userEmail = user.email;
186
193
  // The local server's exchanged grant names its agent connection (see
@@ -214,13 +221,20 @@ export function createMcpAuthMiddleware(
214
221
  // JWT path — same logic as `createAuthMiddleware` in modules/auth, kept
215
222
  // duplicated rather than imported because that one writes its own 401
216
223
  // body shape and we want the WWW-Authenticate header set.
224
+ let session: { userId: string; email: string };
217
225
  try {
218
- const { userId, email } = authService.verifyToken(token);
219
- req.userId = userId;
220
- req.userEmail = email;
221
- next();
222
- } catch {
223
- unauthorized(res, 'Invalid or expired token');
226
+ session = await authService.resolveSession(token);
227
+ } catch (err) {
228
+ if (err instanceof AuthBackendError) {
229
+ log.error('session account lookup failed:', { err: err.cause });
230
+ res.status(500).json({ error: 'Authentication backend unavailable' });
231
+ return;
232
+ }
233
+ unauthorized(res, err instanceof AccountDeactivatedError ? err.message : 'Invalid or expired token');
234
+ return;
224
235
  }
236
+ req.userId = session.userId;
237
+ req.userEmail = session.email;
238
+ next();
225
239
  };
226
240
  }
@@ -309,7 +309,8 @@ export class BevelOAuthProvider implements OAuthServerProvider {
309
309
  // the revoke (a refresh that raced it) is dead on arrival rather than
310
310
  // live until its own expiry. A token from before connections existed
311
311
  // (null `connection_id`) has no connection to be revoked and verifies on
312
- // its own row alone.
312
+ // its own row alone. A switched-off account's tokens fail here too, and
313
+ // come back with it.
313
314
  const [row] = await this.deps.db
314
315
  .select({
315
316
  id: oauthTokens.id,
@@ -330,6 +331,7 @@ export class BevelOAuthProvider implements OAuthServerProvider {
330
331
  isNull(oauthTokens.revokedAt),
331
332
  gt(oauthTokens.expiresAt, now),
332
333
  or(isNull(oauthTokens.connectionId), isNull(agentConnections.revokedAt)),
334
+ isNull(users.deactivatedAt),
333
335
  ),
334
336
  )
335
337
  .limit(1);
@@ -53,7 +53,8 @@ describe('the creation endpoints behind the key-or-session gate', () => {
53
53
  } as never;
54
54
  const authService = {
55
55
  getUserById: async (id: string) => (id === ALICE.id ? ALICE : null),
56
- verifyToken: (t: string) => {
56
+ // A session is accepted only for an account that is on; Alice's is.
57
+ resolveSession: async (t: string) => {
57
58
  if (t !== 'session-alice') throw new Error('bad token');
58
59
  return { userId: ALICE.id, email: ALICE.email };
59
60
  },
@@ -413,6 +413,22 @@ describe('DeploymentSettingsService — validation', () => {
413
413
  );
414
414
  });
415
415
 
416
+ it('rejects a directory name carrying a line break, which every path is prefixed with', async () => {
417
+ // The name is joined onto every workspace-relative path, and a line break
418
+ // in a path is a separator to git's `cat-file --batch` — so a name
419
+ // carrying one would make the normaliser emit a path it refuses from a
420
+ // caller. Refused where the name is SET, which is here and at boot.
421
+ const { db } = makeDb();
422
+ const settings = new DeploymentSettingsService(db, ENC_KEY);
423
+ for (const bad of ['kb\nname', 'kb\rname', 'kb\u0000name', 'kb\u007Fname']) {
424
+ await expect(settings.save({ kbDirName: bad }, null), bad).rejects.toThrow(
425
+ /Invalid settings/,
426
+ );
427
+ }
428
+ // The ordinary name beside them, so the rule is not simply refusing both.
429
+ await expect(settings.save({ kbDirName: 'company-brain' }, null)).resolves.toBeTruthy();
430
+ });
431
+
416
432
  it('rejects a username that would break out of the credential-helper snippet', async () => {
417
433
  const { db } = makeDb();
418
434
  const settings = new DeploymentSettingsService(db, ENC_KEY);