@bevel-software/platform-core-backend 0.4.0 → 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 (305) 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 +157 -54
  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 +97 -5
  159. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  160. package/dist/modules/workflow/workflow.service.js +393 -114
  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__/pending-commits.enqueue-if-absent.test.ts +82 -72
  258. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +295 -5
  259. package/src/modules/workflow/agent-tools/workflow.tools.ts +4 -2
  260. package/src/modules/workflow/event-bus.ts +25 -0
  261. package/src/modules/workflow/file-lock.service.ts +17 -0
  262. package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +9 -49
  263. package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +101 -0
  264. package/src/modules/workflow/git/__tests__/git.service.commitFile.test.ts +219 -211
  265. package/src/modules/workflow/git/__tests__/git.service.fileContentsAtCommit.test.ts +132 -0
  266. package/src/modules/workflow/git/__tests__/git.service.logForFile.test.ts +4 -2
  267. package/src/modules/workflow/git/__tests__/git.service.postMergePull.test.ts +275 -272
  268. package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +311 -306
  269. package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-cancel.test.ts +44 -0
  270. package/src/modules/workflow/git/branch-name.ts +5 -1
  271. package/src/modules/workflow/git/git.service.ts +172 -64
  272. package/src/modules/workflow/git/pull-request.service.ts +22 -11
  273. package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +4 -1
  274. package/src/modules/workflow/review-workflow/review-workflow.service.ts +12 -3
  275. package/src/modules/workflow/workflow.routes.ts +91 -13
  276. package/src/modules/workflow/workflow.service.ts +365 -19
  277. package/src/modules/workspace/__tests__/kb-seed.service.test.ts +255 -24
  278. package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +236 -233
  279. package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +179 -176
  280. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +320 -303
  281. package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +337 -276
  282. package/src/modules/workspace/__tests__/workspace.tools.test.ts +14 -6
  283. package/src/modules/workspace/bevel-ignore.ts +66 -62
  284. package/src/modules/workspace/kb-seed.interface.ts +1 -1
  285. package/src/modules/workspace/kb-seed.service.ts +247 -53
  286. package/src/modules/workspace/workspace.routes.ts +54 -18
  287. package/src/modules/workspace/workspace.service.ts +45 -7
  288. package/src/modules/workspace/workspace.tools.ts +922 -917
  289. package/src/shared/__tests__/frontmatter-id.test.ts +53 -0
  290. package/src/shared/__tests__/join-request.test.ts +96 -0
  291. package/src/shared/__tests__/kb-layout.group.test.ts +45 -0
  292. package/src/shared/kb-layout.test.ts +2 -2
  293. package/src/test-setup.ts +18 -0
  294. package/kb-template/Agents/README.md +0 -38
  295. package/kb-template/CLAUDE.md +0 -419
  296. package/kb-template/Data/.gitkeep +0 -0
  297. package/kb-template/Data/README.md +0 -39
  298. package/kb-template/KnowledgeBase/README.md +0 -37
  299. package/kb-template/Pipelines/.gitkeep +0 -0
  300. package/kb-template/Pipelines/README.md +0 -38
  301. package/kb-template/Skills/.gitkeep +0 -0
  302. package/kb-template/Skills/README.md +0 -32
  303. package/kb-template/Tools/.gitkeep +0 -0
  304. package/kb-template/Tools/README.md +0 -42
  305. /package/kb-template/{Agents → Groups}/.gitkeep +0 -0
@@ -0,0 +1,175 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import {
3
+ DEFAULT_BRANCH,
4
+ joinBranchFor,
5
+ type AuthUser,
6
+ type ChangeRequest,
7
+ type IWorkflowService,
8
+ } from '@bevel-software/platform-shared';
9
+ import {
10
+ workspaceIdForBranch,
11
+ type WorkspaceService,
12
+ } from '../../workspace/workspace.service.js';
13
+ import { JoinRequestsService } from '../join-requests.service.js';
14
+
15
+ /**
16
+ * The derived lifecycle: a join request is open exactly while its branch's
17
+ * `access.md` grants something the default branch's does not, and settling it
18
+ * is what happens when that stops being true — nothing stores a status.
19
+ */
20
+ const ALI = 'ali@bevel.software';
21
+ const FOLDER = 'Groups/GTM';
22
+ const ACCESS_MD = `${FOLDER}/access.md`;
23
+ const ACTOR: AuthUser = { id: 'u-1', email: 'olga@bevel.software', name: 'Olga Ivanova' };
24
+
25
+ const base = (body: string) => `---\nread:\n - everyone\n---\n${body}`;
26
+ const DEFAULT_MD = base('read:\n - GTM Team\n');
27
+ const PROPOSING_MD = base('read:\n - GTM Team\n - Ali Baba <ali@bevel.software>\n');
28
+
29
+ function cr(over: Partial<ChangeRequest> = {}): ChangeRequest {
30
+ return {
31
+ number: 7,
32
+ title: 'Join request: GTM',
33
+ author: { login: 'user-abc' },
34
+ authorId: 'hash-ali',
35
+ appAuthor: { name: 'Ali Baba' },
36
+ branch: joinBranchFor(ALI, 'GTM'),
37
+ base: DEFAULT_BRANCH,
38
+ state: 'open',
39
+ createdAt: '2026-01-01T00:00:00.000Z',
40
+ touchedNodePaths: [ACCESS_MD],
41
+ review: { approvals: 0, changesRequested: 0, pendingLogins: [] },
42
+ url: '/change-requests/7',
43
+ ...over,
44
+ } as ChangeRequest;
45
+ }
46
+
47
+ /** `byRef[ref]` is the access.md content at that ref (undefined ⇒ absent). */
48
+ function makeHarness(byRef: Record<string, string>) {
49
+ const workspaceService = {
50
+ ensureRemotesFetched: vi.fn(async () => undefined),
51
+ readFileAtRef: vi.fn(async (_ws: string, ref: string, path: string) =>
52
+ path === ACCESS_MD ? (byRef[ref] ?? null) : null,
53
+ ),
54
+ } as unknown as WorkspaceService;
55
+ const workflow = {
56
+ rejectChangeRequest: vi.fn(async () => ({ number: 7, state: 'closed' })),
57
+ deleteBranch: vi.fn(async () => undefined),
58
+ } as unknown as IWorkflowService;
59
+ return { svc: new JoinRequestsService(workspaceService, workflow), workflow, workspaceService };
60
+ }
61
+
62
+ const refs = (branchMd: string | undefined, defaultMd = DEFAULT_MD) => ({
63
+ [`origin/${DEFAULT_BRANCH}`]: defaultMd,
64
+ ...(branchMd === undefined ? {} : { [`origin/${joinBranchFor(ALI, 'GTM')}`]: branchMd }),
65
+ });
66
+
67
+ describe('JoinRequestsService.list', () => {
68
+ it('reports a request with its pending proposals, and leaves it open', async () => {
69
+ const h = makeHarness(refs(PROPOSING_MD));
70
+ const out = await h.svc.list('GTM', FOLDER, [cr()], ACTOR);
71
+ expect(out).toHaveLength(1);
72
+ expect(out[0]).toMatchObject({ number: 7, requesterName: 'Ali Baba' });
73
+ expect(out[0].proposals.map((p) => p.label)).toEqual(['Ali Baba']);
74
+ expect(h.workflow.rejectChangeRequest).not.toHaveBeenCalled();
75
+ });
76
+
77
+ it('SETTLES a request whose proposals have all landed: closes it, deletes the branch, omits it', async () => {
78
+ // The grant is on the default branch now, so the branch adds nothing.
79
+ const h = makeHarness(refs(PROPOSING_MD, PROPOSING_MD));
80
+ const out = await h.svc.list('GTM', FOLDER, [cr()], ACTOR);
81
+ expect(out).toEqual([]);
82
+ expect(h.workflow.rejectChangeRequest).toHaveBeenCalledWith(
83
+ 7,
84
+ ACTOR,
85
+ 'open',
86
+ 'hash-ali',
87
+ DEFAULT_BRANCH,
88
+ // The WORKSPACE id, which merely happens to equal the branch name for
89
+ // slash-less branches — assert the encoding so a slashed default
90
+ // branch could never silently break this.
91
+ workspaceIdForBranch(DEFAULT_BRANCH),
92
+ );
93
+ expect(h.workflow.deleteBranch).toHaveBeenCalledWith(
94
+ workspaceIdForBranch(DEFAULT_BRANCH),
95
+ joinBranchFor(ALI, 'GTM'),
96
+ ACTOR,
97
+ );
98
+ });
99
+
100
+ it('ignores change requests that are closed or not on a join branch', async () => {
101
+ const h = makeHarness(refs(PROPOSING_MD));
102
+ const out = await h.svc.list(
103
+ 'GTM',
104
+ FOLDER,
105
+ [cr({ state: 'closed' }), cr({ number: 9, branch: 'ali/some-draft' })],
106
+ ACTOR,
107
+ );
108
+ expect(out).toEqual([]);
109
+ expect(h.workflow.rejectChangeRequest).not.toHaveBeenCalled();
110
+ });
111
+
112
+ it('keeps listing when closing one request fails', async () => {
113
+ const h = makeHarness(refs(PROPOSING_MD, PROPOSING_MD));
114
+ (h.workflow.rejectChangeRequest as ReturnType<typeof vi.fn>).mockRejectedValue(
115
+ new Error('gh down'),
116
+ );
117
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
118
+ await expect(h.svc.list('GTM', FOLDER, [cr()], ACTOR)).resolves.toEqual([]);
119
+ expect(warn).toHaveBeenCalled();
120
+ warn.mockRestore();
121
+ });
122
+
123
+ it('still closes the request when the branch delete fails, and says so', async () => {
124
+ const h = makeHarness(refs(PROPOSING_MD, PROPOSING_MD));
125
+ (h.workflow.deleteBranch as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('protected'));
126
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
127
+ // The request still settles (omitted from the list) — the leftover branch
128
+ // is cosmetic, and the failure is logged rather than swallowed.
129
+ await expect(h.svc.list('GTM', FOLDER, [cr()], ACTOR)).resolves.toEqual([]);
130
+ expect(h.workflow.rejectChangeRequest).toHaveBeenCalled();
131
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('could not delete'));
132
+ warn.mockRestore();
133
+ });
134
+
135
+ it('NEVER settles a slug-colliding other group\'s request (branch match is exact)', async () => {
136
+ // `Finance!` slugs to `finance` too. Its branch's copy of
137
+ // `Groups/finance/access.md` is unchanged, so if the branch matched, the
138
+ // empty diff would settle — closing a change request and deleting a
139
+ // branch that belong to a DIFFERENT group. The group tag in the branch
140
+ // name is what forbids the match.
141
+ const other = cr({ branch: joinBranchFor(ALI, 'Finance!') });
142
+ const h = makeHarness({
143
+ [`origin/${DEFAULT_BRANCH}`]: DEFAULT_MD,
144
+ [`origin/${joinBranchFor(ALI, 'Finance!')}`]: DEFAULT_MD,
145
+ });
146
+ await expect(h.svc.list('finance', 'Groups/finance', [other], ACTOR)).resolves.toEqual([]);
147
+ expect(h.workflow.rejectChangeRequest).not.toHaveBeenCalled();
148
+ expect(h.workflow.deleteBranch).not.toHaveBeenCalled();
149
+ await expect(h.svc.reconcile('finance', 'Groups/finance', other, ACTOR)).resolves.toBe(false);
150
+ expect(h.workflow.rejectChangeRequest).not.toHaveBeenCalled();
151
+ });
152
+ });
153
+
154
+ describe('JoinRequestsService.reconcile', () => {
155
+ it('settles and reports true once nothing is pending', async () => {
156
+ const h = makeHarness(refs(PROPOSING_MD, PROPOSING_MD));
157
+ await expect(h.svc.reconcile('GTM', FOLDER, cr(), ACTOR)).resolves.toBe(true);
158
+ expect(h.workflow.rejectChangeRequest).toHaveBeenCalled();
159
+ expect(h.workflow.deleteBranch).toHaveBeenCalled();
160
+ });
161
+
162
+ it('leaves a request alone while proposals remain', async () => {
163
+ const h = makeHarness(refs(PROPOSING_MD));
164
+ await expect(h.svc.reconcile('GTM', FOLDER, cr(), ACTOR)).resolves.toBe(false);
165
+ expect(h.workflow.rejectChangeRequest).not.toHaveBeenCalled();
166
+ });
167
+
168
+ it('refuses a change request that is not this group\'s join branch', async () => {
169
+ const h = makeHarness(refs(PROPOSING_MD, PROPOSING_MD));
170
+ await expect(
171
+ h.svc.reconcile('GTM', FOLDER, cr({ branch: 'ali/some-draft' }), ACTOR),
172
+ ).resolves.toBe(false);
173
+ expect(h.workflow.rejectChangeRequest).not.toHaveBeenCalled();
174
+ });
175
+ });
@@ -0,0 +1,328 @@
1
+ /**
2
+ * Group provisioning — the ONE privileged door for bringing a `Groups/<name>/`
3
+ * folder into existence, and (via {@link GroupProvisionService.deleteGroup})
4
+ * for taking one back out of it.
5
+ *
6
+ * Creating a group is the single operation the access model cannot govern
7
+ * from inside: the folder that will carry the rules does not exist yet, and
8
+ * the root's own rule (`write: Admin`) says no. The old answer was a
9
+ * carve-out inside the generic write gate — every write path could claim an
10
+ * unused name under `Groups/`, and the gate had to re-derive "is this that
11
+ * one blessed case?" on every check. This service replaces that: the generic
12
+ * gate is uniformly strict again, and the privilege lives here, named,
13
+ * behind its own endpoint.
14
+ *
15
+ * What a provision IS: one exclusive write of the new folder's `access.md`
16
+ * (flag `wx`, so two concurrent creators race for the fs, not for the
17
+ * overwrite), then one SYNCHRONOUS commit+push. Synchronous on purpose — the
18
+ * write gate reads rules at HEAD, so a creation whose access.md were still
19
+ * sitting in the async commit queue would 403 the very next thing its
20
+ * creator does (writing the first skill into it).
21
+ *
22
+ * Two shapes, two templates:
23
+ *
24
+ * - A NAMED group: discoverable by design. The access.md's own frontmatter
25
+ * reads `everyone` — anyone may open the FILE, see the group listed, and
26
+ * ask to join — while the BODY (the folder's actual rules) names only
27
+ * the creator under read, write and owner.
28
+ * - A PERSONAL folder (`personal-<user-id>`): private by design. No
29
+ * frontmatter grant at all, so nobody else can even see it exists; the
30
+ * body names its owner. Created lazily (ensure semantics) on the first
31
+ * personal skill.
32
+ */
33
+
34
+ import fs from 'node:fs/promises';
35
+ import path from 'node:path';
36
+
37
+ import {
38
+ DEFAULT_BRANCH,
39
+ GROUPS_DIR,
40
+ PERSONAL_GROUP_PREFIX,
41
+ isPersonalGroupFolder,
42
+ personalGroupFolderName,
43
+ type AuthUser,
44
+ } from '@bevel-software/platform-shared';
45
+ import type { WorkspaceService } from '../workspace/workspace.service.js';
46
+ import type { IAccessControl } from '../access/access-control.interface.js';
47
+ import { creatorPrincipal } from '../access/creator-access.js';
48
+ import { spliceGrant } from '../access/access-splice.js';
49
+ import { WorkspaceMutex } from '../workflow/git/mutex.js';
50
+
51
+ /** Commit machinery the provision rides — the pending-commit pipeline, run inline. */
52
+ export interface ProvisionCommitDriver {
53
+ runPendingCommit(
54
+ workspaceId: string,
55
+ branch: string,
56
+ targetPath: string,
57
+ user: AuthUser,
58
+ opts?: { systemAuthorized?: boolean },
59
+ ): Promise<void>;
60
+ }
61
+
62
+ export interface ProvisionedGroup {
63
+ /** The folder name under `Groups/` (not the full path). */
64
+ folder: string;
65
+ /** False when an ensure found the folder already there. */
66
+ created: boolean;
67
+ }
68
+
69
+ /** A refusal the route can pass through: message + HTTP status. */
70
+ export class GroupProvisionError extends Error {
71
+ constructor(
72
+ message: string,
73
+ readonly status: number,
74
+ ) {
75
+ super(message);
76
+ this.name = 'GroupProvisionError';
77
+ }
78
+ }
79
+
80
+ export class GroupProvisionService {
81
+ /**
82
+ * Serialises creations by NORMALIZED (lowercased) folder name. The wx write
83
+ * arbitrates same-path races, but on a case-sensitive filesystem `GTM` and
84
+ * `gtm` are different paths — without this lock two concurrent requests
85
+ * could both pass the collision check and both land, breaking the
86
+ * case-insensitive uniqueness the check promises.
87
+ */
88
+ private readonly creations = new WorkspaceMutex();
89
+
90
+ constructor(
91
+ private readonly workspaceService: WorkspaceService,
92
+ private readonly commits: ProvisionCommitDriver,
93
+ private readonly accessControl: IAccessControl,
94
+ private readonly kbDirName: string,
95
+ private readonly events?: { emit(event: { kind: 'fs-tree-changed'; workspaceId: string; branch: string }): void },
96
+ ) {}
97
+
98
+ /**
99
+ * Create `Groups/<name>/` for `user`. Throws `GroupProvisionError` 422 on a
100
+ * name the filesystem or the model cannot carry, 409 when the name is taken
101
+ * (case-insensitively — the workspaces live on case-insensitive
102
+ * filesystems too, where `GTM` and `gtm` are one folder).
103
+ */
104
+ async createGroup(user: AuthUser, rawName: string): Promise<ProvisionedGroup> {
105
+ const name = rawName.trim();
106
+ if (!name) throw new GroupProvisionError('A group needs a name.', 422);
107
+ // eslint-disable-next-line no-control-regex -- NUL and control chars are
108
+ // exactly what a filesystem path cannot carry; refusing them here keeps
109
+ // the refusal a 422 instead of the fs layer's 500.
110
+ if (/[/\\\u0000-\u001f\u007f]/.test(name) || name === '.' || name === '..' || name.startsWith('.')) {
111
+ throw new GroupProvisionError(
112
+ 'A group name can\'t contain / or \\ or control characters, or start with a dot.',
113
+ 422,
114
+ );
115
+ }
116
+ if (name.toLowerCase().startsWith(PERSONAL_GROUP_PREFIX)) {
117
+ // Reserved: the personal-folder namespace. A group squatting there
118
+ // would collide with somebody's future personal folder.
119
+ throw new GroupProvisionError(
120
+ `Group names starting with "${PERSONAL_GROUP_PREFIX}" are reserved.`,
121
+ 422,
122
+ );
123
+ }
124
+ return this.creations.run(`group:${name.toLowerCase()}`, async () => {
125
+ const existing = await this.existingFolder(name);
126
+ if (existing !== null) {
127
+ throw new GroupProvisionError(`A group named "${existing}" already exists.`, 409);
128
+ }
129
+ await this.provision(user, name, groupAccessMd(user));
130
+ return { folder: name, created: true };
131
+ });
132
+ }
133
+
134
+ /**
135
+ * Ensure the caller's personal folder exists — idempotent, keyed to the
136
+ * stable user id. Returns `created: false` when it is already there.
137
+ */
138
+ async ensurePersonalGroup(user: AuthUser): Promise<ProvisionedGroup> {
139
+ const folder = personalGroupFolderName(user.id);
140
+ return this.creations.run(`group:${folder.toLowerCase()}`, async () => {
141
+ if ((await this.existingFolder(folder)) !== null) {
142
+ return { folder, created: false };
143
+ }
144
+ try {
145
+ await this.provision(user, folder, personalAccessMd(user));
146
+ } catch (err) {
147
+ // ENSURE semantics even under a race the lock cannot see (another
148
+ // process, a checkout that appeared between check and write): the
149
+ // folder existing is this method's success case, never its error.
150
+ if (err instanceof GroupProvisionError && err.status === 409) {
151
+ return { folder, created: false };
152
+ }
153
+ throw err;
154
+ }
155
+ return { folder, created: true };
156
+ });
157
+ }
158
+
159
+ /**
160
+ * Delete `Groups/<name>/` — the whole folder, its skills and tools
161
+ * included, in ONE commit. MECHANISM only: the route owns the
162
+ * authorization (the caller must hold the `owner` verb on the folder;
163
+ * this service never re-derives it), exactly as `createGroup` leaves
164
+ * "any signed-in user" to its endpoint.
165
+ *
166
+ * Shape mirrors a provision run in reverse, with the same failure
167
+ * contract: the folder is PARKED (renamed to a dot-prefixed sibling the
168
+ * scanners ignore) rather than removed, the deletion is committed
169
+ * synchronously, and only a landed commit lets the parked bytes go. A
170
+ * refused commit renames the folder back, so a failed delete leaves the
171
+ * group exactly as it was — never half-gone on disk while origin still
172
+ * carries it.
173
+ *
174
+ * Serialised on the same per-name lock creations use, so a delete can
175
+ * never interleave with a re-creation of the same name.
176
+ */
177
+ async deleteGroup(user: AuthUser, rawName: string): Promise<void> {
178
+ const name = rawName.trim();
179
+ if (!name) throw new GroupProvisionError('A group needs a name.', 422);
180
+ if (isPersonalGroupFolder(name)) {
181
+ // Personal folders are not groups (the catalog never lists them), and
182
+ // nobody deletes somebody's private shelf through the group door.
183
+ throw new GroupProvisionError('Unknown group', 404);
184
+ }
185
+ return this.creations.run(`group:${name.toLowerCase()}`, async () => {
186
+ const existing = await this.existingFolder(name);
187
+ // Exact match only — the catalog hands the route the on-disk casing,
188
+ // so a mismatch means the group is gone (or was never there).
189
+ if (existing !== name) throw new GroupProvisionError('Unknown group', 404);
190
+
191
+ const wsId = await this.readyWorkspaceId();
192
+ const wsDir = await this.workspaceService.getWorkspacePath(wsId);
193
+ const groupsDir = path.join(wsDir, this.kbDirName, GROUPS_DIR);
194
+ const folderDir = path.join(groupsDir, name);
195
+ // Dot-prefixed ⇒ invisible to the group scanner and the collision
196
+ // check for the whole window the commit is in flight.
197
+ const parkedDir = path.join(groupsDir, `.${name}.deleting`);
198
+
199
+ await fs.rm(parkedDir, { recursive: true, force: true }); // a stale park from a crashed run
200
+ await fs.rename(folderDir, parkedDir);
201
+ try {
202
+ // Inline and `systemAuthorized`, for `provision`'s reasons in
203
+ // reverse: the gate reads rules at HEAD, and the endpoint has
204
+ // already authorized the delete (owner verdict), so the per-user
205
+ // push gate — which would re-read an access.md this very commit
206
+ // removes — is skipped for exactly this commit. `commitFile` is
207
+ // path-scoped (`git add -- <path>`), and a folder path stages every
208
+ // deletion under it: one commit, one removed group.
209
+ await this.commits.runPendingCommit(
210
+ wsId,
211
+ DEFAULT_BRANCH,
212
+ `${this.kbDirName}/${GROUPS_DIR}/${name}`,
213
+ user,
214
+ { systemAuthorized: true },
215
+ );
216
+ } catch (err) {
217
+ // The commit did not land: put the folder back, so a failed delete
218
+ // is a no-op rather than a group that exists at origin but not here.
219
+ try {
220
+ await fs.rename(parkedDir, folderDir);
221
+ } catch {
222
+ /* the park survives for the next attempt — better than masking the real error */
223
+ }
224
+ throw err;
225
+ }
226
+ await fs.rm(parkedDir, { recursive: true, force: true }).catch(() => {});
227
+ // The folder's rules left the access model — drop the resolver cache
228
+ // so the very next check runs against a tree without them.
229
+ this.accessControl.invalidate(wsId);
230
+ this.events?.emit({ kind: 'fs-tree-changed', workspaceId: wsId, branch: DEFAULT_BRANCH });
231
+ });
232
+ }
233
+
234
+ /** The taken name (in its on-disk casing) colliding with `name`, or null. */
235
+ private async existingFolder(name: string): Promise<string | null> {
236
+ const wsId = await this.readyWorkspaceId();
237
+ const wsDir = await this.workspaceService.getWorkspacePath(wsId);
238
+ let children: string[];
239
+ try {
240
+ children = await fs.readdir(path.join(wsDir, this.kbDirName, GROUPS_DIR));
241
+ } catch {
242
+ return null; // no Groups/ root yet — nothing can collide
243
+ }
244
+ const lower = name.toLowerCase();
245
+ return children.find((c) => c.toLowerCase() === lower) ?? null;
246
+ }
247
+
248
+ private async provision(user: AuthUser, folder: string, accessMd: string): Promise<void> {
249
+ const wsId = await this.readyWorkspaceId();
250
+ const wsRelPath = `${this.kbDirName}/${GROUPS_DIR}/${folder}/access.md`;
251
+ try {
252
+ // Exclusive create — the fs is the arbiter of a same-name race, not
253
+ // the (stale-able) existence check above.
254
+ await this.workspaceService.writeFile(wsId, wsRelPath, accessMd, { failIfExists: true });
255
+ } catch (err) {
256
+ if ((err as { status?: number }).status === 409) {
257
+ throw new GroupProvisionError(`A group named "${folder}" already exists.`, 409);
258
+ }
259
+ throw err;
260
+ }
261
+ try {
262
+ // Inline, not enqueued: the gate reads rules at HEAD, so the folder is
263
+ // only real once this commit lands. `runPendingCommit` is the same
264
+ // commit+push (with pull-rebase recovery) the queue worker runs.
265
+ // `systemAuthorized`: the push gate reads access at origin, where this
266
+ // folder does not exist yet and the root says `write: Admin` — the very
267
+ // rule this endpoint exists to carve through. The endpoint has already
268
+ // authorized the write (any signed-in user, unused name, exclusive
269
+ // create), so the per-user gate is skipped for exactly this commit.
270
+ await this.commits.runPendingCommit(wsId, DEFAULT_BRANCH, wsRelPath, user, {
271
+ systemAuthorized: true,
272
+ });
273
+ } catch (err) {
274
+ // The commit did not land: roll the seeded file back off the disk,
275
+ // best-effort, so a retry doesn't find a half-made group and report
276
+ // "already exists" for something that never got committed. Only OUR
277
+ // file and — when that leaves it empty — the folder; never recursive,
278
+ // so a concurrent writer's bytes can't be collateral.
279
+ try {
280
+ const wsDir = await this.workspaceService.getWorkspacePath(wsId);
281
+ const folderDir = path.join(wsDir, this.kbDirName, GROUPS_DIR, folder);
282
+ await fs.rm(path.join(folderDir, 'access.md'), { force: true });
283
+ await fs.rmdir(folderDir).catch(() => {});
284
+ } catch {
285
+ /* leave it for the next attempt's wx conflict — better than masking the real error */
286
+ }
287
+ throw err;
288
+ }
289
+ // The folder's rules changed the access model — drop the resolver cache
290
+ // so the very next check (the creator's first skill write) sees them.
291
+ this.accessControl.invalidate(wsId);
292
+ this.events?.emit({ kind: 'fs-tree-changed', workspaceId: wsId, branch: DEFAULT_BRANCH });
293
+ }
294
+
295
+ private async readyWorkspaceId(): Promise<string> {
296
+ const ws = await this.workspaceService.getOrCreateForBranch(DEFAULT_BRANCH);
297
+ return ws.id;
298
+ }
299
+ }
300
+
301
+ /**
302
+ * A named group's access.md: discoverable file (frontmatter `read: everyone`
303
+ * — anyone may see the group listed and ask to join), creator-run folder
304
+ * (body read/write/owner name the creator). The `read: []` placeholder makes
305
+ * the body parse as rules from the first byte, so every later splice targets
306
+ * the body rather than the frontmatter.
307
+ */
308
+ export function groupAccessMd(creator: { name: string; email: string }): string {
309
+ return withCreatorGrants('---\nread:\n - everyone\n---\nread: []\n', creator);
310
+ }
311
+
312
+ /**
313
+ * A personal folder's access.md: PRIVATE — no frontmatter grant, so the
314
+ * folder is invisible to everyone but its owner; the body names the owner
315
+ * under read, write and owner.
316
+ */
317
+ export function personalAccessMd(creator: { name: string; email: string }): string {
318
+ return withCreatorGrants('read: []\n', creator);
319
+ }
320
+
321
+ function withCreatorGrants(base: string, creator: { name: string; email: string }): string {
322
+ const principal = creatorPrincipal(creator);
323
+ let out = base;
324
+ for (const verb of ['read', 'write', 'owner'] as const) {
325
+ out = spliceGrant(out, verb, principal, { allowScalar: false, target: 'folder' }).text;
326
+ }
327
+ return out;
328
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Groups — the folders under `Groups/` that carry a team's skills and the
3
+ * tools those skills need. A group is a FOLDER, never a role: one access
4
+ * boundary, one place, derived from the path exactly the way `groupOfPath`
5
+ * derives it.
6
+ *
7
+ * A group EXISTS exactly when its folder carries an `access.md` — the file
8
+ * the provisioning endpoint seeds. A bare directory under `Groups/` is not a
9
+ * group (git cannot record an empty folder, so deleted groups leave ghost
10
+ * directories on live checkouts).
11
+ *
12
+ * Enumeration then follows the KB's read model with NO special cases: an
13
+ * existing group appears for a caller exactly when the access resolution says
14
+ * something about it —
15
+ *
16
+ * - MEMBER: the caller can read the folder itself (`canRead`).
17
+ * - MANAGER: the caller can write the folder's `access.md` (`canWrite`,
18
+ * admin-rescued).
19
+ * - DISCOVERABLE: the caller can read the `access.md` FILE — which, in the
20
+ * new body-governed format, is granted by the file's own
21
+ * `read: everyone` frontmatter (see `accessMdSelfEntries`). The group
22
+ * shows locked, and the caller may ask to join.
23
+ *
24
+ * A group where all three verdicts are false is ABSENT from the response —
25
+ * name, counts and principals never leave the backend. Making a group secret
26
+ * is therefore an ordinary access edit: drop the `everyone` grant from its
27
+ * access.md frontmatter.
28
+ *
29
+ * Requesting access is a CHANGE REQUEST, not a bespoke system: the join
30
+ * route commits "add me to the body's `read:` list" on a draft branch and
31
+ * opens a CR that the folder's writers approve by merging (or by granting
32
+ * via Manage access, which makes the CR moot). To agents these are plain
33
+ * change requests; only the UI dresses them up.
34
+ */
35
+
36
+ /** Access-rule principals as the resolver hands them back. */
37
+ export interface ResolvedPrincipals {
38
+ roles: string[];
39
+ users: { name: string; email: string }[];
40
+ }
41
+
42
+ export interface ResolvedReaders extends ResolvedPrincipals {
43
+ restricted: boolean;
44
+ }
45
+
46
+ /** One group as `GET /api/groups` reports it, resolved for ONE caller. */
47
+ export interface GroupSummary {
48
+ /** Group folder name, e.g. `GTM`. */
49
+ name: string;
50
+ /** Repo-relative constituent folders, e.g. `['Groups/GTM']`. */
51
+ folders: string[];
52
+ /**
53
+ * Per-caller: the caller can read the FOLDER (membership). Every returned
54
+ * group has at least one of `canRead` / `canWrite` / discoverability; a
55
+ * group with none is not returned at all.
56
+ */
57
+ canRead: boolean;
58
+ /**
59
+ * Per-caller; true ⇒ may manage access. Resolved as write on the folder's
60
+ * `access.md`, which admin-rescues — so a platform Admin locked OUT of
61
+ * reading still sees the group and gets the self-service way back in.
62
+ */
63
+ canWrite: boolean;
64
+ /**
65
+ * Per-caller: the caller holds the `owner` verb on the FOLDER — resolved
66
+ * from the `owner:` lists alone, no admin rescue. Deleting the group is the
67
+ * owner's verb (the DELETE route enforces the same verdict); managers who
68
+ * merely write the access.md do not get it.
69
+ */
70
+ isOwner: boolean;
71
+ /** Caller-INDEPENDENT total (the group's whole content, not the caller's slice). */
72
+ skillCount: number;
73
+ toolCount: number;
74
+ /** For display: "Run by …" (fallback chain: owners → writers → 'the workspace admins'). */
75
+ owners: ResolvedPrincipals;
76
+ writers: ResolvedPrincipals;
77
+ readers: ResolvedReaders;
78
+ /**
79
+ * The caller has an OPEN join change request for this group (their
80
+ * deterministic join branch has an open CR). Always false for a member.
81
+ */
82
+ hasRequested: boolean;
83
+ /** The open join CR's number when `hasRequested` (deep-links the UI). */
84
+ requestNumber: number | null;
85
+ }
86
+
87
+ /**
88
+ * The caller-INDEPENDENT slice of a group — what `catalog()` computes once and
89
+ * caches. The per-caller verdicts (`canRead`/`canWrite`, and whether the group
90
+ * appears at all) are resolved per request in the route.
91
+ */
92
+ export interface GroupCatalogEntry {
93
+ name: string;
94
+ folders: string[];
95
+ skillCount: number;
96
+ toolCount: number;
97
+ owners: ResolvedPrincipals;
98
+ writers: ResolvedPrincipals;
99
+ readers: ResolvedReaders;
100
+ }
101
+
102
+ export interface IGroupIndexService {
103
+ /** Caller-independent catalog part (folders, counts, principals) — cached 60s. */
104
+ catalog(): Promise<GroupCatalogEntry[]>;
105
+ /** Drop the cached catalog (call after a default-branch change under a group root). */
106
+ invalidate(): void;
107
+ }