@bevel-software/platform-core-backend 0.4.1 → 0.5.1

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/THIRD-PARTY-NOTICES.md +6118 -0
  2. package/dist/core/core-ports.d.ts +15 -0
  3. package/dist/core/core-ports.d.ts.map +1 -1
  4. package/dist/core/core-ports.js.map +1 -1
  5. package/dist/core/create-core-server.d.ts.map +1 -1
  6. package/dist/core/create-core-server.js +40 -6
  7. package/dist/core/create-core-server.js.map +1 -1
  8. package/dist/core/create-core-services.d.ts +17 -1
  9. package/dist/core/create-core-services.d.ts.map +1 -1
  10. package/dist/core/create-core-services.js +153 -29
  11. package/dist/core/create-core-services.js.map +1 -1
  12. package/dist/core-config.d.ts +70 -21
  13. package/dist/core-config.d.ts.map +1 -1
  14. package/dist/core-config.js +134 -58
  15. package/dist/core-config.js.map +1 -1
  16. package/dist/modules/access/access-control.interface.d.ts +6 -0
  17. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  18. package/dist/modules/access/access-control.service.d.ts +74 -5
  19. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  20. package/dist/modules/access/access-control.service.js +116 -15
  21. package/dist/modules/access/access-control.service.js.map +1 -1
  22. package/dist/modules/access/access-declarations.d.ts +93 -0
  23. package/dist/modules/access/access-declarations.d.ts.map +1 -0
  24. package/dist/modules/access/access-declarations.js +168 -0
  25. package/dist/modules/access/access-declarations.js.map +1 -0
  26. package/dist/modules/access/access-mutation.service.js +4 -4
  27. package/dist/modules/access/access-mutation.service.js.map +1 -1
  28. package/dist/modules/access/access-splice.d.ts +16 -1
  29. package/dist/modules/access/access-splice.d.ts.map +1 -1
  30. package/dist/modules/access/access-splice.js +40 -21
  31. package/dist/modules/access/access-splice.js.map +1 -1
  32. package/dist/modules/access/access.routes.d.ts.map +1 -1
  33. package/dist/modules/access/access.routes.js +51 -0
  34. package/dist/modules/access/access.routes.js.map +1 -1
  35. package/dist/modules/access/creator-access.d.ts +8 -0
  36. package/dist/modules/access/creator-access.d.ts.map +1 -1
  37. package/dist/modules/access/creator-access.js +21 -2
  38. package/dist/modules/access/creator-access.js.map +1 -1
  39. package/dist/modules/admin/admin-access.service.d.ts +1 -1
  40. package/dist/modules/admin/admin-access.service.d.ts.map +1 -1
  41. package/dist/modules/admin/admin-access.service.js +3 -2
  42. package/dist/modules/admin/admin-access.service.js.map +1 -1
  43. package/dist/modules/auth/auth.routes.d.ts.map +1 -1
  44. package/dist/modules/auth/auth.routes.js +50 -1
  45. package/dist/modules/auth/auth.routes.js.map +1 -1
  46. package/dist/modules/auth/auth.service.d.ts +39 -10
  47. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  48. package/dist/modules/auth/auth.service.js +38 -16
  49. package/dist/modules/auth/auth.service.js.map +1 -1
  50. package/dist/modules/database/core-schema.d.ts +124 -0
  51. package/dist/modules/database/core-schema.d.ts.map +1 -1
  52. package/dist/modules/database/core-schema.js +45 -0
  53. package/dist/modules/database/core-schema.js.map +1 -1
  54. package/dist/modules/groups/group-provision.service.d.ts +130 -0
  55. package/dist/modules/groups/group-provision.service.d.ts.map +1 -0
  56. package/dist/modules/groups/group-provision.service.js +288 -0
  57. package/dist/modules/groups/group-provision.service.js.map +1 -0
  58. package/dist/modules/groups/groups.contract.d.ts +106 -0
  59. package/dist/modules/groups/groups.contract.d.ts.map +1 -0
  60. package/dist/modules/groups/groups.contract.js +36 -0
  61. package/dist/modules/groups/groups.contract.js.map +1 -0
  62. package/dist/modules/groups/groups.routes.d.ts +42 -0
  63. package/dist/modules/groups/groups.routes.d.ts.map +1 -0
  64. package/dist/modules/groups/groups.routes.js +379 -0
  65. package/dist/modules/groups/groups.routes.js.map +1 -0
  66. package/dist/modules/groups/groups.service.d.ts +60 -0
  67. package/dist/modules/groups/groups.service.d.ts.map +1 -0
  68. package/dist/modules/groups/groups.service.js +172 -0
  69. package/dist/modules/groups/groups.service.js.map +1 -0
  70. package/dist/modules/groups/index.d.ts +7 -0
  71. package/dist/modules/groups/index.d.ts.map +1 -0
  72. package/dist/modules/groups/index.js +6 -0
  73. package/dist/modules/groups/index.js.map +1 -0
  74. package/dist/modules/groups/join-proposals.d.ts +53 -0
  75. package/dist/modules/groups/join-proposals.d.ts.map +1 -0
  76. package/dist/modules/groups/join-proposals.js +67 -0
  77. package/dist/modules/groups/join-proposals.js.map +1 -0
  78. package/dist/modules/groups/join-requests.service.d.ts +81 -0
  79. package/dist/modules/groups/join-requests.service.d.ts.map +1 -0
  80. package/dist/modules/groups/join-requests.service.js +135 -0
  81. package/dist/modules/groups/join-requests.service.js.map +1 -0
  82. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts +20 -0
  83. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
  84. package/dist/modules/secrets-vault/secrets-vault.routes.js +59 -11
  85. package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
  86. package/dist/modules/settings/deployment-settings.service.d.ts +129 -0
  87. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -0
  88. package/dist/modules/settings/deployment-settings.service.js +368 -0
  89. package/dist/modules/settings/deployment-settings.service.js.map +1 -0
  90. package/dist/modules/settings/setup.routes.d.ts +49 -0
  91. package/dist/modules/settings/setup.routes.d.ts.map +1 -0
  92. package/dist/modules/settings/setup.routes.js +292 -0
  93. package/dist/modules/settings/setup.routes.js.map +1 -0
  94. package/dist/modules/skills/index.d.ts +2 -1
  95. package/dist/modules/skills/index.d.ts.map +1 -1
  96. package/dist/modules/skills/index.js +1 -0
  97. package/dist/modules/skills/index.js.map +1 -1
  98. package/dist/modules/skills/pending-skills.service.d.ts +40 -0
  99. package/dist/modules/skills/pending-skills.service.d.ts.map +1 -0
  100. package/dist/modules/skills/pending-skills.service.js +147 -0
  101. package/dist/modules/skills/pending-skills.service.js.map +1 -0
  102. package/dist/modules/skills/skills.contract.d.ts +32 -3
  103. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  104. package/dist/modules/skills/skills.contract.js +1 -1
  105. package/dist/modules/skills/skills.routes.d.ts +3 -2
  106. package/dist/modules/skills/skills.routes.d.ts.map +1 -1
  107. package/dist/modules/skills/skills.routes.js +18 -1
  108. package/dist/modules/skills/skills.routes.js.map +1 -1
  109. package/dist/modules/skills/skills.service.d.ts +22 -0
  110. package/dist/modules/skills/skills.service.d.ts.map +1 -1
  111. package/dist/modules/skills/skills.service.js +24 -12
  112. package/dist/modules/skills/skills.service.js.map +1 -1
  113. package/dist/modules/tool-manuals/index.d.ts +1 -1
  114. package/dist/modules/tool-manuals/index.d.ts.map +1 -1
  115. package/dist/modules/tool-manuals/index.js.map +1 -1
  116. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +47 -2
  117. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  118. package/dist/modules/tool-manuals/tool-manuals.contract.js +1 -1
  119. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts +4 -0
  120. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -1
  121. package/dist/modules/tool-manuals/tool-manuals.routes.js +23 -0
  122. package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -1
  123. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +3 -1
  124. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  125. package/dist/modules/tool-manuals/tool-manuals.service.js +84 -17
  126. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  127. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  128. package/dist/modules/tool-manuals/tool-manuals.tools.js +6 -2
  129. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  130. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  131. package/dist/modules/workflow/agent-tools/workflow.tools.js +4 -2
  132. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  133. package/dist/modules/workflow/event-bus.d.ts +11 -0
  134. package/dist/modules/workflow/event-bus.d.ts.map +1 -1
  135. package/dist/modules/workflow/event-bus.js +22 -0
  136. package/dist/modules/workflow/event-bus.js.map +1 -1
  137. package/dist/modules/workflow/file-lock.service.d.ts +9 -0
  138. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  139. package/dist/modules/workflow/file-lock.service.js +16 -0
  140. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  141. package/dist/modules/workflow/git/branch-name.d.ts +1 -1
  142. package/dist/modules/workflow/git/branch-name.d.ts.map +1 -1
  143. package/dist/modules/workflow/git/branch-name.js +1 -1
  144. package/dist/modules/workflow/git/branch-name.js.map +1 -1
  145. package/dist/modules/workflow/git/git.service.d.ts +31 -2
  146. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  147. package/dist/modules/workflow/git/git.service.js +144 -52
  148. package/dist/modules/workflow/git/git.service.js.map +1 -1
  149. package/dist/modules/workflow/git/pull-request.service.d.ts +12 -6
  150. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  151. package/dist/modules/workflow/git/pull-request.service.js +19 -9
  152. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  153. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
  154. package/dist/modules/workflow/review-workflow/review-workflow.service.js +12 -3
  155. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  156. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  157. package/dist/modules/workflow/workflow.routes.js +95 -14
  158. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  159. package/dist/modules/workflow/workflow.service.d.ts +92 -3
  160. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  161. package/dist/modules/workflow/workflow.service.js +381 -108
  162. package/dist/modules/workflow/workflow.service.js.map +1 -1
  163. package/dist/modules/workspace/bevel-ignore.d.ts +5 -0
  164. package/dist/modules/workspace/bevel-ignore.d.ts.map +1 -1
  165. package/dist/modules/workspace/bevel-ignore.js +5 -1
  166. package/dist/modules/workspace/bevel-ignore.js.map +1 -1
  167. package/dist/modules/workspace/kb-seed.interface.d.ts +1 -1
  168. package/dist/modules/workspace/kb-seed.service.d.ts +60 -5
  169. package/dist/modules/workspace/kb-seed.service.d.ts.map +1 -1
  170. package/dist/modules/workspace/kb-seed.service.js +228 -51
  171. package/dist/modules/workspace/kb-seed.service.js.map +1 -1
  172. package/dist/modules/workspace/workspace.routes.d.ts +2 -1
  173. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  174. package/dist/modules/workspace/workspace.routes.js +51 -18
  175. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  176. package/dist/modules/workspace/workspace.service.d.ts +16 -4
  177. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  178. package/dist/modules/workspace/workspace.service.js +36 -9
  179. package/dist/modules/workspace/workspace.service.js.map +1 -1
  180. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  181. package/dist/modules/workspace/workspace.tools.js +8 -3
  182. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  183. package/dist/shared/kb-layout.test.js +2 -2
  184. package/dist/test-setup.d.ts +2 -0
  185. package/dist/test-setup.d.ts.map +1 -0
  186. package/dist/test-setup.js +18 -0
  187. package/dist/test-setup.js.map +1 -0
  188. package/kb-template/.bevelignore +6 -2
  189. package/kb-template/AGENTS.md +272 -0
  190. package/kb-template/KnowledgeBase/How to get started.md +65 -0
  191. package/kb-template/access.md +36 -36
  192. package/migrations/0002_user_onboarding_done.sql +1 -0
  193. package/migrations/0003_deployment_settings.sql +9 -0
  194. package/migrations/meta/0001_snapshot.json +1486 -0
  195. package/migrations/meta/0002_snapshot.json +1493 -0
  196. package/migrations/meta/0003_snapshot.json +1552 -0
  197. package/migrations/meta/_journal.json +15 -1
  198. package/package.json +5 -4
  199. package/src/__tests__/branch-model.test.ts +79 -0
  200. package/src/__tests__/core-config.admin.test.ts +114 -0
  201. package/src/__tests__/database-url.test.ts +70 -0
  202. package/src/core/core-ports.ts +15 -0
  203. package/src/core/create-core-server.ts +55 -6
  204. package/src/core/create-core-services.ts +201 -36
  205. package/src/core-config.ts +139 -59
  206. package/src/modules/access/__tests__/access-declarations.test.ts +195 -0
  207. package/src/modules/access/__tests__/access-md-format.test.ts +191 -0
  208. package/src/modules/access/__tests__/access-splice.test.ts +21 -0
  209. package/src/modules/access/__tests__/access.routes.overrides.test.ts +214 -0
  210. package/src/modules/access/access-control.interface.ts +11 -0
  211. package/src/modules/access/access-control.service.ts +113 -15
  212. package/src/modules/access/access-declarations.ts +251 -0
  213. package/src/modules/access/access-mutation.service.ts +4 -4
  214. package/src/modules/access/access-splice.ts +64 -21
  215. package/src/modules/access/access.routes.ts +61 -0
  216. package/src/modules/access/creator-access.ts +22 -2
  217. package/src/modules/admin/admin-access.service.ts +4 -3
  218. package/src/modules/auth/__tests__/auth.service.test.ts +57 -0
  219. package/src/modules/auth/__tests__/onboarding-done.route.test.ts +147 -0
  220. package/src/modules/auth/auth.routes.ts +50 -1
  221. package/src/modules/auth/auth.service.ts +55 -17
  222. package/src/modules/database/core-schema.ts +46 -0
  223. package/src/modules/groups/__tests__/group-index.service.test.ts +225 -0
  224. package/src/modules/groups/__tests__/group-provision.service.test.ts +247 -0
  225. package/src/modules/groups/__tests__/groups.routes.test.ts +493 -0
  226. package/src/modules/groups/__tests__/join-proposals.test.ts +101 -0
  227. package/src/modules/groups/__tests__/join-requests.service.test.ts +175 -0
  228. package/src/modules/groups/group-provision.service.ts +328 -0
  229. package/src/modules/groups/groups.contract.ts +107 -0
  230. package/src/modules/groups/groups.routes.ts +433 -0
  231. package/src/modules/groups/groups.service.ts +193 -0
  232. package/src/modules/groups/index.ts +17 -0
  233. package/src/modules/groups/join-proposals.ts +114 -0
  234. package/src/modules/groups/join-requests.service.ts +185 -0
  235. package/src/modules/secrets-vault/__tests__/oauth-return-to.route.test.ts +283 -0
  236. package/src/modules/secrets-vault/secrets-vault.routes.ts +68 -12
  237. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +256 -0
  238. package/src/modules/settings/__tests__/setup.routes.test.ts +201 -0
  239. package/src/modules/settings/deployment-settings.service.ts +419 -0
  240. package/src/modules/settings/setup.routes.ts +317 -0
  241. package/src/modules/skills/__tests__/pending-skills.service.test.ts +240 -0
  242. package/src/modules/skills/__tests__/skills.service.test.ts +13 -13
  243. package/src/modules/skills/index.ts +3 -0
  244. package/src/modules/skills/pending-skills.service.ts +168 -0
  245. package/src/modules/skills/skills.contract.ts +34 -3
  246. package/src/modules/skills/skills.routes.ts +23 -2
  247. package/src/modules/skills/skills.service.ts +24 -16
  248. package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +262 -0
  249. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +2 -2
  250. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +61 -23
  251. package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +156 -156
  252. package/src/modules/tool-manuals/index.ts +2 -0
  253. package/src/modules/tool-manuals/tool-manuals.contract.ts +50 -2
  254. package/src/modules/tool-manuals/tool-manuals.routes.ts +21 -0
  255. package/src/modules/tool-manuals/tool-manuals.service.ts +86 -17
  256. package/src/modules/tool-manuals/tool-manuals.tools.ts +206 -202
  257. package/src/modules/workflow/__tests__/event-bus.test.ts +35 -0
  258. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +251 -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 +5 -0
  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 +159 -62
  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 +353 -13
  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,433 @@
1
+ import express from 'express';
2
+ import '../auth/auth.middleware.js'; // Express Request.userId / userEmail augmentation
3
+ import {
4
+ DEFAULT_BRANCH,
5
+ joinBranchFor,
6
+ type AuthUser,
7
+ type ChangeRequest,
8
+ type IWorkflowService,
9
+ } from '@bevel-software/platform-shared';
10
+ import type { IAccessControl } from '../access/access-control.interface.js';
11
+ import { spliceGrant } from '../access/access-splice.js';
12
+ import { WorkflowDomainError } from '../workflow/workflow.errors.js';
13
+ import type { WorkspaceService } from '../workspace/workspace.service.js';
14
+ import { groupsWorkspaceId } from './groups.service.js';
15
+ import { GroupProvisionError, type GroupProvisionService } from './group-provision.service.js';
16
+ import type { JoinRequestsService } from './join-requests.service.js';
17
+ import type {
18
+ GroupCatalogEntry,
19
+ GroupSummary,
20
+ IGroupIndexService,
21
+ } from './groups.contract.js';
22
+
23
+ /**
24
+ * Browser-facing (JWT) group routes, mounted behind `authMiddleware`:
25
+ *
26
+ * GET /api/groups → { groups: GroupSummary[] }
27
+ * DELETE /api/groups/:name → { ok } (owners)
28
+ * POST /api/groups/:name/join-request → { ok, number } (opens a CR)
29
+ * GET /api/groups/:name/join-requests → { requests } (managers)
30
+ * POST /api/groups/:name/join-requests/:n/reconcile → { closed }
31
+ *
32
+ * Enumeration is three verdicts per (caller, group), all of them ordinary
33
+ * access resolution — no special cases, no side tables:
34
+ *
35
+ * member canRead on the group FOLDER
36
+ * manager canWrite on the folder's access.md (admin-rescued)
37
+ * discoverable canRead on the access.md FILE — in the body-governed format
38
+ * its own `read: everyone` frontmatter grants this
39
+ *
40
+ * All three false ⇒ the group is absent from the response entirely.
41
+ *
42
+ * A join request is a plain change request whose branch edits the group's
43
+ * `access.md`. Managers do NOT merge it: they read its individual proposals
44
+ * (see `join-proposals.ts`), grant the ones they accept through the ordinary
45
+ * access path, and the request retires itself once its rules are a subset of
46
+ * the default branch's — reconciled here, lazily on listing and eagerly right
47
+ * after a grant. Nothing about the lifecycle is stored; it is derived from
48
+ * two copies of one file.
49
+ *
50
+ * Auth gating is explicit and uniform: `authMiddleware` at the mount, PLUS a
51
+ * `req.userEmail` check in every handler (the skills-routes pattern) so a
52
+ * middleware change can never silently un-gate one. Nothing here is reachable
53
+ * with an agent connection key or a manual-auth bearer.
54
+ */
55
+ export function createGroupsRoutes(
56
+ groupIndex: IGroupIndexService,
57
+ accessControl: IAccessControl,
58
+ workflow: IWorkflowService,
59
+ workspaceService: WorkspaceService,
60
+ joinRequests: JoinRequestsService,
61
+ provision: GroupProvisionService,
62
+ kbDirName: string,
63
+ resolveUser: (req: express.Request) => Promise<AuthUser | null>,
64
+ ): express.Router {
65
+ const router = express.Router();
66
+
67
+ /** The folder-chain probe for MEMBERSHIP — the folder itself. */
68
+ const memberProbe = (folder: string) => folder;
69
+ /** The FILE probe for discovery/management — the folder's access.md. */
70
+ const accessMdOf = (folder: string) => `${folder}/access.md`;
71
+
72
+ const probesFor = (groups: GroupCatalogEntry[]): string[] => [
73
+ ...new Set(groups.flatMap((g) => g.folders.flatMap((f) => [memberProbe(f), accessMdOf(f)]))),
74
+ ];
75
+
76
+ /** The caller's open join CR for `group`, or null. */
77
+ const openJoinCr = (mine: ChangeRequest[], email: string, group: string): ChangeRequest | null =>
78
+ mine.find((cr) => cr.state === 'open' && cr.branch === joinBranchFor(email, group)) ?? null;
79
+
80
+ router.get('/groups', async (req, res) => {
81
+ const email = req.userEmail;
82
+ if (!email) {
83
+ res.status(401).json({ error: 'Unauthenticated' });
84
+ return;
85
+ }
86
+ try {
87
+ const catalog = await groupIndex.catalog();
88
+ if (catalog.length === 0) {
89
+ res.json({ groups: [] });
90
+ return;
91
+ }
92
+ const wsId = groupsWorkspaceId();
93
+ const probes = probesFor(catalog);
94
+ const [readable, writable, owned] = await Promise.all([
95
+ accessControl.canReadBatch(wsId, email, probes),
96
+ accessControl.canWriteBatch(wsId, email, probes),
97
+ accessControl.canOwnerBatch(wsId, email, probes),
98
+ ]);
99
+ // Fail closed on every verdict: a path missing from the map is denied.
100
+ const any = (map: Map<string, boolean>, g: GroupCatalogEntry, probe: (f: string) => string) =>
101
+ g.folders.some((f) => map.get(probe(f)) === true);
102
+
103
+ // The caller's own open join CRs — drives `hasRequested`. A CR-listing
104
+ // hiccup must not take group enumeration down: degrade to "nothing
105
+ // requested" and let the next load repair it.
106
+ let mine: ChangeRequest[] = [];
107
+ try {
108
+ mine = await workflow.listChangeRequestsAuthoredBy(email);
109
+ } catch (err) {
110
+ console.warn(
111
+ `[groups] join-request lookup failed: ${err instanceof Error ? err.message : String(err)}`,
112
+ );
113
+ }
114
+
115
+ const groups: GroupSummary[] = [];
116
+ for (const g of catalog) {
117
+ const member = any(readable, g, memberProbe);
118
+ const manager = any(writable, g, accessMdOf);
119
+ // The FOLDER verdict, owner-lists-only — the delete gate, mirrored
120
+ // here so the UI shows the verb to exactly the people the DELETE
121
+ // route will let through.
122
+ const owner = any(owned, g, memberProbe);
123
+ const discoverable = member || any(readable, g, accessMdOf);
124
+ if (!member && !manager && !discoverable) continue; // absent — fail closed
125
+ const joinCr = member ? null : openJoinCr(mine, email, g.name);
126
+ groups.push({
127
+ name: g.name,
128
+ folders: g.folders,
129
+ canRead: member,
130
+ canWrite: manager,
131
+ isOwner: owner,
132
+ skillCount: g.skillCount,
133
+ toolCount: g.toolCount,
134
+ owners: g.owners,
135
+ writers: g.writers,
136
+ readers: g.readers,
137
+ hasRequested: joinCr !== null,
138
+ requestNumber: joinCr?.number ?? null,
139
+ });
140
+ }
141
+ res.json({ groups });
142
+ } catch (err) {
143
+ console.error('[groups] failed to list groups:', err);
144
+ res.status(500).json({ error: 'Failed to list groups' });
145
+ }
146
+ });
147
+
148
+ /**
149
+ * Open (or return the existing) join change request for the caller.
150
+ *
151
+ * Idempotent via the deterministic branch name: a second click finds the
152
+ * open CR and returns it. Every step is an existing primitive — branch,
153
+ * splice, commit-and-push, open CR — so the security story is exactly the
154
+ * workflow's: draft branches are ungated, and the merge gate requires an
155
+ * approver who can write the touched access.md.
156
+ */
157
+ /**
158
+ * Create a group — the ONE door through which `Groups/<name>/` folders come
159
+ * to exist. Any authenticated user may create one; that is the product
160
+ * model (making a group makes you the one who runs it), and the seeded
161
+ * access.md immediately fences the new folder off from everyone else. See
162
+ * `GroupProvisionService` for why this is an endpoint and not a write path.
163
+ */
164
+ router.post('/groups', async (req, res) => {
165
+ const user = await resolveUser(req);
166
+ if (!user) {
167
+ res.status(401).json({ error: 'Unauthenticated' });
168
+ return;
169
+ }
170
+ // `req.body` is undefined when no JSON body was sent at all — that is a
171
+ // 400, not a destructuring crash.
172
+ const { name } = (req.body ?? {}) as { name?: string };
173
+ if (typeof name !== 'string') {
174
+ res.status(400).json({ error: 'name is required in body' });
175
+ return;
176
+ }
177
+ try {
178
+ const result = await provision.createGroup(user, name);
179
+ res.status(201).json(result);
180
+ } catch (err) {
181
+ if (err instanceof GroupProvisionError) {
182
+ res.status(err.status).json({ error: err.message });
183
+ return;
184
+ }
185
+ console.error('[groups] create failed:', err);
186
+ res.status(500).json({ error: 'Failed to create the group' });
187
+ }
188
+ });
189
+
190
+ /**
191
+ * Ensure the caller's personal folder (`Groups/personal-<id>/`) exists —
192
+ * idempotent; the UI calls it lazily right before the first personal-skill
193
+ * write. Private by construction: its access.md names only the caller.
194
+ */
195
+ router.post('/groups/personal', async (req, res) => {
196
+ const user = await resolveUser(req);
197
+ if (!user) {
198
+ res.status(401).json({ error: 'Unauthenticated' });
199
+ return;
200
+ }
201
+ try {
202
+ res.json(await provision.ensurePersonalGroup(user));
203
+ } catch (err) {
204
+ console.error('[groups] personal-folder ensure failed:', err);
205
+ res.status(500).json({ error: 'Failed to prepare your personal folder' });
206
+ }
207
+ });
208
+
209
+ /**
210
+ * Delete a group — the OWNER's verb, and only theirs. Creating a group
211
+ * makes you the one who runs it; deleting it is the other end of that same
212
+ * promise, so the gate is the `owner` verdict on the folder (owner-lists
213
+ * only, no admin rescue) — a manager who merely writes the access.md, and
214
+ * an admin rescued into managing it, do not get it.
215
+ *
216
+ * Fail-closed like every other group surface: an unknown group and a group
217
+ * the caller does not own answer IDENTICALLY, so probing the endpoint can
218
+ * confirm nothing about what exists. The mechanism (park, one commit,
219
+ * rollback on refusal) lives in `GroupProvisionService.deleteGroup`.
220
+ */
221
+ router.delete('/groups/:name', async (req, res) => {
222
+ const email = req.userEmail;
223
+ if (!email) {
224
+ res.status(401).json({ error: 'Unauthenticated' });
225
+ return;
226
+ }
227
+ try {
228
+ const user = await resolveUser(req);
229
+ if (!user) {
230
+ res.status(401).json({ error: 'Unauthenticated' });
231
+ return;
232
+ }
233
+ // Case-sensitive, like `groupOfPath` — the group name IS the folder name.
234
+ const group = (await groupIndex.catalog()).find((g) => g.name === req.params.name);
235
+ const wsId = groupsWorkspaceId();
236
+ const ownerVerdicts = group
237
+ ? await Promise.all(
238
+ group.folders.map((f) => accessControl.canOwner(wsId, email, memberProbe(f))),
239
+ )
240
+ : [];
241
+ if (!group || !ownerVerdicts.some((v) => v === true)) {
242
+ res.status(404).json({ error: 'Unknown group', kind: 'unknown-group' });
243
+ return;
244
+ }
245
+ await provision.deleteGroup(user, group.name);
246
+ groupIndex.invalidate();
247
+ res.json({ ok: true });
248
+ } catch (err) {
249
+ if (err instanceof GroupProvisionError) {
250
+ res.status(err.status).json({ error: err.message });
251
+ return;
252
+ }
253
+ console.error('[groups] delete failed:', err);
254
+ res.status(500).json({ error: 'Failed to delete the group' });
255
+ }
256
+ });
257
+
258
+ router.post('/groups/:name/join-request', async (req, res) => {
259
+ const email = req.userEmail;
260
+ if (!email) {
261
+ res.status(401).json({ error: 'Unauthenticated' });
262
+ return;
263
+ }
264
+ try {
265
+ const user = await resolveUser(req);
266
+ if (!user) {
267
+ res.status(401).json({ error: 'Unauthenticated' });
268
+ return;
269
+ }
270
+ const catalog = await groupIndex.catalog();
271
+ // Case-sensitive, like `groupOfPath` — the group name IS the folder name.
272
+ const group = catalog.find((g) => g.name === req.params.name);
273
+ const wsId = groupsWorkspaceId();
274
+ // Same ANY-folder shape `GET /groups` resolves with, so a group can
275
+ // never be listed as discoverable there and rejected as unknown here.
276
+ const verdicts = group
277
+ ? await accessControl.canReadBatch(
278
+ wsId,
279
+ email,
280
+ group.folders.flatMap((f) => [memberProbe(f), accessMdOf(f)]),
281
+ )
282
+ : new Map<string, boolean>();
283
+ const any = (probe: (f: string) => string) =>
284
+ group?.folders.some((f) => verdicts.get(probe(f)) === true) ?? false;
285
+ // Discovery gate, fail-closed: an unknown group and a group the caller
286
+ // cannot discover answer IDENTICALLY, so probing can't confirm existence.
287
+ if (!group || !any(accessMdOf)) {
288
+ res.status(404).json({ error: 'Unknown group', kind: 'unknown-group' });
289
+ return;
290
+ }
291
+ if (any(memberProbe)) {
292
+ // Access landed between page load and click — reload, don't ask.
293
+ res.status(409).json({ error: 'You can already read this group', kind: 'already-readable' });
294
+ return;
295
+ }
296
+ // The grant is written to the group's primary folder — the one the
297
+ // summary's `folders[0]` names and the banner's touched-path check
298
+ // expects.
299
+ const folder = group.folders[0];
300
+
301
+ const branch = joinBranchFor(email, group.name);
302
+ const existing = openJoinCr(await workflow.listChangeRequestsAuthoredBy(email), email, group.name);
303
+ if (existing) {
304
+ res.json({ ok: true, number: existing.number });
305
+ return;
306
+ }
307
+
308
+ // A leftover branch from a rejected/withdrawn request is reused — the
309
+ // grant commit is already on it and the splice below no-ops.
310
+ try {
311
+ await workflow.createBranch(groupsWorkspaceId(), branch, DEFAULT_BRANCH);
312
+ } catch {
313
+ // exists (or raced) — proceed against it
314
+ }
315
+ const ws = await workspaceService.getOrCreateForBranch(branch);
316
+ const accessPath = `${kbDirName}/${accessMdOf(folder)}`;
317
+ const current = await workspaceService.readFile(ws.id, accessPath).catch(() => '');
318
+ const spliced = spliceGrant(
319
+ current,
320
+ 'read',
321
+ { kind: 'user', email: user.email, displayName: user.name },
322
+ { target: 'folder' },
323
+ );
324
+ if (spliced.changed) {
325
+ await workspaceService.writeFile(ws.id, accessPath, spliced.text);
326
+ await workflow.commitChanges(ws.id, user, `Request access to ${group.name}`);
327
+ }
328
+ const detail = await workflow.openChangeRequest(ws.id, user, {
329
+ sourceBranch: branch,
330
+ targetBranch: DEFAULT_BRANCH,
331
+ title: `Join request: ${group.name}`,
332
+ description:
333
+ `${user.name} asked to join ${group.name}. A manager of the group accepts by ` +
334
+ `granting the access this branch proposes; the request closes itself once ` +
335
+ `every proposal has landed.`,
336
+ });
337
+ res.json({ ok: true, number: detail.number });
338
+ } catch (err) {
339
+ if (err instanceof WorkflowDomainError) {
340
+ res.status(err.status).json({ error: err.message, ...(err.payload ?? {}) });
341
+ return;
342
+ }
343
+ console.error('[groups] failed to open a join request:', err);
344
+ res.status(500).json({ error: 'Failed to request access' });
345
+ }
346
+ });
347
+
348
+ /**
349
+ * Resolve the caller as a MANAGER of `name` — the only role that may see or
350
+ * settle its join requests. Returns the group's primary folder, or null
351
+ * after answering the request.
352
+ *
353
+ * A non-manager gets an empty list rather than a 403 (the frontend asks
354
+ * unconditionally, exactly as it does for every other group surface), and
355
+ * an unknown group is indistinguishable from an unmanaged one.
356
+ */
357
+ async function requireManager(
358
+ req: express.Request,
359
+ res: express.Response,
360
+ onDenied: () => void,
361
+ ): Promise<{ group: GroupCatalogEntry; folder: string; user: AuthUser } | null> {
362
+ const email = req.userEmail;
363
+ if (!email) {
364
+ res.status(401).json({ error: 'Unauthenticated' });
365
+ return null;
366
+ }
367
+ const group = (await groupIndex.catalog()).find((g) => g.name === req.params.name);
368
+ if (!group) {
369
+ onDenied();
370
+ return null;
371
+ }
372
+ const writable = await accessControl.canWriteBatch(
373
+ groupsWorkspaceId(),
374
+ email,
375
+ group.folders.map(accessMdOf),
376
+ );
377
+ if (!group.folders.some((f) => writable.get(accessMdOf(f)) === true)) {
378
+ onDenied();
379
+ return null;
380
+ }
381
+ const user = await resolveUser(req);
382
+ if (!user) {
383
+ res.status(401).json({ error: 'Unauthenticated' });
384
+ return null;
385
+ }
386
+ return { group, folder: group.folders[0], user };
387
+ }
388
+
389
+ router.get('/groups/:name/join-requests', async (req, res) => {
390
+ try {
391
+ const ctx = await requireManager(req, res, () => res.json({ requests: [] }));
392
+ if (!ctx) return;
393
+ const crs = await workflow.listChangeRequests();
394
+ res.json({
395
+ requests: await joinRequests.list(ctx.group.name, ctx.folder, crs, ctx.user),
396
+ });
397
+ } catch (err) {
398
+ console.error('[groups] failed to list join requests:', err);
399
+ res.status(500).json({ error: 'Failed to list join requests' });
400
+ }
401
+ });
402
+
403
+ /**
404
+ * Settle one request if its proposals have all landed on the default
405
+ * branch. Called right after a grant so the banner updates in the same
406
+ * round-trip; the listing does the same thing lazily, so skipping this (a
407
+ * dropped response, a closed tab) only delays it.
408
+ */
409
+ router.post('/groups/:name/join-requests/:number/reconcile', async (req, res) => {
410
+ try {
411
+ const ctx = await requireManager(req, res, () =>
412
+ res.status(404).json({ error: 'Not found' }),
413
+ );
414
+ if (!ctx) return;
415
+ const crNumber = Number(req.params.number);
416
+ if (!Number.isSafeInteger(crNumber) || crNumber <= 0) {
417
+ res.status(400).json({ error: 'Invalid change request number' });
418
+ return;
419
+ }
420
+ const cr = await workflow.getChangeRequest(crNumber);
421
+ if (!cr) {
422
+ res.status(404).json({ error: 'Not found' });
423
+ return;
424
+ }
425
+ res.json({ closed: await joinRequests.reconcile(ctx.group.name, ctx.folder, cr, ctx.user) });
426
+ } catch (err) {
427
+ console.error('[groups] failed to reconcile a join request:', err);
428
+ res.status(500).json({ error: 'Failed to update the request' });
429
+ }
430
+ });
431
+
432
+ return router;
433
+ }
@@ -0,0 +1,193 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs/promises';
3
+ import type { Dirent } from 'node:fs';
4
+ import {
5
+ DEFAULT_BRANCH,
6
+ GROUPS_DIR,
7
+ groupOfPath,
8
+ isPersonalGroupFolder,
9
+ } from '@bevel-software/platform-shared';
10
+ import type { WorkspaceService } from '../workspace/workspace.service.js';
11
+ import { workspaceIdForBranch } from '../workspace/workspace.service.js';
12
+ import type { IAccessControl } from '../access/access-control.interface.js';
13
+ import type { ISkillService } from '../skills/skills.contract.js';
14
+ import type { IToolManualService } from '../tool-manuals/tool-manuals.contract.js';
15
+ import { TtlCache } from '../../shared/ttl-cache.js';
16
+ import type { GroupCatalogEntry, IGroupIndexService } from './groups.contract.js';
17
+
18
+ const CACHE_TTL_MS = 60_000;
19
+
20
+ /**
21
+ * The group index: every group folder in the default-branch KB, with its
22
+ * caller-independent totals and access principals.
23
+ *
24
+ * Two decisions worth keeping straight:
25
+ *
26
+ * - **Existence is an `access.md`, counting is not.** A group EXISTS because
27
+ * its folder carries an `access.md` — the file the provisioning endpoint
28
+ * seeds and the one every per-caller verdict (member / manager /
29
+ * discoverable) resolves against. A bare directory under `Groups/` is NOT
30
+ * a group: git cannot record an empty folder, so deleting a group's files
31
+ * leaves its directory behind on live checkouts, and enumerating by
32
+ * directory would resurrect every such ghost. Counts come from the
33
+ * already-cached global catalogs — `skillService.listSkills(undefined)` and
34
+ * `toolManualService.listAllSummaries()` — bucketed by `groupOfPath`. A
35
+ * second `walkFiles` pass would re-read the same tree and could disagree
36
+ * with the catalogs about what counts as a skill.
37
+ * - **Loose files are not groups.** `Groups/slack.tool` sits directly under
38
+ * the root, so it is a file, not a directory — the same ≥3-segment rule
39
+ * `groupOfPath` applies, arrived at from the other side.
40
+ *
41
+ * Cached for {@link CACHE_TTL_MS} and dropped by `invalidate()` from the
42
+ * file-change subscriber, so a grant committed on the default branch is
43
+ * reflected within one round-trip rather than one TTL.
44
+ */
45
+ export class GroupIndexService implements IGroupIndexService {
46
+ private readonly cache: TtlCache<GroupCatalogEntry[]>;
47
+
48
+ constructor(
49
+ private readonly workspaceService: WorkspaceService,
50
+ private readonly accessControl: IAccessControl,
51
+ private readonly skillService: ISkillService,
52
+ private readonly toolManualService: IToolManualService,
53
+ private readonly kbDirName: string,
54
+ now: () => number = Date.now,
55
+ ) {
56
+ this.cache = new TtlCache(CACHE_TTL_MS, now);
57
+ }
58
+
59
+ invalidate(): void {
60
+ this.cache.invalidate();
61
+ }
62
+
63
+ async catalog(): Promise<GroupCatalogEntry[]> {
64
+ const cached = this.cache.get();
65
+ if (cached) return cached;
66
+ const entries = await this.build();
67
+ // A failed scan and a KB with genuinely no group folders both serve `[]`,
68
+ // but only the second is a fact worth holding for the TTL. Caching the
69
+ // failure would hide every group from every user for the full 60s AFTER
70
+ // the cause is gone — a clone mid-creation, a readdir that raced a
71
+ // checkout — and `invalidate()` only fires on a file-change event that may
72
+ // never arrive in that window. So a degraded read is served, not stored,
73
+ // and the next caller retries.
74
+ if (entries === null) return [];
75
+ this.cache.set(entries);
76
+ return entries;
77
+ }
78
+
79
+ // --- internal --------------------------------------------------------------
80
+
81
+ /**
82
+ * Degrades on ANY failure rather than throwing. The Library must never break
83
+ * because a group folder can't be read — same philosophy as the skill and
84
+ * tool-manual scanners, and the reason the route's 500 is reserved for the
85
+ * per-caller half.
86
+ *
87
+ * `null` is that degraded case and is deliberately NOT the same value as an
88
+ * empty array: a KB with no group folders returns `[]`, which is true and
89
+ * cacheable, while a failure returns `null`, which `catalog()` serves as `[]`
90
+ * without storing it.
91
+ */
92
+ private async build(): Promise<GroupCatalogEntry[] | null> {
93
+ try {
94
+ const wsId = (await this.workspaceService.getOrCreateForBranch(DEFAULT_BRANCH)).id;
95
+ const kbRoot = path.join(await this.workspaceService.getWorkspacePath(wsId), this.kbDirName);
96
+
97
+ const folders = await this.scanFolders(kbRoot);
98
+ if (folders.size === 0) return [];
99
+
100
+ const [skillCounts, toolCounts] = await Promise.all([
101
+ this.countSkills(),
102
+ this.countTools(),
103
+ ]);
104
+
105
+ const entries: GroupCatalogEntry[] = [];
106
+ for (const [name, groupFolders] of folders) {
107
+ // One folder, one access boundary — the folder IS the group.
108
+ const [primary] = groupFolders;
109
+ const [owners, writers, readers] = await Promise.all([
110
+ this.accessControl.eligibleOwners(wsId, primary),
111
+ this.accessControl.eligibleWriters(wsId, primary),
112
+ this.accessControl.eligibleReaders(wsId, primary),
113
+ ]);
114
+ entries.push({
115
+ name,
116
+ folders: groupFolders,
117
+ skillCount: skillCounts.get(name) ?? 0,
118
+ toolCount: toolCounts.get(name) ?? 0,
119
+ owners,
120
+ writers,
121
+ readers,
122
+ });
123
+ }
124
+ return entries.sort((a, b) => a.name.localeCompare(b.name));
125
+ } catch (err) {
126
+ console.warn(
127
+ `[groups] group index unavailable: ${err instanceof Error ? err.message : String(err)}`,
128
+ );
129
+ return null;
130
+ }
131
+ }
132
+
133
+ /** name → repo-relative group folder (always a single-element list). */
134
+ private async scanFolders(kbRoot: string): Promise<Map<string, string[]>> {
135
+ const byName = new Map<string, string[]>();
136
+ let children: Dirent[];
137
+ try {
138
+ children = await fs.readdir(path.join(kbRoot, GROUPS_DIR), { withFileTypes: true });
139
+ } catch {
140
+ return byName; // a KB without a `Groups/` root simply has no groups
141
+ }
142
+ const candidates = children.filter(
143
+ (child) =>
144
+ child.isDirectory() &&
145
+ !child.name.startsWith('.') &&
146
+ // Personal folders live under Groups/ but are not groups: one exists
147
+ // per person, private by construction, and listing them would put a
148
+ // locked row per employee in everyone's index.
149
+ !isPersonalGroupFolder(child.name),
150
+ );
151
+ // A group exists exactly when its folder carries an `access.md` (see the
152
+ // class doc) — stat that file, don't trust the directory.
153
+ const verdicts = await Promise.all(
154
+ candidates.map(async (child) => {
155
+ try {
156
+ return (await fs.stat(path.join(kbRoot, GROUPS_DIR, child.name, 'access.md'))).isFile();
157
+ } catch {
158
+ return false;
159
+ }
160
+ }),
161
+ );
162
+ candidates.forEach((child, i) => {
163
+ if (verdicts[i]) byName.set(child.name, [`${GROUPS_DIR}/${child.name}`]);
164
+ });
165
+ return byName;
166
+ }
167
+
168
+ private async countSkills(): Promise<Map<string, number>> {
169
+ // `undefined` is the documented GLOBAL, unfiltered mode — counts are a
170
+ // property of the group, not of who is asking.
171
+ return bucketByGroup(await this.skillService.listSkills(undefined));
172
+ }
173
+
174
+ private async countTools(): Promise<Map<string, number>> {
175
+ return bucketByGroup(await this.toolManualService.listAllSummaries());
176
+ }
177
+ }
178
+
179
+ /** Count items per group folder name; ungrouped items (`null`) count nowhere. */
180
+ function bucketByGroup(items: { path: string }[]): Map<string, number> {
181
+ const counts = new Map<string, number>();
182
+ for (const item of items) {
183
+ const group = groupOfPath(item.path);
184
+ if (group === null) continue;
185
+ counts.set(group, (counts.get(group) ?? 0) + 1);
186
+ }
187
+ return counts;
188
+ }
189
+
190
+ /** The default-branch workspace id every group resolution runs against. */
191
+ export function groupsWorkspaceId(): string {
192
+ return workspaceIdForBranch(DEFAULT_BRANCH);
193
+ }
@@ -0,0 +1,17 @@
1
+ export { GroupIndexService, groupsWorkspaceId } from './groups.service.js';
2
+ export {
3
+ GroupProvisionService,
4
+ GroupProvisionError,
5
+ groupAccessMd,
6
+ personalAccessMd,
7
+ } from './group-provision.service.js';
8
+ export { JoinRequestsService, type JoinRequest } from './join-requests.service.js';
9
+ export { pendingProposals, type JoinProposal } from './join-proposals.js';
10
+ export { createGroupsRoutes } from './groups.routes.js';
11
+ export type {
12
+ IGroupIndexService,
13
+ GroupCatalogEntry,
14
+ GroupSummary,
15
+ ResolvedPrincipals,
16
+ ResolvedReaders,
17
+ } from './groups.contract.js';