@bevel-software/platform-core-backend 0.4.1 → 0.5.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 (304) hide show
  1. package/dist/core/core-ports.d.ts +15 -0
  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.map +1 -1
  5. package/dist/core/create-core-server.js +40 -6
  6. package/dist/core/create-core-server.js.map +1 -1
  7. package/dist/core/create-core-services.d.ts +17 -1
  8. package/dist/core/create-core-services.d.ts.map +1 -1
  9. package/dist/core/create-core-services.js +153 -29
  10. package/dist/core/create-core-services.js.map +1 -1
  11. package/dist/core-config.d.ts +70 -21
  12. package/dist/core-config.d.ts.map +1 -1
  13. package/dist/core-config.js +134 -58
  14. package/dist/core-config.js.map +1 -1
  15. package/dist/modules/access/access-control.interface.d.ts +6 -0
  16. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  17. package/dist/modules/access/access-control.service.d.ts +74 -5
  18. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  19. package/dist/modules/access/access-control.service.js +116 -15
  20. package/dist/modules/access/access-control.service.js.map +1 -1
  21. package/dist/modules/access/access-declarations.d.ts +93 -0
  22. package/dist/modules/access/access-declarations.d.ts.map +1 -0
  23. package/dist/modules/access/access-declarations.js +168 -0
  24. package/dist/modules/access/access-declarations.js.map +1 -0
  25. package/dist/modules/access/access-mutation.service.js +4 -4
  26. package/dist/modules/access/access-mutation.service.js.map +1 -1
  27. package/dist/modules/access/access-splice.d.ts +16 -1
  28. package/dist/modules/access/access-splice.d.ts.map +1 -1
  29. package/dist/modules/access/access-splice.js +40 -21
  30. package/dist/modules/access/access-splice.js.map +1 -1
  31. package/dist/modules/access/access.routes.d.ts.map +1 -1
  32. package/dist/modules/access/access.routes.js +51 -0
  33. package/dist/modules/access/access.routes.js.map +1 -1
  34. package/dist/modules/access/creator-access.d.ts +8 -0
  35. package/dist/modules/access/creator-access.d.ts.map +1 -1
  36. package/dist/modules/access/creator-access.js +21 -2
  37. package/dist/modules/access/creator-access.js.map +1 -1
  38. package/dist/modules/admin/admin-access.service.d.ts +1 -1
  39. package/dist/modules/admin/admin-access.service.d.ts.map +1 -1
  40. package/dist/modules/admin/admin-access.service.js +3 -2
  41. package/dist/modules/admin/admin-access.service.js.map +1 -1
  42. package/dist/modules/auth/auth.routes.d.ts.map +1 -1
  43. package/dist/modules/auth/auth.routes.js +50 -1
  44. package/dist/modules/auth/auth.routes.js.map +1 -1
  45. package/dist/modules/auth/auth.service.d.ts +39 -10
  46. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  47. package/dist/modules/auth/auth.service.js +38 -16
  48. package/dist/modules/auth/auth.service.js.map +1 -1
  49. package/dist/modules/database/core-schema.d.ts +124 -0
  50. package/dist/modules/database/core-schema.d.ts.map +1 -1
  51. package/dist/modules/database/core-schema.js +45 -0
  52. package/dist/modules/database/core-schema.js.map +1 -1
  53. package/dist/modules/groups/group-provision.service.d.ts +130 -0
  54. package/dist/modules/groups/group-provision.service.d.ts.map +1 -0
  55. package/dist/modules/groups/group-provision.service.js +288 -0
  56. package/dist/modules/groups/group-provision.service.js.map +1 -0
  57. package/dist/modules/groups/groups.contract.d.ts +106 -0
  58. package/dist/modules/groups/groups.contract.d.ts.map +1 -0
  59. package/dist/modules/groups/groups.contract.js +36 -0
  60. package/dist/modules/groups/groups.contract.js.map +1 -0
  61. package/dist/modules/groups/groups.routes.d.ts +42 -0
  62. package/dist/modules/groups/groups.routes.d.ts.map +1 -0
  63. package/dist/modules/groups/groups.routes.js +379 -0
  64. package/dist/modules/groups/groups.routes.js.map +1 -0
  65. package/dist/modules/groups/groups.service.d.ts +60 -0
  66. package/dist/modules/groups/groups.service.d.ts.map +1 -0
  67. package/dist/modules/groups/groups.service.js +172 -0
  68. package/dist/modules/groups/groups.service.js.map +1 -0
  69. package/dist/modules/groups/index.d.ts +7 -0
  70. package/dist/modules/groups/index.d.ts.map +1 -0
  71. package/dist/modules/groups/index.js +6 -0
  72. package/dist/modules/groups/index.js.map +1 -0
  73. package/dist/modules/groups/join-proposals.d.ts +53 -0
  74. package/dist/modules/groups/join-proposals.d.ts.map +1 -0
  75. package/dist/modules/groups/join-proposals.js +67 -0
  76. package/dist/modules/groups/join-proposals.js.map +1 -0
  77. package/dist/modules/groups/join-requests.service.d.ts +81 -0
  78. package/dist/modules/groups/join-requests.service.d.ts.map +1 -0
  79. package/dist/modules/groups/join-requests.service.js +135 -0
  80. package/dist/modules/groups/join-requests.service.js.map +1 -0
  81. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts +20 -0
  82. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
  83. package/dist/modules/secrets-vault/secrets-vault.routes.js +59 -11
  84. package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
  85. package/dist/modules/settings/deployment-settings.service.d.ts +129 -0
  86. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -0
  87. package/dist/modules/settings/deployment-settings.service.js +368 -0
  88. package/dist/modules/settings/deployment-settings.service.js.map +1 -0
  89. package/dist/modules/settings/setup.routes.d.ts +49 -0
  90. package/dist/modules/settings/setup.routes.d.ts.map +1 -0
  91. package/dist/modules/settings/setup.routes.js +292 -0
  92. package/dist/modules/settings/setup.routes.js.map +1 -0
  93. package/dist/modules/skills/index.d.ts +2 -1
  94. package/dist/modules/skills/index.d.ts.map +1 -1
  95. package/dist/modules/skills/index.js +1 -0
  96. package/dist/modules/skills/index.js.map +1 -1
  97. package/dist/modules/skills/pending-skills.service.d.ts +40 -0
  98. package/dist/modules/skills/pending-skills.service.d.ts.map +1 -0
  99. package/dist/modules/skills/pending-skills.service.js +147 -0
  100. package/dist/modules/skills/pending-skills.service.js.map +1 -0
  101. package/dist/modules/skills/skills.contract.d.ts +32 -3
  102. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  103. package/dist/modules/skills/skills.contract.js +1 -1
  104. package/dist/modules/skills/skills.routes.d.ts +3 -2
  105. package/dist/modules/skills/skills.routes.d.ts.map +1 -1
  106. package/dist/modules/skills/skills.routes.js +18 -1
  107. package/dist/modules/skills/skills.routes.js.map +1 -1
  108. package/dist/modules/skills/skills.service.d.ts +22 -0
  109. package/dist/modules/skills/skills.service.d.ts.map +1 -1
  110. package/dist/modules/skills/skills.service.js +24 -12
  111. package/dist/modules/skills/skills.service.js.map +1 -1
  112. package/dist/modules/tool-manuals/index.d.ts +1 -1
  113. package/dist/modules/tool-manuals/index.d.ts.map +1 -1
  114. package/dist/modules/tool-manuals/index.js.map +1 -1
  115. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +47 -2
  116. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  117. package/dist/modules/tool-manuals/tool-manuals.contract.js +1 -1
  118. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts +4 -0
  119. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -1
  120. package/dist/modules/tool-manuals/tool-manuals.routes.js +23 -0
  121. package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -1
  122. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +3 -1
  123. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  124. package/dist/modules/tool-manuals/tool-manuals.service.js +84 -17
  125. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  126. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  127. package/dist/modules/tool-manuals/tool-manuals.tools.js +6 -2
  128. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  129. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  130. package/dist/modules/workflow/agent-tools/workflow.tools.js +4 -2
  131. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  132. package/dist/modules/workflow/event-bus.d.ts +11 -0
  133. package/dist/modules/workflow/event-bus.d.ts.map +1 -1
  134. package/dist/modules/workflow/event-bus.js +22 -0
  135. package/dist/modules/workflow/event-bus.js.map +1 -1
  136. package/dist/modules/workflow/file-lock.service.d.ts +9 -0
  137. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  138. package/dist/modules/workflow/file-lock.service.js +16 -0
  139. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  140. package/dist/modules/workflow/git/branch-name.d.ts +1 -1
  141. package/dist/modules/workflow/git/branch-name.d.ts.map +1 -1
  142. package/dist/modules/workflow/git/branch-name.js +1 -1
  143. package/dist/modules/workflow/git/branch-name.js.map +1 -1
  144. package/dist/modules/workflow/git/git.service.d.ts +31 -2
  145. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  146. package/dist/modules/workflow/git/git.service.js +144 -52
  147. package/dist/modules/workflow/git/git.service.js.map +1 -1
  148. package/dist/modules/workflow/git/pull-request.service.d.ts +12 -6
  149. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  150. package/dist/modules/workflow/git/pull-request.service.js +19 -9
  151. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  152. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
  153. package/dist/modules/workflow/review-workflow/review-workflow.service.js +12 -3
  154. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  155. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  156. package/dist/modules/workflow/workflow.routes.js +95 -14
  157. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  158. package/dist/modules/workflow/workflow.service.d.ts +92 -3
  159. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  160. package/dist/modules/workflow/workflow.service.js +381 -108
  161. package/dist/modules/workflow/workflow.service.js.map +1 -1
  162. package/dist/modules/workspace/bevel-ignore.d.ts +5 -0
  163. package/dist/modules/workspace/bevel-ignore.d.ts.map +1 -1
  164. package/dist/modules/workspace/bevel-ignore.js +5 -1
  165. package/dist/modules/workspace/bevel-ignore.js.map +1 -1
  166. package/dist/modules/workspace/kb-seed.interface.d.ts +1 -1
  167. package/dist/modules/workspace/kb-seed.service.d.ts +60 -5
  168. package/dist/modules/workspace/kb-seed.service.d.ts.map +1 -1
  169. package/dist/modules/workspace/kb-seed.service.js +228 -51
  170. package/dist/modules/workspace/kb-seed.service.js.map +1 -1
  171. package/dist/modules/workspace/workspace.routes.d.ts +2 -1
  172. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  173. package/dist/modules/workspace/workspace.routes.js +51 -18
  174. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  175. package/dist/modules/workspace/workspace.service.d.ts +16 -4
  176. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  177. package/dist/modules/workspace/workspace.service.js +36 -9
  178. package/dist/modules/workspace/workspace.service.js.map +1 -1
  179. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  180. package/dist/modules/workspace/workspace.tools.js +8 -3
  181. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  182. package/dist/shared/kb-layout.test.js +2 -2
  183. package/dist/test-setup.d.ts +2 -0
  184. package/dist/test-setup.d.ts.map +1 -0
  185. package/dist/test-setup.js +18 -0
  186. package/dist/test-setup.js.map +1 -0
  187. package/kb-template/.bevelignore +6 -2
  188. package/kb-template/AGENTS.md +272 -0
  189. package/kb-template/KnowledgeBase/How to get started.md +65 -0
  190. package/kb-template/access.md +36 -36
  191. package/migrations/0002_user_onboarding_done.sql +1 -0
  192. package/migrations/0003_deployment_settings.sql +9 -0
  193. package/migrations/meta/0001_snapshot.json +1486 -0
  194. package/migrations/meta/0002_snapshot.json +1493 -0
  195. package/migrations/meta/0003_snapshot.json +1552 -0
  196. package/migrations/meta/_journal.json +15 -1
  197. package/package.json +3 -3
  198. package/src/__tests__/branch-model.test.ts +79 -0
  199. package/src/__tests__/core-config.admin.test.ts +114 -0
  200. package/src/__tests__/database-url.test.ts +70 -0
  201. package/src/core/core-ports.ts +15 -0
  202. package/src/core/create-core-server.ts +55 -6
  203. package/src/core/create-core-services.ts +201 -36
  204. package/src/core-config.ts +139 -59
  205. package/src/modules/access/__tests__/access-declarations.test.ts +195 -0
  206. package/src/modules/access/__tests__/access-md-format.test.ts +191 -0
  207. package/src/modules/access/__tests__/access-splice.test.ts +21 -0
  208. package/src/modules/access/__tests__/access.routes.overrides.test.ts +214 -0
  209. package/src/modules/access/access-control.interface.ts +11 -0
  210. package/src/modules/access/access-control.service.ts +113 -15
  211. package/src/modules/access/access-declarations.ts +251 -0
  212. package/src/modules/access/access-mutation.service.ts +4 -4
  213. package/src/modules/access/access-splice.ts +64 -21
  214. package/src/modules/access/access.routes.ts +61 -0
  215. package/src/modules/access/creator-access.ts +22 -2
  216. package/src/modules/admin/admin-access.service.ts +4 -3
  217. package/src/modules/auth/__tests__/auth.service.test.ts +57 -0
  218. package/src/modules/auth/__tests__/onboarding-done.route.test.ts +147 -0
  219. package/src/modules/auth/auth.routes.ts +50 -1
  220. package/src/modules/auth/auth.service.ts +55 -17
  221. package/src/modules/database/core-schema.ts +46 -0
  222. package/src/modules/groups/__tests__/group-index.service.test.ts +225 -0
  223. package/src/modules/groups/__tests__/group-provision.service.test.ts +247 -0
  224. package/src/modules/groups/__tests__/groups.routes.test.ts +493 -0
  225. package/src/modules/groups/__tests__/join-proposals.test.ts +101 -0
  226. package/src/modules/groups/__tests__/join-requests.service.test.ts +175 -0
  227. package/src/modules/groups/group-provision.service.ts +328 -0
  228. package/src/modules/groups/groups.contract.ts +107 -0
  229. package/src/modules/groups/groups.routes.ts +433 -0
  230. package/src/modules/groups/groups.service.ts +193 -0
  231. package/src/modules/groups/index.ts +17 -0
  232. package/src/modules/groups/join-proposals.ts +114 -0
  233. package/src/modules/groups/join-requests.service.ts +185 -0
  234. package/src/modules/secrets-vault/__tests__/oauth-return-to.route.test.ts +283 -0
  235. package/src/modules/secrets-vault/secrets-vault.routes.ts +68 -12
  236. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +256 -0
  237. package/src/modules/settings/__tests__/setup.routes.test.ts +201 -0
  238. package/src/modules/settings/deployment-settings.service.ts +419 -0
  239. package/src/modules/settings/setup.routes.ts +317 -0
  240. package/src/modules/skills/__tests__/pending-skills.service.test.ts +240 -0
  241. package/src/modules/skills/__tests__/skills.service.test.ts +13 -13
  242. package/src/modules/skills/index.ts +3 -0
  243. package/src/modules/skills/pending-skills.service.ts +168 -0
  244. package/src/modules/skills/skills.contract.ts +34 -3
  245. package/src/modules/skills/skills.routes.ts +23 -2
  246. package/src/modules/skills/skills.service.ts +24 -16
  247. package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +262 -0
  248. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +2 -2
  249. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +61 -23
  250. package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +156 -156
  251. package/src/modules/tool-manuals/index.ts +2 -0
  252. package/src/modules/tool-manuals/tool-manuals.contract.ts +50 -2
  253. package/src/modules/tool-manuals/tool-manuals.routes.ts +21 -0
  254. package/src/modules/tool-manuals/tool-manuals.service.ts +86 -17
  255. package/src/modules/tool-manuals/tool-manuals.tools.ts +206 -202
  256. package/src/modules/workflow/__tests__/event-bus.test.ts +35 -0
  257. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +251 -5
  258. package/src/modules/workflow/agent-tools/workflow.tools.ts +4 -2
  259. package/src/modules/workflow/event-bus.ts +25 -0
  260. package/src/modules/workflow/file-lock.service.ts +17 -0
  261. package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +9 -49
  262. package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +101 -0
  263. package/src/modules/workflow/git/__tests__/git.service.commitFile.test.ts +219 -211
  264. package/src/modules/workflow/git/__tests__/git.service.fileContentsAtCommit.test.ts +132 -0
  265. package/src/modules/workflow/git/__tests__/git.service.logForFile.test.ts +4 -2
  266. package/src/modules/workflow/git/__tests__/git.service.postMergePull.test.ts +275 -272
  267. package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +5 -0
  268. package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-cancel.test.ts +44 -0
  269. package/src/modules/workflow/git/branch-name.ts +5 -1
  270. package/src/modules/workflow/git/git.service.ts +159 -62
  271. package/src/modules/workflow/git/pull-request.service.ts +22 -11
  272. package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +4 -1
  273. package/src/modules/workflow/review-workflow/review-workflow.service.ts +12 -3
  274. package/src/modules/workflow/workflow.routes.ts +91 -13
  275. package/src/modules/workflow/workflow.service.ts +353 -13
  276. package/src/modules/workspace/__tests__/kb-seed.service.test.ts +255 -24
  277. package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +236 -233
  278. package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +179 -176
  279. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +320 -303
  280. package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +337 -276
  281. package/src/modules/workspace/__tests__/workspace.tools.test.ts +14 -6
  282. package/src/modules/workspace/bevel-ignore.ts +66 -62
  283. package/src/modules/workspace/kb-seed.interface.ts +1 -1
  284. package/src/modules/workspace/kb-seed.service.ts +247 -53
  285. package/src/modules/workspace/workspace.routes.ts +54 -18
  286. package/src/modules/workspace/workspace.service.ts +45 -7
  287. package/src/modules/workspace/workspace.tools.ts +922 -917
  288. package/src/shared/__tests__/frontmatter-id.test.ts +53 -0
  289. package/src/shared/__tests__/join-request.test.ts +96 -0
  290. package/src/shared/__tests__/kb-layout.group.test.ts +45 -0
  291. package/src/shared/kb-layout.test.ts +2 -2
  292. package/src/test-setup.ts +18 -0
  293. package/kb-template/Agents/README.md +0 -38
  294. package/kb-template/CLAUDE.md +0 -419
  295. package/kb-template/Data/.gitkeep +0 -0
  296. package/kb-template/Data/README.md +0 -39
  297. package/kb-template/KnowledgeBase/README.md +0 -37
  298. package/kb-template/Pipelines/.gitkeep +0 -0
  299. package/kb-template/Pipelines/README.md +0 -38
  300. package/kb-template/Skills/.gitkeep +0 -0
  301. package/kb-template/Skills/README.md +0 -32
  302. package/kb-template/Tools/.gitkeep +0 -0
  303. package/kb-template/Tools/README.md +0 -42
  304. /package/kb-template/{Agents → Groups}/.gitkeep +0 -0
@@ -0,0 +1,147 @@
1
+ import type { Server as HttpServer } from 'node:http';
2
+ import type { AddressInfo } from 'node:net';
3
+ import express from 'express';
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+ import { createAuthRoutes } from '../auth.routes.js';
6
+ import type { AuthService } from '../auth.service.js';
7
+
8
+ /**
9
+ * POST /auth/onboarding-done — the one write behind the welcome page's Done
10
+ * AND the reminder pill's ×. The contract worth pinning:
11
+ *
12
+ * - it acts on the AUTHENTICATED user (req.userId), never on a body param —
13
+ * there is no way to conclude someone else's onboarding;
14
+ * - it REQUIRES the caller to name the account it means, and refuses anything
15
+ * that is not an exact match — including a claim that is absent or not a
16
+ * string, since "said nothing" is not the same as "meant this account";
17
+ * - it is idempotent from the caller's view (the service call is an UPDATE
18
+ * to the value it may already have), so both buttons can fire it blind;
19
+ * - unauthenticated callers never reach the service.
20
+ */
21
+
22
+ const USER_ID = 'user-123';
23
+
24
+ let server: HttpServer | null = null;
25
+
26
+ afterEach(() => {
27
+ server?.close();
28
+ server = null;
29
+ // In a hook, not at the end of a test body: a failing assertion would
30
+ // otherwise leave a `console.error` spy installed and silence the rest of
31
+ // the file.
32
+ vi.restoreAllMocks();
33
+ });
34
+
35
+ /**
36
+ * Mount the auth router on a throwaway port with a stand-in auth middleware,
37
+ * and return its base URL. `authed: false` makes that middleware reject like
38
+ * the real one, which is how the "never reaches the service" case is proved
39
+ * rather than assumed.
40
+ */
41
+ function listen(service: Pick<AuthService, 'markOnboardingDone'>, authed = true): string {
42
+ const app = express();
43
+ app.use(express.json());
44
+ const middleware: express.RequestHandler = (req, res, next) => {
45
+ if (!authed) {
46
+ res.status(401).json({ error: 'Unauthorized' });
47
+ return;
48
+ }
49
+ req.userId = USER_ID;
50
+ next();
51
+ };
52
+ app.use('/api', createAuthRoutes(service as AuthService, middleware));
53
+ server = app.listen(0);
54
+ const { port } = server.address() as AddressInfo;
55
+ return `http://127.0.0.1:${port}`;
56
+ }
57
+
58
+ describe('POST /auth/onboarding-done', () => {
59
+ it('marks the caller done when the claimed account matches the token', async () => {
60
+ const markOnboardingDone = vi.fn().mockResolvedValue(undefined);
61
+ const base = listen({ markOnboardingDone });
62
+ const res = await fetch(`${base}/api/auth/onboarding-done`, {
63
+ method: 'POST',
64
+ headers: { 'content-type': 'application/json' },
65
+ body: JSON.stringify({ userId: USER_ID }),
66
+ });
67
+ expect(res.status).toBe(200);
68
+ expect(await res.json()).toEqual({ ok: true });
69
+ expect(markOnboardingDone).toHaveBeenCalledTimes(1);
70
+ expect(markOnboardingDone).toHaveBeenCalledWith(USER_ID);
71
+ });
72
+
73
+ /**
74
+ * A caller that names no account has not expressed the intent this route
75
+ * needs. Accepting `{}` as consent would reopen the stale-tab hole from the
76
+ * other side — an old or malformed client posting an empty body while
77
+ * rendering account A would conclude account B's onboarding unopposed — so
78
+ * the absent claim is refused exactly like a wrong one.
79
+ */
80
+ it('refuses a caller that claims nothing, and writes nothing', async () => {
81
+ const markOnboardingDone = vi.fn();
82
+ const base = listen({ markOnboardingDone });
83
+ const res = await fetch(`${base}/api/auth/onboarding-done`, { method: 'POST' });
84
+ expect(res.status).toBe(409);
85
+ expect(markOnboardingDone).not.toHaveBeenCalled();
86
+ });
87
+
88
+ // Same rule for a claim of the wrong TYPE: only an exact string match is
89
+ // consent, so a number that happens to stringify to the user id is not.
90
+ it('refuses a claim that is not a string', async () => {
91
+ const markOnboardingDone = vi.fn();
92
+ const base = listen({ markOnboardingDone });
93
+ const res = await fetch(`${base}/api/auth/onboarding-done`, {
94
+ method: 'POST',
95
+ headers: { 'content-type': 'application/json' },
96
+ body: JSON.stringify({ userId: { toString: () => USER_ID } }),
97
+ });
98
+ expect(res.status).toBe(409);
99
+ expect(markOnboardingDone).not.toHaveBeenCalled();
100
+ });
101
+
102
+ /**
103
+ * The stale-tab case. Two accounts share one bearer-token key in
104
+ * localStorage: a tab still rendering A, after B signs in elsewhere, sends
105
+ * A's intent with B's token. Concluding B's onboarding there would be
106
+ * irreversible — there is no API to undo it — so the mismatch is refused
107
+ * and NOTHING is written.
108
+ */
109
+ it('refuses when the claimed account is not the token holder, and writes nothing', async () => {
110
+ const markOnboardingDone = vi.fn();
111
+ const base = listen({ markOnboardingDone });
112
+ const res = await fetch(`${base}/api/auth/onboarding-done`, {
113
+ method: 'POST',
114
+ headers: { 'content-type': 'application/json' },
115
+ body: JSON.stringify({ userId: 'a-different-account' }),
116
+ });
117
+ expect(res.status).toBe(409);
118
+ expect(markOnboardingDone).not.toHaveBeenCalled();
119
+ });
120
+
121
+ it('never reaches the service unauthenticated', async () => {
122
+ const markOnboardingDone = vi.fn();
123
+ const base = listen({ markOnboardingDone }, false);
124
+ const res = await fetch(`${base}/api/auth/onboarding-done`, { method: 'POST' });
125
+ expect(res.status).toBe(401);
126
+ expect(markOnboardingDone).not.toHaveBeenCalled();
127
+ });
128
+
129
+ // A failed write must not be reported as success — and must not narrate the
130
+ // database to the client either. Same rule as /auth/login.
131
+ it('reports a failed write as a generic 500, keeping the detail server-side', async () => {
132
+ const detail = 'connection to 10.0.0.4:5432 refused';
133
+ const markOnboardingDone = vi.fn().mockRejectedValue(new Error(detail));
134
+ const logged = vi.spyOn(console, 'error').mockImplementation(() => {});
135
+ const base = listen({ markOnboardingDone });
136
+ const res = await fetch(`${base}/api/auth/onboarding-done`, {
137
+ method: 'POST',
138
+ headers: { 'content-type': 'application/json' },
139
+ body: JSON.stringify({ userId: USER_ID }),
140
+ });
141
+ expect(res.status).toBe(500);
142
+ const body = (await res.json()) as { error: string };
143
+ expect(body.error).toBe('Could not save that');
144
+ expect(body.error).not.toContain(detail);
145
+ expect(logged).toHaveBeenCalledWith('Onboarding-done error:', detail);
146
+ });
147
+ });
@@ -65,8 +65,19 @@ export function createAuthRoutes(
65
65
  return;
66
66
  }
67
67
  const ip = clientIp(req);
68
+ // IP budget FIRST, and it returns before the pair key exists. Order is the
69
+ // whole guard here: `consume` inserts an entry for any key it has not seen,
70
+ // and that entry lives for the full 15-minute window. Consuming the pair
71
+ // budget first therefore let an address that was ALREADY over its IP limit
72
+ // keep minting `ip|email` entries, one per attacker-chosen email — and
73
+ // since `consume` scans the whole map on every call, that growth is also a
74
+ // latency tax on every legitimate login for the rest of the window.
75
+ if (!loginIpLimiter.consume(ip)) {
76
+ res.status(429).json({ error: 'Too many attempts. Try again later.' });
77
+ return;
78
+ }
68
79
  const pairKey = `${ip}|${email.trim().toLowerCase()}`;
69
- if (!loginPairLimiter.consume(pairKey) || !loginIpLimiter.consume(ip)) {
80
+ if (!loginPairLimiter.consume(pairKey)) {
70
81
  res.status(429).json({ error: 'Too many attempts. Try again later.' });
71
82
  return;
72
83
  }
@@ -136,6 +147,44 @@ export function createAuthRoutes(
136
147
  }
137
148
  });
138
149
 
150
+ // POST /api/auth/onboarding-done — conclude the connect-your-agent
151
+ // onboarding for the caller (protected). One-way and idempotent; the
152
+ // welcome page's Done and the reminder pill's × are its two callers.
153
+ router.post('/auth/onboarding-done', authMiddleware, async (req, res) => {
154
+ try {
155
+ // The caller must state WHICH account it believes it is concluding, and
156
+ // anything but an exact match is refused rather than applied to the
157
+ // token's owner.
158
+ //
159
+ // The bearer token lives in one shared localStorage key, so two accounts
160
+ // in two tabs share it: a stale tab still rendering A, after B signs in
161
+ // elsewhere, would otherwise send A's intent with B's token and
162
+ // irreversibly conclude B's onboarding — an account B has no way to
163
+ // reopen. `userId` is an assertion about intent, never an authorization:
164
+ // the write still targets `req.userId` alone.
165
+ //
166
+ // REQUIRED, not merely checked-when-present. Treating an absent claim as
167
+ // consent reopens the same hole from the other side: a stale or malformed
168
+ // client that posts `{}` while rendering A would conclude B's onboarding
169
+ // unopposed. A caller that cannot name the account it means has not
170
+ // expressed the intent this route needs.
171
+ const claimed = (req.body as { userId?: unknown } | undefined)?.userId;
172
+ if (typeof claimed !== 'string' || claimed !== req.userId) {
173
+ res.status(409).json({ error: 'Session changed — sign in again' });
174
+ return;
175
+ }
176
+ await authService.markOnboardingDone(req.userId!);
177
+ res.json({ ok: true });
178
+ } catch (error) {
179
+ // Logged in full, returned generic — same rule as `/auth/login` above.
180
+ // A raw driver message here would hand an unauthenticated-adjacent
181
+ // caller the schema, the host, or the connection string.
182
+ const msg = error instanceof Error ? error.message : 'Unknown error';
183
+ console.error('Onboarding-done error:', msg);
184
+ res.status(500).json({ error: 'Could not save that' });
185
+ }
186
+ });
187
+
139
188
  // GET /api/auth/me — validate stored JWT, return user info (protected)
140
189
  router.get('/auth/me', authMiddleware, async (req, res) => {
141
190
  try {
@@ -27,24 +27,46 @@ function decoyHash(): Promise<string> {
27
27
  return decoyHashPromise;
28
28
  }
29
29
 
30
+ /**
31
+ * Narrow a `users` row down to what a client is allowed to see. One place
32
+ * rather than three inline object literals, so a column added to the table is
33
+ * never accidentally shipped to the browser — and so every session-issuing
34
+ * path returns the same shape.
35
+ */
30
36
  function toAuthUser(user: {
31
37
  id: string;
32
38
  email: string;
33
39
  name: string;
34
40
  avatarUrl: string | null;
41
+ onboardingDone: boolean;
35
42
  }): AuthUser {
36
43
  return {
37
44
  id: user.id,
38
45
  email: user.email,
39
46
  name: user.name,
40
47
  avatarUrl: user.avatarUrl ?? undefined,
48
+ onboardingDone: user.onboardingDone,
41
49
  };
42
50
  }
43
51
 
52
+ /**
53
+ * What this service needs from the deployment's configuration, and nothing
54
+ * more. Narrowed from `CoreConfig` so the caller can supply a value that is
55
+ * RESOLVED rather than read straight off the environment — the SSO domain
56
+ * allow-list is settable from the setup screen, and taking the whole config
57
+ * object would have quietly pinned it to the env-only half.
58
+ */
59
+ export interface AuthConfig {
60
+ jwtSecret: string;
61
+ adminEmail: string;
62
+ adminPassword: string;
63
+ allowedEmailDomains: string[];
64
+ }
65
+
44
66
  export class AuthService {
45
67
  constructor(
46
68
  private readonly db: Database,
47
- private readonly config: CoreConfig,
69
+ private readonly config: AuthConfig,
48
70
  ) {}
49
71
 
50
72
  /**
@@ -71,7 +93,6 @@ export class AuthService {
71
93
  if (!EMAIL_REGEX.test(normalizedEmail)) {
72
94
  throw new Error('Invalid credentials');
73
95
  }
74
- this.assertAllowedDomain(normalizedEmail);
75
96
 
76
97
  const provided = password ?? '';
77
98
  const isEnvAdmin =
@@ -139,7 +160,6 @@ export class AuthService {
139
160
  if (!EMAIL_REGEX.test(normalizedEmail)) {
140
161
  throw new Error('Invalid email');
141
162
  }
142
- this.assertAllowedDomain(normalizedEmail);
143
163
  this.assertPasswordPolicy(password);
144
164
  const suppliedName = (name ?? '').trim();
145
165
  const displayName = suppliedName || normalizedEmail.split('@')[0] || normalizedEmail;
@@ -280,21 +300,31 @@ export class AuthService {
280
300
 
281
301
  if (!user) return null;
282
302
 
283
- return {
284
- id: user.id,
285
- email: user.email,
286
- name: user.name,
287
- avatarUrl: user.avatarUrl ?? undefined,
288
- };
303
+ return toAuthUser(user);
289
304
  }
290
305
 
291
306
  /**
292
- * Whether `email` passes the optional `ALLOWED_EMAIL_DOMAINS` guard. Returns
293
- * true when no guard is configured (the default). Matches the email's domain
294
- * exactly or as a subdomain, so `bevel.software` admits `a@bevel.software`
295
- * and `a@eu.bevel.software` but not `a@notbevel.software`. When a guard IS
296
- * configured, a missing/blank email fails closed. Exposed so non-login
297
- * surfaces (the embed panel) can apply the same gate.
307
+ * Conclude the connect-your-agent onboarding for `userId`. Idempotent by
308
+ * construction (an UPDATE to the value it already has), so the welcome
309
+ * page's Done and the reminder pill's dismiss can both call it without
310
+ * coordinating. There is deliberately no way back to false over the API:
311
+ * "not onboarded again" is not a state a user can be put in.
312
+ */
313
+ async markOnboardingDone(userId: string): Promise<void> {
314
+ await this.db
315
+ .update(users)
316
+ .set({ onboardingDone: true, updatedAt: new Date() })
317
+ .where(eq(users.id, userId));
318
+ }
319
+
320
+ /**
321
+ * Whether `email` passes the optional `ALLOWED_EMAIL_DOMAINS` guard — the
322
+ * SSO allow-list. Returns true when no guard is configured (the default).
323
+ * Matches the email's domain exactly or as a subdomain, so `bevel.software`
324
+ * admits `a@bevel.software` and `a@eu.bevel.software` but not
325
+ * `a@notbevel.software`. When a guard IS configured, a missing/blank email
326
+ * fails closed. Exposed so non-login surfaces (the embed panel) can apply
327
+ * the same gate.
298
328
  */
299
329
  isEmailDomainAllowed(email: string): boolean {
300
330
  const allowed = this.config.allowedEmailDomains;
@@ -305,9 +335,17 @@ export class AuthService {
305
335
  }
306
336
 
307
337
  /**
308
- * Enforce {@link isEmailDomainAllowed} at the login paths. No-op when the
309
- * guard is unset. Expects an already-normalized (trimmed, lower-cased,
338
+ * Enforce {@link isEmailDomainAllowed} on SSO sign-in. No-op when the guard
339
+ * is unset. Expects an already-normalized (trimmed, lower-cased,
310
340
  * regex-validated) email.
341
+ *
342
+ * SSO ONLY, deliberately. It exists because SSO auto-provisions — an account
343
+ * appears the first time the issuer authenticates someone, with nobody
344
+ * approving it. The other two entry points do not have that property: an
345
+ * admin-created account is vetted by the act of creating it, and password
346
+ * login can only reach an account that already exists. Applying it there
347
+ * gated nothing, and could lock out a bootstrap admin whose own address sits
348
+ * outside the list.
311
349
  */
312
350
  private assertAllowedDomain(normalizedEmail: string): void {
313
351
  if (!this.isEmailDomainAllowed(normalizedEmail)) {
@@ -24,6 +24,19 @@ export const users = pgTable('users', {
24
24
  * until an admin (or the user, from their Account page) sets one.
25
25
  */
26
26
  passwordHash: text('password_hash'),
27
+ /**
28
+ * The one onboarding fact the server keeps: has this person concluded the
29
+ * connect-your-agent setup (the welcome page's Done, or the reminder
30
+ * pill's dismiss — one field, both doors). False drives the pill in every
31
+ * browser the account signs into; true ends it everywhere at once, which
32
+ * is exactly what localStorage could not promise.
33
+ *
34
+ * Existing accounts are NOT backfilled to true: the column simply defaults
35
+ * to false, so everyone is greeted once and anyone already connected
36
+ * clicks Done. A plain `ADD COLUMN` is worth more than the branching a
37
+ * backfill would need, and being shown the setup once costs a click.
38
+ */
39
+ onboardingDone: boolean('onboarding_done').default(false).notNull(),
27
40
  createdAt: timestamp('created_at').defaultNow().notNull(),
28
41
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
29
42
  });
@@ -435,3 +448,36 @@ export const sessionOntologyTouches = pgTable('session_ontology_touches', {
435
448
  // Backstop sweep of abandoned runs via `touched_at < cutoff`.
436
449
  byTouchedAt: index('session_ontology_touches_by_touched_at').on(t.touchedAt),
437
450
  }));
451
+
452
+ /**
453
+ * Deployment-wide settings an admin can change from the setup screen, so a
454
+ * fresh install does not have to be told everything through the environment
455
+ * before it will boot.
456
+ *
457
+ * KEY/VALUE rather than typed columns: the set grows (the KB remote first, the
458
+ * branch model after it), and a migration per setting buys nothing when every
459
+ * value arrives from a text input anyway. Validation lives with the setting's
460
+ * definition, not in the column type.
461
+ *
462
+ * The ENVIRONMENT still wins over anything stored here — a row is a fallback,
463
+ * never an override. That is what lets an existing deployment adopt this table
464
+ * with no behaviour change, and stops a value typed once in a browser from
465
+ * quietly outranking the infrastructure config that is under review.
466
+ */
467
+ export const deploymentSettings = pgTable('deployment_settings', {
468
+ key: text('key').primaryKey(),
469
+ /** Ciphertext when `encrypted`, plain text otherwise. */
470
+ value: text('value').notNull(),
471
+ /**
472
+ * Whether `value` is sealed with the secrets key. Stored per row rather than
473
+ * inferred from the key name so a reader never has to know the catalogue to
474
+ * know whether it is holding a secret.
475
+ */
476
+ encrypted: boolean('encrypted').default(false).notNull(),
477
+ /**
478
+ * Who last wrote it. `set null` on delete rather than cascade: erasing a
479
+ * person must not take the deployment's configuration with them.
480
+ */
481
+ updatedBy: uuid('updated_by').references(() => users.id, { onDelete: 'set null' }),
482
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
483
+ });
@@ -0,0 +1,225 @@
1
+ import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { DEFAULT_BRANCH } from '@bevel-software/platform-shared';
6
+ import { GroupIndexService } from '../groups.service.js';
7
+ import { workspaceIdForBranch } from '../../workspace/workspace.service.js';
8
+ import type { WorkspaceService } from '../../workspace/workspace.service.js';
9
+ import type { IAccessControl } from '../../access/access-control.interface.js';
10
+ import type { ISkillService, SkillSummary } from '../../skills/skills.contract.js';
11
+ import type { IToolManualService, ToolManualSummary } from '../../tool-manuals/tool-manuals.contract.js';
12
+
13
+ const KB_DIR = 'knowledge-base';
14
+ const wsId = workspaceIdForBranch(DEFAULT_BRANCH);
15
+
16
+ const OLGA = { name: 'Olga Ivanova', email: 'olga@bevel.software' };
17
+
18
+ function skills(...paths: string[]): SkillSummary[] {
19
+ return paths.map((path) => ({ name: path.split('/').pop()!, description: '', path }));
20
+ }
21
+
22
+ function tools(...paths: string[]): ToolManualSummary[] {
23
+ return paths.map((path) => {
24
+ const name = path.split('/').pop()!.replace(/\.tool$/, '');
25
+ return { slug: name, name, path, type: 'inline' as const };
26
+ });
27
+ }
28
+
29
+ describe('GroupIndexService', () => {
30
+ let root: string;
31
+
32
+ const workspaceService = {
33
+ getOrCreateForBranch: async () => ({ id: wsId }),
34
+ getWorkspacePath: async (id: string) => join(root, id),
35
+ } as unknown as WorkspaceService;
36
+
37
+ const principals: IAccessControl = {
38
+ eligibleOwners: async () => ({ roles: [], users: [OLGA] }),
39
+ eligibleWriters: async () => ({ roles: ['Admin'], users: [] }),
40
+ eligibleReaders: async () => ({ restricted: true, roles: ['GTM Team'], users: [] }),
41
+ } as unknown as IAccessControl;
42
+
43
+ const skillService = (list: SkillSummary[] = []): ISkillService =>
44
+ ({ listSkills: async () => list }) as unknown as ISkillService;
45
+
46
+ const toolService = (list: ToolManualSummary[] = []): IToolManualService =>
47
+ ({ listAllSummaries: async () => list }) as unknown as IToolManualService;
48
+
49
+ const svc = (opts: {
50
+ access?: IAccessControl;
51
+ skills?: SkillSummary[];
52
+ tools?: ToolManualSummary[];
53
+ workspace?: WorkspaceService;
54
+ } = {}) =>
55
+ new GroupIndexService(
56
+ opts.workspace ?? workspaceService,
57
+ opts.access ?? principals,
58
+ skillService(opts.skills),
59
+ toolService(opts.tools),
60
+ KB_DIR,
61
+ );
62
+
63
+ const kb = () => join(root, wsId, KB_DIR);
64
+
65
+ /** A REAL group: a folder carrying the access.md that makes it exist. */
66
+ const groupDir = async (name: string) => {
67
+ await mkdir(join(kb(), 'Groups', name), { recursive: true });
68
+ await writeFile(join(kb(), 'Groups', name, 'access.md'), '---\nread:\n - everyone\n---\n');
69
+ };
70
+
71
+ beforeEach(async () => {
72
+ root = await mkdtemp(join(tmpdir(), 'group-index-'));
73
+ await mkdir(kb(), { recursive: true });
74
+ });
75
+ afterEach(() => rm(root, { recursive: true, force: true }));
76
+
77
+ test('enumerates Groups/ folders carrying an access.md, sorted by name', async () => {
78
+ await groupDir('GTM');
79
+ await groupDir('Engineering');
80
+
81
+ const catalog = await svc().catalog();
82
+ expect(catalog.map((g) => g.name)).toEqual(['Engineering', 'GTM']);
83
+ expect(catalog[1].folders).toEqual(['Groups/GTM']);
84
+ });
85
+
86
+ test('a folder without an access.md is not a group', async () => {
87
+ // The residue a deleted group leaves behind: git cannot record an empty
88
+ // directory, so the folder outlives its files on live checkouts — and a
89
+ // folder with content but no access.md is just as much a non-group.
90
+ await mkdir(join(kb(), 'Groups', 'Ghost'), { recursive: true });
91
+ await mkdir(join(kb(), 'Groups', 'Residue'), { recursive: true });
92
+ await writeFile(join(kb(), 'Groups', 'Residue', 'notes.md'), 'leftover');
93
+ await groupDir('Real');
94
+
95
+ const catalog = await svc().catalog();
96
+ expect(catalog.map((g) => g.name)).toEqual(['Real']);
97
+ });
98
+
99
+ test('the retired Skills/ and Tools/ roots are NOT group roots', async () => {
100
+ await mkdir(join(kb(), 'Skills', 'GTM'), { recursive: true });
101
+ await mkdir(join(kb(), 'Tools', 'GTM'), { recursive: true });
102
+ await groupDir('Engineering');
103
+
104
+ const catalog = await svc().catalog();
105
+ expect(catalog.map((g) => g.name)).toEqual(['Engineering']);
106
+ });
107
+
108
+ test('ignores loose files and dot-dirs under the group root', async () => {
109
+ // The dot filter wins even over a folder that carries an access.md.
110
+ await mkdir(join(kb(), 'Groups', '.hidden'), { recursive: true });
111
+ await writeFile(join(kb(), 'Groups', '.hidden', 'access.md'), '---\nread:\n - everyone\n---\n');
112
+ await groupDir('GTM');
113
+ await writeFile(join(kb(), 'Groups', 'slack.tool'), '{}');
114
+
115
+ const catalog = await svc().catalog();
116
+ expect(catalog.map((g) => g.name)).toEqual(['GTM']);
117
+ });
118
+
119
+ test('counts skills and tools from the global catalogs by groupOfPath', async () => {
120
+ await groupDir('GTM');
121
+ await groupDir('Product');
122
+
123
+ const catalog = await svc({
124
+ skills: skills('Groups/GTM/outreach', 'Groups/GTM/newsletter', 'Groups/Product/roadmap'),
125
+ // `Groups/slack.tool` is ungrouped (two segments) — it counts nowhere.
126
+ tools: tools('Groups/GTM/heyreach.tool', 'Groups/slack.tool'),
127
+ }).catalog();
128
+
129
+ const gtm = catalog.find((g) => g.name === 'GTM')!;
130
+ expect(gtm.skillCount).toBe(2);
131
+ expect(gtm.toolCount).toBe(1);
132
+ const product = catalog.find((g) => g.name === 'Product')!;
133
+ expect(product.skillCount).toBe(1);
134
+ expect(product.toolCount).toBe(0);
135
+ });
136
+
137
+ test('resolves principals on the group folder', async () => {
138
+ await groupDir('GTM');
139
+
140
+ const seen: string[] = [];
141
+ const access = {
142
+ eligibleOwners: async (_w: string, p: string) => {
143
+ seen.push(p);
144
+ return { roles: [], users: [OLGA] };
145
+ },
146
+ eligibleWriters: async () => ({ roles: [], users: [] }),
147
+ eligibleReaders: async () => ({ restricted: false, roles: [], users: [] }),
148
+ } as unknown as IAccessControl;
149
+
150
+ const catalog = await svc({ access }).catalog();
151
+ expect(catalog[0].folders).toEqual(['Groups/GTM']);
152
+ expect(seen).toEqual(['Groups/GTM']);
153
+ expect(catalog[0].owners.users).toEqual([OLGA]);
154
+ expect(catalog[0].readers).toEqual({ restricted: false, roles: [], users: [] });
155
+ });
156
+
157
+ test('a missing Groups/ root yields an empty list, not an error', async () => {
158
+ await expect(svc().catalog()).resolves.toEqual([]);
159
+ });
160
+
161
+ test('degrades to [] when the workspace cannot be created', async () => {
162
+ const broken = {
163
+ getOrCreateForBranch: async () => {
164
+ throw new Error('no clone');
165
+ },
166
+ getWorkspacePath: async () => root,
167
+ } as unknown as WorkspaceService;
168
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
169
+ await expect(svc({ workspace: broken }).catalog()).resolves.toEqual([]);
170
+ expect(warn).toHaveBeenCalled();
171
+ warn.mockRestore();
172
+ });
173
+
174
+ test('a failed scan is served but NOT cached — the next call retries', async () => {
175
+ await groupDir('GTM');
176
+ let fail = true;
177
+ const flaky = {
178
+ // Fails once, the way a default-branch clone mid-creation does, then
179
+ // recovers. If the empty result were cached, GTM would stay hidden from
180
+ // every user for the full TTL after the cause was gone.
181
+ getOrCreateForBranch: async () => {
182
+ if (fail) throw new Error('clone in progress');
183
+ return { id: wsId };
184
+ },
185
+ getWorkspacePath: async (id: string) => join(root, id),
186
+ } as unknown as WorkspaceService;
187
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
188
+ const service = svc({ workspace: flaky });
189
+
190
+ await expect(service.catalog()).resolves.toEqual([]);
191
+
192
+ fail = false;
193
+ expect((await service.catalog()).map((g) => g.name)).toEqual(['GTM']);
194
+ warn.mockRestore();
195
+ });
196
+
197
+ test('a genuinely empty KB IS cached — an empty result is a fact, not a failure', async () => {
198
+ let scans = 0;
199
+ const counting = {
200
+ getOrCreateForBranch: async () => {
201
+ scans += 1;
202
+ return { id: wsId };
203
+ },
204
+ getWorkspacePath: async (id: string) => join(root, id),
205
+ } as unknown as WorkspaceService;
206
+ const service = svc({ workspace: counting });
207
+
208
+ await expect(service.catalog()).resolves.toEqual([]);
209
+ await expect(service.catalog()).resolves.toEqual([]);
210
+ expect(scans).toBe(1);
211
+ });
212
+
213
+ test('caches for the TTL and rescans after invalidate()', async () => {
214
+ await groupDir('GTM');
215
+ const service = svc();
216
+
217
+ expect((await service.catalog()).map((g) => g.name)).toEqual(['GTM']);
218
+ // A group added out of band is NOT seen while the cache holds…
219
+ await groupDir('Finance');
220
+ expect((await service.catalog()).map((g) => g.name)).toEqual(['GTM']);
221
+ // …and IS seen once the file-change subscriber drops it.
222
+ service.invalidate();
223
+ expect((await service.catalog()).map((g) => g.name)).toEqual(['Finance', 'GTM']);
224
+ });
225
+ });