@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,168 @@
1
+ import {
2
+ DEFAULT_BRANCH,
3
+ GROUPS_DIR,
4
+ type ChangeRequest,
5
+ type IWorkflowService,
6
+ } from '@bevel-software/platform-shared';
7
+ import { workspaceIdForBranch, type WorkspaceService } from '../workspace/workspace.service.js';
8
+ import type { IAccessControl } from '../access/access-control.interface.js';
9
+ import { hashEmail } from '../../shared/hash-email.js';
10
+ import { resolveDeclaredId } from '../../shared/frontmatter-id.js';
11
+ import { isSafeSkillName, parseSkillFrontmatter } from './skills.service.js';
12
+ import type { IPendingSkillService, ISkillService, PendingSkill } from './skills.contract.js';
13
+
14
+ const SKILL_DOC = 'SKILL.md';
15
+
16
+ /**
17
+ * Skills that exist only on an open change request — proposed, not released.
18
+ *
19
+ * The catalog (`SkillService`) is pinned to the default branch, which is the
20
+ * right answer for everything that LOADS a skill and the wrong one for the
21
+ * person who just proposed it: a change request that ADDS a skill folder has
22
+ * nothing on the default branch to hang itself off, so until it merged the
23
+ * proposal was invisible to its author and to the people who had to approve it.
24
+ * This service is that missing half, and it is deliberately a separate surface
25
+ * rather than a widening of the catalog — an unapproved skill must never become
26
+ * loadable just because it became visible.
27
+ *
28
+ * WHO MAY SEE ONE is narrower than who may see the catalog: the author, and
29
+ * whoever could approve it. "Could approve it" is not re-stated here as a
30
+ * group-admin check — it is `canWrite` on the SKILL.md the request would
31
+ * create. The new folder carries no `access.md` of its own, so that verdict
32
+ * inherits from the group folder, which IS the group's admin; asking the access
33
+ * tree keeps this surface and the merge gate answering with one voice, and it
34
+ * keeps working for skills nested in a category subfolder that carries rules of
35
+ * its own.
36
+ *
37
+ * Everything degrades to "nothing pending" rather than throwing: this hangs off
38
+ * the library's list load, and a review surface that cannot answer must not
39
+ * take the shelf down with it.
40
+ */
41
+ export class PendingSkillsService implements IPendingSkillService {
42
+ constructor(
43
+ private readonly workspaceService: WorkspaceService,
44
+ private readonly accessControl: IAccessControl,
45
+ private readonly skillService: ISkillService,
46
+ private readonly workflow: IWorkflowService,
47
+ ) {}
48
+
49
+ async listPendingSkills(userEmail: string): Promise<PendingSkill[]> {
50
+ const email = userEmail.trim().toLowerCase();
51
+ if (!email) return [];
52
+
53
+ let crs: ChangeRequest[];
54
+ try {
55
+ crs = (await this.workflow.listChangeRequests()).filter((c) => c.state === 'open');
56
+ } catch {
57
+ return [];
58
+ }
59
+ if (crs.length === 0) return [];
60
+
61
+ // The released set, UNFILTERED by the caller's read access. A skill someone
62
+ // else can read and this caller cannot is still released — counting it as
63
+ // pending would invent a review that is not happening, and would leak the
64
+ // fact that the folder exists.
65
+ let released: Set<string>;
66
+ try {
67
+ released = new Set((await this.skillService.listSkills()).map((s) => s.path));
68
+ } catch {
69
+ return [];
70
+ }
71
+
72
+ const candidates = crs.flatMap((cr) =>
73
+ cr.touchedNodePaths
74
+ .filter((p) => isSkillDoc(p) && !released.has(folderOf(p)))
75
+ .map((p) => ({ cr, docPath: p, folder: folderOf(p) })),
76
+ );
77
+ if (candidates.length === 0) return [];
78
+
79
+ const wsId = workspaceIdForBranch(DEFAULT_BRANCH);
80
+ const authorHash = hashEmail(email);
81
+
82
+ // One access load for every candidate. Resolved on the DEFAULT branch —
83
+ // the tree as it stands is what says who owns the group the skill is
84
+ // arriving into; the branch's own copy is written by the proposer and must
85
+ // not get a say in who reviews them.
86
+ const writable = await this.accessControl
87
+ .canWriteBatch(
88
+ wsId,
89
+ email,
90
+ candidates.map((c) => c.docPath),
91
+ )
92
+ .catch(() => new Map<string, boolean>());
93
+
94
+ // The branch copies are read through the default branch's clone at
95
+ // `origin/<branch>`, so a proposal never needs a clone of its own. Refresh
96
+ // first, best-effort: a request pushed seconds ago is worth one fetch, and
97
+ // a failed fetch should degrade to "read what we have".
98
+ await this.workspaceService.ensureRemotesFetched(wsId).catch(() => undefined);
99
+
100
+ const out: PendingSkill[] = [];
101
+ for (const { cr, docPath, folder } of candidates) {
102
+ const isAuthor = !!cr.authorId && cr.authorId === authorHash;
103
+ // Fail closed, matching the catalog: only an explicit `true` admits a
104
+ // reviewer. A missing entry (the batch skipped the path, the tree could
105
+ // not be read) means denied, never shown.
106
+ if (!isAuthor && writable.get(docPath) !== true) continue;
107
+
108
+ const raw = await this.readAt(wsId, cr.branch, docPath);
109
+ // Nothing to describe: the request touched the path but the file is gone
110
+ // at its head (proposed then withdrawn within the branch), or unreadable.
111
+ if (raw === null) continue;
112
+
113
+ const fm = parseSkillFrontmatter(raw);
114
+ const folderName = folder.split('/').pop() ?? folder;
115
+ const declared = resolveDeclaredId(fm.frontmatter, folderName);
116
+ out.push({
117
+ name: isSafeSkillName(declared) ? declared : folderName,
118
+ description: fm.description,
119
+ version: fm.version,
120
+ path: folder,
121
+ changeRequestNumber: cr.number,
122
+ branch: cr.branch,
123
+ authorName: cr.appAuthor?.name ?? cr.author.name ?? 'Someone',
124
+ createdAt: cr.createdAt,
125
+ isAuthor,
126
+ });
127
+ }
128
+ // Oldest first — the one that has been waiting longest is the one that
129
+ // needs answering, and a list that reorders as requests arrive moves under
130
+ // the pointer.
131
+ return out.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
132
+ }
133
+
134
+ /** The file at a branch, or null when it is absent/unreadable there. */
135
+ private async readAt(
136
+ wsId: string,
137
+ branch: string,
138
+ repoRelPath: string,
139
+ ): Promise<string | null> {
140
+ try {
141
+ return await this.workspaceService.readFileAtRef(wsId, `origin/${branch}`, repoRelPath);
142
+ } catch {
143
+ return null;
144
+ }
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Is this touched path a skill's own SKILL.md?
150
+ *
151
+ * `Groups/<group>/<skill>/SKILL.md` is the shallowest shape, hence four
152
+ * segments; deeper ones are skills nested in a category subfolder, which the
153
+ * catalog already supports. `Groups/<group>/SKILL.md` is NOT a skill — a group
154
+ * folder is not itself one.
155
+ */
156
+ function isSkillDoc(repoRelPath: string): boolean {
157
+ const segments = repoRelPath.split('/');
158
+ return (
159
+ segments.length >= 4 &&
160
+ segments[0] === GROUPS_DIR &&
161
+ segments[segments.length - 1] === SKILL_DOC
162
+ );
163
+ }
164
+
165
+ /** The skill folder holding a SKILL.md. */
166
+ function folderOf(skillDocPath: string): string {
167
+ return skillDocPath.slice(0, -(SKILL_DOC.length + 1));
168
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Skills are reusable specialist instructions living under `Skills/<name>/SKILL.md`
2
+ * Skills are reusable specialist instructions living under `Groups/<group>/<name>/SKILL.md`
3
3
  * in the KB repo. Discovery/loading is pinned to the DEFAULT branch — the catalog
4
4
  * is a single global, released set (a skill on a draft isn't discoverable until
5
5
  * merged). Progressive disclosure: `listSkills` = name + description (level 1),
@@ -12,7 +12,7 @@ export interface SkillSummary {
12
12
  name: string;
13
13
  description: string;
14
14
  version?: string;
15
- /** Repo-root-relative skill folder, e.g. `Skills/rfi`. */
15
+ /** Repo-root-relative skill folder, e.g. `Groups/Everyone/rfi`. */
16
16
  path: string;
17
17
  }
18
18
 
@@ -21,7 +21,7 @@ export interface Skill extends SkillSummary {
21
21
  body: string;
22
22
  /** Pre-approved tools from the `allowed-tools` frontmatter, if declared. */
23
23
  allowedTools?: string[];
24
- /** Repo-root-relative bundled file paths, e.g. `Skills/rfi/scripts/build_xlsx.py`. */
24
+ /** Repo-root-relative bundled file paths, e.g. `Groups/Everyone/rfi/scripts/build_xlsx.py`. */
25
25
  files: string[];
26
26
  }
27
27
 
@@ -40,6 +40,37 @@ export type GetSkillResult =
40
40
  | { ok: true; kind: 'file'; file: SkillFileContent }
41
41
  | { ok: false; error: 'not_found' | 'forbidden' | 'invalid_file' };
42
42
 
43
+ /**
44
+ * A skill that exists ONLY on an open change request's branch — proposed, not
45
+ * released. It is deliberately NOT a `SkillSummary` the catalog returns: the
46
+ * catalog is what agents load, and a skill nobody has approved must not be
47
+ * loadable. This is a review surface only.
48
+ *
49
+ * Who may see one is narrower than who may see the catalog: its author, and
50
+ * whoever could approve it. See `PendingSkillsService` for why that predicate
51
+ * is the access tree's answer rather than a group-admin check spelled out
52
+ * again here.
53
+ */
54
+ export interface PendingSkill extends SkillSummary {
55
+ /** The open change request that would release it. */
56
+ changeRequestNumber: number;
57
+ branch: string;
58
+ /** Display name of whoever opened the request (person or agent). */
59
+ authorName: string;
60
+ createdAt: string;
61
+ /** True when the caller opened the request themselves. */
62
+ isAuthor: boolean;
63
+ }
64
+
65
+ export interface IPendingSkillService {
66
+ /**
67
+ * Skills awaiting approval that `userEmail` is entitled to see — the ones
68
+ * they proposed, and the ones they could approve. Never throws: a review
69
+ * surface failing must not take the library down with it.
70
+ */
71
+ listPendingSkills(userEmail: string): Promise<PendingSkill[]>;
72
+ }
73
+
43
74
  export interface ISkillService {
44
75
  /**
45
76
  * The default-branch skill catalog. When `userEmail` is given, filtered to
@@ -1,6 +1,6 @@
1
1
  import express from 'express';
2
2
  import '../auth/auth.middleware.js'; // Express Request.userId / userEmail augmentation
3
- import type { ISkillService } from './skills.contract.js';
3
+ import type { IPendingSkillService, ISkillService } from './skills.contract.js';
4
4
 
5
5
  /**
6
6
  * Browser-facing (JWT) skill routes for the `/`-command menu, mounted behind
@@ -9,10 +9,14 @@ import type { ISkillService } from './skills.contract.js';
9
9
  * ONCE — the frontend never re-implements skill discovery or bypasses access.
10
10
  *
11
11
  * GET /api/skills → { skills: SkillSummary[] } (filtered to this user)
12
+ * GET /api/skills/pending → { skills: PendingSkill[] } (proposed, not released)
12
13
  * GET /api/skills/:name → GetSkillResult (body + files)
13
14
  * GET /api/skills/:name?file=… → GetSkillResult (a bundled file's content)
14
15
  */
15
- export function createSkillsRoutes(skillService: ISkillService): express.Router {
16
+ export function createSkillsRoutes(
17
+ skillService: ISkillService,
18
+ pendingSkills?: IPendingSkillService,
19
+ ): express.Router {
16
20
  const router = express.Router();
17
21
 
18
22
  router.get('/skills', async (req, res) => {
@@ -24,6 +28,23 @@ export function createSkillsRoutes(skillService: ISkillService): express.Router
24
28
  res.json({ skills: await skillService.listSkills(email) });
25
29
  });
26
30
 
31
+ /**
32
+ * BEFORE `/skills/:name`, and it has to stay there: Express matches in
33
+ * declaration order, so the parameterised route would otherwise swallow
34
+ * `pending` and answer "no such skill".
35
+ *
36
+ * Optional dependency so a host that composes its own service set gets an
37
+ * empty review shelf rather than a 404 the frontend has to special-case.
38
+ */
39
+ router.get('/skills/pending', async (req, res) => {
40
+ const email = req.userEmail;
41
+ if (!email) {
42
+ res.status(401).json({ error: 'Unauthenticated' });
43
+ return;
44
+ }
45
+ res.json({ skills: pendingSkills ? await pendingSkills.listPendingSkills(email) : [] });
46
+ });
47
+
27
48
  router.get('/skills/:name', async (req, res) => {
28
49
  const email = req.userEmail;
29
50
  if (!email) {
@@ -1,7 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import fs from 'node:fs/promises';
3
3
  import { parseDocument } from 'yaml';
4
- import { DEFAULT_BRANCH, SKILLS_DIR } from '@bevel-software/platform-shared';
4
+ import { DEFAULT_BRANCH, GROUPS_DIR } from '@bevel-software/platform-shared';
5
5
  import type { WorkspaceService } from '../workspace/workspace.service.js';
6
6
  import { workspaceIdForBranch } from '../workspace/workspace.service.js';
7
7
  import type { IAccessControl } from '../access/access-control.interface.js';
@@ -63,7 +63,7 @@ export class SkillService implements ISkillService {
63
63
  }
64
64
 
65
65
  async getSkill(userEmail: string, name: string, file?: string): Promise<GetSkillResult> {
66
- if (!isSafeName(name)) return { ok: false, error: 'not_found' };
66
+ if (!isSafeSkillName(name)) return { ok: false, error: 'not_found' };
67
67
  const found = (await this.scan()).find((s) => s.summary.name === name);
68
68
  if (!found) return { ok: false, error: 'not_found' };
69
69
 
@@ -103,8 +103,8 @@ export class SkillService implements ISkillService {
103
103
  }
104
104
 
105
105
  private async scanDisk(): Promise<ParsedSkill[]> {
106
- // Ensure the default-branch clone exists, then scan its Skills/ dir. Any
107
- // failure (no workspace, no Skills/ dir) degrades to an empty catalog — the
106
+ // Ensure the default-branch clone exists, then scan its Groups/ dir. Any
107
+ // failure (no workspace, no Groups/ dir) degrades to an empty catalog — the
108
108
  // manual/tools must never break because skills can't be read.
109
109
  let wsId: string;
110
110
  try {
@@ -112,18 +112,14 @@ export class SkillService implements ISkillService {
112
112
  } catch {
113
113
  return [];
114
114
  }
115
- const root = path.join(
116
- await this.workspaceService.getWorkspacePath(wsId),
117
- this.kbDirName,
118
- SKILLS_DIR,
119
- );
115
+ const kbRoot = path.join(await this.workspaceService.getWorkspacePath(wsId), this.kbDirName);
120
116
 
121
117
  // Skills may be grouped in category subfolders (each carrying its own
122
- // access.md), so a SKILL.md can live at any depth under Skills/. Walk the
118
+ // access.md), so a SKILL.md can live at any depth under the root. Walk the
123
119
  // tree and treat every folder that directly contains a SKILL.md as a skill;
124
120
  // don't descend past it — its inner files are bundled assets, not nested
125
121
  // skills. The skill name is the leaf folder name; its path is the full
126
- // Skills-relative folder (e.g. `Skills/Development/coding-guidelines`).
122
+ // repo-relative folder (e.g. `Groups/Development/coding-guidelines`).
127
123
  const out: ParsedSkill[] = [];
128
124
  const walk = async (dir: string, relFolder: string): Promise<void> => {
129
125
  let entries: import('node:fs').Dirent[];
@@ -146,7 +142,7 @@ export class SkillService implements ISkillService {
146
142
  // fall back to the folder name (a readdir entry, safe by construction)
147
143
  // to keep listing and lookup consistent.
148
144
  const declared = resolveDeclaredId(fm.frontmatter, path.basename(dir));
149
- const name = isSafeName(declared) ? declared : path.basename(dir);
145
+ const name = isSafeSkillName(declared) ? declared : path.basename(dir);
150
146
  out.push({
151
147
  summary: { name, description: fm.description, version: fm.version, path: relFolder },
152
148
  body: fm.body,
@@ -160,7 +156,7 @@ export class SkillService implements ISkillService {
160
156
  await walk(path.join(dir, entry.name), `${relFolder}/${entry.name}`);
161
157
  }
162
158
  };
163
- await walk(root, SKILLS_DIR);
159
+ await walk(path.join(kbRoot, GROUPS_DIR), GROUPS_DIR);
164
160
  // A skill's id (frontmatter `id`/`name`, else folder name) is how getSkill()
165
161
  // resolves it, so it must be unique. Sort by (name, path) for a deterministic
166
162
  // winner, then REFUSE later duplicates via the shared dedup — the same rule
@@ -181,8 +177,14 @@ export class SkillService implements ISkillService {
181
177
 
182
178
  // --- helpers ------------------------------------------------------------------
183
179
 
184
- /** Skill names are folder names; reject anything that could escape the folder. */
185
- function isSafeName(name: string): boolean {
180
+ /**
181
+ * Skill names are folder names; reject anything that could escape the folder.
182
+ *
183
+ * Exported because the pending-skill surface resolves a name from a SKILL.md
184
+ * that is not on disk yet and must land on the SAME id the catalog will give it
185
+ * once merged — a second copy of this rule would drift.
186
+ */
187
+ export function isSafeSkillName(name: string): boolean {
186
188
  return name.length > 0 && !name.includes('/') && !name.includes('\\') && name !== '.' && name !== '..';
187
189
  }
188
190
 
@@ -198,7 +200,13 @@ function scalarToString(value: unknown): string | undefined {
198
200
  return undefined;
199
201
  }
200
202
 
201
- function parseSkillFrontmatter(raw: string): {
203
+ /**
204
+ * Exported for the pending-skill surface, which parses a SKILL.md read at a
205
+ * change request's ref rather than off disk. Same parser deliberately: a
206
+ * proposed skill must be described by the same rules that will describe it once
207
+ * it is released, or the card in review and the card after approval disagree.
208
+ */
209
+ export function parseSkillFrontmatter(raw: string): {
202
210
  description: string;
203
211
  version?: string;
204
212
  allowedTools?: string[];
@@ -0,0 +1,262 @@
1
+ import type { Server as HttpServer } from 'node:http';
2
+ import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import express from 'express';
6
+ import { afterEach, describe, expect, it, test, vi } from 'vitest';
7
+ import { DEFAULT_BRANCH } from '@bevel-software/platform-shared';
8
+ import { createToolManualsBrowserRoutes } from '../tool-manuals.routes.js';
9
+ import { ToolManualService } from '../tool-manuals.service.js';
10
+ import { workspaceIdForBranch } from '../../workspace/workspace.service.js';
11
+ import type { WorkspaceService } from '../../workspace/workspace.service.js';
12
+ import type { IAccessControl } from '../../access/access-control.interface.js';
13
+ import type { IToolManualService, ToolManualDetail } from '../tool-manuals.contract.js';
14
+
15
+ /**
16
+ * `GET /api/tools/:slug` — the browser tool page's detail read. Two halves:
17
+ * the ROUTE (auth gating + the fail-closed 404, over a stub service on a real
18
+ * loopback server, the harness style the other route tests use) and the
19
+ * SERVICE's capability derivation (over a real temp KB, because deriving
20
+ * capabilities from author-written `tools` is the only logic here worth
21
+ * testing and a stub would test nothing).
22
+ */
23
+
24
+ const DETAIL: ToolManualDetail = {
25
+ slug: 'github',
26
+ name: 'github',
27
+ path: 'Groups/Engineering/github.tool',
28
+ type: 'inline',
29
+ description: 'Read and write GitHub issues and PRs.',
30
+ capabilities: [{ name: 'create_issue', description: 'Open an issue in a repo.' }],
31
+ };
32
+
33
+ let httpServer: HttpServer | undefined;
34
+
35
+ /** Mount the browser routes behind a fake auth middleware; `email` undefined ⇒ unauthenticated. */
36
+ async function baseUrlAs(
37
+ email: string | undefined,
38
+ getDetail: IToolManualService['getDetail'],
39
+ ): Promise<string> {
40
+ const app = express();
41
+ app.use(express.json());
42
+ app.use((req, _res, next) => {
43
+ if (email) req.userEmail = email;
44
+ next();
45
+ });
46
+ const toolManualService = { getDetail } as unknown as IToolManualService;
47
+ app.use('/api', createToolManualsBrowserRoutes(toolManualService));
48
+ httpServer = await new Promise<HttpServer>((resolve) => {
49
+ const s = app.listen(0, () => resolve(s));
50
+ });
51
+ const port = (httpServer.address() as { port: number }).port;
52
+ return `http://127.0.0.1:${port}`;
53
+ }
54
+
55
+ afterEach(async () => {
56
+ if (httpServer) await new Promise<void>((r) => httpServer!.close(() => r()));
57
+ httpServer = undefined;
58
+ vi.restoreAllMocks();
59
+ });
60
+
61
+ describe('GET /api/tools/:slug — route', () => {
62
+ it('returns 200 { tool } with description and capabilities for a readable tool', async () => {
63
+ const base = await baseUrlAs('reader@x.com', async () => DETAIL);
64
+ const res = await fetch(`${base}/api/tools/github`);
65
+ expect(res.status).toBe(200);
66
+ expect(await res.json()).toEqual({ tool: DETAIL });
67
+ });
68
+
69
+ it('passes the caller email and the slug through to the service', async () => {
70
+ const getDetail = vi.fn(async () => DETAIL);
71
+ const base = await baseUrlAs('reader@x.com', getDetail);
72
+ await fetch(`${base}/api/tools/github`);
73
+ expect(getDetail).toHaveBeenCalledWith('reader@x.com', 'github');
74
+ });
75
+
76
+ it('404s an unknown slug', async () => {
77
+ const base = await baseUrlAs('reader@x.com', async () => null);
78
+ const res = await fetch(`${base}/api/tools/nope`);
79
+ expect(res.status).toBe(404);
80
+ expect(await res.json()).toEqual({ error: 'Not found' });
81
+ });
82
+
83
+ it('404s — identically — a tool the caller cannot read', async () => {
84
+ // Fail-closed: a caller without read access gets the SAME response an
85
+ // absent slug produces, so a 404 can never confirm a hidden tool exists.
86
+ const unreadable = await baseUrlAs('outsider@x.com', async () => null);
87
+ const unknownRes = await fetch(`${unreadable}/api/tools/github`);
88
+ expect(unknownRes.status).toBe(404);
89
+ expect(await unknownRes.json()).toEqual({ error: 'Not found' });
90
+ });
91
+
92
+ it('401s without an authenticated email', async () => {
93
+ const getDetail = vi.fn(async () => DETAIL);
94
+ const base = await baseUrlAs(undefined, getDetail);
95
+ const res = await fetch(`${base}/api/tools/github`);
96
+ expect(res.status).toBe(401);
97
+ expect(await res.json()).toEqual({ error: 'Not authenticated' });
98
+ expect(getDetail).not.toHaveBeenCalled(); // never even asks the service
99
+ });
100
+
101
+ it('500s with a generic message when the service throws', async () => {
102
+ vi.spyOn(console, 'error').mockImplementation(() => {});
103
+ const base = await baseUrlAs('reader@x.com', async () => {
104
+ throw new Error('disk on fire');
105
+ });
106
+ const res = await fetch(`${base}/api/tools/github`);
107
+ expect(res.status).toBe(500);
108
+ expect(await res.json()).toEqual({ error: 'Failed to load tool' });
109
+ });
110
+
111
+ it('does not shadow the literal POST /tools/preview sibling', async () => {
112
+ // The param route is GET-only, so the draft-validation route still gets its
113
+ // own POSTs — the one registration-order hazard this route introduces.
114
+ const preview = vi.fn(async () => ({ ok: true }));
115
+ const app = express();
116
+ app.use(express.json());
117
+ app.use((req, _res, next) => {
118
+ req.userEmail = 'reader@x.com';
119
+ next();
120
+ });
121
+ app.use(
122
+ '/api',
123
+ createToolManualsBrowserRoutes({
124
+ getDetail: async () => DETAIL,
125
+ preview,
126
+ } as unknown as IToolManualService),
127
+ );
128
+ httpServer = await new Promise<HttpServer>((resolve) => {
129
+ const s = app.listen(0, () => resolve(s));
130
+ });
131
+ const port = (httpServer.address() as { port: number }).port;
132
+ const res = await fetch(`http://127.0.0.1:${port}/api/tools/preview`, {
133
+ method: 'POST',
134
+ headers: { 'Content-Type': 'application/json' },
135
+ body: JSON.stringify({ content: '{}' }),
136
+ });
137
+ expect(res.status).toBe(200);
138
+ expect(await res.json()).toEqual({ ok: true });
139
+ expect(preview).toHaveBeenCalledWith('{}');
140
+ });
141
+ });
142
+
143
+ describe('ToolManualService.getDetail — capabilities + access', () => {
144
+ const KB_DIR = 'knowledge-base';
145
+ const wsId = workspaceIdForBranch(DEFAULT_BRANCH);
146
+ let root: string;
147
+
148
+ const workspaceService = {
149
+ getOrCreateForBranch: async () => ({ id: wsId }),
150
+ getWorkspacePath: async (id: string) => join(root, id),
151
+ } as unknown as WorkspaceService;
152
+
153
+ const allowAll: IAccessControl = {
154
+ canRead: async () => true,
155
+ canReadBatch: async (_w: string, _e: string, paths: string[]) => new Map(paths.map((p) => [p, true])),
156
+ } as unknown as IAccessControl;
157
+
158
+ const denyAll: IAccessControl = {
159
+ canRead: async () => false,
160
+ canReadBatch: async (_w: string, _e: string, paths: string[]) => new Map(paths.map((p) => [p, false])),
161
+ } as unknown as IAccessControl;
162
+
163
+ const svc = (access: IAccessControl = allowAll) => new ToolManualService(workspaceService, access, KB_DIR);
164
+
165
+ /** Write one `.tool` into a fresh temp KB and return the service over it. */
166
+ async function withTool(file: string, content: string): Promise<void> {
167
+ root = await mkdtemp(join(tmpdir(), 'tooldetail-'));
168
+ const tools = join(root, wsId, KB_DIR, 'Groups');
169
+ await mkdir(tools, { recursive: true });
170
+ await writeFile(join(tools, file), content);
171
+ }
172
+
173
+ afterEach(() => rm(root, { recursive: true, force: true }));
174
+
175
+ test('an inline tool reports its description and its embedded tools as capabilities', async () => {
176
+ await withTool(
177
+ 'github.tool',
178
+ JSON.stringify({
179
+ name: 'github',
180
+ type: 'inline',
181
+ description: ' Read and write GitHub issues and PRs. ',
182
+ tools: [
183
+ { name: 'create_issue', description: 'Open an issue in a repo.' },
184
+ { name: 'list_prs' }, // no description → null, still a capability
185
+ ],
186
+ }),
187
+ );
188
+ const detail = await svc().getDetail('user@x.eu', 'github');
189
+ expect(detail).toMatchObject({
190
+ slug: 'github',
191
+ name: 'github',
192
+ path: 'Groups/github.tool',
193
+ type: 'inline',
194
+ description: 'Read and write GitHub issues and PRs.',
195
+ capabilities: [
196
+ { name: 'create_issue', description: 'Open an issue in a repo.' },
197
+ { name: 'list_prs', description: null },
198
+ ],
199
+ });
200
+ });
201
+
202
+ test('description is null (not undefined) when the file declares none', async () => {
203
+ await withTool('bare.tool', JSON.stringify({ name: 'bare', type: 'inline', tools: [] }));
204
+ const detail = await svc().getDetail('user@x.eu', 'bare');
205
+ expect(detail?.description).toBeNull();
206
+ expect(detail?.capabilities).toEqual([]);
207
+ });
208
+
209
+ test('http and mcp manuals report no capabilities (their tools resolve at call time)', async () => {
210
+ await withTool('billing.tool', JSON.stringify({ name: 'billing', type: 'http', url: 'https://api.example.com/utcp' }));
211
+ expect((await svc().getDetail('user@x.eu', 'billing'))?.capabilities).toEqual([]);
212
+ await withTool('remote.tool', JSON.stringify({ name: 'remote', type: 'mcp', url: 'https://mcp.example.com/mcp' }));
213
+ expect((await svc().getDetail('user@x.eu', 'remote'))?.capabilities).toEqual([]);
214
+ });
215
+
216
+ test('malformed `tools` entries are skipped, not rendered as blank bullets', async () => {
217
+ // `tools` is `unknown[]` — only validated when actually served as a UTCP
218
+ // manual — so the page's derivation has to re-check every entry itself.
219
+ await withTool(
220
+ 'messy.tool',
221
+ JSON.stringify({
222
+ name: 'messy',
223
+ type: 'inline',
224
+ tools: [
225
+ null,
226
+ 'just a string',
227
+ ['an', 'array'],
228
+ { description: 'nameless' },
229
+ { name: 42 },
230
+ { name: ' ' },
231
+ { name: 'ok', description: 7 }, // non-string description → null
232
+ { name: 'fine', description: 'Works.' },
233
+ ],
234
+ }),
235
+ );
236
+ expect((await svc().getDetail('user@x.eu', 'messy'))?.capabilities).toEqual([
237
+ { name: 'ok', description: null },
238
+ { name: 'fine', description: 'Works.' },
239
+ ]);
240
+ });
241
+
242
+ test('caps capabilities at 100 so one `.tool` cannot produce an unbounded page', async () => {
243
+ await withTool(
244
+ 'many.tool',
245
+ JSON.stringify({
246
+ name: 'many',
247
+ type: 'inline',
248
+ tools: Array.from({ length: 150 }, (_, i) => ({ name: `t${i}` })),
249
+ }),
250
+ );
251
+ const caps = (await svc().getDetail('user@x.eu', 'many'))!.capabilities;
252
+ expect(caps).toHaveLength(100);
253
+ expect(caps[0].name).toBe('t0');
254
+ expect(caps[99].name).toBe('t99');
255
+ });
256
+
257
+ test('returns null for an unknown slug and for a tool the caller cannot read', async () => {
258
+ await withTool('github.tool', JSON.stringify({ name: 'github', type: 'inline', tools: [] }));
259
+ expect(await svc().getDetail('user@x.eu', 'nosuchtool')).toBeNull();
260
+ expect(await svc(denyAll).getDetail('user@x.eu', 'github')).toBeNull();
261
+ });
262
+ });
@@ -46,7 +46,7 @@ describe('ToolManualService — MCP OAuth auto-discovery decoration', () => {
46
46
 
47
47
  beforeEach(async () => {
48
48
  root = await mkdtemp(join(tmpdir(), 'tools-oauth-'));
49
- const toolsDir = join(root, wsId, KB_DIR, 'Tools');
49
+ const toolsDir = join(root, wsId, KB_DIR, 'Groups');
50
50
  await mkdir(toolsDir, { recursive: true });
51
51
  await writeFile(join(toolsDir, 'notion.tool'), BARE_MCP_TOOL);
52
52
  await writeFile(join(toolsDir, 'jira.tool'), KEYED_MCP_TOOL);
@@ -109,7 +109,7 @@ describe('ToolManualService — MCP OAuth auto-discovery decoration', () => {
109
109
  });
110
110
 
111
111
  test('referenced-but-undeclared ${VAR}s are auto-surfaced as admin keys (any tool type)', async () => {
112
- const toolsDir = join(root, wsId, KB_DIR, 'Tools');
112
+ const toolsDir = join(root, wsId, KB_DIR, 'Groups');
113
113
  await writeFile(
114
114
  join(toolsDir, 'billing.tool'),
115
115
  JSON.stringify({