@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
@@ -33,6 +33,37 @@ function redirectUriFor(publicBackendUrl: string): string {
33
33
  return `${publicBackendUrl}/api/secrets/oauth/callback`;
34
34
  }
35
35
 
36
+ /**
37
+ * Is this a SAME-ORIGIN path we're willing to bounce the browser back to after a
38
+ * sign-in? The callback builds `${publicFrontendUrl}${dest}#…`, so anything that
39
+ * survives this check gets concatenated onto our own origin — which makes every
40
+ * rule here an open-redirect rule, not a formatting preference:
41
+ * - must start with a single `/` — `//evil.com` is a protocol-relative URL and
42
+ * `https://evil.com` an absolute one; both would leave our origin entirely.
43
+ * - no `\` — WHATWG folds a backslash to a slash for http(s), so `/\evil.com`
44
+ * is `//evil.com` by another spelling.
45
+ * - no `#` — the callback appends its own fragment; a caller-supplied one would
46
+ * swallow the `#authorized=…`/`#error=…` the landing page reads.
47
+ * - no CR/LF — never let a caller-controlled string reach a `Location` header
48
+ * with a line break in it (response splitting).
49
+ * - ≤512 chars — a signed `state` rides in the provider's authorize URL, and
50
+ * some providers cap its length; this keeps the round-trip inside every
51
+ * budget without needing to know each provider's limit.
52
+ * Exported for the route tests: this predicate IS the security boundary, so it
53
+ * is pinned by a unit table rather than only exercised end-to-end.
54
+ */
55
+ export function isSafeReturnPath(returnTo: unknown): returnTo is string {
56
+ return (
57
+ typeof returnTo === 'string' &&
58
+ returnTo.length <= 512 &&
59
+ returnTo.startsWith('/') &&
60
+ !returnTo.startsWith('//') &&
61
+ !returnTo.includes('\\') &&
62
+ !returnTo.includes('#') &&
63
+ !/[\r\n]/.test(returnTo)
64
+ );
65
+ }
66
+
36
67
  /**
37
68
  * Authenticated Secrets Vault CRUD + OAuth start. Every route is scoped to
38
69
  * `req.userId` — secrets are private per user. Mounted behind the JWT middleware.
@@ -40,7 +71,14 @@ function redirectUriFor(publicBackendUrl: string): string {
40
71
  export function createSecretsVaultRoutes(deps: SecretsVaultRoutesDeps): express.Router {
41
72
  const { secretsVault, toolManualService, accessControl } = deps;
42
73
  const router = express.Router();
43
- const defaultWs = workspaceIdForBranch(DEFAULT_BRANCH);
74
+ // A FUNCTION, not a constant. Routers are constructed at boot, and on a
75
+ // deployment configured through the setup screen the branch model does not
76
+ // exist yet at that moment — `DEFAULT_BRANCH` is still ''. The live binding
77
+ // updates when setup applies the model, but only reads INSIDE a function
78
+ // body see it; a construction-time capture would keep handing an empty
79
+ // workspace id to the access resolver until the next restart ("Invalid
80
+ // workspace ID" on every /secrets/tools call).
81
+ const defaultWs = () => workspaceIdForBranch(DEFAULT_BRANCH);
44
82
 
45
83
  // The vault key = the exact key UTCP looks the var up under (doubles underscores
46
84
  // in the manual namespace), so storage and resolution agree for snake_case ids.
@@ -165,7 +203,7 @@ export function createSecretsVaultRoutes(deps: SecretsVaultRoutesDeps): express.
165
203
  path: m.path,
166
204
  type: m.type,
167
205
  setup: m.setup ?? null,
168
- canWrite: await accessControl.canWrite(defaultWs, email, m.path),
206
+ canWrite: await accessControl.canWrite(defaultWs(), email, m.path),
169
207
  variables: (m.variables ?? []).map((v) => {
170
208
  const key = varKey(m.name, v.name);
171
209
  const st = statusByKey.get(key);
@@ -290,7 +328,7 @@ export function createSecretsVaultRoutes(deps: SecretsVaultRoutesDeps): express.
290
328
  if (found.variable.scope !== 'admin') {
291
329
  return void res.status(422).json({ error: 'This variable is set by each user, not the tool owner.' });
292
330
  }
293
- if (!(await accessControl.canWrite(defaultWs, email, found.manual.path))) {
331
+ if (!(await accessControl.canWrite(defaultWs(), email, found.manual.path))) {
294
332
  return void res.status(403).json({ error: 'You need write access to this tool to set its shared secrets.' });
295
333
  }
296
334
  const body = req.body ?? {};
@@ -343,7 +381,7 @@ export function createSecretsVaultRoutes(deps: SecretsVaultRoutesDeps): express.
343
381
  if (!found.variable.oauth) {
344
382
  return void res.status(422).json({ error: 'This variable is not an OAuth sign-in.' });
345
383
  }
346
- if (!(await accessControl.canWrite(defaultWs, email, found.manual.path))) {
384
+ if (!(await accessControl.canWrite(defaultWs(), email, found.manual.path))) {
347
385
  return void res.status(403).json({ error: 'You need write access to this tool to set its client secret.' });
348
386
  }
349
387
  const clientSecret = (req.body ?? {}).clientSecret;
@@ -369,7 +407,9 @@ export function createSecretsVaultRoutes(deps: SecretsVaultRoutesDeps): express.
369
407
 
370
408
  // Start sign-in for a tool's OAuth-backed variable. Provisions the caller's row
371
409
  // from the owner-set secret and returns the provider consent URL; the callback
372
- // bounces back to /connect (via `r` in the signed state).
410
+ // bounces back to wherever the flow started (via `r` in the signed state) — an
411
+ // optional `{ returnTo }` body when the caller asks for a specific page, else
412
+ // /connect, which is where every caller came from before `returnTo` existed.
373
413
  router.post('/secrets/tools/:slug/vars/:var/oauth/start', async (req, res) => {
374
414
  const userId = req.userId;
375
415
  const email = req.userEmail;
@@ -395,11 +435,17 @@ export function createSecretsVaultRoutes(deps: SecretsVaultRoutesDeps): express.
395
435
  // on the next sign-in. The secret/clientId/addresses stay owner-pinned.
396
436
  scopes: found.variable.oauth.scopes,
397
437
  });
438
+ // The return path is signed INTO the state (HMAC-SHA256) rather than kept
439
+ // in a query param or a cookie: the callback arrives un-authenticated from
440
+ // the provider, so the state is the only thing it can trust. An absent or
441
+ // unsafe `returnTo` falls back to the legacy `'connect'` marker, which
442
+ // keeps a body-less start byte-identical to what it produced before.
443
+ const returnTo: unknown = (req.body ?? {}).returnTo;
398
444
  const state = signState(deps.stateSecret, {
399
445
  u: userId,
400
446
  i: id,
401
447
  n: randomBytes(8).toString('hex'),
402
- r: 'connect',
448
+ r: isSafeReturnPath(returnTo) ? returnTo : 'connect',
403
449
  });
404
450
  const consentUrl = new URL(url);
405
451
  consentUrl.searchParams.set('state', state);
@@ -416,7 +462,7 @@ export function createSecretsVaultRoutes(deps: SecretsVaultRoutesDeps): express.
416
462
  try {
417
463
  const found = await findManualVar(email, req.params.slug, req.params.var);
418
464
  if (!found) return void res.status(404).json({ error: 'Tool or variable not found' });
419
- if (!(await accessControl.canWrite(defaultWs, email, found.manual.path))) {
465
+ if (!(await accessControl.canWrite(defaultWs(), email, found.manual.path))) {
420
466
  return void res.status(403).json({ error: 'You need write access to this tool to remove its shared secrets.' });
421
467
  }
422
468
  await secretsVault.removeShared(varKey(found.manual.name, found.variable.name));
@@ -456,16 +502,21 @@ export function createSecretsVaultPublicRoutes(deps: SecretsVaultRoutesDeps): ex
456
502
  const code = typeof req.query.code === 'string' ? req.query.code : '';
457
503
  const stateRaw = typeof req.query.state === 'string' ? req.query.state : '';
458
504
  // The landing page depends on where the flow started (the `r` field in the
459
- // signed state): a tool-var sign-in returns to /connect, the standalone
460
- // Secrets page to /secrets. Pre-verification errors can't know `r`, so they
461
- // fall back to /secrets.
505
+ // signed state): an explicit same-origin path returns there, the legacy
506
+ // `'connect'` marker to /connect, the standalone Secrets page to /secrets.
507
+ // Pre-verification errors can't know `r`, so they fall back to /secrets.
462
508
  const back = (frag: string, dest = '/secrets') =>
463
509
  res.redirect(`${deps.publicFrontendUrl}${dest}#${frag}`);
464
510
  if (!code || !stateRaw) return void back(`error=${encodeURIComponent('Invalid OAuth callback.')}`);
465
511
 
466
512
  const state = verifyState(deps.stateSecret, stateRaw);
467
513
  if (!state) return void back(`error=${encodeURIComponent('OAuth state mismatch.')}`);
468
- const dest = state.r === 'connect' ? '/connect' : '/secrets';
514
+ // Re-validated HERE even though `r` was validated on the way in and is
515
+ // HMAC-signed: this is the line that concatenates a stored string onto our
516
+ // public origin and hands it to the browser as a `Location`. Defense in
517
+ // depth — the check costs nothing, and it means an open redirect would need
518
+ // BOTH the signing key and a validation bug, not either one.
519
+ const dest = isSafeReturnPath(state.r) ? state.r : state.r === 'connect' ? '/connect' : '/secrets';
469
520
 
470
521
  try {
471
522
  await secretsVault.completeOAuth(state.u, state.i, code, redirectUriFor(deps.publicBackendUrl));
@@ -494,7 +545,12 @@ interface OAuthState {
494
545
  i: string; // secret id
495
546
  n: string; // nonce
496
547
  iat: number; // issued-at (epoch ms)
497
- r?: string; // return path hint ('connect' → land on /connect; else /secrets)
548
+ /**
549
+ * Return path hint: a same-origin path (`isSafeReturnPath`) → land there; the
550
+ * legacy marker `'connect'` → /connect; anything else (incl. absent) →
551
+ * /secrets. Signed, so the callback can trust it — but re-validated anyway.
552
+ */
553
+ r?: string;
498
554
  }
499
555
 
500
556
  const OAUTH_STATE_MAX_AGE_MS = 10 * 60_000;
@@ -0,0 +1,256 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { randomBytes } from 'node:crypto';
3
+ import {
4
+ DeploymentSettingsService,
5
+ SettingsValidationError,
6
+ CORE_SETTINGS,
7
+ } from '../deployment-settings.service.js';
8
+ import type { Database } from '../../database/connection.js';
9
+
10
+ const ENC_KEY = randomBytes(32).toString('base64');
11
+
12
+ /**
13
+ * An in-memory stand-in for the one table this service owns. Enough of the
14
+ * drizzle surface to exercise load/save round-trips without a live Postgres —
15
+ * the SQL itself is one insert with an upsert target, which the migration
16
+ * covers.
17
+ */
18
+ function makeDb() {
19
+ const rows: { key: string; value: string; encrypted: boolean }[] = [];
20
+ const db = {
21
+ select: () => ({ from: () => Promise.resolve(rows.map((r) => ({ ...r }))) }),
22
+ insert: () => ({
23
+ values: (v: { key: string; value: string; encrypted: boolean }) => ({
24
+ onConflictDoUpdate: () => {
25
+ const existing = rows.find((r) => r.key === v.key);
26
+ if (existing) Object.assign(existing, v);
27
+ else rows.push({ key: v.key, value: v.value, encrypted: v.encrypted });
28
+ return Promise.resolve();
29
+ },
30
+ }),
31
+ }),
32
+ delete: () => ({ where: () => Promise.resolve() }),
33
+ } as unknown as Database;
34
+ return { db, rows };
35
+ }
36
+
37
+ let saved: NodeJS.ProcessEnv;
38
+ beforeEach(() => {
39
+ saved = { ...process.env };
40
+ for (const def of CORE_SETTINGS) delete process.env[def.envVar];
41
+ delete process.env.GITHUB_TOKEN;
42
+ });
43
+ afterEach(() => {
44
+ process.env = saved;
45
+ });
46
+
47
+ describe('DeploymentSettingsService — precedence', () => {
48
+ /**
49
+ * The rule the whole design rests on: a stored row is a FALLBACK for a
50
+ * variable nobody set, never an override of one they did. It is what lets an
51
+ * existing deployment adopt this table with no behaviour change, and what
52
+ * stops a value typed into a browser outranking the infrastructure config
53
+ * someone is reviewing in a repo.
54
+ */
55
+ it('lets the environment win over a stored value', async () => {
56
+ const { db } = makeDb();
57
+ const settings = new DeploymentSettingsService(db, ENC_KEY);
58
+ await settings.save({ kbRepoUrl: 'https://example.com/stored.git' }, null);
59
+
60
+ process.env.KB_REPO_URL = 'https://example.com/from-env.git';
61
+ expect(settings.resolve('kbRepoUrl')).toBe('https://example.com/from-env.git');
62
+ expect(settings.sourceOf('kbRepoUrl')).toBe('env');
63
+
64
+ delete process.env.KB_REPO_URL;
65
+ expect(settings.resolve('kbRepoUrl')).toBe('https://example.com/stored.git');
66
+ expect(settings.sourceOf('kbRepoUrl')).toBe('stored');
67
+ });
68
+
69
+ /**
70
+ * Refused, not silently accepted. Storing it would write a row that can never
71
+ * take effect, and leave the screen implying it had.
72
+ */
73
+ it('refuses to store a setting the environment already supplies', async () => {
74
+ process.env.KB_REPO_URL = 'https://example.com/from-env.git';
75
+ const { db } = makeDb();
76
+ const settings = new DeploymentSettingsService(db, ENC_KEY);
77
+ await expect(
78
+ settings.save({ kbRepoUrl: 'https://example.com/other.git' }, null),
79
+ ).rejects.toBeInstanceOf(SettingsValidationError);
80
+ });
81
+ });
82
+
83
+ describe('DeploymentSettingsService — secrets', () => {
84
+ it('stores the token as ciphertext and reads it back', async () => {
85
+ const { db, rows } = makeDb();
86
+ const settings = new DeploymentSettingsService(db, ENC_KEY);
87
+ await settings.save({ gitToken: 'ghp_verysecret' }, null);
88
+
89
+ const row = rows.find((r) => r.key === 'gitToken');
90
+ expect(row?.encrypted).toBe(true);
91
+ expect(row?.value).not.toContain('ghp_verysecret');
92
+
93
+ // A fresh instance decrypts from the same rows — the round trip, not just
94
+ // the in-memory cache.
95
+ const reloaded = new DeploymentSettingsService(db, ENC_KEY);
96
+ await reloaded.load();
97
+ expect(reloaded.resolve('gitToken')).toBe('ghp_verysecret');
98
+ });
99
+
100
+ it('never puts a secret value in what the screen is sent', async () => {
101
+ const { db } = makeDb();
102
+ const settings = new DeploymentSettingsService(db, ENC_KEY);
103
+ await settings.save({ gitToken: 'ghp_verysecret' }, null);
104
+
105
+ const described = settings.describe();
106
+ expect(JSON.stringify(described)).not.toContain('ghp_verysecret');
107
+ const token = described.find((s) => s.key === 'gitToken');
108
+ // Configured is all a client learns — enough to render "replace it?".
109
+ expect(token).toMatchObject({ secret: true, configured: true });
110
+ expect(token).not.toHaveProperty('value');
111
+ });
112
+
113
+ it('publishes a stored token as GITHUB_TOKEN, which is what git reads', async () => {
114
+ const { db } = makeDb();
115
+ const settings = new DeploymentSettingsService(db, ENC_KEY);
116
+ await settings.save({ gitToken: 'ghp_fromsetup' }, null);
117
+ expect(process.env.GITHUB_TOKEN).toBe('ghp_fromsetup');
118
+ });
119
+
120
+ it('does not overwrite a git token the environment supplied', async () => {
121
+ process.env.GIT_TOKEN = 'ghp_fromenv';
122
+ process.env.GITHUB_TOKEN = 'ghp_fromenv';
123
+ const { db } = makeDb();
124
+ const settings = new DeploymentSettingsService(db, ENC_KEY);
125
+ settings.syncGitTokenEnv();
126
+ expect(process.env.GITHUB_TOKEN).toBe('ghp_fromenv');
127
+ });
128
+
129
+ it('refuses to store a secret with no encryption key rather than writing plaintext', async () => {
130
+ const { db } = makeDb();
131
+ const settings = new DeploymentSettingsService(db, '');
132
+ await expect(settings.save({ gitToken: 'ghp_verysecret' }, null)).rejects.toBeInstanceOf(
133
+ SettingsValidationError,
134
+ );
135
+ });
136
+
137
+ /**
138
+ * A rotated or mistyped key must not stop the server booting — the screen
139
+ * where it gets fixed is on the other side of that boot.
140
+ */
141
+ it('survives an undecryptable row instead of failing to start', async () => {
142
+ const { db, rows } = makeDb();
143
+ rows.push({ key: 'gitToken', value: 'not-a-valid-blob', encrypted: true });
144
+ const settings = new DeploymentSettingsService(db, ENC_KEY);
145
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
146
+ await expect(settings.load()).resolves.toBeUndefined();
147
+ expect(settings.resolve('gitToken')).toBe('');
148
+ expect(spy).toHaveBeenCalled();
149
+ spy.mockRestore();
150
+ });
151
+ });
152
+
153
+ describe('DeploymentSettingsService — validation', () => {
154
+ it('rejects the whole batch when one field is wrong', async () => {
155
+ const { db, rows } = makeDb();
156
+ const settings = new DeploymentSettingsService(db, ENC_KEY);
157
+ await expect(
158
+ settings.save({ kbRepoUrl: 'not-a-url', gitToken: 'ghp_ok' }, null),
159
+ ).rejects.toBeInstanceOf(SettingsValidationError);
160
+ // Nothing written: a URL saved without its token is a deployment that
161
+ // fails at the first clone.
162
+ expect(rows).toHaveLength(0);
163
+ });
164
+
165
+ it('rejects a non-https remote', async () => {
166
+ const { db } = makeDb();
167
+ const settings = new DeploymentSettingsService(db, ENC_KEY);
168
+ await expect(
169
+ settings.save({ kbRepoUrl: 'git@github.com:acme/kb.git' }, null),
170
+ ).rejects.toThrow(/Invalid settings/);
171
+ });
172
+
173
+ it('rejects a directory name that could escape the workspace', async () => {
174
+ const { db } = makeDb();
175
+ const settings = new DeploymentSettingsService(db, ENC_KEY);
176
+ await expect(settings.save({ kbDirName: '../elsewhere' }, null)).rejects.toThrow(
177
+ /Invalid settings/,
178
+ );
179
+ });
180
+
181
+ it('rejects a username that would break out of the credential-helper snippet', async () => {
182
+ const { db } = makeDb();
183
+ const settings = new DeploymentSettingsService(db, ENC_KEY);
184
+ await expect(settings.save({ gitUsername: 'x"; rm -rf /; #' }, null)).rejects.toThrow(
185
+ /Invalid settings/,
186
+ );
187
+ });
188
+
189
+ /**
190
+ * A blank field means "leave it alone". Treating it as a delete would let a
191
+ * stray Enter on a half-filled form unconfigure a running deployment.
192
+ */
193
+ it('treats a blank field as no change, not as an erase', async () => {
194
+ const { db } = makeDb();
195
+ const settings = new DeploymentSettingsService(db, ENC_KEY);
196
+ await settings.save({ gitToken: 'ghp_keepme' }, null);
197
+ await settings.save({ gitToken: ' ' }, null);
198
+ expect(settings.resolve('gitToken')).toBe('ghp_keepme');
199
+ });
200
+
201
+ /**
202
+ * The branch pair is the one rule no per-field check can express: the default
203
+ * has to appear in the protected list, so each name is only valid in the
204
+ * other's company. If it were not checked, the pair would be rejected far
205
+ * later — by `configureBranchModel` at the NEXT BOOT, which is a deployment
206
+ * that saved successfully and then would not start.
207
+ */
208
+ it('refuses a default branch that is not in the protected list', async () => {
209
+ const { db } = makeDb();
210
+ const settings = new DeploymentSettingsService(db, ENC_KEY);
211
+ await expect(
212
+ settings.save({ defaultBranch: 'main', protectedBranches: 'release' }, null),
213
+ ).rejects.toBeInstanceOf(SettingsValidationError);
214
+ });
215
+
216
+ it('accepts a pair that agrees', async () => {
217
+ const { db } = makeDb();
218
+ const settings = new DeploymentSettingsService(db, ENC_KEY);
219
+ await settings.save({ defaultBranch: 'main', protectedBranches: 'main, release' }, null);
220
+ expect(settings.resolve('defaultBranch')).toBe('main');
221
+ });
222
+
223
+ /**
224
+ * Judged on the model the save WOULD produce, not on the input: setting one
225
+ * half against an existing other half has to be checked against the result.
226
+ */
227
+ it('checks a half-save against what is already in effect', async () => {
228
+ const { db } = makeDb();
229
+ const settings = new DeploymentSettingsService(db, ENC_KEY);
230
+ await settings.save({ defaultBranch: 'main', protectedBranches: 'main' }, null);
231
+ // Narrowing the protected list to one that excludes the standing default.
232
+ await expect(settings.save({ protectedBranches: 'release' }, null)).rejects.toBeInstanceOf(
233
+ SettingsValidationError,
234
+ );
235
+ // …and the other way: a default that the standing list does cover.
236
+ await settings.save({ protectedBranches: 'main, release' }, null);
237
+ await expect(settings.save({ defaultBranch: 'release' }, null)).resolves.toBeTruthy();
238
+ });
239
+
240
+ it('seals the SSO client secret like the git token', async () => {
241
+ const { db, rows } = makeDb();
242
+ const settings = new DeploymentSettingsService(db, ENC_KEY);
243
+ await settings.save({ oidcClientSecret: 'sso-very-secret' }, null);
244
+ expect(rows.find((r) => r.key === 'oidcClientSecret')?.encrypted).toBe(true);
245
+ expect(JSON.stringify(settings.describe())).not.toContain('sso-very-secret');
246
+ });
247
+
248
+ it('reports a restart only for a setting a running server cannot pick up', async () => {
249
+ const { db } = makeDb();
250
+ const settings = new DeploymentSettingsService(db, ENC_KEY);
251
+ const live = await settings.save({ kbRepoUrl: 'https://example.com/kb.git' }, null);
252
+ expect(live.restartRequired).toBe(false);
253
+ const staged = await settings.save({ kbDirName: 'company-brain' }, null);
254
+ expect(staged.restartRequired).toBe(true);
255
+ });
256
+ });
@@ -0,0 +1,201 @@
1
+ import type { Server as HttpServer } from 'node:http';
2
+ import type { AddressInfo } from 'node:net';
3
+ import express from 'express';
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
+ import { createSetupRoutes } from '../setup.routes.js';
6
+ import { DeploymentSettingsService } from '../deployment-settings.service.js';
7
+ import type { Database } from '../../database/connection.js';
8
+ import type { IAdminAccessService } from '../../admin/admin.interface.js';
9
+
10
+ const ENC_KEY = 'kToAi8FXWDpDn3A6yQ/60O39bv05N7XzVOIu/0CJrFc=';
11
+
12
+ let server: HttpServer | null = null;
13
+
14
+ /**
15
+ * These suites clear the KB variables so `settings.resolve` falls through to
16
+ * the stored layer. They are RESTORED afterwards rather than left deleted: the
17
+ * repo `.env` is loaded into `process.env` by the vitest config, so a suite
18
+ * that runs later in the same worker would otherwise see a different
19
+ * environment than the one it was written against.
20
+ */
21
+ const KB_ENV = ['KB_REPO_URL', 'GIT_TOKEN', 'GIT_USERNAME', 'KB_DIR_NAME'] as const;
22
+ let savedEnv: Partial<Record<(typeof KB_ENV)[number], string | undefined>> = {};
23
+
24
+ beforeEach(() => {
25
+ savedEnv = {};
26
+ for (const k of KB_ENV) {
27
+ savedEnv[k] = process.env[k];
28
+ delete process.env[k];
29
+ }
30
+ });
31
+
32
+ afterEach(() => {
33
+ server?.close();
34
+ server = null;
35
+ for (const k of KB_ENV) {
36
+ const original = savedEnv[k];
37
+ if (original === undefined) delete process.env[k];
38
+ else process.env[k] = original;
39
+ }
40
+ });
41
+
42
+ /** Mount the setup router on a throwaway port and hand back its base URL. */
43
+ function listen(isAdmin = true) {
44
+ const db = {
45
+ select: () => ({ from: () => Promise.resolve([]) }),
46
+ insert: () => ({ values: () => ({ onConflictDoUpdate: () => Promise.resolve() }) }),
47
+ delete: () => ({ where: () => Promise.resolve() }),
48
+ } as unknown as Database;
49
+ const settings = new DeploymentSettingsService(db, ENC_KEY);
50
+ const app = express();
51
+ app.use(express.json());
52
+ app.use((req, _res, next) => {
53
+ req.userEmail = 'root@example.com';
54
+ req.userId = 'user-1';
55
+ next();
56
+ });
57
+ app.use('/api', createSetupRoutes(settings, { isAdmin: async () => isAdmin } as IAdminAccessService));
58
+ server = app.listen(0);
59
+ const { port } = server.address() as AddressInfo;
60
+ return { base: `http://127.0.0.1:${port}`, settings };
61
+ }
62
+
63
+ const post = (base: string, path: string, body: unknown) =>
64
+ fetch(`${base}${path}`, {
65
+ method: 'POST',
66
+ headers: { 'content-type': 'application/json' },
67
+ body: JSON.stringify(body),
68
+ });
69
+
70
+ /**
71
+ * The connection test hands its input to `git`. Anything reaching that command
72
+ * unvalidated is argument injection — admin-only, but "admin" is not "may run
73
+ * arbitrary commands as the server process".
74
+ */
75
+ describe('POST /setup/test-connection — what may reach git', () => {
76
+ it('refuses a value git would read as an option', async () => {
77
+ const { base } = listen();
78
+ const res = await post(base, '/api/setup/test-connection', {
79
+ kbRepoUrl: '--upload-pack=touch /tmp/pwned',
80
+ });
81
+ expect(res.status).toBe(400);
82
+ expect((await res.json()).ok).toBe(false);
83
+ });
84
+
85
+ /** `ext::` is a git transport whose whole purpose is to run a command. */
86
+ it('refuses the ext:: transport', async () => {
87
+ const { base } = listen();
88
+ const res = await post(base, '/api/setup/test-connection', {
89
+ kbRepoUrl: 'ext::sh -c "touch /tmp/pwned"',
90
+ });
91
+ expect(res.status).toBe(400);
92
+ });
93
+
94
+ it('refuses a non-https scheme', async () => {
95
+ const { base } = listen();
96
+ const res = await post(base, '/api/setup/test-connection', { kbRepoUrl: 'file:///etc/passwd' });
97
+ expect(res.status).toBe(400);
98
+ });
99
+
100
+ it('refuses a username that would break out of the credential-helper snippet', async () => {
101
+ const { base } = listen();
102
+ const res = await post(base, '/api/setup/test-connection', {
103
+ kbRepoUrl: 'https://example.com/acme/kb.git',
104
+ gitUsername: 'x"; touch /tmp/pwned; #',
105
+ });
106
+ expect(res.status).toBe(400);
107
+ });
108
+
109
+ it('is admins-only', async () => {
110
+ const { base } = listen(false);
111
+ const res = await post(base, '/api/setup/test-connection', {
112
+ kbRepoUrl: 'https://example.com/acme/kb.git',
113
+ });
114
+ expect(res.status).toBe(403);
115
+ });
116
+ });
117
+
118
+ /**
119
+ * The stored token is deliberately unreadable — `describe()` omits it — so an
120
+ * endpoint that would send it to a caller-named host hands it straight back.
121
+ */
122
+ describe('POST /setup/test-connection — where the saved token may go', () => {
123
+ it('refuses to test a different repository with the saved token', async () => {
124
+ const { base, settings } = listen();
125
+ await settings.save(
126
+ { kbRepoUrl: 'https://example.com/acme/kb.git', gitToken: 'ghp_verysecret' },
127
+ null,
128
+ );
129
+ const res = await post(base, '/api/setup/test-connection', {
130
+ kbRepoUrl: 'https://attacker.example/collector.git',
131
+ });
132
+ expect(res.status).toBe(400);
133
+ expect((await res.json()).error).toMatch(/access token for that repository/i);
134
+ });
135
+
136
+ it('still lets an admin re-test the repository that IS configured', async () => {
137
+ const { base, settings } = listen();
138
+ await settings.save(
139
+ { kbRepoUrl: 'https://127.0.0.1:1/acme/kb.git', gitToken: 'ghp_verysecret' },
140
+ null,
141
+ );
142
+ // No credentials in the body: the saved pair is used together, which is the
143
+ // "does the token I saved last week still work?" case.
144
+ const res = await post(base, '/api/setup/test-connection', {});
145
+ // Port 1 refuses instantly — reaching a connection error proves the request
146
+ // got past the guards and ran git, rather than being rejected up front.
147
+ expect(res.status).toBe(200);
148
+ expect((await res.json()).ok).toBe(false);
149
+ });
150
+
151
+ it('accepts a different repository when the request brings its own token', async () => {
152
+ const { base, settings } = listen();
153
+ await settings.save(
154
+ { kbRepoUrl: 'https://example.com/acme/kb.git', gitToken: 'ghp_verysecret' },
155
+ null,
156
+ );
157
+ const res = await post(base, '/api/setup/test-connection', {
158
+ kbRepoUrl: 'https://127.0.0.1:1/other/kb.git',
159
+ gitToken: 'ghp_its_own',
160
+ });
161
+ expect(res.status).toBe(200);
162
+ });
163
+ });
164
+
165
+ /**
166
+ * The arguments the route builds have to be ones git actually accepts —
167
+ * `--end-of-options` among them. A rejected FLAG and a refused CONNECTION look
168
+ * alike from the outside (`ok: false`), so this aims at a port nothing listens
169
+ * on: reaching a connection error proves git parsed the command line.
170
+ */
171
+ describe('POST /setup/test-connection — the command git is given', () => {
172
+ it('builds a command git accepts', async () => {
173
+ const { base } = listen();
174
+ const res = await post(base, '/api/setup/test-connection', {
175
+ kbRepoUrl: 'https://127.0.0.1:1/acme/kb.git',
176
+ gitToken: 'ghp_whatever',
177
+ });
178
+ expect(res.status).toBe(200);
179
+ const body = await res.json();
180
+ expect(body.ok).toBe(false);
181
+ expect(body.error).not.toMatch(/unknown option|usage: git/i);
182
+ });
183
+ });
184
+
185
+ describe('GET /setup/status', () => {
186
+ it('tells a non-admin whether setup is done, and nothing else', async () => {
187
+ const { base } = listen(false);
188
+ const res = await fetch(`${base}/api/setup/status`);
189
+ const body = await res.json();
190
+ expect(body).toEqual({ complete: false, isAdmin: false });
191
+ // No catalogue, so no variable names and no hint at what is missing.
192
+ expect(JSON.stringify(body)).not.toContain('KB_REPO_URL');
193
+ });
194
+
195
+ it('never includes a stored secret, even for an admin', async () => {
196
+ const { base, settings } = listen();
197
+ await settings.save({ gitToken: 'ghp_verysecret' }, null);
198
+ const res = await fetch(`${base}/api/setup/status`);
199
+ expect(JSON.stringify(await res.json())).not.toContain('ghp_verysecret');
200
+ });
201
+ });