@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
@@ -5,7 +5,7 @@ import { logger } from '../../shared/logging.js';
5
5
  const log = logger('access');
6
6
 
7
7
  import { isAbsence, type ITreeWalker, type WalkListener } from '../../shared/fs.contract.js';
8
- import { isGitTimeout, type IGitRunner } from '../../shared/git.contract.js';
8
+ import { assertOneSpecPerLine, isGitTimeout, type IGitRunner } from '../../shared/git.contract.js';
9
9
  import { hashEmail } from '../../shared/email-identity.js';
10
10
  import { NodeGitRunner } from '../workflow/git/node-git-runner.js';
11
11
  import type { WorkspaceService } from '../workspace/workspace.service.js';
@@ -19,6 +19,7 @@ import type {
19
19
  GrantSource,
20
20
  GrantSources,
21
21
  PathHolders,
22
+ PathVerbs,
22
23
  ProspectiveHolders,
23
24
  ResolvedPrincipal,
24
25
  } from './access-control.interface.js';
@@ -31,7 +32,6 @@ import {
31
32
  } from '@bevel-software/platform-shared';
32
33
  import type { KbContext } from '../../shared/kb-context.js';
33
34
  import { AccessConfigError, AccessUnreadableError } from '../access-model/access-errors.js';
34
- import { WorkflowDomainError } from '../../shared/domain-errors.js';
35
35
  import { synthesizePluginPrincipals } from '../access-model/plugin-principals.js';
36
36
  import {
37
37
  GROUPS_YAML,
@@ -81,6 +81,12 @@ async function catFileBatch(
81
81
  ): Promise<(string | null)[]> {
82
82
  // Bytes, not text: every `<size>` in the header counts bytes, and the walk
83
83
  // below has to step by that count through content that is not all ASCII.
84
+ // One spec per LINE is the protocol, and this walk depends on getting back
85
+ // exactly as many answers as it sent: a spec carrying a line break would
86
+ // insert an extra answer and shift every result after it, so each directory
87
+ // would be handed the NEXT one's `access.md`. Paths are refused a control
88
+ // character at the normaliser; this is the fence at the protocol.
89
+ assertOneSpecPerLine(specs);
84
90
  const { stdout: out } = await runner.run(repoDir, ['cat-file', '--batch'], {
85
91
  input: `${specs.join('\n')}\n`,
86
92
  encoding: 'buffer',
@@ -951,6 +957,95 @@ function resolveDenialSourcesForVerb(
951
957
  return out;
952
958
  }
953
959
 
960
+ /**
961
+ * The model as it will be once `fromPath` has been moved or copied to
962
+ * `toPath`: every `access.md` the operation carries, keyed at the directory
963
+ * it lands in, since the rules a folder carries travel with its bytes.
964
+ *
965
+ * What travels is BYTES, and the name they land under decides what they
966
+ * govern — `landing` is the caller's answer to that (see
967
+ * `relocationSubject`), already parsed as the destination path:
968
+ *
969
+ * - a FOLDER takes every `access.md` at or under it, each re-keyed to the
970
+ * directory it lands in, and `landing` plays no part;
971
+ * - a FILE governs a directory exactly when it lands as that directory's
972
+ * `access.md`, whatever it was called before — a copy `copy_file` allows,
973
+ * and one that governs the destination folder the moment it lands. Its key
974
+ * is a PARENT directory, never a path under `fromPath`, which is why the
975
+ * prefix walk above cannot see it.
976
+ *
977
+ * The emptied side is the mirror: an `access.md` that MOVES stops governing
978
+ * the directory it left, whether or not it governs anything where it lands.
979
+ *
980
+ * PREVIEW ONLY. Nothing here reads disk and nothing here decides whether an
981
+ * operation may run — the gates resolve against the model as it IS. This
982
+ * answers the other question, the one a dry run asks: what will be true
983
+ * afterwards. Judging the destination as it stands today is what made a
984
+ * rename warn about losing the owner access the folder's own `access.md` was
985
+ * about to hand straight back.
986
+ *
987
+ * `sourceRemains` is the difference between a copy and a move: a copy leaves
988
+ * the rules where they were, a move takes them away. It changes the answer
989
+ * only when the destination resolves through a directory the source
990
+ * occupied; nothing else consults those keys.
991
+ */
992
+ function withRelocatedAccessFiles(
993
+ model: AccessModel,
994
+ fromPath: string,
995
+ toPath: string,
996
+ opts: { sourceRemains: boolean; sourceIsFolder: boolean; landing?: AccessFile | null },
997
+ ): AccessModel {
998
+ /** Directories the operation takes an `access.md` AWAY from. */
999
+ const emptied: string[] = [];
1000
+ /** Directories it leaves one IN, with the file as it will be read there. */
1001
+ const landing: { toDir: string; file: AccessFile }[] = [];
1002
+ if (opts.sourceIsFolder) {
1003
+ for (const [dir, file] of model.accessFilesByDir) {
1004
+ if (dir !== fromPath && !dir.startsWith(`${fromPath}/`)) continue;
1005
+ emptied.push(dir);
1006
+ landing.push({ toDir: `${toPath}${dir.slice(fromPath.length)}`, file });
1007
+ }
1008
+ } else {
1009
+ const fromDir = parentDirOf(fromPath);
1010
+ if (isAccessMdPath(fromPath) && model.accessFilesByDir.has(fromDir)) emptied.push(fromDir);
1011
+ if (opts.landing) landing.push({ toDir: parentDirOf(toPath), file: opts.landing });
1012
+ }
1013
+ if (emptied.length === 0 && landing.length === 0) return model;
1014
+ const accessFilesByDir = new Map(model.accessFilesByDir);
1015
+ if (!opts.sourceRemains) for (const dir of emptied) accessFilesByDir.delete(dir);
1016
+ for (const { toDir, file } of landing) {
1017
+ // A directory that already has an `access.md` keeps it. The operation
1018
+ // would be refused for landing on something that exists long before the
1019
+ // two files could meet, so the model must not invent a merge either.
1020
+ if (accessFilesByDir.has(toDir)) continue;
1021
+ accessFilesByDir.set(toDir, {
1022
+ ...file,
1023
+ dir: toDir,
1024
+ path: toDir ? `${toDir}/access.md` : 'access.md',
1025
+ });
1026
+ }
1027
+ return { ...model, accessFilesByDir };
1028
+ }
1029
+
1030
+ /** The directory a repo-relative file path sits in; `''` for the repo root. */
1031
+ function parentDirOf(relativePath: string): string {
1032
+ const cut = relativePath.lastIndexOf('/');
1033
+ return cut === -1 ? '' : relativePath.slice(0, cut);
1034
+ }
1035
+
1036
+ /**
1037
+ * One file's own-entries, parsed as the path `asName` would be read.
1038
+ *
1039
+ * The same frontmatter means two different things by name: in an `access.md`
1040
+ * it is the FOLDER's rules (and the file governs itself only through the
1041
+ * body format — see {@link accessMdSelfEntries}), and anywhere else it is the
1042
+ * file's own. Which is why a preview must read the bytes as the name they
1043
+ * LAND under, not the one they are leaving.
1044
+ */
1045
+ function ownEntriesFromText(text: string, asName: string): OwnEntries | null {
1046
+ return isAccessMdPath(asName) ? accessMdSelfEntries(text) : parseOwnAccessEntries(text);
1047
+ }
1048
+
954
1049
  /**
955
1050
  * Build the repo-root → path chain of directory scopes that govern
956
1051
  * `relativePath`, e.g. `Knowledge/Sales/Foo.md` →
@@ -1586,16 +1681,25 @@ export class AccessControlService implements IAccessControl {
1586
1681
  }
1587
1682
 
1588
1683
  /**
1589
- * The read and write holders of one file at its current path and at a path
1590
- * it has not moved to yet (see the interface).
1684
+ * The read and write holders of one file or folder at its current path and
1685
+ * at a path it has not moved to yet (see the interface).
1686
+ *
1687
+ * A move changes nothing about what the thing itself carries — a file's
1688
+ * frontmatter and a folder's `access.md` files travel with the bytes — and
1689
+ * everything about the folder chain above it. So the hypothetical side is
1690
+ * the ordinary resolution at the destination path, over what travels:
1591
1691
  *
1592
- * A move changes nothing about the file's own frontmatter — that travels
1593
- * with the bytes — and everything about the folder chain above it. So the
1594
- * hypothetical side is the ordinary resolution with the destination path
1595
- * substituted: the same model, the same own-entries (read from the SOURCE,
1596
- * since nothing sits at the destination to read), resolved against the
1597
- * destination's ancestors. One model load and one file read serve all four
1598
- * lookups.
1692
+ * - a FILE's own-entries, read from the SOURCE (nothing sits at the
1693
+ * destination to read) and parsed as the name they LAND under, layered
1694
+ * over the destination's ancestors — the two sides can differ, because
1695
+ * the same frontmatter is a folder's rules in an `access.md` and the
1696
+ * file's own anywhere else;
1697
+ * - a FOLDER's `access.md` files, keyed at the directories they land in,
1698
+ * and a file landing as a directory's `access.md` at the directory it
1699
+ * lands in (`withRelocatedAccessFiles`). A folder has no frontmatter of
1700
+ * its own, so it passes none.
1701
+ *
1702
+ * One model load and one file read serve all four lookups.
1599
1703
  */
1600
1704
  async prospectiveHolders(
1601
1705
  workspaceId: string,
@@ -1604,31 +1708,64 @@ export class AccessControlService implements IAccessControl {
1604
1708
  ): Promise<ProspectiveHolders> {
1605
1709
  const model = await this.loadModel(workspaceId);
1606
1710
  const repoDir = await this.repoDir(workspaceId);
1607
- // A FILE question only. A folder carries its own `access.md` — which moves
1608
- // with it and governs everything under it — so resolving it as a file
1609
- // would read frontmatter it does not have and omit the rules it does,
1610
- // naming principals that are not the ones a folder move changes. Refuse
1611
- // rather than answer the wrong question convincingly.
1612
- //
1613
- // Only absence is an answer here (see `isAbsence`): a probe that failed on
1614
- // permissions or I/O does not say "this is a file", and folding it into
1615
- // one would let the very case above through on an unreadable source.
1616
- const fromStat = await fs.stat(path.join(repoDir, fromPath)).catch((err: unknown) => {
1617
- if (isAbsence(err)) return null;
1618
- throw err;
1711
+ const { isFolder, ownHere, ownThere, landing } =
1712
+ await this.relocationSubject(repoDir, fromPath, toPath);
1713
+ const after = withRelocatedAccessFiles(model, fromPath, toPath, {
1714
+ sourceRemains: false,
1715
+ sourceIsFolder: isFolder,
1716
+ landing,
1619
1717
  });
1620
- if (fromStat?.isDirectory()) {
1621
- throw new WorkflowDomainError(
1622
- 'prospective access answers for a file, not a folder',
1623
- 400,
1624
- );
1625
- }
1626
- const own = await this.readOwnEntries(repoDir, fromPath);
1627
- const holdersAt = (relativePath: string): PathHolders => ({
1628
- read: eligibleHoldersResolved(model, 'read', relativePath, own),
1629
- write: eligibleHoldersResolved(model, 'write', relativePath, own),
1718
+ const holdersAt = (m: AccessModel, relativePath: string, own: OwnEntries | null): PathHolders => ({
1719
+ read: eligibleHoldersResolved(m, 'read', relativePath, own),
1720
+ write: eligibleHoldersResolved(m, 'write', relativePath, own),
1630
1721
  });
1631
- return { before: holdersAt(fromPath), after: holdersAt(toPath) };
1722
+ return {
1723
+ before: holdersAt(model, fromPath, ownHere),
1724
+ after: holdersAt(after, toPath, ownThere),
1725
+ };
1726
+ }
1727
+
1728
+ /**
1729
+ * The caller's own verbs at `toPath` as they will be once `fromPath` has
1730
+ * been moved or copied there (see the interface).
1731
+ *
1732
+ * Identical to asking `canRead`/`canWrite`/`canDownload`/`canOwner` at
1733
+ * `toPath` whenever nothing travels — an ordinary file with no rules of its
1734
+ * own, or a folder with no `access.md` of its own — so the cases this was
1735
+ * not written for answer exactly as they always did.
1736
+ *
1737
+ * What travels is what `prospectiveHolders` says travels, and for the same
1738
+ * reason: the preview and the dialog's holder lists must not disagree about
1739
+ * one move. A file's own-entries come from the SOURCE, where the bytes
1740
+ * carrying them still are — asking the destination, which does not exist
1741
+ * yet, always answered null and so dropped the frontmatter a rename hands
1742
+ * straight back (requirement 6: what the caller has afterwards equals what
1743
+ * the preview said) — and they are parsed as the name they LAND under,
1744
+ * because that is the parse the gates will use a moment later.
1745
+ */
1746
+ async previewAccessAfterRelocation(
1747
+ workspaceId: string,
1748
+ userEmail: string,
1749
+ fromPath: string,
1750
+ toPath: string,
1751
+ opts?: { sourceRemains?: boolean },
1752
+ ): Promise<PathVerbs> {
1753
+ const repoDir = await this.repoDir(workspaceId);
1754
+ const { isFolder, ownThere: own, landing } =
1755
+ await this.relocationSubject(repoDir, fromPath, toPath);
1756
+ const model = withRelocatedAccessFiles(
1757
+ await this.loadModel(workspaceId),
1758
+ fromPath,
1759
+ toPath,
1760
+ { sourceRemains: opts?.sourceRemains === true, sourceIsFolder: isFolder, landing },
1761
+ );
1762
+ const machineOwned = this.machineOwnedWriteRule(userEmail, toPath);
1763
+ return {
1764
+ read: canReadResolved(model, userEmail, toPath, own),
1765
+ write: machineOwned ?? hasPermissionResolved(model, 'write', userEmail, toPath, own),
1766
+ download: hasPermissionResolved(model, 'download', userEmail, toPath, own),
1767
+ owner: hasPermissionResolved(model, 'owner', userEmail, toPath, own),
1768
+ };
1632
1769
  }
1633
1770
 
1634
1771
  async eligibleWriterEmails(
@@ -2219,8 +2356,69 @@ export class AccessControlService implements IAccessControl {
2219
2356
  } catch {
2220
2357
  return null;
2221
2358
  }
2222
- if (isAccessMdPath(relativePath)) return accessMdSelfEntries(text);
2223
- return parseOwnAccessEntries(text);
2359
+ return ownEntriesFromText(text, relativePath);
2360
+ }
2361
+
2362
+ /**
2363
+ * What a move or a copy of `fromPath` to `toPath` CARRIES — the one disk
2364
+ * read both preview surfaces need:
2365
+ *
2366
+ * - `isFolder`: the source is a folder, so the `access.md` files under it
2367
+ * travel, keyed at the directories they land in;
2368
+ * - `ownHere` / `ownThere`: a file's own-entries as the SOURCE's name reads
2369
+ * them and as the DESTINATION's name will. They differ because the same
2370
+ * frontmatter means two things by name (see `ownEntriesFromText`), so
2371
+ * `Sales/access.md` copied to `Legal/rules-copy.md` arrives as an
2372
+ * ordinary file whose frontmatter is its own — reading it as the source's
2373
+ * name had the preview contradict the copy it was previewing;
2374
+ * - `landing`: the folder rules the bytes WILL be, when they land under the
2375
+ * name `access.md`. Parsed as the destination path, so the directory it
2376
+ * governs is already the right one. An `access.md` that will not parse
2377
+ * travels as nothing, as the loader treats it.
2378
+ *
2379
+ * `lstat`, not `stat`: a SYMLINK is none of this. `move_file` refuses a
2380
+ * symlink source outright, so a link to a directory must not be previewed
2381
+ * as a folder taking access files along, and nothing is read THROUGH the
2382
+ * link either — the link's target is not what would move.
2383
+ *
2384
+ * Only absence is an answer (see `isAbsence`): a probe that failed on
2385
+ * permissions or I/O does not say "this is a file", and reading a folder as
2386
+ * one would resolve it through frontmatter it does not have.
2387
+ */
2388
+ private async relocationSubject(
2389
+ repoDir: string,
2390
+ fromPath: string,
2391
+ toPath: string,
2392
+ ): Promise<{
2393
+ isFolder: boolean;
2394
+ ownHere: OwnEntries | null;
2395
+ ownThere: OwnEntries | null;
2396
+ landing: AccessFile | null;
2397
+ }> {
2398
+ const nothing = { isFolder: false, ownHere: null, ownThere: null, landing: null };
2399
+ const fromStat = await fs.lstat(path.join(repoDir, fromPath)).catch((err: unknown) => {
2400
+ if (isAbsence(err)) return null;
2401
+ throw err;
2402
+ });
2403
+ if (fromStat?.isSymbolicLink() === true) return nothing;
2404
+ if (fromStat?.isDirectory() === true) return { ...nothing, isFolder: true };
2405
+ // `roles.yaml` carries no own-entries wherever it sits, exactly as
2406
+ // `readOwnEntries` has it; a read that fails for any reason is no rules,
2407
+ // which is what the gates would say about the same file.
2408
+ if (fromPath === 'roles.yaml' || toPath === 'roles.yaml') return nothing;
2409
+ let text: string;
2410
+ try {
2411
+ text = await fs.readFile(path.join(repoDir, fromPath), 'utf-8');
2412
+ } catch {
2413
+ return nothing;
2414
+ }
2415
+ const parsed = isAccessMdPath(toPath) ? parseAccessFile(text, toPath) : null;
2416
+ return {
2417
+ isFolder: false,
2418
+ ownHere: ownEntriesFromText(text, fromPath),
2419
+ ownThere: ownEntriesFromText(text, toPath),
2420
+ landing: parsed?.ok === true ? parsed.file : null,
2421
+ };
2224
2422
  }
2225
2423
 
2226
2424
  /**
@@ -368,27 +368,25 @@ export function createAccessRoutes(
368
368
  });
369
369
 
370
370
  /**
371
- * GET /api/workspace/:id/access/prospective?from=<file>&toDir=<folder>
371
+ * GET /api/workspace/:id/access/prospective?from=<file|folder>&toDir=<folder>
372
372
  *
373
- * Who can open and who can edit one file where it is, and where a move
374
- * would put it — `{ before, after }`, each `{ read, write }` lists of
375
- * principals named as their grants name them. `toDir` is the destination
376
- * FOLDER (`''` is the repo root); the file keeps its name, so the route
377
- * derives the destination path itself rather than trusting a second one.
373
+ * Who can open and who can edit one file OR FOLDER where it is, and where
374
+ * a move would put it — `{ before, after }`, each `{ read, write }` lists
375
+ * of principals named as their grants name them. `toDir` is the
376
+ * destination FOLDER (`''` is the repo root); the thing keeps its name, so
377
+ * the route derives the destination path itself rather than trusting a
378
+ * second one.
378
379
  *
379
380
  * The destination path does not exist yet, which is why this cannot be two
380
- * calls to `GET /access`: the resolver is asked for a hypothetical, with
381
- * the file's own frontmatter (read where the file actually is) layered over
382
- * the destination's folder chain. Nothing is written and nothing is moved.
381
+ * calls to `GET /access`: the resolver is asked for a hypothetical, over
382
+ * what the move carries — a file's own frontmatter, or the `access.md`
383
+ * files inside a folder, at the paths they land on. Nothing is written and
384
+ * nothing is moved.
383
385
  *
384
386
  * Gated like the sibling `overrides` route rather than the permissive
385
- * `GET /access`: the caller must resolve read on the file being moved. The
386
- * lists name people, and someone who cannot see the file has no business
387
+ * `GET /access`: the caller must resolve read on the thing being moved. The
388
+ * lists name people, and someone who cannot see it has no business
387
389
  * learning who can.
388
- *
389
- * `from` must be a FILE. A folder carries its own `access.md` and governs
390
- * everything under it, which is a different question; the resolver refuses
391
- * one with a 400 rather than answering it as if it were a file.
392
390
  */
393
391
  router.get('/workspace/:id/access/prospective', async (req, res) => {
394
392
  const user = await requireUser(req, res);
@@ -31,11 +31,13 @@ async function serve(readPreamble: () => Promise<string | null>): Promise<string
31
31
  looksLikeInternalToken: (t: string) => t.startsWith('bit_'),
32
32
  verify: (t: string) => (t === INTERNAL_TOKEN ? { userId: 'u-internal' } : null),
33
33
  } as unknown as InternalTokenService;
34
+ // Sessions and internal tokens are accepted only for an account that is on; these are.
34
35
  const authService = {
35
- verifyToken: (t: string) => {
36
+ resolveSession: async (t: string) => {
36
37
  if (t !== BROWSER_JWT) throw new Error('bad jwt');
37
38
  return { userId: 'u-browser' };
38
39
  },
40
+ isActive: async () => true,
39
41
  } as unknown as AuthService;
40
42
  const app = express();
41
43
  app.use('/api', createAgentInstructionsRoutes(createManualAuthMiddleware(externalKeys, internalTokens, authService), readPreamble));
@@ -0,0 +1,252 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import type { Database } from '../../database/connection.js';
3
+ import type { CoreConfig } from '../../../core-config.js';
4
+ import { AuthService } from '../auth.service.js';
5
+ import {
6
+ AccountAdmissionRefusedError,
7
+ AccountChangeRefusedError,
8
+ AccountDeactivatedError,
9
+ AuthBackendError,
10
+ type AccountProvisionReason,
11
+ type IAccountAdmission,
12
+ } from '../account-admission.js';
13
+ import { hashPassword } from '../password-hash.js';
14
+ import { RECOVERY_BOT_EMAIL } from '../../workflow/recovery-bot.js';
15
+
16
+ /**
17
+ * Drizzle chain stub, as in `auth.service.test.ts`: each db.select() /
18
+ * insert() / update() consumes the next queued result; awaiting any point of
19
+ * the chain resolves it. Records what was inserted and set.
20
+ */
21
+ function makeFakeDb(queue: unknown[]) {
22
+ const captured: { values: Record<string, unknown>[]; set: Record<string, unknown>[] } = { values: [], set: [] };
23
+ function nextChain(): unknown {
24
+ const result = queue.shift();
25
+ const chain: Record<string, unknown> = {};
26
+ const passthrough = (recorder?: (arg: Record<string, unknown>) => void) =>
27
+ vi.fn((arg?: unknown) => {
28
+ recorder?.(arg as Record<string, unknown>);
29
+ return chain;
30
+ });
31
+ Object.assign(chain, {
32
+ values: passthrough((a) => captured.values.push(a)),
33
+ set: passthrough((a) => captured.set.push(a)),
34
+ where: passthrough(),
35
+ limit: passthrough(),
36
+ from: passthrough(),
37
+ orderBy: passthrough(),
38
+ returning: passthrough(),
39
+ onConflictDoUpdate: passthrough(),
40
+ onConflictDoNothing: passthrough(),
41
+ then: (ok: (v: unknown) => unknown, ko: (e: unknown) => unknown) => Promise.resolve(result).then(ok, ko),
42
+ });
43
+ return chain;
44
+ }
45
+ const db = {
46
+ insert: vi.fn(() => nextChain()),
47
+ select: vi.fn(() => nextChain()),
48
+ update: vi.fn(() => nextChain()),
49
+ } as unknown as Database;
50
+ return { db, captured };
51
+ }
52
+
53
+ function makeConfig(over: Partial<CoreConfig> = {}): CoreConfig {
54
+ return { jwtSecret: 'test-jwt-secret', adminEmail: '', adminPassword: '', allowedEmailDomains: [], ...over } as CoreConfig;
55
+ }
56
+
57
+ const ROW = { id: 'user-1', email: 'alice@example.com', name: 'Alice', avatarUrl: null, onboardingDone: false, deactivatedAt: null };
58
+ const OFF = { ...ROW, deactivatedAt: new Date('2026-09-01') };
59
+
60
+ function recordingPort(verdict: Awaited<ReturnType<IAccountAdmission['canProvision']>>) {
61
+ const asked: Array<[string, AccountProvisionReason]> = [];
62
+ const port: IAccountAdmission = {
63
+ canProvision: async (email, reason) => {
64
+ asked.push([email, reason]);
65
+ return verdict;
66
+ },
67
+ };
68
+ return { port, asked };
69
+ }
70
+
71
+ describe('signing in with a switched-off account', () => {
72
+ it('refuses a password sign-in once the password is proven, and says why', async () => {
73
+ const passwordHash = await hashPassword('a-long-enough-password');
74
+ const { db } = makeFakeDb([[{ ...OFF, passwordHash }]]);
75
+ const svc = new AuthService(db, makeConfig());
76
+ await expect(svc.loginWithPassword('alice@example.com', 'a-long-enough-password')).rejects.toBeInstanceOf(
77
+ AccountDeactivatedError,
78
+ );
79
+ });
80
+
81
+ it('gives a wrong password the same answer as for any account, so it reveals nothing', async () => {
82
+ const passwordHash = await hashPassword('a-long-enough-password');
83
+ const { db } = makeFakeDb([[{ ...OFF, passwordHash }]]);
84
+ const svc = new AuthService(db, makeConfig());
85
+ await expect(svc.loginWithPassword('alice@example.com', 'the-wrong-password')).rejects.toThrow('Invalid credentials');
86
+ });
87
+
88
+ it('refuses a single sign-on sign-in, without issuing a session', async () => {
89
+ const { db } = makeFakeDb([[OFF]]);
90
+ const svc = new AuthService(db, makeConfig());
91
+ await expect(svc.loginWithSso('alice@example.com', 'Alice')).rejects.toBeInstanceOf(AccountDeactivatedError);
92
+ });
93
+
94
+ it('refuses an embed identity', async () => {
95
+ const { db } = makeFakeDb([[OFF]]);
96
+ const svc = new AuthService(db, makeConfig());
97
+ await expect(svc.getOrCreateByEmail('alice@example.com')).rejects.toBeInstanceOf(AccountDeactivatedError);
98
+ });
99
+ });
100
+
101
+ describe('a session that outlives a deactivation', () => {
102
+ it('stops being accepted once the account is switched off', async () => {
103
+ const { db } = makeFakeDb([[ROW], [{ deactivatedAt: new Date() }]]);
104
+ const svc = new AuthService(db, makeConfig());
105
+ const { token } = await svc.loginWithSso('alice@example.com', 'Alice');
106
+ // The signature alone is still good…
107
+ expect(svc.verifyToken(token).userId).toBe(ROW.id);
108
+ // …but the session is not.
109
+ await expect(svc.resolveSession(token)).rejects.toBeInstanceOf(AccountDeactivatedError);
110
+ });
111
+
112
+ it('is told apart from a refusal when the account cannot be looked up', async () => {
113
+ const { db } = makeFakeDb([[ROW]]);
114
+ const svc = new AuthService(db, makeConfig());
115
+ const { token } = await svc.loginWithSso('alice@example.com', 'Alice');
116
+ vi.mocked(db.select).mockImplementationOnce(() => {
117
+ throw new Error('db down');
118
+ });
119
+ await expect(svc.resolveSession(token)).rejects.toBeInstanceOf(AuthBackendError);
120
+ });
121
+
122
+ it('is accepted while the account is on', async () => {
123
+ const { db } = makeFakeDb([[ROW], [{ deactivatedAt: null }]]);
124
+ const svc = new AuthService(db, makeConfig());
125
+ const { token } = await svc.loginWithSso('alice@example.com', 'Alice');
126
+ await expect(svc.resolveSession(token)).resolves.toEqual({ userId: ROW.id, email: ROW.email });
127
+ });
128
+ });
129
+
130
+ describe('AuthService.isActive', () => {
131
+ it('reads once and keeps the answer briefly', async () => {
132
+ const { db } = makeFakeDb([[{ deactivatedAt: null }]]);
133
+ const svc = new AuthService(db, makeConfig());
134
+ expect(await svc.isActive('user-1')).toBe(true);
135
+ expect(await svc.isActive('user-1')).toBe(true);
136
+ expect(vi.mocked(db.select)).toHaveBeenCalledTimes(1);
137
+ });
138
+
139
+ it('treats the deployment admin as on whatever its row says, so its rescue session works', async () => {
140
+ const config = makeConfig({ adminEmail: 'root@example.com', adminPassword: 'sup3r-secret' });
141
+ const { db } = makeFakeDb([[{ email: 'root@example.com', deactivatedAt: new Date() }]]);
142
+ expect(await new AuthService(db, config).isActive('root-id')).toBe(true);
143
+ });
144
+
145
+ it('treats an account that no longer exists as off', async () => {
146
+ const { db } = makeFakeDb([[]]);
147
+ expect(await new AuthService(db, makeConfig()).isActive('gone')).toBe(false);
148
+ });
149
+
150
+ it('forgets the cached answer when this process switches the account off', async () => {
151
+ const { db } = makeFakeDb([[{ deactivatedAt: null }], [ROW], [], [{ deactivatedAt: new Date() }]]);
152
+ const svc = new AuthService(db, makeConfig());
153
+ expect(await svc.isActive(ROW.id)).toBe(true);
154
+ expect(await svc.deactivate(ROW.id)).toBe(true);
155
+ expect(await svc.isActive(ROW.id)).toBe(false);
156
+ });
157
+ });
158
+
159
+ describe('AuthService.deactivate', () => {
160
+ it('records when the account was switched off', async () => {
161
+ const { db, captured } = makeFakeDb([[ROW], []]);
162
+ expect(await new AuthService(db, makeConfig()).deactivate(ROW.id)).toBe(true);
163
+ expect(captured.set[0].deactivatedAt).toBeInstanceOf(Date);
164
+ });
165
+
166
+ it('answers false for an account that does not exist', async () => {
167
+ const { db } = makeFakeDb([[]]);
168
+ expect(await new AuthService(db, makeConfig()).deactivate('nobody')).toBe(false);
169
+ });
170
+
171
+ it('refuses the deployment admin, whose environment password is the way back in', async () => {
172
+ const config = makeConfig({ adminEmail: 'root@example.com', adminPassword: 'sup3r-secret' });
173
+ const { db } = makeFakeDb([[{ ...ROW, email: 'root@example.com' }]]);
174
+ await expect(new AuthService(db, config).deactivate(ROW.id)).rejects.toBeInstanceOf(AccountChangeRefusedError);
175
+ expect(vi.mocked(db.update)).not.toHaveBeenCalled();
176
+ });
177
+
178
+ it('refuses the accounts the platform runs its own work as', async () => {
179
+ const { db } = makeFakeDb([[{ ...ROW, email: RECOVERY_BOT_EMAIL }]]);
180
+ await expect(new AuthService(db, makeConfig()).deactivate(ROW.id)).rejects.toThrow('platform itself');
181
+ expect(vi.mocked(db.update)).not.toHaveBeenCalled();
182
+ });
183
+ });
184
+
185
+ describe('AuthService.reactivate', () => {
186
+ it('asks the admission port, as for a new account, and switches it on when admitted', async () => {
187
+ const { port, asked } = recordingPort({ ok: true });
188
+ const { db, captured } = makeFakeDb([[OFF], []]);
189
+ expect(await new AuthService(db, makeConfig(), port).reactivate(ROW.id)).toBe(true);
190
+ expect(asked).toEqual([['alice@example.com', 'reactivate']]);
191
+ expect(captured.set[0].deactivatedAt).toBeNull();
192
+ });
193
+
194
+ it("refuses with the port's words when there is no room, and changes nothing", async () => {
195
+ const { port } = recordingPort({ ok: false, message: 'All 3 seats are taken' });
196
+ const { db } = makeFakeDb([[OFF]]);
197
+ const svc = new AuthService(db, makeConfig(), port);
198
+ await expect(svc.reactivate(ROW.id)).rejects.toThrow('All 3 seats are taken');
199
+ expect(vi.mocked(db.update)).not.toHaveBeenCalled();
200
+ });
201
+
202
+ it('does not ask about an account that is already on', async () => {
203
+ const { port, asked } = recordingPort({ ok: false, message: 'full' });
204
+ const { db } = makeFakeDb([[ROW]]);
205
+ expect(await new AuthService(db, makeConfig(), port).reactivate(ROW.id)).toBe(true);
206
+ expect(asked).toEqual([]);
207
+ });
208
+ });
209
+
210
+ describe('a first sign-in the port would rather keep waiting', () => {
211
+ const waiting = { ok: false as const, message: 'No seat left; the admin can let you in', waitForAdmin: true };
212
+
213
+ it('puts the person on file switched off, and still refuses the sign-in', async () => {
214
+ const { port } = recordingPort(waiting);
215
+ const { db, captured } = makeFakeDb([[], []]);
216
+ const svc = new AuthService(db, makeConfig(), port);
217
+ const err = await svc.loginWithSso('new@example.com', 'New').catch((e: unknown) => e);
218
+ expect(err).toBeInstanceOf(AccountAdmissionRefusedError);
219
+ expect((err as AccountAdmissionRefusedError).waitingForAdmin).toBe(true);
220
+ expect((err as Error).message).toBe(waiting.message);
221
+ expect(captured.values).toHaveLength(1);
222
+ expect(captured.values[0]).toMatchObject({ email: 'new@example.com' });
223
+ expect(captured.values[0].deactivatedAt).toBeInstanceOf(Date);
224
+ });
225
+
226
+ it('is a plain refusal when an admin creates the account', async () => {
227
+ const { port } = recordingPort(waiting);
228
+ const { db } = makeFakeDb([[]]);
229
+ const err = await new AuthService(db, makeConfig(), port)
230
+ .createAccount('new@example.com', 'New')
231
+ .catch((e: unknown) => e);
232
+ expect(err).toBeInstanceOf(AccountAdmissionRefusedError);
233
+ expect((err as AccountAdmissionRefusedError).waitingForAdmin).toBe(false);
234
+ expect(vi.mocked(db.insert)).not.toHaveBeenCalled();
235
+ });
236
+ });
237
+
238
+ describe('AuthService.createAccount without a password', () => {
239
+ it('makes an account for single sign-on, with no hash stored', async () => {
240
+ const { db, captured } = makeFakeDb([[{ ...ROW, passwordHash: null }]]);
241
+ const user = await new AuthService(db, makeConfig()).createAccount('Alice@Example.com', '');
242
+ expect(user.email).toBe('alice@example.com');
243
+ expect(captured.values[0]).toEqual({ email: 'alice@example.com', name: 'alice' });
244
+ });
245
+
246
+ it('asks the admission port, as any new account does', async () => {
247
+ const { port, asked } = recordingPort({ ok: true });
248
+ const { db } = makeFakeDb([[], [{ ...ROW, passwordHash: null }]]);
249
+ await new AuthService(db, makeConfig(), port).createAccount('alice@example.com', 'Alice');
250
+ expect(asked).toEqual([['alice@example.com', 'admin-create']]);
251
+ });
252
+ });