@bevel-software/platform-core-backend 0.21.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 (434) 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 +62 -13
  7. package/dist/core/create-core-server.js.map +1 -1
  8. package/dist/core/create-core-services.d.ts +22 -3
  9. package/dist/core/create-core-services.d.ts.map +1 -1
  10. package/dist/core/create-core-services.js +158 -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-requests.contract.d.ts +75 -0
  31. package/dist/modules/access/access-requests.contract.d.ts.map +1 -0
  32. package/dist/modules/access/access-requests.contract.js +20 -0
  33. package/dist/modules/access/access-requests.contract.js.map +1 -0
  34. package/dist/modules/access/access-requests.routes.d.ts +35 -0
  35. package/dist/modules/access/access-requests.routes.d.ts.map +1 -0
  36. package/dist/modules/access/access-requests.routes.js +237 -0
  37. package/dist/modules/access/access-requests.routes.js.map +1 -0
  38. package/dist/modules/access/access-requests.service.d.ts +123 -0
  39. package/dist/modules/access/access-requests.service.d.ts.map +1 -0
  40. package/dist/modules/access/access-requests.service.js +337 -0
  41. package/dist/modules/access/access-requests.service.js.map +1 -0
  42. package/dist/modules/access/access.routes.d.ts.map +1 -1
  43. package/dist/modules/access/access.routes.js +13 -15
  44. package/dist/modules/access/access.routes.js.map +1 -1
  45. package/dist/modules/access-model/access-grammar.d.ts +12 -0
  46. package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
  47. package/dist/modules/access-model/access-grammar.js +24 -0
  48. package/dist/modules/access-model/access-grammar.js.map +1 -1
  49. package/dist/modules/auth/account-admission.d.ts +58 -7
  50. package/dist/modules/auth/account-admission.d.ts.map +1 -1
  51. package/dist/modules/auth/account-admission.js +44 -1
  52. package/dist/modules/auth/account-admission.js.map +1 -1
  53. package/dist/modules/auth/account.routes.d.ts +1 -1
  54. package/dist/modules/auth/account.routes.d.ts.map +1 -1
  55. package/dist/modules/auth/account.routes.js +61 -5
  56. package/dist/modules/auth/account.routes.js.map +1 -1
  57. package/dist/modules/auth/auth.middleware.d.ts +1 -1
  58. package/dist/modules/auth/auth.middleware.d.ts.map +1 -1
  59. package/dist/modules/auth/auth.middleware.js +18 -7
  60. package/dist/modules/auth/auth.middleware.js.map +1 -1
  61. package/dist/modules/auth/auth.routes.d.ts.map +1 -1
  62. package/dist/modules/auth/auth.routes.js +8 -0
  63. package/dist/modules/auth/auth.routes.js.map +1 -1
  64. package/dist/modules/auth/auth.service.d.ts +96 -3
  65. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  66. package/dist/modules/auth/auth.service.js +187 -9
  67. package/dist/modules/auth/auth.service.js.map +1 -1
  68. package/dist/modules/auth/oidc-auth-provider.d.ts.map +1 -1
  69. package/dist/modules/auth/oidc-auth-provider.js +7 -2
  70. package/dist/modules/auth/oidc-auth-provider.js.map +1 -1
  71. package/dist/modules/database/core-schema.d.ts +41 -24
  72. package/dist/modules/database/core-schema.d.ts.map +1 -1
  73. package/dist/modules/database/core-schema.js +35 -27
  74. package/dist/modules/database/core-schema.js.map +1 -1
  75. package/dist/modules/github-app/github-app.client.d.ts +101 -0
  76. package/dist/modules/github-app/github-app.client.d.ts.map +1 -0
  77. package/dist/modules/github-app/github-app.client.js +222 -0
  78. package/dist/modules/github-app/github-app.client.js.map +1 -0
  79. package/dist/modules/github-app/github-app.connection.d.ts +102 -0
  80. package/dist/modules/github-app/github-app.connection.d.ts.map +1 -0
  81. package/dist/modules/github-app/github-app.connection.js +185 -0
  82. package/dist/modules/github-app/github-app.connection.js.map +1 -0
  83. package/dist/modules/github-app/github-app.routes.d.ts +59 -0
  84. package/dist/modules/github-app/github-app.routes.d.ts.map +1 -0
  85. package/dist/modules/github-app/github-app.routes.js +323 -0
  86. package/dist/modules/github-app/github-app.routes.js.map +1 -0
  87. package/dist/modules/github-app/index.d.ts +4 -0
  88. package/dist/modules/github-app/index.d.ts.map +1 -0
  89. package/dist/modules/github-app/index.js +4 -0
  90. package/dist/modules/github-app/index.js.map +1 -0
  91. package/dist/modules/kb-fs/branch-name.d.ts.map +1 -1
  92. package/dist/modules/kb-fs/branch-name.js +12 -2
  93. package/dist/modules/kb-fs/branch-name.js.map +1 -1
  94. package/dist/modules/kb-fs/remote-url.d.ts +22 -0
  95. package/dist/modules/kb-fs/remote-url.d.ts.map +1 -0
  96. package/dist/modules/kb-fs/remote-url.js +35 -0
  97. package/dist/modules/kb-fs/remote-url.js.map +1 -0
  98. package/dist/modules/kb-fs/repo-path.d.ts +11 -16
  99. package/dist/modules/kb-fs/repo-path.d.ts.map +1 -1
  100. package/dist/modules/kb-fs/repo-path.js +79 -0
  101. package/dist/modules/kb-fs/repo-path.js.map +1 -1
  102. package/dist/modules/kb-sync/kb-sync.routes.d.ts +2 -2
  103. package/dist/modules/kb-sync/kb-sync.routes.d.ts.map +1 -1
  104. package/dist/modules/kb-sync/kb-sync.routes.js +14 -3
  105. package/dist/modules/kb-sync/kb-sync.routes.js.map +1 -1
  106. package/dist/modules/kb-sync/sync-auth.d.ts +3 -1
  107. package/dist/modules/kb-sync/sync-auth.d.ts.map +1 -1
  108. package/dist/modules/kb-sync/sync-auth.js +1 -1
  109. package/dist/modules/kb-sync/sync-auth.js.map +1 -1
  110. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
  111. package/dist/modules/mcp/mcp-auth.middleware.js +21 -6
  112. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
  113. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
  114. package/dist/modules/mcp/oauth/bevel-oauth-provider.js +3 -2
  115. package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
  116. package/dist/modules/plugins/join-proposals.d.ts +17 -5
  117. package/dist/modules/plugins/join-proposals.d.ts.map +1 -1
  118. package/dist/modules/plugins/join-proposals.js +76 -22
  119. package/dist/modules/plugins/join-proposals.js.map +1 -1
  120. package/dist/modules/plugins/join-requests.service.d.ts +111 -18
  121. package/dist/modules/plugins/join-requests.service.d.ts.map +1 -1
  122. package/dist/modules/plugins/join-requests.service.js +173 -29
  123. package/dist/modules/plugins/join-requests.service.js.map +1 -1
  124. package/dist/modules/plugins/plugins.routes.d.ts.map +1 -1
  125. package/dist/modules/plugins/plugins.routes.js +3 -2
  126. package/dist/modules/plugins/plugins.routes.js.map +1 -1
  127. package/dist/modules/settings/deployment-settings.service.d.ts +22 -1
  128. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
  129. package/dist/modules/settings/deployment-settings.service.js +101 -7
  130. package/dist/modules/settings/deployment-settings.service.js.map +1 -1
  131. package/dist/modules/settings/managed-repository.d.ts +43 -0
  132. package/dist/modules/settings/managed-repository.d.ts.map +1 -0
  133. package/dist/modules/settings/managed-repository.js +60 -0
  134. package/dist/modules/settings/managed-repository.js.map +1 -0
  135. package/dist/modules/settings/repository-source.d.ts +128 -0
  136. package/dist/modules/settings/repository-source.d.ts.map +1 -0
  137. package/dist/modules/settings/repository-source.js +150 -0
  138. package/dist/modules/settings/repository-source.js.map +1 -0
  139. package/dist/modules/settings/setup.routes.d.ts +78 -4
  140. package/dist/modules/settings/setup.routes.d.ts.map +1 -1
  141. package/dist/modules/settings/setup.routes.js +379 -17
  142. package/dist/modules/settings/setup.routes.js.map +1 -1
  143. package/dist/modules/skills/skill-access-requests.routes.d.ts +6 -8
  144. package/dist/modules/skills/skill-access-requests.routes.d.ts.map +1 -1
  145. package/dist/modules/skills/skill-access-requests.routes.js +24 -63
  146. package/dist/modules/skills/skill-access-requests.routes.js.map +1 -1
  147. package/dist/modules/skills/skills.contract.d.ts +90 -18
  148. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  149. package/dist/modules/skills/skills.contract.js +4 -1
  150. package/dist/modules/skills/skills.contract.js.map +1 -1
  151. package/dist/modules/skills/skills.service.d.ts +98 -9
  152. package/dist/modules/skills/skills.service.d.ts.map +1 -1
  153. package/dist/modules/skills/skills.service.js +239 -37
  154. package/dist/modules/skills/skills.service.js.map +1 -1
  155. package/dist/modules/skills/skills.tools.d.ts +7 -0
  156. package/dist/modules/skills/skills.tools.d.ts.map +1 -1
  157. package/dist/modules/skills/skills.tools.js +71 -7
  158. package/dist/modules/skills/skills.tools.js.map +1 -1
  159. package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
  160. package/dist/modules/tool-auth/external-api-key.service.js +4 -2
  161. package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
  162. package/dist/modules/tool-auth/internal-token.service.d.ts +3 -3
  163. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +16 -7
  164. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
  165. package/dist/modules/tool-auth/tool-auth.middleware.js +34 -12
  166. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
  167. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  168. package/dist/modules/tool-helpers/tool-context.js +15 -0
  169. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  170. package/dist/modules/tool-helpers/tool-handler.d.ts +2 -1
  171. package/dist/modules/tool-helpers/tool-handler.d.ts.map +1 -1
  172. package/dist/modules/tool-helpers/tool-handler.js +25 -4
  173. package/dist/modules/tool-helpers/tool-handler.js.map +1 -1
  174. package/dist/modules/tool-helpers/tool.contract.d.ts +3 -2
  175. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
  176. package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
  177. package/dist/modules/tool-helpers/validate-token.js +1 -1
  178. package/dist/modules/tool-helpers/validate-token.js.map +1 -1
  179. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +98 -0
  180. package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -0
  181. package/dist/modules/workflow/agent-tools/change-request-summary.js +81 -0
  182. package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -0
  183. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  184. package/dist/modules/workflow/agent-tools/workflow.tools.js +113 -37
  185. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  186. package/dist/modules/workflow/file-lock.service.d.ts +24 -0
  187. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  188. package/dist/modules/workflow/file-lock.service.js +30 -0
  189. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  190. package/dist/modules/workflow/git/git.service.d.ts +72 -1
  191. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  192. package/dist/modules/workflow/git/git.service.js +170 -6
  193. package/dist/modules/workflow/git/git.service.js.map +1 -1
  194. package/dist/modules/workflow/git/node-git-runner.d.ts.map +1 -1
  195. package/dist/modules/workflow/git/node-git-runner.js +5 -0
  196. package/dist/modules/workflow/git/node-git-runner.js.map +1 -1
  197. package/dist/modules/workflow/git/pull-request.service.d.ts +45 -0
  198. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  199. package/dist/modules/workflow/git/pull-request.service.js +99 -0
  200. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  201. package/dist/modules/workflow/pending-commits.service.d.ts +38 -0
  202. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  203. package/dist/modules/workflow/pending-commits.service.js +55 -0
  204. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  205. package/dist/modules/workflow/workflow-hooks.d.ts +54 -32
  206. package/dist/modules/workflow/workflow-hooks.d.ts.map +1 -1
  207. package/dist/modules/workflow/workflow-hooks.js +16 -1
  208. package/dist/modules/workflow/workflow-hooks.js.map +1 -1
  209. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  210. package/dist/modules/workflow/workflow.routes.js +3 -1
  211. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  212. package/dist/modules/workflow/workflow.service.d.ts +94 -13
  213. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  214. package/dist/modules/workflow/workflow.service.js +258 -51
  215. package/dist/modules/workflow/workflow.service.js.map +1 -1
  216. package/dist/modules/workspace/agent-access.gate.d.ts +94 -0
  217. package/dist/modules/workspace/agent-access.gate.d.ts.map +1 -0
  218. package/dist/modules/workspace/agent-access.gate.js +123 -0
  219. package/dist/modules/workspace/agent-access.gate.js.map +1 -0
  220. package/dist/modules/workspace/routine-write-policy.d.ts +5 -6
  221. package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
  222. package/dist/modules/workspace/routine-write-policy.js +5 -6
  223. package/dist/modules/workspace/routine-write-policy.js.map +1 -1
  224. package/dist/modules/workspace/session-sink.d.ts +5 -5
  225. package/dist/modules/workspace/set-aside-clone.d.ts +46 -0
  226. package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -0
  227. package/dist/modules/workspace/set-aside-clone.js +92 -0
  228. package/dist/modules/workspace/set-aside-clone.js.map +1 -0
  229. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +117 -1
  230. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  231. package/dist/modules/workspace/startup/kb-startup-runner.js +190 -1
  232. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  233. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  234. package/dist/modules/workspace/workspace.routes.js +92 -3
  235. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  236. package/dist/modules/workspace/workspace.service.d.ts +115 -5
  237. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  238. package/dist/modules/workspace/workspace.service.js +272 -26
  239. package/dist/modules/workspace/workspace.service.js.map +1 -1
  240. package/dist/modules/workspace/workspace.tools.d.ts +2 -2
  241. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  242. package/dist/modules/workspace/workspace.tools.js +337 -117
  243. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  244. package/dist/modules/write-access/write-access.d.ts +60 -0
  245. package/dist/modules/write-access/write-access.d.ts.map +1 -0
  246. package/dist/modules/write-access/write-access.js +129 -0
  247. package/dist/modules/write-access/write-access.js.map +1 -0
  248. package/dist/shared/domain-errors.d.ts +64 -0
  249. package/dist/shared/domain-errors.d.ts.map +1 -1
  250. package/dist/shared/domain-errors.js +83 -0
  251. package/dist/shared/domain-errors.js.map +1 -1
  252. package/dist/shared/git.contract.d.ts +30 -0
  253. package/dist/shared/git.contract.d.ts.map +1 -1
  254. package/dist/shared/git.contract.js +26 -0
  255. package/dist/shared/git.contract.js.map +1 -1
  256. package/dist/tenancy/static-tenant-source.d.ts +0 -1
  257. package/dist/tenancy/static-tenant-source.d.ts.map +1 -1
  258. package/dist/tenancy/static-tenant-source.js +0 -2
  259. package/dist/tenancy/static-tenant-source.js.map +1 -1
  260. package/migrations/0014_change_request_closed_reason.sql +1 -0
  261. package/migrations/0015_account_deactivation.sql +3 -0
  262. package/migrations/meta/0014_snapshot.json +2265 -0
  263. package/migrations/meta/0015_snapshot.json +2271 -0
  264. package/migrations/meta/_journal.json +14 -0
  265. package/package.json +3 -3
  266. package/src/__tests__/kb-layout-config.test.ts +0 -2
  267. package/src/__tests__/retired-settings.test.ts +96 -0
  268. package/src/core/__tests__/gated-boot.test.ts +132 -0
  269. package/src/core/__tests__/lifecycle.test.ts +170 -1
  270. package/src/core/__tests__/set-aside-root-is-one-place.test.ts +63 -0
  271. package/src/core/core-ports.ts +11 -1
  272. package/src/core/create-core-server.ts +69 -15
  273. package/src/core/create-core-services.ts +190 -27
  274. package/src/core/lifecycle.ts +98 -13
  275. package/src/core-config.ts +13 -14
  276. package/src/index.ts +12 -1
  277. package/src/modules/access/__tests__/access-control.preview-relocation.test.ts +385 -0
  278. package/src/modules/access/__tests__/access-control.prospective.test.ts +94 -18
  279. package/src/modules/access/__tests__/access-requests.recut.test.ts +134 -0
  280. package/src/modules/access/__tests__/access-requests.routes.test.ts +610 -0
  281. package/src/modules/access/__tests__/access.routes.prospective.test.ts +6 -7
  282. package/src/modules/access/access-control.interface.ts +43 -12
  283. package/src/modules/access/access-control.service.ts +234 -36
  284. package/src/modules/access/access-requests.contract.ts +93 -0
  285. package/src/modules/access/access-requests.routes.ts +286 -0
  286. package/src/modules/access/access-requests.service.ts +420 -0
  287. package/src/modules/access/access.routes.ts +13 -15
  288. package/src/modules/access-model/access-grammar.ts +22 -0
  289. package/src/modules/agent-instructions/__tests__/agent-instructions.route.test.ts +3 -1
  290. package/src/modules/auth/__tests__/account-deactivation.test.ts +252 -0
  291. package/src/modules/auth/__tests__/account.routes.test.ts +81 -3
  292. package/src/modules/auth/__tests__/auth.middleware.test.ts +45 -22
  293. package/src/modules/auth/__tests__/auth.service.test.ts +39 -1
  294. package/src/modules/auth/account-admission.ts +78 -9
  295. package/src/modules/auth/account.routes.ts +63 -7
  296. package/src/modules/auth/auth.middleware.ts +19 -8
  297. package/src/modules/auth/auth.routes.ts +8 -0
  298. package/src/modules/auth/auth.service.ts +207 -7
  299. package/src/modules/auth/oidc-auth-provider.ts +7 -2
  300. package/src/modules/database/core-schema.ts +35 -27
  301. package/src/modules/github-app/__tests__/github-app.test.ts +848 -0
  302. package/src/modules/github-app/github-app.client.ts +268 -0
  303. package/src/modules/github-app/github-app.connection.ts +204 -0
  304. package/src/modules/github-app/github-app.routes.ts +359 -0
  305. package/src/modules/github-app/index.ts +19 -0
  306. package/src/modules/kb-fs/__tests__/branch-name.test.ts +10 -0
  307. package/src/modules/kb-fs/__tests__/remote-url.test.ts +38 -0
  308. package/src/modules/kb-fs/__tests__/repo-path.test.ts +123 -0
  309. package/src/modules/kb-fs/branch-name.ts +14 -1
  310. package/src/modules/kb-fs/remote-url.ts +35 -0
  311. package/src/modules/kb-fs/repo-path.ts +85 -0
  312. package/src/modules/kb-sync/__tests__/kb-sync.routes.test.ts +26 -1
  313. package/src/modules/kb-sync/kb-sync.routes.ts +14 -4
  314. package/src/modules/kb-sync/sync-auth.ts +2 -2
  315. package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +60 -3
  316. package/src/modules/mcp/__tests__/mcp.service.test.ts +68 -6
  317. package/src/modules/mcp/mcp-auth.middleware.ts +20 -6
  318. package/src/modules/mcp/oauth/bevel-oauth-provider.ts +3 -1
  319. package/src/modules/plugins/__tests__/join-proposals.test.ts +100 -19
  320. package/src/modules/plugins/__tests__/join-requests.service.test.ts +27 -11
  321. package/src/modules/plugins/__tests__/join-requests.settlement.test.ts +371 -0
  322. package/src/modules/plugins/__tests__/plugins.routes.test.ts +1 -1
  323. package/src/modules/plugins/__tests__/plugins.tools.test.ts +2 -1
  324. package/src/modules/plugins/join-proposals.ts +87 -19
  325. package/src/modules/plugins/join-requests.service.ts +199 -39
  326. package/src/modules/plugins/plugins.routes.ts +3 -2
  327. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +16 -0
  328. package/src/modules/settings/__tests__/repository-source.test.ts +191 -0
  329. package/src/modules/settings/__tests__/setup.routes.git-mode.test.ts +386 -0
  330. package/src/modules/settings/__tests__/setup.routes.github-app.test.ts +299 -0
  331. package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +236 -0
  332. package/src/modules/settings/__tests__/setup.routes.repository-change.test.ts +390 -0
  333. package/src/modules/settings/__tests__/setup.routes.test.ts +3 -0
  334. package/src/modules/settings/deployment-settings.service.ts +117 -6
  335. package/src/modules/settings/managed-repository.ts +68 -0
  336. package/src/modules/settings/repository-source.ts +197 -0
  337. package/src/modules/settings/setup.routes.ts +463 -15
  338. package/src/modules/skills/__tests__/allowed-tools-warn.tools.test.ts +2 -1
  339. package/src/modules/skills/__tests__/branch-skills.tools.test.ts +218 -0
  340. package/src/modules/skills/__tests__/skill-access-requests.routes.test.ts +5 -1
  341. package/src/modules/skills/__tests__/skills.service.test.ts +229 -5
  342. package/src/modules/skills/skill-access-requests.routes.ts +25 -75
  343. package/src/modules/skills/skills.contract.ts +91 -18
  344. package/src/modules/skills/skills.service.ts +278 -41
  345. package/src/modules/skills/skills.tools.ts +80 -8
  346. package/src/modules/tool-auth/__tests__/manual-auth.middleware.test.ts +14 -1
  347. package/src/modules/tool-auth/external-api-key.service.ts +4 -2
  348. package/src/modules/tool-auth/internal-token.service.ts +3 -3
  349. package/src/modules/tool-auth/tool-auth.middleware.ts +34 -11
  350. package/src/modules/tool-helpers/__tests__/agent-roles-write.test.ts +5 -3
  351. package/src/modules/tool-helpers/__tests__/phase4-tools.test.ts +3 -3
  352. package/src/modules/tool-helpers/__tests__/validate-token.test.ts +11 -1
  353. package/src/modules/tool-helpers/tool-context.ts +15 -0
  354. package/src/modules/tool-helpers/tool-handler.ts +24 -4
  355. package/src/modules/tool-helpers/tool.contract.ts +3 -2
  356. package/src/modules/tool-helpers/validate-token.ts +1 -1
  357. package/src/modules/workflow/__tests__/apply-failure.test.ts +9 -1
  358. package/src/modules/workflow/__tests__/pending-commits.repository-replaced.test.ts +111 -0
  359. package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +25 -0
  360. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +159 -8
  361. package/src/modules/workflow/__tests__/workflow.service.repository-replaced.test.ts +249 -0
  362. package/src/modules/workflow/__tests__/workflow.service.update-from-target.test.ts +146 -40
  363. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +405 -7
  364. package/src/modules/workflow/agent-tools/change-request-summary.ts +182 -0
  365. package/src/modules/workflow/agent-tools/workflow.tools.ts +135 -38
  366. package/src/modules/workflow/file-lock.service.ts +31 -0
  367. package/src/modules/workflow/git/__tests__/git.service.fileBytesAtCommit.test.ts +260 -0
  368. package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +121 -0
  369. package/src/modules/workflow/git/__tests__/pull-request.service.getPrDetail.test.ts +145 -2
  370. package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-delete.test.ts +166 -0
  371. package/src/modules/workflow/git/git.service.ts +193 -4
  372. package/src/modules/workflow/git/node-git-runner.ts +6 -0
  373. package/src/modules/workflow/git/pull-request.service.ts +119 -0
  374. package/src/modules/workflow/pending-commits.service.ts +59 -1
  375. package/src/modules/workflow/workflow-hooks.ts +64 -26
  376. package/src/modules/workflow/workflow.routes.ts +3 -1
  377. package/src/modules/workflow/workflow.service.ts +290 -55
  378. package/src/modules/workspace/__tests__/agent-access.gate.test.ts +208 -0
  379. package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +384 -0
  380. package/src/modules/workspace/__tests__/file-stat-access.test.ts +2 -1
  381. package/src/modules/workspace/__tests__/git-internals.security.test.ts +2 -1
  382. package/src/modules/workspace/__tests__/set-aside-clone.test.ts +52 -0
  383. package/src/modules/workspace/__tests__/workspace.routes.at-ref.test.ts +305 -0
  384. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +2 -0
  385. package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +147 -0
  386. package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +326 -0
  387. package/src/modules/workspace/__tests__/workspace.service.test.ts +20 -9
  388. package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +43 -0
  389. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +2 -3
  390. package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +235 -10
  391. package/src/modules/workspace/__tests__/workspace.tools.test.ts +720 -17
  392. package/src/modules/workspace/agent-access.gate.ts +164 -0
  393. package/src/modules/workspace/routine-write-policy.ts +5 -6
  394. package/src/modules/workspace/session-sink.ts +5 -5
  395. package/src/modules/workspace/set-aside-clone.ts +96 -0
  396. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +297 -0
  397. package/src/modules/workspace/startup/kb-startup-runner.ts +242 -2
  398. package/src/modules/workspace/workspace.routes.ts +95 -3
  399. package/src/modules/workspace/workspace.service.ts +273 -25
  400. package/src/modules/workspace/workspace.tools.ts +373 -119
  401. package/src/modules/write-access/__tests__/write-access.test.ts +248 -0
  402. package/src/modules/write-access/write-access.ts +153 -0
  403. package/src/shared/domain-errors.ts +89 -0
  404. package/src/shared/git.contract.ts +37 -0
  405. package/src/tenancy/__tests__/static-tenant-source.test.ts +0 -1
  406. package/src/tenancy/static-tenant-source.ts +0 -3
  407. package/dist/modules/workflow/session-ontology.policy.d.ts +0 -52
  408. package/dist/modules/workflow/session-ontology.policy.d.ts.map +0 -1
  409. package/dist/modules/workflow/session-ontology.policy.js +0 -62
  410. package/dist/modules/workflow/session-ontology.policy.js.map +0 -1
  411. package/dist/modules/workflow/session-ontology.service.d.ts +0 -105
  412. package/dist/modules/workflow/session-ontology.service.d.ts.map +0 -1
  413. package/dist/modules/workflow/session-ontology.service.js +0 -147
  414. package/dist/modules/workflow/session-ontology.service.js.map +0 -1
  415. package/dist/modules/workspace/session-ontology.gate.d.ts +0 -114
  416. package/dist/modules/workspace/session-ontology.gate.d.ts.map +0 -1
  417. package/dist/modules/workspace/session-ontology.gate.js +0 -161
  418. package/dist/modules/workspace/session-ontology.gate.js.map +0 -1
  419. package/dist/shared/kb-layout.d.ts +0 -39
  420. package/dist/shared/kb-layout.d.ts.map +0 -1
  421. package/dist/shared/kb-layout.js +0 -103
  422. package/dist/shared/kb-layout.js.map +0 -1
  423. package/dist/shared/kb-layout.test.d.ts +0 -2
  424. package/dist/shared/kb-layout.test.d.ts.map +0 -1
  425. package/dist/shared/kb-layout.test.js +0 -75
  426. package/dist/shared/kb-layout.test.js.map +0 -1
  427. package/src/modules/workflow/__tests__/session-ontology.policy.test.ts +0 -62
  428. package/src/modules/workflow/__tests__/session-ontology.service.test.ts +0 -201
  429. package/src/modules/workflow/session-ontology.policy.ts +0 -70
  430. package/src/modules/workflow/session-ontology.service.ts +0 -183
  431. package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +0 -239
  432. package/src/modules/workspace/session-ontology.gate.ts +0 -191
  433. package/src/shared/kb-layout.test.ts +0 -98
  434. package/src/shared/kb-layout.ts +0 -102
@@ -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
+ });
@@ -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
  });
@@ -574,6 +574,44 @@ describe('AuthService — the SSO domain allow-list', () => {
574
574
  ).rejects.toThrow(/domain is not allowed/i);
575
575
  });
576
576
 
577
+ /**
578
+ * One rule per sign-in. A provider that decided for itself who may enter
579
+ * (an invitation, a claimed domain) is not overruled by a list the admin
580
+ * set for the deployment's own provider; the plan still has its say.
581
+ */
582
+ it('is not laid on top of a provider that decided admission itself', async () => {
583
+ const { db } = makeFakeDb([[{ ...ROW, email: 'invited@gmail.com', name: 'Invited' }]]);
584
+ const out = await new AuthService(db, config).loginWithSso('invited@gmail.com', 'Invited', {
585
+ admittedByProvider: true,
586
+ });
587
+ expect(out.user.email).toBe('invited@gmail.com');
588
+ });
589
+
590
+ it('still asks the plan about someone the provider admitted', async () => {
591
+ const asked: Array<[string, string]> = [];
592
+ const plan: IAccountAdmission = {
593
+ canProvision: async (email, reason) => {
594
+ asked.push([email, reason]);
595
+ return { ok: false, message: 'No seat left on this plan' };
596
+ },
597
+ };
598
+ const { db } = makeFakeDb([[]]);
599
+ await expect(
600
+ new AuthService(db, config, plan).loginWithSso('invited@gmail.com', 'Invited', { admittedByProvider: true }),
601
+ ).rejects.toBeInstanceOf(AccountAdmissionRefusedError);
602
+ expect(asked).toEqual([['invited@gmail.com', 'sso']]);
603
+ expect(vi.mocked(db.insert)).not.toHaveBeenCalled();
604
+ });
605
+
606
+ it('governs a provider that says nothing, or says it did not decide', async () => {
607
+ for (const opts of [undefined, {}, { admittedByProvider: false }]) {
608
+ const { db } = makeFakeDb([[]]);
609
+ await expect(new AuthService(db, config).loginWithSso('someone@gmail.com', 'Someone', opts)).rejects.toThrow(
610
+ /domain is not allowed/i,
611
+ );
612
+ }
613
+ });
614
+
577
615
  it('admits a subdomain of an allowed domain', async () => {
578
616
  const { db } = makeFakeDb([[{ ...ROW, email: 'eu@eu.bevel.software', name: 'EU' }]]);
579
617
  const out = await new AuthService(db, config).loginWithSso('eu@eu.bevel.software', 'EU');
@@ -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.';