@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
@@ -3,7 +3,7 @@ import express from 'express';
3
3
  import type { Server } from 'node:http';
4
4
  import { createAccountRoutes } from '../account.routes.js';
5
5
  import { AuthService } from '../auth.service.js';
6
- import { AccountAdmissionRefusedError } from '../account-admission.js';
6
+ import { AccountAdmissionRefusedError, AccountChangeRefusedError } from '../account-admission.js';
7
7
  import { hashPassword } from '../password-hash.js';
8
8
  import type { Database } from '../../database/connection.js';
9
9
  import type { IAdminAccessService } from '../../admin/admin.interface.js';
@@ -13,6 +13,8 @@ const authService = {
13
13
  { id: 'u1', email: 'a@example.com', name: 'A', hasPassword: true, createdAt: new Date() },
14
14
  ]),
15
15
  createAccount: vi.fn(async (email: string) => ({ id: 'u2', email, name: 'B' })),
16
+ deactivate: vi.fn(async (userId: string) => userId !== 'missing'),
17
+ reactivate: vi.fn(async (userId: string) => userId !== 'missing'),
16
18
  } as unknown as AuthService;
17
19
 
18
20
  // Satisfies the route's narrow Pick<IAccountErasureService, 'eraseUser'>
@@ -172,12 +174,12 @@ describe('account routes — admin gate', () => {
172
174
  expect(JSON.stringify(body)).not.toContain('relation');
173
175
  });
174
176
 
175
- it('400s on missing fields and surfaces service validation errors', async () => {
177
+ it('400s without an email (a password is optional) and surfaces service validation errors', async () => {
176
178
  const base = await listen(makeApp({ admin: true }));
177
179
  const missing = await fetch(`${base}/api/admin/accounts`, {
178
180
  method: 'POST',
179
181
  headers: { 'Content-Type': 'application/json' },
180
- body: JSON.stringify({ email: 'b@example.com' }),
182
+ body: JSON.stringify({ name: 'B' }),
181
183
  });
182
184
  expect(missing.status).toBe(400);
183
185
 
@@ -326,3 +328,79 @@ describe('account routes — removing the erased address from access files', ()
326
328
  expect(accessRemoval.remove).not.toHaveBeenCalled();
327
329
  });
328
330
  });
331
+
332
+ describe('account routes — switching an account off and on', () => {
333
+ const post = (base: string, path: string) => fetch(`${base}/api/admin/accounts/${path}`, { method: 'POST' });
334
+
335
+ it('switches another account off and back on', async () => {
336
+ const base = await listen(makeApp({ admin: true }));
337
+ expect((await post(base, 'u2/deactivate')).status).toBe(204);
338
+ expect(authService.deactivate).toHaveBeenCalledWith('u2');
339
+ expect((await post(base, 'u2/reactivate')).status).toBe(204);
340
+ expect(authService.reactivate).toHaveBeenCalledWith('u2');
341
+ });
342
+
343
+ it('refuses an admin switching off their own account, so an admin who can sign in always remains', async () => {
344
+ vi.mocked(authService.deactivate).mockClear();
345
+ const base = await listen(makeApp({ admin: true }));
346
+ const res = await post(base, 'u1/deactivate');
347
+ expect(res.status).toBe(400);
348
+ expect(authService.deactivate).not.toHaveBeenCalled();
349
+ });
350
+
351
+ it('refuses non-admins', async () => {
352
+ vi.mocked(authService.deactivate).mockClear();
353
+ const base = await listen(makeApp({ admin: false }));
354
+ expect((await post(base, 'u2/deactivate')).status).toBe(403);
355
+ expect((await post(base, 'u2/reactivate')).status).toBe(403);
356
+ expect(authService.deactivate).not.toHaveBeenCalled();
357
+ });
358
+
359
+ it('404s for an account that does not exist', async () => {
360
+ const base = await listen(makeApp({ admin: true }));
361
+ expect((await post(base, 'missing/deactivate')).status).toBe(404);
362
+ expect((await post(base, 'missing/reactivate')).status).toBe(404);
363
+ });
364
+
365
+ it("answers a reactivation the deployment has no room for with 403 and the port's words", async () => {
366
+ vi.mocked(authService.reactivate).mockRejectedValueOnce(new AccountAdmissionRefusedError('All 3 seats are taken'));
367
+ const base = await listen(makeApp({ admin: true }));
368
+ const res = await post(base, 'u2/reactivate');
369
+ expect(res.status).toBe(403);
370
+ expect(((await res.json()) as { error: string }).error).toBe('All 3 seats are taken');
371
+ });
372
+
373
+ it('answers a refused deactivation (the deployment admin, say) with 400 and the reason', async () => {
374
+ vi.mocked(authService.deactivate).mockRejectedValueOnce(new AccountChangeRefusedError('The deployment admin cannot be switched off'));
375
+ const base = await listen(makeApp({ admin: true }));
376
+ const res = await post(base, 'u2/deactivate');
377
+ expect(res.status).toBe(400);
378
+ expect(((await res.json()) as { error: string }).error).toContain('deployment admin');
379
+ });
380
+
381
+ it('answers an unexpected failure with a 500 that names nothing of it', async () => {
382
+ vi.mocked(authService.deactivate).mockRejectedValueOnce(new Error('relation "users" does not exist'));
383
+ vi.mocked(authService.reactivate).mockRejectedValueOnce(new Error('relation "users" does not exist'));
384
+ const base = await listen(makeApp({ admin: true }));
385
+ const off = await post(base, 'u2/deactivate');
386
+ expect(off.status).toBe(500);
387
+ expect(JSON.stringify(await off.json())).not.toContain('relation');
388
+ const on = await post(base, 'u2/reactivate');
389
+ expect(on.status).toBe(500);
390
+ expect(JSON.stringify(await on.json())).not.toContain('relation');
391
+ });
392
+
393
+ it('refuses a password that is not a string, rather than making an account for single sign-on', async () => {
394
+ vi.mocked(authService.createAccount).mockClear();
395
+ const base = await listen(makeApp({ admin: true }));
396
+ for (const password of [false, null, 0]) {
397
+ const res = await fetch(`${base}/api/admin/accounts`, {
398
+ method: 'POST',
399
+ headers: { 'Content-Type': 'application/json' },
400
+ body: JSON.stringify({ email: 'b@example.com', password }),
401
+ });
402
+ expect(res.status).toBe(400);
403
+ }
404
+ expect(authService.createAccount).not.toHaveBeenCalled();
405
+ });
406
+ });
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect, vi } from 'vitest';
2
2
  import type { Request, Response, NextFunction } from 'express';
3
3
  import { createAuthMiddleware, AUTH_COOKIE_NAME } from '../auth.middleware.js';
4
+ import { AccountDeactivatedError, AuthBackendError } from '../account-admission.js';
4
5
  import type { AuthService } from '../auth.service.js';
5
6
 
6
7
  /**
@@ -11,68 +12,90 @@ import type { AuthService } from '../auth.service.js';
11
12
  */
12
13
 
13
14
  const GOOD_TOKEN = 'good-token';
15
+ const OFF_TOKEN = 'switched-off-token';
16
+ const DOWN_TOKEN = 'database-down-token';
14
17
  const IDENTITY = { userId: 'user-1', email: 'alice@example.com' };
15
18
 
16
- function run(headers: Record<string, string>) {
17
- const verifyToken = vi.fn((token: string) => {
19
+ async function run(headers: Record<string, string>) {
20
+ const resolveSession = vi.fn(async (token: string) => {
21
+ if (token === OFF_TOKEN) throw new AccountDeactivatedError();
22
+ if (token === DOWN_TOKEN) throw new AuthBackendError(new Error('db down'));
18
23
  if (token !== GOOD_TOKEN) throw new Error('bad token');
19
24
  return IDENTITY;
20
25
  });
21
- const authService = { verifyToken } as unknown as AuthService;
26
+ const authService = { resolveSession } as unknown as AuthService;
22
27
  const req = { headers } as unknown as Request;
23
28
  const json = vi.fn();
24
29
  const status = vi.fn(() => ({ json }));
25
30
  const res = { status } as unknown as Response;
26
31
  const next = vi.fn() as unknown as NextFunction;
27
- createAuthMiddleware(authService)(req, res, next);
28
- return { req, next, status, json, verifyToken };
32
+ await createAuthMiddleware(authService)(req, res, next);
33
+ return { req, next, status, json, resolveSession };
29
34
  }
30
35
 
31
- describe('createAuthMiddleware', () => {
32
- it('authenticates a request that carries only the auth cookie (an <img>, an EventSource)', () => {
33
- const { req, next, status } = run({ cookie: `${AUTH_COOKIE_NAME}=${GOOD_TOKEN}` });
36
+ describe('createAuthMiddleware', async () => {
37
+ it('authenticates a request that carries only the auth cookie (an <img>, an EventSource)', async () => {
38
+ const { req, next, status } = await run({ cookie: `${AUTH_COOKIE_NAME}=${GOOD_TOKEN}` });
34
39
  expect(next).toHaveBeenCalledTimes(1);
35
40
  expect(status).not.toHaveBeenCalled();
36
41
  expect(req.userId).toBe(IDENTITY.userId);
37
42
  expect(req.userEmail).toBe(IDENTITY.email);
38
43
  });
39
44
 
40
- it('finds the cookie among others, and decodes a percent-encoded value', () => {
41
- const { next, verifyToken } = run({
45
+ it('finds the cookie among others, and decodes a percent-encoded value', async () => {
46
+ const { next, resolveSession } = await run({
42
47
  cookie: `theme=dark; ${AUTH_COOKIE_NAME}=${encodeURIComponent(GOOD_TOKEN)}; seen=1`,
43
48
  });
44
- expect(verifyToken).toHaveBeenCalledWith(GOOD_TOKEN);
49
+ expect(resolveSession).toHaveBeenCalledWith(GOOD_TOKEN);
45
50
  expect(next).toHaveBeenCalledTimes(1);
46
51
  });
47
52
 
48
- it('prefers the Bearer header when both are present', () => {
49
- const { next, verifyToken } = run({
53
+ it('prefers the Bearer header when both are present', async () => {
54
+ const { next, resolveSession } = await run({
50
55
  authorization: `Bearer ${GOOD_TOKEN}`,
51
56
  cookie: `${AUTH_COOKIE_NAME}=stale-cookie`,
52
57
  });
53
- expect(verifyToken).toHaveBeenCalledTimes(1);
54
- expect(verifyToken).toHaveBeenCalledWith(GOOD_TOKEN);
58
+ expect(resolveSession).toHaveBeenCalledTimes(1);
59
+ expect(resolveSession).toHaveBeenCalledWith(GOOD_TOKEN);
55
60
  expect(next).toHaveBeenCalledTimes(1);
56
61
  });
57
62
 
58
- it('refuses a request with neither', () => {
59
- const { next, status, json } = run({});
63
+ it('refuses a request with neither', async () => {
64
+ const { next, status, json } = await run({});
60
65
  expect(next).not.toHaveBeenCalled();
61
66
  expect(status).toHaveBeenCalledWith(401);
62
67
  expect(json.mock.calls[0][0].error).toContain('auth cookie');
63
68
  });
64
69
 
65
- it('refuses an empty cookie value as missing, not as a token to verify', () => {
66
- const { next, status, verifyToken } = run({ cookie: `${AUTH_COOKIE_NAME}=` });
67
- expect(verifyToken).not.toHaveBeenCalled();
70
+ it('refuses an empty cookie value as missing, not as a token to verify', async () => {
71
+ const { next, status, resolveSession } = await run({ cookie: `${AUTH_COOKIE_NAME}=` });
72
+ expect(resolveSession).not.toHaveBeenCalled();
68
73
  expect(next).not.toHaveBeenCalled();
69
74
  expect(status).toHaveBeenCalledWith(401);
70
75
  });
71
76
 
72
- it('refuses a cookie the auth service rejects', () => {
73
- const { next, status, json } = run({ cookie: `${AUTH_COOKIE_NAME}=forged` });
77
+ it('refuses a cookie the auth service rejects', async () => {
78
+ const { next, status, json } = await run({ cookie: `${AUTH_COOKIE_NAME}=forged` });
74
79
  expect(next).not.toHaveBeenCalled();
75
80
  expect(status).toHaveBeenCalledWith(401);
76
81
  expect(json.mock.calls[0][0].error).toBe('Invalid or expired token');
77
82
  });
78
83
  });
84
+
85
+ describe('createAuthMiddleware — a switched-off account', () => {
86
+ it('refuses a valid session whose account an admin switched off, and says why', async () => {
87
+ const { next, status, json } = await run({ authorization: `Bearer ${OFF_TOKEN}` });
88
+ expect(next).not.toHaveBeenCalled();
89
+ expect(status).toHaveBeenCalledWith(401);
90
+ expect(json.mock.calls[0][0].error).toContain('switched off');
91
+ });
92
+ });
93
+
94
+ describe('createAuthMiddleware — the account cannot be looked up', () => {
95
+ it('500s rather than signing a valid session out during an outage', async () => {
96
+ const { next, status, json } = await run({ authorization: `Bearer ${DOWN_TOKEN}` });
97
+ expect(next).not.toHaveBeenCalled();
98
+ expect(status).toHaveBeenCalledWith(500);
99
+ expect(json.mock.calls[0][0].error).toBe('Authentication backend unavailable');
100
+ });
101
+ });
@@ -537,7 +537,7 @@ describe('AuthService.listAccounts', () => {
537
537
  expect(json).not.toContain('sup3r-secret');
538
538
  for (const account of [...withHash, ...withoutHash]) {
539
539
  expect(Object.keys(account).sort()).toEqual(
540
- ['createdAt', 'email', 'hasPassword', 'id', 'isEnvAdmin', 'name'],
540
+ ['createdAt', 'deactivatedAt', 'email', 'hasPassword', 'id', 'isEnvAdmin', 'isSystem', 'name'],
541
541
  );
542
542
  }
543
543
  });
@@ -1,12 +1,14 @@
1
1
  /**
2
2
  * Whether a NEW account may be provisioned on this deployment, asked before
3
- * the row is inserted. The one seam a host that sells seats needs: core
4
- * admits everyone, and a cloud overlay answers from its plan without
5
- * forking any sign-in path.
3
+ * the row is inserted — and whether a deactivated one may be switched back
4
+ * on, which takes up the same place. The one seam a host that sells seats
5
+ * needs: core admits everyone, and a cloud overlay answers from its plan
6
+ * without forking any sign-in path.
6
7
  *
7
- * Asked only for an address with no account yet. An existing user's sign-in
8
- * is never a provisioning, so a refusal can never lock out someone who is
9
- * already in.
8
+ * Never asked for an account that is already on. An existing user's sign-in
9
+ * is not a provisioning, so a refusal can never lock out someone who is
10
+ * already in; taking someone out is the admin's act (deactivation), not the
11
+ * port's.
10
12
  */
11
13
  export type AccountProvisionReason =
12
14
  /** A first sign-in through single sign-on, which provisions on its own. */
@@ -16,9 +18,29 @@ export type AccountProvisionReason =
16
18
  /** The deployment owner's first password sign-in, which creates their row. */
17
19
  | 'bootstrap'
18
20
  /** A verified identity from an embedding surface that needs a row without a session. */
19
- | 'embed';
21
+ | 'embed'
22
+ /** An admin switching a deactivated account back on. */
23
+ | 'reactivate';
20
24
 
21
- export type AccountAdmissionVerdict = { ok: true } | { ok: false; message: string };
25
+ export type AccountAdmissionVerdict =
26
+ | { ok: true }
27
+ | {
28
+ ok: false;
29
+ message: string;
30
+ /**
31
+ * Keep the person on file instead of turning them away: the account is
32
+ * created DEACTIVATED, the sign-in is still refused with `message`, and
33
+ * an admin finds them on the accounts list, ready to be switched on.
34
+ * For a host whose rule is "whoever is on our domain may join while
35
+ * there is room" — the one past the room waits instead of bouncing.
36
+ *
37
+ * Honoured only for a sign-in through single sign-on, the one
38
+ * provisioning nobody asked for. Everywhere else a verdict carrying it
39
+ * is a plain refusal: an admin creating an account would be told no,
40
+ * and a deactivated account is never reactivated into one.
41
+ */
42
+ waitForAdmin?: boolean;
43
+ };
22
44
 
23
45
  export interface IAccountAdmission {
24
46
  canProvision(email: string, reason: AccountProvisionReason): Promise<AccountAdmissionVerdict>;
@@ -36,9 +58,56 @@ export const admitEveryone: IAccountAdmission = {
36
58
  */
37
59
  export class AccountAdmissionRefusedError extends Error {
38
60
  readonly status = 403;
61
+ /** The person was put on file, switched off, for an admin to switch on (see `waitForAdmin`). */
62
+ readonly waitingForAdmin: boolean;
39
63
 
40
- constructor(message: string) {
64
+ constructor(message: string, opts: { waitingForAdmin?: boolean } = {}) {
41
65
  super(message);
42
66
  this.name = 'AccountAdmissionRefusedError';
67
+ this.waitingForAdmin = opts.waitingForAdmin ?? false;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * The account exists and an admin switched it off — at sign-in, or for a
73
+ * credential it still holds. A 403 like a refusal: the identity is fine,
74
+ * the deployment no longer lets it in.
75
+ */
76
+ export class AccountDeactivatedError extends Error {
77
+ readonly status = 403;
78
+
79
+ constructor(message = ACCOUNT_DEACTIVATED_MESSAGE) {
80
+ super(message);
81
+ this.name = 'AccountDeactivatedError';
43
82
  }
44
83
  }
84
+
85
+ /**
86
+ * Whether the account behind a credential is on could not be asked — the
87
+ * database failed. Not the caller's fault and not a verdict on them: a
88
+ * route answers it with a 500, never with the 401 that would sign a valid
89
+ * caller out during an outage.
90
+ */
91
+ export class AuthBackendError extends Error {
92
+ constructor(readonly cause: unknown) {
93
+ super('The account could not be looked up');
94
+ this.name = 'AuthBackendError';
95
+ }
96
+ }
97
+
98
+ /**
99
+ * An admin's change to an account that the deployment refuses on purpose
100
+ * (switching off the deployment admin, or an account the platform runs its
101
+ * own work as). Its message is for the admin; a route answers it with 400.
102
+ */
103
+ export class AccountChangeRefusedError extends Error {
104
+ readonly status = 400;
105
+
106
+ constructor(message: string) {
107
+ super(message);
108
+ this.name = 'AccountChangeRefusedError';
109
+ }
110
+ }
111
+
112
+ /** What a person with a deactivated account is told, wherever they knock. */
113
+ export const ACCOUNT_DEACTIVATED_MESSAGE = 'Your account on this workspace is switched off. Ask its admin to turn it back on.';
@@ -6,7 +6,7 @@ import { printable } from '../../shared/printable.js';
6
6
  const log = logger('accounts');
7
7
  import type { AuthUser } from '@bevel-software/platform-shared';
8
8
  import type { AuthService } from './auth.service.js';
9
- import { AccountAdmissionRefusedError } from './account-admission.js';
9
+ import { AccountAdmissionRefusedError, AccountChangeRefusedError } from './account-admission.js';
10
10
  import { erasedAccountId, type IAccountErasureService } from './account-erasure.service.js';
11
11
  import type { IAdminAccessService } from '../admin/admin.interface.js';
12
12
  import type { UserAccessRemovalService } from '../access/user-access-removal.service.js';
@@ -26,7 +26,7 @@ import './auth.middleware.js'; // Express Request augmentation
26
26
  * bootstrap admin (always-admin, see AdminAccessService).
27
27
  */
28
28
  export function createAccountRoutes(
29
- authService: Pick<AuthService, 'listAccounts' | 'createAccount' | 'getUserById'>,
29
+ authService: Pick<AuthService, 'listAccounts' | 'createAccount' | 'getUserById' | 'deactivate' | 'reactivate'>,
30
30
  adminAccess: IAdminAccessService,
31
31
  accountErasure: Pick<IAccountErasureService, 'eraseUser'>,
32
32
  accessRemoval?: Pick<UserAccessRemovalService, 'report' | 'assertRemovable' | 'remove' | 'filesNaming'>,
@@ -47,17 +47,23 @@ export function createAccountRoutes(
47
47
  res.json({ accounts: await authService.listAccounts() });
48
48
  });
49
49
 
50
- // POST /api/admin/accounts { email, name?, password } — create an account
50
+ // POST /api/admin/accounts { email, name?, password? } — create an account
51
51
  // (or reset the password of an existing one; upsert-by-email is deliberate,
52
- // see AuthService.createAccount).
52
+ // see AuthService.createAccount). Without a password the account is for
53
+ // single sign-on: the person finds it waiting when they first sign in.
53
54
  router.post('/admin/accounts', requireAdmin, async (req, res) => {
54
55
  const { email, name, password } = req.body as {
55
56
  email?: string;
56
57
  name?: string;
57
- password?: string;
58
+ password?: unknown;
58
59
  };
59
- if (!email || !password) {
60
- res.status(400).json({ error: 'email and password are required' });
60
+ if (!email) {
61
+ res.status(400).json({ error: 'email is required' });
62
+ return;
63
+ }
64
+ // Absent means single sign-on; anything else that is not text is a mistake, not that.
65
+ if (password !== undefined && typeof password !== 'string') {
66
+ res.status(400).json({ error: 'password must be a string' });
61
67
  return;
62
68
  }
63
69
  try {
@@ -71,6 +77,56 @@ export function createAccountRoutes(
71
77
  }
72
78
  });
73
79
 
80
+ // POST /api/admin/accounts/:userId/deactivate — switch an account off. It
81
+ // keeps its row, history and place in roles and groups; nothing it holds
82
+ // signs in any more (see AuthService.isActive). An admin's own account is
83
+ // refused, so whoever does this is still an admin who is on afterwards —
84
+ // the deployment is never left with no admin able to sign in.
85
+ router.post('/admin/accounts/:userId/deactivate', requireAdmin, async (req, res) => {
86
+ const userId = String(req.params.userId);
87
+ if (userId === req.userId) {
88
+ res.status(400).json({ error: 'You cannot switch off your own account. Ask another admin.' });
89
+ return;
90
+ }
91
+ try {
92
+ if (!(await authService.deactivate(userId))) {
93
+ res.status(404).json({ error: 'No such user' });
94
+ return;
95
+ }
96
+ log.info('account audit:', { action: 'deactivate', actorUserId: req.userId, targetUserId: userId });
97
+ res.status(204).end();
98
+ } catch (error) {
99
+ if (error instanceof AccountChangeRefusedError) {
100
+ res.status(400).json({ error: error.message });
101
+ return;
102
+ }
103
+ log.error('switching an account off failed:', { err: error });
104
+ res.status(500).json({ error: 'Could not switch this account off' });
105
+ }
106
+ });
107
+
108
+ // POST /api/admin/accounts/:userId/reactivate — switch it back on, if the
109
+ // deployment has room: the admission port is asked, as for a new account
110
+ // (a host that sells seats answers from its plan; 403 with its words).
111
+ router.post('/admin/accounts/:userId/reactivate', requireAdmin, async (req, res) => {
112
+ const userId = String(req.params.userId);
113
+ try {
114
+ if (!(await authService.reactivate(userId))) {
115
+ res.status(404).json({ error: 'No such user' });
116
+ return;
117
+ }
118
+ log.info('account audit:', { action: 'reactivate', actorUserId: req.userId, targetUserId: userId });
119
+ res.status(204).end();
120
+ } catch (error) {
121
+ if (error instanceof AccountAdmissionRefusedError) {
122
+ res.status(403).json({ error: error.message });
123
+ return;
124
+ }
125
+ log.error('switching an account on failed:', { err: error });
126
+ res.status(500).json({ error: 'Could not switch this account on' });
127
+ }
128
+ });
129
+
74
130
  // GET /api/admin/accounts/:userId/references — how many places in the
75
131
  // knowledge base name this account's address (roles, groups, access rules,
76
132
  // file grants) and whether removing them is allowed. Feeds the delete
@@ -1,5 +1,6 @@
1
1
  import type { Request, Response, NextFunction } from 'express';
2
2
  import type { AuthService } from './auth.service.js';
3
+ import { AccountDeactivatedError, AuthBackendError } from './account-admission.js';
3
4
 
4
5
  // Augment Express Request so userId/userEmail are available after auth middleware
5
6
  declare global {
@@ -46,8 +47,8 @@ function readAuthCookie(req: Request): string | null {
46
47
  return null;
47
48
  }
48
49
 
49
- export function createAuthMiddleware(authService: AuthService) {
50
- return (req: Request, res: Response, next: NextFunction) => {
50
+ export function createAuthMiddleware(authService: Pick<AuthService, 'resolveSession'>) {
51
+ return async (req: Request, res: Response, next: NextFunction) => {
51
52
  // Bearer is the primary path — every JSON API call in the frontend
52
53
  // attaches it via `authFetch`. Cookie is the fallback for transports
53
54
  // that can't carry headers (EventSource, image tags), set at login by
@@ -66,13 +67,23 @@ export function createAuthMiddleware(authService: AuthService) {
66
67
  return;
67
68
  }
68
69
 
70
+ let session: { userId: string; email: string };
69
71
  try {
70
- const { userId, email } = authService.verifyToken(token);
71
- req.userId = userId;
72
- req.userEmail = email;
73
- next();
74
- } catch {
75
- res.status(401).json({ error: 'Invalid or expired token' });
72
+ session = await authService.resolveSession(token);
73
+ } catch (err) {
74
+ // The account could not be looked up: not the caller's doing, and a
75
+ // 401 here would sign every valid session out during an outage.
76
+ if (err instanceof AuthBackendError) {
77
+ res.status(500).json({ error: 'Authentication backend unavailable' });
78
+ return;
79
+ }
80
+ // A 401 otherwise, so the app drops the session and shows the login
81
+ // page; a switched-off account is told why there.
82
+ res.status(401).json({ error: err instanceof AccountDeactivatedError ? err.message : 'Invalid or expired token' });
83
+ return;
76
84
  }
85
+ req.userId = session.userId;
86
+ req.userEmail = session.email;
87
+ next();
77
88
  };
78
89
  }
@@ -5,6 +5,7 @@ const log = logger('auth');
5
5
  import type { AuthService } from './auth.service.js';
6
6
  import { AUTH_COOKIE_NAME } from './auth.middleware.js'; // also imports Express Request augmentation
7
7
  import { FixedWindowRateLimiter } from './rate-limit.js';
8
+ import { AccountDeactivatedError } from './account-admission.js';
8
9
  import { canonicalEmail } from '../../shared/email-identity.js';
9
10
 
10
11
  /**
@@ -113,6 +114,13 @@ export function createAuthRoutes(
113
114
  });
114
115
  res.json(result);
115
116
  } catch (error) {
117
+ // The password was right and an admin switched the account off: said
118
+ // as such (only ever after the password is proven, see
119
+ // `loginWithPassword`), so the person knows whom to ask.
120
+ if (error instanceof AccountDeactivatedError) {
121
+ res.status(403).json({ error: error.message });
122
+ return;
123
+ }
116
124
  const msg = error instanceof Error ? error.message : 'Unknown error';
117
125
  log.error('Login error:', { detail: msg });
118
126
  res.status(401).json({ error: 'Authentication failed' });