@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,114 @@
1
+ import {
2
+ KNOWN_VERBS,
3
+ parseAccessFile,
4
+ type ParsedEntry,
5
+ type Verb,
6
+ } from '../access/access-control.service.js';
7
+
8
+ /**
9
+ * What a join branch is PROPOSING, relative to the default branch.
10
+ *
11
+ * A join request is a change request whose branch edits one group's
12
+ * `access.md`. Rather than treating that change request as an all-or-nothing
13
+ * merge, the group's managers see its individual proposals — "grant Ali read",
14
+ * "grant GTM Team write" — and answer them one at a time. Approving a proposal
15
+ * writes THAT ONE grant onto the default branch through the ordinary access
16
+ * mutation path; the branch is never merged, so nothing else it happens to
17
+ * contain can ride along, and a change request naming five people can be
18
+ * answered with two yeses and three ignores.
19
+ *
20
+ * A proposal is any (principal, verb) GRANT the branch's copy of the file
21
+ * carries that the default branch's copy does not. Deliberately:
22
+ *
23
+ * - grants only. A `deny` entry is not something to "accept", and a branch
24
+ * that REMOVES an existing grant proposes nothing — it is a revocation,
25
+ * which is not what this surface is for. Both are simply invisible here,
26
+ * and both leave the branch as a normal change request in the review UI.
27
+ * - every verb, not just `read`. A branch asking for `write` must be visible
28
+ * AS a write request rather than hiding behind "asked to join"; and the
29
+ * subset test that retires a request has to cover everything the file can
30
+ * express, or a request proposing `write` would never settle.
31
+ *
32
+ * When the list comes back EMPTY the branch adds nothing the default branch
33
+ * does not already grant — its proposals have all been accepted (or were
34
+ * never anything) — and the change request has no reason to stay open.
35
+ */
36
+ export interface JoinProposal {
37
+ verb: Verb;
38
+ /** Canonical identity — lowercased email, or canonical role name. */
39
+ id: string;
40
+ principal:
41
+ | { kind: 'user'; email: string; displayName: string }
42
+ | { kind: 'role'; role: string };
43
+ /** How to name this principal in the UI. */
44
+ label: string;
45
+ }
46
+
47
+ /** Canonical identity of an entry, for set comparison across the two files. */
48
+ function identityOf(entry: ParsedEntry): string {
49
+ return entry.kind === 'user' ? `user:${entry.email}` : `role:${entry.role}`;
50
+ }
51
+
52
+ /**
53
+ * Folder rules from one copy of an `access.md`, per verb, grants only.
54
+ *
55
+ * A file that fails to parse yields NO grants. On the branch side that means
56
+ * a malformed proposal offers nothing to accept (fail-closed). On the default
57
+ * side it means every branch grant looks incoming — which is the safe
58
+ * direction too: the manager is shown proposals to consider rather than
59
+ * having them silently swallowed by an unreadable baseline.
60
+ */
61
+ function grantsByVerb(text: string | null, path: string): Map<Verb, Map<string, ParsedEntry>> {
62
+ const out = new Map<Verb, Map<string, ParsedEntry>>();
63
+ for (const verb of KNOWN_VERBS) out.set(verb, new Map());
64
+ if (text === null) return out;
65
+ const parsed = parseAccessFile(text, path);
66
+ if (!parsed.ok) return out;
67
+ for (const verb of KNOWN_VERBS) {
68
+ const byId = out.get(verb)!;
69
+ for (const entry of parsed.file.entries[verb]) {
70
+ if (entry.deny) continue;
71
+ byId.set(identityOf(entry), entry);
72
+ }
73
+ }
74
+ return out;
75
+ }
76
+
77
+ /**
78
+ * The grants `branchText` adds over `defaultText` — the proposals a manager
79
+ * can accept. Empty ⇒ the branch's rules are a subset of the default's.
80
+ *
81
+ * `path` is only used to label parse errors; both texts are the same file at
82
+ * two refs.
83
+ */
84
+ export function pendingProposals(
85
+ branchText: string | null,
86
+ defaultText: string | null,
87
+ path: string,
88
+ ): JoinProposal[] {
89
+ const branch = grantsByVerb(branchText, path);
90
+ const base = grantsByVerb(defaultText, path);
91
+ const out: JoinProposal[] = [];
92
+ for (const verb of KNOWN_VERBS) {
93
+ const baseIds = base.get(verb)!;
94
+ for (const [id, entry] of branch.get(verb)!) {
95
+ if (baseIds.has(id)) continue;
96
+ out.push(
97
+ entry.kind === 'user'
98
+ ? {
99
+ verb,
100
+ id,
101
+ principal: { kind: 'user', email: entry.email, displayName: entry.displayName },
102
+ label: entry.displayName,
103
+ }
104
+ : {
105
+ verb,
106
+ id,
107
+ principal: { kind: 'role', role: entry.displayRole },
108
+ label: entry.displayRole,
109
+ },
110
+ );
111
+ }
112
+ }
113
+ return out;
114
+ }
@@ -0,0 +1,185 @@
1
+ import {
2
+ DEFAULT_BRANCH,
3
+ isJoinBranchFor,
4
+ type AuthUser,
5
+ type ChangeRequest,
6
+ type IWorkflowService,
7
+ } from '@bevel-software/platform-shared';
8
+ import { workspaceIdForBranch, type WorkspaceService } from '../workspace/workspace.service.js';
9
+ import { pendingProposals, type JoinProposal } from './join-proposals.js';
10
+
11
+ /** One open join change request, with what it still proposes. */
12
+ export interface JoinRequest {
13
+ number: number;
14
+ branch: string;
15
+ requesterName: string;
16
+ createdAt: string;
17
+ /** Grants the branch adds over the default branch. Never empty (see below). */
18
+ proposals: JoinProposal[];
19
+ }
20
+
21
+ /**
22
+ * Join requests as PROPOSALS, and the reconciliation that retires them.
23
+ *
24
+ * The lifecycle has no state of its own — it is derived, every time, from two
25
+ * copies of one file:
26
+ *
27
+ * open the branch's `access.md` grants something the default's does not
28
+ * settled it does not
29
+ *
30
+ * so "approve" is not an operation on the request at all. A manager grants a
31
+ * principal through the ordinary access path (the same lock + commit every
32
+ * other access edit uses, gated the same way), the default branch gains that
33
+ * entry, and the proposal stops being pending because the diff no longer
34
+ * contains it. When the last one goes, the change request has nothing left to
35
+ * say and is rejected, its branch deleted.
36
+ *
37
+ * That reconciliation runs LAZILY on every listing as well as on demand, so a
38
+ * grant made anywhere — this banner, the Manage-access dialog, a hand edit —
39
+ * settles the request that asked for it. Nothing has to remember to.
40
+ */
41
+ export class JoinRequestsService {
42
+ constructor(
43
+ private readonly workspaceService: WorkspaceService,
44
+ private readonly workflow: IWorkflowService,
45
+ ) {}
46
+
47
+ /**
48
+ * Open join requests for `group`, each carrying only its still-pending
49
+ * proposals. Requests with none left are reconciled away (rejected, branch
50
+ * deleted) and omitted.
51
+ *
52
+ * `actor` is who the reconciliation acts as — a manager of the group, since
53
+ * rejecting a change request requires write on everything it touches.
54
+ */
55
+ async list(
56
+ group: string,
57
+ folder: string,
58
+ crs: ChangeRequest[],
59
+ actor: AuthUser,
60
+ ): Promise<JoinRequest[]> {
61
+ await this.refresh();
62
+ const accessPath = `${folder}/access.md`;
63
+ const baseText = await this.readAt(DEFAULT_BRANCH, accessPath);
64
+ const out: JoinRequest[] = [];
65
+ for (const cr of crs) {
66
+ if (cr.state !== 'open' || !isJoinBranchFor(cr.branch, group)) continue;
67
+ const proposals = pendingProposals(
68
+ await this.readAt(cr.branch, accessPath),
69
+ baseText,
70
+ accessPath,
71
+ );
72
+ if (proposals.length === 0) {
73
+ await this.settle(cr, actor);
74
+ continue;
75
+ }
76
+ out.push({
77
+ number: cr.number,
78
+ branch: cr.branch,
79
+ requesterName: cr.appAuthor?.name ?? 'Someone',
80
+ createdAt: cr.createdAt,
81
+ proposals,
82
+ });
83
+ }
84
+ return out.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
85
+ }
86
+
87
+ /**
88
+ * Re-check ONE request and settle it if nothing is pending. Returns whether
89
+ * it was settled. Called right after a grant so the banner updates in the
90
+ * same round-trip instead of waiting for the next listing.
91
+ */
92
+ async reconcile(
93
+ group: string,
94
+ folder: string,
95
+ cr: ChangeRequest,
96
+ actor: AuthUser,
97
+ ): Promise<boolean> {
98
+ if (cr.state !== 'open' || !isJoinBranchFor(cr.branch, group)) return false;
99
+ await this.refresh();
100
+ const accessPath = `${folder}/access.md`;
101
+ const proposals = pendingProposals(
102
+ await this.readAt(cr.branch, accessPath),
103
+ await this.readAt(DEFAULT_BRANCH, accessPath),
104
+ accessPath,
105
+ );
106
+ if (proposals.length > 0) return false;
107
+ await this.settle(cr, actor);
108
+ return true;
109
+ }
110
+
111
+ /**
112
+ * Close a request whose proposals have all landed, and delete its branch.
113
+ *
114
+ * Rejecting rather than merging is the point: the grants are already on the
115
+ * default branch, so merging would replay a diff that is now empty of
116
+ * meaning while dragging along anything else the branch carries. The branch
117
+ * goes too — it exists only to hold a proposal that no longer exists.
118
+ *
119
+ * Best-effort by contract: this runs inside a listing, and a request that
120
+ * cannot be closed right now must not take the listing down with it. The
121
+ * next pass tries again.
122
+ */
123
+ private async settle(cr: ChangeRequest, actor: AuthUser): Promise<void> {
124
+ const wsId = workspaceIdForBranch(DEFAULT_BRANCH);
125
+ try {
126
+ await this.workflow.rejectChangeRequest(
127
+ cr.number,
128
+ actor,
129
+ cr.state,
130
+ cr.authorId ?? null,
131
+ cr.base,
132
+ wsId,
133
+ );
134
+ } catch (err) {
135
+ console.warn(
136
+ `[groups] could not close settled join request #${cr.number}: ${
137
+ err instanceof Error ? err.message : String(err)
138
+ }`,
139
+ );
140
+ return;
141
+ }
142
+ try {
143
+ await this.workflow.deleteBranch(wsId, cr.branch, actor);
144
+ } catch (err) {
145
+ // The request is closed either way; a leftover branch is cosmetic and
146
+ // the author can still delete it themselves.
147
+ console.warn(
148
+ `[groups] closed join request #${cr.number} but could not delete "${cr.branch}": ${
149
+ err instanceof Error ? err.message : String(err)
150
+ }`,
151
+ );
152
+ }
153
+ }
154
+
155
+ /**
156
+ * The file at one branch, or null when it is absent/unreadable there.
157
+ *
158
+ * Every read runs against the DEFAULT branch's clone at `origin/<branch>`,
159
+ * so one workspace answers for every request and a join branch never needs
160
+ * a clone of its own. `readFileAtRef` takes a REPO-relative path (the KB
161
+ * dir is the repo root there).
162
+ */
163
+ private async readAt(branch: string, repoRelPath: string): Promise<string | null> {
164
+ try {
165
+ return await this.workspaceService.readFileAtRef(
166
+ workspaceIdForBranch(DEFAULT_BRANCH),
167
+ `origin/${branch}`,
168
+ repoRelPath,
169
+ );
170
+ } catch {
171
+ return null;
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Refresh remote-tracking refs before reading them. Throttled inside, and
177
+ * best-effort: a request pushed seconds ago is worth one fetch, but a fetch
178
+ * failure must degrade to "read what we have" rather than empty the list.
179
+ */
180
+ private async refresh(): Promise<void> {
181
+ await this.workspaceService
182
+ .ensureRemotesFetched(workspaceIdForBranch(DEFAULT_BRANCH))
183
+ .catch(() => undefined);
184
+ }
185
+ }
@@ -0,0 +1,283 @@
1
+ import type { Server as HttpServer } from 'node:http';
2
+ import { createHmac } from 'node:crypto';
3
+ import express from 'express';
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+ import {
6
+ createSecretsVaultRoutes,
7
+ createSecretsVaultPublicRoutes,
8
+ isSafeReturnPath,
9
+ } from '../secrets-vault.routes.js';
10
+
11
+ /**
12
+ * OAUTH `returnTo`: a sign-in started from a page other than /connect must land
13
+ * back on THAT page. The return path rides in the HMAC-signed `state` (field
14
+ * `r`) because the provider's callback arrives un-authenticated — the state is
15
+ * the only thing the callback can trust.
16
+ *
17
+ * The whole risk here is an open redirect: the callback concatenates `r` onto
18
+ * our own public origin and hands it to the browser as a `Location`. So the
19
+ * validation is pinned twice — as a unit table on `isSafeReturnPath`, and
20
+ * end-to-end on both the start route (what gets signed) and the callback (what
21
+ * gets redirected to, re-validated).
22
+ *
23
+ * Backward compatibility is the second thing under test: a start with NO body
24
+ * must produce exactly the state it produced before `returnTo` existed
25
+ * (`r: 'connect'`), and a legacy `'connect'` state must still land on /connect.
26
+ */
27
+
28
+ const STATE_SECRET = 'test-secret';
29
+ const FRONTEND = 'http://localhost:5173';
30
+ const TOOL_PATH = 'Tools/weather.tool';
31
+ const USER = 'user@x.com';
32
+ const RETURN_TO = '/skills-and-tools/tools/weather';
33
+
34
+ const toolManualService = {
35
+ listAccessible: async () => [
36
+ {
37
+ slug: 'weather',
38
+ name: 'weather',
39
+ path: TOOL_PATH,
40
+ type: 'mcp' as const,
41
+ variables: [
42
+ {
43
+ name: 'SIGNIN',
44
+ scope: 'user' as const,
45
+ label: 'Weather sign-in',
46
+ oauth: {
47
+ authorizationUrl: 'https://auth.example.com/authorize',
48
+ tokenUrl: 'https://auth.example.com/token',
49
+ clientId: 'client-1',
50
+ },
51
+ },
52
+ ],
53
+ },
54
+ ],
55
+ } as unknown as Parameters<typeof createSecretsVaultRoutes>[0]['toolManualService'];
56
+
57
+ const accessControl = {
58
+ canRead: async () => true,
59
+ canWrite: async () => false, // a plain reader may authorize their OWN account
60
+ } as unknown as Parameters<typeof createSecretsVaultRoutes>[0]['accessControl'];
61
+
62
+ const completeOAuth = vi.fn(async () => {});
63
+ const secretsVault = {
64
+ beginToolOAuthByKey: async () => ({ id: 'secret-1', url: 'https://auth.example.com/authorize?client_id=client-1' }),
65
+ completeOAuth,
66
+ } as unknown as Parameters<typeof createSecretsVaultRoutes>[0]['secretsVault'];
67
+
68
+ const deps = {
69
+ secretsVault,
70
+ toolManualService,
71
+ accessControl,
72
+ stateSecret: STATE_SECRET,
73
+ publicBackendUrl: 'http://localhost:3000',
74
+ publicFrontendUrl: FRONTEND,
75
+ };
76
+
77
+ let httpServer: HttpServer | undefined;
78
+
79
+ /** One server carrying BOTH routers, so a start's state can be replayed at the callback. */
80
+ async function baseUrl(): Promise<string> {
81
+ const app = express();
82
+ app.use(express.json());
83
+ app.use('/api', createSecretsVaultPublicRoutes(deps)); // un-authed callback, mounted first
84
+ app.use((req, _res, next) => {
85
+ req.userId = `id-${USER}`;
86
+ req.userEmail = USER;
87
+ next();
88
+ });
89
+ app.use('/api', createSecretsVaultRoutes(deps));
90
+ httpServer = await new Promise<HttpServer>((resolve) => {
91
+ const s = app.listen(0, () => resolve(s));
92
+ });
93
+ const port = (httpServer.address() as { port: number }).port;
94
+ return `http://127.0.0.1:${port}`;
95
+ }
96
+
97
+ /** POST the start route with (or without) a body; return the signed `state` it put on the consent URL. */
98
+ async function startAndReadState(base: string, body?: unknown): Promise<Record<string, unknown>> {
99
+ const res = await fetch(`${base}/api/secrets/tools/weather/vars/SIGNIN/oauth/start`, {
100
+ method: 'POST',
101
+ ...(body === undefined
102
+ ? {}
103
+ : { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }),
104
+ });
105
+ expect(res.status).toBe(200);
106
+ const { url } = (await res.json()) as { url: string };
107
+ const state = new URL(url).searchParams.get('state')!;
108
+ expect(state).toBeTruthy();
109
+ return decodeState(state);
110
+ }
111
+
112
+ /** Decode a signed state's payload (the signature is verified by the callback tests). */
113
+ function decodeState(token: string): Record<string, unknown> {
114
+ const [body] = token.split('.');
115
+ return JSON.parse(Buffer.from(body, 'base64url').toString()) as Record<string, unknown>;
116
+ }
117
+
118
+ /** Sign a state the way the routes do — used to drive the callback directly. */
119
+ function signState(payload: Record<string, unknown>): string {
120
+ const body = Buffer.from(JSON.stringify({ iat: Date.now(), ...payload })).toString('base64url');
121
+ const sig = createHmac('sha256', STATE_SECRET).update(body).digest('base64url');
122
+ return `${body}.${sig}`;
123
+ }
124
+
125
+ /** Follow no redirects — the Location header IS the assertion. */
126
+ async function callback(base: string, state: string): Promise<string> {
127
+ const res = await fetch(`${base}/api/secrets/oauth/callback?code=abc&state=${encodeURIComponent(state)}`, {
128
+ redirect: 'manual',
129
+ });
130
+ expect(res.status).toBe(302);
131
+ return res.headers.get('location')!;
132
+ }
133
+
134
+ afterEach(async () => {
135
+ if (httpServer) await new Promise<void>((r) => httpServer!.close(() => r()));
136
+ httpServer = undefined;
137
+ completeOAuth.mockClear();
138
+ completeOAuth.mockImplementation(async () => {});
139
+ vi.restoreAllMocks();
140
+ });
141
+
142
+ describe('isSafeReturnPath', () => {
143
+ it.each([
144
+ ['/secrets', true],
145
+ ['/skills-and-tools/tools/weather', true],
146
+ ['/', true],
147
+ ['/a?b=c&d=e', true], // a query is fine — it stays on our origin
148
+ ['/' + 'a'.repeat(511), true], // exactly 512 chars
149
+ ])('accepts %j', (value, expected) => {
150
+ expect(isSafeReturnPath(value)).toBe(expected);
151
+ });
152
+
153
+ it.each([
154
+ ['//evil.com', 'protocol-relative — leaves our origin'],
155
+ ['https://evil.com', 'absolute URL'],
156
+ ['http://evil.com', 'absolute URL'],
157
+ ['/\\evil.com', 'backslash folds to a slash in WHATWG http(s)'],
158
+ ['\\\\evil.com', 'backslash, no leading slash'],
159
+ ['/ok#fragment', 'would swallow the #authorized fragment'],
160
+ ['/ok\r\nSet-Cookie: a=b', 'response splitting'],
161
+ ['/ok\nX: y', 'response splitting'],
162
+ ['secrets', 'relative — no leading slash'],
163
+ ['', 'empty'],
164
+ ['/' + 'a'.repeat(512), '513 chars — over the state budget'],
165
+ ])('rejects %j (%s)', (value) => {
166
+ expect(isSafeReturnPath(value)).toBe(false);
167
+ });
168
+
169
+ it.each([[undefined], [null], [42], [{}], [['/ok']], [true]])('rejects the non-string %j', (value) => {
170
+ expect(isSafeReturnPath(value)).toBe(false);
171
+ });
172
+ });
173
+
174
+ describe('POST …/oauth/start — what gets signed into the state', () => {
175
+ it('signs a valid returnTo verbatim as `r`', async () => {
176
+ const state = await startAndReadState(await baseUrl(), { returnTo: RETURN_TO });
177
+ expect(state.r).toBe(RETURN_TO);
178
+ expect(state.u).toBe(`id-${USER}`);
179
+ expect(state.i).toBe('secret-1');
180
+ });
181
+
182
+ it('falls back to the legacy `connect` marker with NO body — byte-identical to the old behavior', async () => {
183
+ const state = await startAndReadState(await baseUrl());
184
+ expect(state.r).toBe('connect');
185
+ });
186
+
187
+ it('falls back to `connect` for an empty body and an absent returnTo', async () => {
188
+ const base = await baseUrl();
189
+ expect((await startAndReadState(base, {})).r).toBe('connect');
190
+ expect((await startAndReadState(base, { returnTo: undefined })).r).toBe('connect');
191
+ });
192
+
193
+ it.each([
194
+ ['//evil.com'],
195
+ ['https://evil.com'],
196
+ ['/ok#fragment'],
197
+ ['/\\evil.com'],
198
+ ['/' + 'a'.repeat(512)],
199
+ ['/ok\r\nSet-Cookie: a=b'],
200
+ ['not-a-path'],
201
+ [42],
202
+ [{ nested: true }],
203
+ ])('refuses %j and falls back to `connect`', async (returnTo) => {
204
+ const state = await startAndReadState(await baseUrl(), { returnTo });
205
+ expect(state.r).toBe('connect');
206
+ });
207
+ });
208
+
209
+ describe('GET /api/secrets/oauth/callback — where the browser lands', () => {
210
+ it('returns to a signed same-origin path', async () => {
211
+ const base = await baseUrl();
212
+ const state = signState({ u: 'u1', i: 'secret-1', n: 'nonce', r: RETURN_TO });
213
+ expect(await callback(base, state)).toBe(`${FRONTEND}${RETURN_TO}#authorized=secret-1`);
214
+ });
215
+
216
+ it('still lands a legacy `connect` state on /connect', async () => {
217
+ const base = await baseUrl();
218
+ const state = signState({ u: 'u1', i: 'secret-1', n: 'nonce', r: 'connect' });
219
+ expect(await callback(base, state)).toBe(`${FRONTEND}/connect#authorized=secret-1`);
220
+ });
221
+
222
+ it('lands a state with no `r` on /secrets (the standalone Secrets flow)', async () => {
223
+ const base = await baseUrl();
224
+ const state = signState({ u: 'u1', i: 'secret-1', n: 'nonce' });
225
+ expect(await callback(base, state)).toBe(`${FRONTEND}/secrets#authorized=secret-1`);
226
+ });
227
+
228
+ it.each([['//evil.com'], ['https://evil.com'], ['/ok#frag'], ['/\\evil.com'], ['/ok\r\nX: y']])(
229
+ 'refuses an unsafe `r` (%j) at CALLBACK time and falls back to /secrets',
230
+ async (unsafe) => {
231
+ // Defense in depth: this `r` is correctly signed — it could only exist if
232
+ // the signing key leaked or the start-side validation regressed. The
233
+ // callback re-checks anyway, because it is the line that builds the
234
+ // Location header.
235
+ const base = await baseUrl();
236
+ const state = signState({ u: 'u1', i: 'secret-1', n: 'nonce', r: unsafe });
237
+ expect(await callback(base, state)).toBe(`${FRONTEND}/secrets#authorized=secret-1`);
238
+ },
239
+ );
240
+
241
+ it('puts the error fragment on the RESOLVED destination when the exchange fails', async () => {
242
+ vi.spyOn(console, 'error').mockImplementation(() => {});
243
+ completeOAuth.mockImplementation(async () => {
244
+ throw new Error('token endpoint said no');
245
+ });
246
+ const base = await baseUrl();
247
+ const state = signState({ u: 'u1', i: 'secret-1', n: 'nonce', r: RETURN_TO });
248
+ const location = await callback(base, state);
249
+ expect(location.startsWith(`${FRONTEND}${RETURN_TO}#error=`)).toBe(true);
250
+ // The user-facing message is generic — provider detail stays in the log.
251
+ expect(decodeURIComponent(location.split('#error=')[1])).toBe(
252
+ 'Authorization failed. Check the provider configuration and try again.',
253
+ );
254
+ });
255
+
256
+ it('lands PRE-verification errors on /secrets, unchanged', async () => {
257
+ const base = await baseUrl();
258
+ // A tampered state can't be trusted for `r`, so its destination is the
259
+ // fallback — this is the behavior `returnTo` must not have altered.
260
+ const res = await fetch(`${base}/api/secrets/oauth/callback?code=abc&state=garbage.sig`, {
261
+ redirect: 'manual',
262
+ });
263
+ expect(res.headers.get('location')).toBe(
264
+ `${FRONTEND}/secrets#error=${encodeURIComponent('OAuth state mismatch.')}`,
265
+ );
266
+ const noCode = await fetch(`${base}/api/secrets/oauth/callback`, { redirect: 'manual' });
267
+ expect(noCode.headers.get('location')).toBe(
268
+ `${FRONTEND}/secrets#error=${encodeURIComponent('Invalid OAuth callback.')}`,
269
+ );
270
+ });
271
+
272
+ it('round-trips a returnTo end to end: start signs it, callback honors it', async () => {
273
+ const base = await baseUrl();
274
+ const res = await fetch(`${base}/api/secrets/tools/weather/vars/SIGNIN/oauth/start`, {
275
+ method: 'POST',
276
+ headers: { 'Content-Type': 'application/json' },
277
+ body: JSON.stringify({ returnTo: RETURN_TO }),
278
+ });
279
+ const { url } = (await res.json()) as { url: string };
280
+ const state = new URL(url).searchParams.get('state')!;
281
+ expect(await callback(base, state)).toBe(`${FRONTEND}${RETURN_TO}#authorized=secret-1`);
282
+ });
283
+ });