@bevel-software/platform-core-backend 0.7.5 → 0.8.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 (230) hide show
  1. package/LICENSE +202 -202
  2. package/THIRD-PARTY-NOTICES.md +428 -454
  3. package/dist/core/core-ports.d.ts +1 -1
  4. package/dist/core/create-core-server.d.ts.map +1 -1
  5. package/dist/core/create-core-server.js +51 -7
  6. package/dist/core/create-core-server.js.map +1 -1
  7. package/dist/core/create-core-services.d.ts +5 -3
  8. package/dist/core/create-core-services.d.ts.map +1 -1
  9. package/dist/core/create-core-services.js +30 -18
  10. package/dist/core/create-core-services.js.map +1 -1
  11. package/dist/modules/access/access-control.interface.d.ts +8 -7
  12. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  13. package/dist/modules/access/access-control.service.d.ts +1 -1
  14. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  15. package/dist/modules/access/access-control.service.js +6 -6
  16. package/dist/modules/access/access-control.service.js.map +1 -1
  17. package/dist/modules/access/access-declarations.d.ts +5 -5
  18. package/dist/modules/access/access-declarations.js +3 -3
  19. package/dist/modules/access/access-mutation.service.d.ts +3 -3
  20. package/dist/modules/access/access-mutation.service.d.ts.map +1 -1
  21. package/dist/modules/access/access-mutation.service.js +3 -3
  22. package/dist/modules/access/access-mutation.service.js.map +1 -1
  23. package/dist/modules/access/access-splice.js +4 -4
  24. package/dist/modules/access/access-splice.js.map +1 -1
  25. package/dist/modules/access/access.routes.js +19 -19
  26. package/dist/modules/access/access.routes.js.map +1 -1
  27. package/dist/modules/access/creator-access.d.ts +2 -2
  28. package/dist/modules/access/creator-access.js +5 -5
  29. package/dist/modules/access/creator-access.js.map +1 -1
  30. package/dist/modules/access/roles-admin.service.d.ts +18 -4
  31. package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
  32. package/dist/modules/access/roles-admin.service.js +14 -4
  33. package/dist/modules/access/roles-admin.service.js.map +1 -1
  34. package/dist/modules/code-mode/code-mode-names.d.ts +5 -13
  35. package/dist/modules/code-mode/code-mode-names.d.ts.map +1 -1
  36. package/dist/modules/code-mode/code-mode-names.js +5 -27
  37. package/dist/modules/code-mode/code-mode-names.js.map +1 -1
  38. package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -1
  39. package/dist/modules/code-mode/code-mode.tool.js +29 -7
  40. package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
  41. package/dist/modules/mcp/mcp.service.d.ts +11 -52
  42. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  43. package/dist/modules/mcp/mcp.service.js +33 -395
  44. package/dist/modules/mcp/mcp.service.js.map +1 -1
  45. package/dist/modules/plugins/index.d.ts +7 -0
  46. package/dist/modules/plugins/index.d.ts.map +1 -0
  47. package/dist/modules/plugins/index.js +6 -0
  48. package/dist/modules/plugins/index.js.map +1 -0
  49. package/dist/modules/plugins/join-proposals.d.ts +53 -0
  50. package/dist/modules/plugins/join-proposals.d.ts.map +1 -0
  51. package/dist/modules/plugins/join-proposals.js +67 -0
  52. package/dist/modules/plugins/join-proposals.js.map +1 -0
  53. package/dist/modules/plugins/join-requests.service.d.ts +81 -0
  54. package/dist/modules/plugins/join-requests.service.d.ts.map +1 -0
  55. package/dist/modules/plugins/join-requests.service.js +135 -0
  56. package/dist/modules/plugins/join-requests.service.js.map +1 -0
  57. package/dist/modules/plugins/plugin-provision.service.d.ts +134 -0
  58. package/dist/modules/plugins/plugin-provision.service.d.ts.map +1 -0
  59. package/dist/modules/plugins/plugin-provision.service.js +344 -0
  60. package/dist/modules/plugins/plugin-provision.service.js.map +1 -0
  61. package/dist/modules/plugins/plugins.contract.d.ts +106 -0
  62. package/dist/modules/plugins/plugins.contract.d.ts.map +1 -0
  63. package/dist/modules/plugins/plugins.contract.js +36 -0
  64. package/dist/modules/plugins/plugins.contract.js.map +1 -0
  65. package/dist/modules/plugins/plugins.routes.d.ts +42 -0
  66. package/dist/modules/plugins/plugins.routes.d.ts.map +1 -0
  67. package/dist/modules/plugins/plugins.routes.js +379 -0
  68. package/dist/modules/plugins/plugins.routes.js.map +1 -0
  69. package/dist/modules/plugins/plugins.service.d.ts +60 -0
  70. package/dist/modules/plugins/plugins.service.d.ts.map +1 -0
  71. package/dist/modules/plugins/plugins.service.js +172 -0
  72. package/dist/modules/plugins/plugins.service.js.map +1 -0
  73. package/dist/modules/skills/pending-skills.service.d.ts +2 -2
  74. package/dist/modules/skills/pending-skills.service.js +7 -7
  75. package/dist/modules/skills/pending-skills.service.js.map +1 -1
  76. package/dist/modules/skills/skills.contract.d.ts +4 -4
  77. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  78. package/dist/modules/skills/skills.contract.js +1 -1
  79. package/dist/modules/skills/skills.service.js +5 -5
  80. package/dist/modules/skills/skills.service.js.map +1 -1
  81. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts +65 -0
  82. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -0
  83. package/dist/modules/tool-manuals/mcp-json-discovery.js +276 -0
  84. package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -0
  85. package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts +92 -0
  86. package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -0
  87. package/dist/modules/tool-manuals/mcp-server-edit.service.js +328 -0
  88. package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -0
  89. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +38 -12
  90. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  91. package/dist/modules/tool-manuals/tool-manuals.contract.js +1 -1
  92. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts +13 -2
  93. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -1
  94. package/dist/modules/tool-manuals/tool-manuals.routes.js +233 -2
  95. package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -1
  96. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  97. package/dist/modules/tool-manuals/tool-manuals.service.js +74 -37
  98. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  99. package/dist/modules/tool-manuals/tool-manuals.tools.js +6 -3
  100. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  101. package/dist/modules/workflow/git/git.service.js +2 -2
  102. package/dist/modules/workflow/git/git.service.js.map +1 -1
  103. package/dist/modules/workspace/kb-seed.service.d.ts +2 -2
  104. package/dist/modules/workspace/kb-seed.service.d.ts.map +1 -1
  105. package/dist/modules/workspace/kb-seed.service.js +43 -10
  106. package/dist/modules/workspace/kb-seed.service.js.map +1 -1
  107. package/dist/modules/workspace/plugins-migration.d.ts +50 -0
  108. package/dist/modules/workspace/plugins-migration.d.ts.map +1 -0
  109. package/dist/modules/workspace/plugins-migration.js +379 -0
  110. package/dist/modules/workspace/plugins-migration.js.map +1 -0
  111. package/dist/modules/workspace/workspace.routes.js +3 -3
  112. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  113. package/dist/modules/workspace/workspace.service.d.ts +26 -0
  114. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  115. package/dist/modules/workspace/workspace.service.js +83 -12
  116. package/dist/modules/workspace/workspace.service.js.map +1 -1
  117. package/dist/shared/kb-layout.test.js +3 -3
  118. package/dist/shared/kb-layout.test.js.map +1 -1
  119. package/dist/shared/utcp-namespace.d.ts +6 -27
  120. package/dist/shared/utcp-namespace.d.ts.map +1 -1
  121. package/dist/shared/utcp-namespace.js +6 -63
  122. package/dist/shared/utcp-namespace.js.map +1 -1
  123. package/dist/shared/variable-refs.d.ts +42 -0
  124. package/dist/shared/variable-refs.d.ts.map +1 -0
  125. package/dist/shared/variable-refs.js +60 -0
  126. package/dist/shared/variable-refs.js.map +1 -0
  127. package/kb-template/.bevelignore +1 -1
  128. package/kb-template/AGENTS.md +88 -35
  129. package/kb-template/KnowledgeBase/How to get started.md +10 -10
  130. package/kb-template/access.md +36 -36
  131. package/migrations/meta/0000_snapshot.json +1479 -1479
  132. package/package.json +5 -4
  133. package/src/assets.ts +25 -25
  134. package/src/core/core-ports.ts +106 -106
  135. package/src/core/create-core-server.ts +55 -9
  136. package/src/core/create-core-services.ts +40 -20
  137. package/src/index.ts +69 -69
  138. package/src/modules/access/__tests__/access-control.atref-batch.test.ts +98 -98
  139. package/src/modules/access/__tests__/access-declarations.test.ts +28 -28
  140. package/src/modules/access/__tests__/access-md-format.test.ts +18 -18
  141. package/src/modules/access/__tests__/access-mutation.service.test.ts +5 -5
  142. package/src/modules/access/__tests__/access-splice.test.ts +2 -2
  143. package/src/modules/access/__tests__/access.routes.overrides.test.ts +16 -16
  144. package/src/modules/access/__tests__/grant-sources.test.ts +12 -12
  145. package/src/modules/access/__tests__/roles-admin.service.test.ts +13 -1
  146. package/src/modules/access/access-control.interface.ts +8 -7
  147. package/src/modules/access/access-control.service.ts +7 -7
  148. package/src/modules/access/access-declarations.ts +5 -5
  149. package/src/modules/access/access-mutation.service.ts +3 -3
  150. package/src/modules/access/access-splice.ts +4 -4
  151. package/src/modules/access/access.routes.ts +20 -20
  152. package/src/modules/access/creator-access.ts +5 -5
  153. package/src/modules/access/roles-admin.service.ts +13 -2
  154. package/src/modules/admin/admin-access.routes.ts +29 -29
  155. package/src/modules/auth/__tests__/auth.routes.test.ts +91 -91
  156. package/src/modules/auth/__tests__/rate-limit.test.ts +36 -36
  157. package/src/modules/auth/rate-limit.ts +45 -45
  158. package/src/modules/code-mode/__tests__/code-mode.tool.test.ts +67 -0
  159. package/src/modules/code-mode/code-mode-names.ts +10 -36
  160. package/src/modules/code-mode/code-mode.tool.ts +27 -7
  161. package/src/modules/database/connection.ts +15 -15
  162. package/src/modules/database/schema.ts +11 -11
  163. package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +150 -150
  164. package/src/modules/mcp/mcp.service.ts +57 -435
  165. package/src/modules/{groups → plugins}/__tests__/join-proposals.test.ts +1 -1
  166. package/src/modules/{groups → plugins}/__tests__/join-requests.service.test.ts +7 -7
  167. package/src/modules/{groups/__tests__/group-index.service.test.ts → plugins/__tests__/plugin-index.service.test.ts} +41 -41
  168. package/src/modules/plugins/__tests__/plugin-provision.service.test.ts +312 -0
  169. package/src/modules/{groups/__tests__/groups.routes.test.ts → plugins/__tests__/plugins.routes.test.ts} +100 -100
  170. package/src/modules/plugins/index.ts +17 -0
  171. package/src/modules/{groups → plugins}/join-proposals.ts +2 -2
  172. package/src/modules/{groups → plugins}/join-requests.service.ts +8 -8
  173. package/src/modules/{groups/group-provision.service.ts → plugins/plugin-provision.service.ts} +139 -69
  174. package/src/modules/{groups/groups.contract.ts → plugins/plugins.contract.ts} +26 -26
  175. package/src/modules/{groups/groups.routes.ts → plugins/plugins.routes.ts} +102 -102
  176. package/src/modules/{groups/groups.service.ts → plugins/plugins.service.ts} +43 -43
  177. package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +143 -143
  178. package/src/modules/skills/__tests__/pending-skills.service.test.ts +14 -14
  179. package/src/modules/skills/__tests__/skills.service.test.ts +13 -13
  180. package/src/modules/skills/pending-skills.service.ts +7 -7
  181. package/src/modules/skills/skills.contract.ts +4 -4
  182. package/src/modules/skills/skills.service.ts +5 -5
  183. package/src/modules/tool-auth/llm-usage-meter.ts +19 -19
  184. package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +198 -0
  185. package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +346 -0
  186. package/src/modules/tool-manuals/__tests__/tool-manuals.archive.route.test.ts +101 -0
  187. package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +3 -3
  188. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +2 -2
  189. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +27 -27
  190. package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +3 -3
  191. package/src/modules/tool-manuals/mcp-json-discovery.ts +328 -0
  192. package/src/modules/tool-manuals/mcp-server-edit.service.ts +434 -0
  193. package/src/modules/tool-manuals/tool-manuals.contract.ts +35 -12
  194. package/src/modules/tool-manuals/tool-manuals.routes.ts +222 -1
  195. package/src/modules/tool-manuals/tool-manuals.service.ts +82 -42
  196. package/src/modules/tool-manuals/tool-manuals.tools.ts +6 -3
  197. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +1 -1
  198. package/src/modules/workflow/git/__tests__/branch-name.test.ts +3 -3
  199. package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +3 -3
  200. package/src/modules/workflow/git/__tests__/git.service.commitFile.test.ts +13 -8
  201. package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +1 -1
  202. package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +1 -1
  203. package/src/modules/workflow/git/git.service.ts +2 -2
  204. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +1 -1
  205. package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +1 -1
  206. package/src/modules/workflow/workflow-hooks.ts +101 -101
  207. package/src/modules/workspace/__tests__/kb-seed.service.test.ts +81 -13
  208. package/src/modules/workspace/__tests__/plugins-migration.test.ts +427 -0
  209. package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +237 -237
  210. package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +236 -236
  211. package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +179 -179
  212. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +320 -320
  213. package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +337 -337
  214. package/src/modules/workspace/__tests__/workspace.service.test.ts +116 -0
  215. package/src/modules/workspace/bevel-ignore.ts +66 -66
  216. package/src/modules/workspace/kb-seed.service.ts +38 -9
  217. package/src/modules/workspace/plugins-migration.ts +479 -0
  218. package/src/modules/workspace/session-sink.ts +25 -25
  219. package/src/modules/workspace/workspace.routes.ts +3 -3
  220. package/src/modules/workspace/workspace.service.ts +85 -14
  221. package/src/modules/workspace/workspace.tools.ts +922 -922
  222. package/src/shared/__tests__/join-request.test.ts +13 -13
  223. package/src/shared/__tests__/kb-layout.plugin.test.ts +45 -0
  224. package/src/shared/kb-layout.test.ts +3 -3
  225. package/src/shared/utcp-namespace.ts +10 -68
  226. package/src/shared/variable-refs.ts +64 -0
  227. package/src/modules/groups/__tests__/group-provision.service.test.ts +0 -247
  228. package/src/modules/groups/index.ts +0 -17
  229. package/src/shared/__tests__/kb-layout.group.test.ts +0 -45
  230. /package/kb-template/{Groups → Plugins}/.gitkeep +0 -0
@@ -3,7 +3,7 @@ import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { DEFAULT_BRANCH } from '@bevel-software/platform-shared';
6
- import { GroupIndexService } from '../groups.service.js';
6
+ import { PluginIndexService } from '../plugins.service.js';
7
7
  import { workspaceIdForBranch } from '../../workspace/workspace.service.js';
8
8
  import type { WorkspaceService } from '../../workspace/workspace.service.js';
9
9
  import type { IAccessControl } from '../../access/access-control.interface.js';
@@ -26,7 +26,7 @@ function tools(...paths: string[]): ToolManualSummary[] {
26
26
  });
27
27
  }
28
28
 
29
- describe('GroupIndexService', () => {
29
+ describe('PluginIndexService', () => {
30
30
  let root: string;
31
31
 
32
32
  const workspaceService = {
@@ -52,7 +52,7 @@ describe('GroupIndexService', () => {
52
52
  tools?: ToolManualSummary[];
53
53
  workspace?: WorkspaceService;
54
54
  } = {}) =>
55
- new GroupIndexService(
55
+ new PluginIndexService(
56
56
  opts.workspace ?? workspaceService,
57
57
  opts.access ?? principals,
58
58
  skillService(opts.skills),
@@ -62,68 +62,68 @@ describe('GroupIndexService', () => {
62
62
 
63
63
  const kb = () => join(root, wsId, KB_DIR);
64
64
 
65
- /** A REAL group: a folder carrying the access.md that makes it exist. */
66
- const groupDir = async (name: string) => {
67
- await mkdir(join(kb(), 'Groups', name), { recursive: true });
68
- await writeFile(join(kb(), 'Groups', name, 'access.md'), '---\nread:\n - everyone\n---\n');
65
+ /** A REAL plugin: a folder carrying the access.md that makes it exist. */
66
+ const pluginDir = async (name: string) => {
67
+ await mkdir(join(kb(), 'Plugins', name), { recursive: true });
68
+ await writeFile(join(kb(), 'Plugins', name, 'access.md'), '---\nread:\n - everyone\n---\n');
69
69
  };
70
70
 
71
71
  beforeEach(async () => {
72
- root = await mkdtemp(join(tmpdir(), 'group-index-'));
72
+ root = await mkdtemp(join(tmpdir(), 'plugin-index-'));
73
73
  await mkdir(kb(), { recursive: true });
74
74
  });
75
75
  afterEach(() => rm(root, { recursive: true, force: true }));
76
76
 
77
- test('enumerates Groups/ folders carrying an access.md, sorted by name', async () => {
78
- await groupDir('GTM');
79
- await groupDir('Engineering');
77
+ test('enumerates Plugins/ folders carrying an access.md, sorted by name', async () => {
78
+ await pluginDir('GTM');
79
+ await pluginDir('Engineering');
80
80
 
81
81
  const catalog = await svc().catalog();
82
82
  expect(catalog.map((g) => g.name)).toEqual(['Engineering', 'GTM']);
83
- expect(catalog[1].folders).toEqual(['Groups/GTM']);
83
+ expect(catalog[1].folders).toEqual(['Plugins/GTM']);
84
84
  });
85
85
 
86
- test('a folder without an access.md is not a group', async () => {
87
- // The residue a deleted group leaves behind: git cannot record an empty
86
+ test('a folder without an access.md is not a plugin', async () => {
87
+ // The residue a deleted plugin leaves behind: git cannot record an empty
88
88
  // directory, so the folder outlives its files on live checkouts — and a
89
- // folder with content but no access.md is just as much a non-group.
90
- await mkdir(join(kb(), 'Groups', 'Ghost'), { recursive: true });
91
- await mkdir(join(kb(), 'Groups', 'Residue'), { recursive: true });
92
- await writeFile(join(kb(), 'Groups', 'Residue', 'notes.md'), 'leftover');
93
- await groupDir('Real');
89
+ // folder with content but no access.md is just as much a non-plugin.
90
+ await mkdir(join(kb(), 'Plugins', 'Ghost'), { recursive: true });
91
+ await mkdir(join(kb(), 'Plugins', 'Residue'), { recursive: true });
92
+ await writeFile(join(kb(), 'Plugins', 'Residue', 'notes.md'), 'leftover');
93
+ await pluginDir('Real');
94
94
 
95
95
  const catalog = await svc().catalog();
96
96
  expect(catalog.map((g) => g.name)).toEqual(['Real']);
97
97
  });
98
98
 
99
- test('the retired Skills/ and Tools/ roots are NOT group roots', async () => {
99
+ test('the retired Skills/ and Tools/ roots are NOT plugin roots', async () => {
100
100
  await mkdir(join(kb(), 'Skills', 'GTM'), { recursive: true });
101
101
  await mkdir(join(kb(), 'Tools', 'GTM'), { recursive: true });
102
- await groupDir('Engineering');
102
+ await pluginDir('Engineering');
103
103
 
104
104
  const catalog = await svc().catalog();
105
105
  expect(catalog.map((g) => g.name)).toEqual(['Engineering']);
106
106
  });
107
107
 
108
- test('ignores loose files and dot-dirs under the group root', async () => {
108
+ test('ignores loose files and dot-dirs under the plugin root', async () => {
109
109
  // The dot filter wins even over a folder that carries an access.md.
110
- await mkdir(join(kb(), 'Groups', '.hidden'), { recursive: true });
111
- await writeFile(join(kb(), 'Groups', '.hidden', 'access.md'), '---\nread:\n - everyone\n---\n');
112
- await groupDir('GTM');
113
- await writeFile(join(kb(), 'Groups', 'slack.tool'), '{}');
110
+ await mkdir(join(kb(), 'Plugins', '.hidden'), { recursive: true });
111
+ await writeFile(join(kb(), 'Plugins', '.hidden', 'access.md'), '---\nread:\n - everyone\n---\n');
112
+ await pluginDir('GTM');
113
+ await writeFile(join(kb(), 'Plugins', 'slack.tool'), '{}');
114
114
 
115
115
  const catalog = await svc().catalog();
116
116
  expect(catalog.map((g) => g.name)).toEqual(['GTM']);
117
117
  });
118
118
 
119
- test('counts skills and tools from the global catalogs by groupOfPath', async () => {
120
- await groupDir('GTM');
121
- await groupDir('Product');
119
+ test('counts skills and tools from the global catalogs by pluginOfPath', async () => {
120
+ await pluginDir('GTM');
121
+ await pluginDir('Product');
122
122
 
123
123
  const catalog = await svc({
124
- skills: skills('Groups/GTM/outreach', 'Groups/GTM/newsletter', 'Groups/Product/roadmap'),
125
- // `Groups/slack.tool` is ungrouped (two segments) — it counts nowhere.
126
- tools: tools('Groups/GTM/heyreach.tool', 'Groups/slack.tool'),
124
+ skills: skills('Plugins/GTM/outreach', 'Plugins/GTM/newsletter', 'Plugins/Product/roadmap'),
125
+ // `Plugins/slack.tool` is ungrouped (two segments) — it counts nowhere.
126
+ tools: tools('Plugins/GTM/heyreach.tool', 'Plugins/slack.tool'),
127
127
  }).catalog();
128
128
 
129
129
  const gtm = catalog.find((g) => g.name === 'GTM')!;
@@ -134,8 +134,8 @@ describe('GroupIndexService', () => {
134
134
  expect(product.toolCount).toBe(0);
135
135
  });
136
136
 
137
- test('resolves principals on the group folder', async () => {
138
- await groupDir('GTM');
137
+ test('resolves principals on the plugin folder', async () => {
138
+ await pluginDir('GTM');
139
139
 
140
140
  const seen: string[] = [];
141
141
  const access = {
@@ -148,13 +148,13 @@ describe('GroupIndexService', () => {
148
148
  } as unknown as IAccessControl;
149
149
 
150
150
  const catalog = await svc({ access }).catalog();
151
- expect(catalog[0].folders).toEqual(['Groups/GTM']);
152
- expect(seen).toEqual(['Groups/GTM']);
151
+ expect(catalog[0].folders).toEqual(['Plugins/GTM']);
152
+ expect(seen).toEqual(['Plugins/GTM']);
153
153
  expect(catalog[0].owners.users).toEqual([OLGA]);
154
154
  expect(catalog[0].readers).toEqual({ restricted: false, roles: [], users: [] });
155
155
  });
156
156
 
157
- test('a missing Groups/ root yields an empty list, not an error', async () => {
157
+ test('a missing Plugins/ root yields an empty list, not an error', async () => {
158
158
  await expect(svc().catalog()).resolves.toEqual([]);
159
159
  });
160
160
 
@@ -172,7 +172,7 @@ describe('GroupIndexService', () => {
172
172
  });
173
173
 
174
174
  test('a failed scan is served but NOT cached — the next call retries', async () => {
175
- await groupDir('GTM');
175
+ await pluginDir('GTM');
176
176
  let fail = true;
177
177
  const flaky = {
178
178
  // Fails once, the way a default-branch clone mid-creation does, then
@@ -211,12 +211,12 @@ describe('GroupIndexService', () => {
211
211
  });
212
212
 
213
213
  test('caches for the TTL and rescans after invalidate()', async () => {
214
- await groupDir('GTM');
214
+ await pluginDir('GTM');
215
215
  const service = svc();
216
216
 
217
217
  expect((await service.catalog()).map((g) => g.name)).toEqual(['GTM']);
218
- // A group added out of band is NOT seen while the cache holds…
219
- await groupDir('Finance');
218
+ // A plugin added out of band is NOT seen while the cache holds…
219
+ await pluginDir('Finance');
220
220
  expect((await service.catalog()).map((g) => g.name)).toEqual(['GTM']);
221
221
  // …and IS seen once the file-change subscriber drops it.
222
222
  service.invalidate();
@@ -0,0 +1,312 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { DEFAULT_BRANCH, personalPluginFolderName } from '@bevel-software/platform-shared';
6
+ import type { AuthUser } from '@bevel-software/platform-shared';
7
+ import type { WorkspaceService } from '../../workspace/workspace.service.js';
8
+ import type { IAccessControl } from '../../access/access-control.interface.js';
9
+ import {
10
+ PluginProvisionError,
11
+ PluginProvisionService,
12
+ pluginAccessMd,
13
+ personalAccessMd,
14
+ } from '../plugin-provision.service.js';
15
+
16
+ const KB = 'knowledge-base';
17
+ const USER: AuthUser = { id: 'u1-abcd', email: 'ali@example.com', name: 'Ali Vega' } as AuthUser;
18
+
19
+ /**
20
+ * The service is exercised against a REAL temp directory (the existence
21
+ * check and the rollback are filesystem semantics, not mockable branches),
22
+ * with the workspace, commit and access dependencies stubbed at their seams.
23
+ */
24
+ async function makeHarness() {
25
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-provision-'));
26
+ const writeFile = vi.fn(
27
+ async (_id: string, rel: string, content: string, opts?: { failIfExists?: boolean }) => {
28
+ const abs = path.join(dir, rel);
29
+ await fs.mkdir(path.dirname(abs), { recursive: true });
30
+ try {
31
+ await fs.writeFile(abs, content, { encoding: 'utf-8', flag: opts?.failIfExists ? 'wx' : 'w' });
32
+ } catch (err) {
33
+ if ((err as NodeJS.ErrnoException).code === 'EEXIST') {
34
+ const conflict: Error & { status?: number } = new Error(`"${rel}" already exists.`);
35
+ conflict.status = 409;
36
+ throw conflict;
37
+ }
38
+ throw err;
39
+ }
40
+ },
41
+ );
42
+ const workspaceService = {
43
+ getOrCreateForBranch: vi.fn(async () => ({ id: 'ws-main' })),
44
+ getWorkspacePath: vi.fn(async () => dir),
45
+ writeFile,
46
+ } as unknown as WorkspaceService;
47
+ const commits = { runPendingCommit: vi.fn(async () => undefined) };
48
+ const accessControl = { invalidate: vi.fn() } as unknown as IAccessControl;
49
+ const events = { emit: vi.fn() };
50
+ const svc = new PluginProvisionService(workspaceService, commits, accessControl, KB, events);
51
+ return { svc, dir, commits, accessControl, events, writeFile };
52
+ }
53
+
54
+ describe('PluginProvisionService.createPlugin', () => {
55
+ let h: Awaited<ReturnType<typeof makeHarness>>;
56
+ beforeEach(async () => {
57
+ h = await makeHarness();
58
+ });
59
+
60
+ it('writes the discoverable template, commits it inline, and drops the access cache', async () => {
61
+ const result = await h.svc.createPlugin(USER, 'GTM');
62
+ expect(result).toEqual({ folder: 'GTM', created: true });
63
+
64
+ const accessMd = await fs.readFile(path.join(h.dir, KB, 'Plugins/GTM/access.md'), 'utf-8');
65
+ // Discoverable FILE (frontmatter read: everyone), creator-run FOLDER
66
+ // (body names the creator under all three verbs).
67
+ expect(accessMd.startsWith('---\nread:\n - everyone\n---\n')).toBe(true);
68
+ const body = accessMd.slice(accessMd.indexOf('---\n', 4) + 4);
69
+ for (const verb of ['read', 'write', 'owner']) {
70
+ expect(body).toMatch(new RegExp(`${verb}:[\\s\\S]*Ali Vega <ali@example.com>`));
71
+ }
72
+
73
+ // The commit ran INLINE — the gate reads at HEAD, so an async commit
74
+ // would 403 the creator's very next write into the folder.
75
+ // `systemAuthorized`: the endpoint is the authorization — without it the
76
+ // push gate reads origin (where the folder does not exist) and refuses
77
+ // every non-admin the product promised a plugin to.
78
+ expect(h.commits.runPendingCommit).toHaveBeenCalledWith(
79
+ 'ws-main',
80
+ DEFAULT_BRANCH,
81
+ // FOLDER-scoped: plugin.json must land in the same commit as the rules,
82
+ // or the folder is briefly a plugin to us and not to any other client.
83
+ `${KB}/Plugins/GTM`,
84
+ USER,
85
+ { systemAuthorized: true },
86
+ );
87
+ expect(h.accessControl.invalidate).toHaveBeenCalledWith('ws-main');
88
+ });
89
+
90
+ it('writes a conformant plugin.json naming the folder in slug form', async () => {
91
+ await h.svc.createPlugin(USER, 'GTM');
92
+ const manifest = JSON.parse(
93
+ await fs.readFile(path.join(h.dir, KB, 'Plugins/GTM/plugin.json'), 'utf-8'),
94
+ );
95
+ expect(manifest).toEqual({
96
+ $schema: 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json',
97
+ name: 'gtm',
98
+ });
99
+ // The schema's `name` pattern is the thing a conformant client refuses on.
100
+ expect(manifest.name).toMatch(/^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/);
101
+ });
102
+
103
+ it('refuses a taken name case-insensitively with 409', async () => {
104
+ await h.svc.createPlugin(USER, 'GTM');
105
+ await expect(h.svc.createPlugin(USER, 'gtm')).rejects.toMatchObject({ status: 409 });
106
+ });
107
+
108
+ it('refuses a name whose manifest slug is already claimed by another folder with 409', async () => {
109
+ // The manifest `name` is a LOSSY slug of the folder — `Sales Team` and
110
+ // `Sales-Team` both become `sales-team` — and it is the identity a
111
+ // conformant client keys plugins on. Folder uniqueness alone would let
112
+ // two plugins publish one name.
113
+ await h.svc.createPlugin(USER, 'Sales Team');
114
+ await expect(h.svc.createPlugin(USER, 'Sales-Team')).rejects.toMatchObject({ status: 409 });
115
+ await expect(h.svc.createPlugin(USER, 'Sales_Team')).rejects.toMatchObject({ status: 409 });
116
+ // Only the first folder landed.
117
+ expect(await fs.readdir(path.join(h.dir, KB, 'Plugins'))).toEqual(['Sales Team']);
118
+ // A genuinely distinct slug still goes through.
119
+ await expect(h.svc.createPlugin(USER, 'Sales Ops')).resolves.toEqual({
120
+ folder: 'Sales Ops',
121
+ created: true,
122
+ });
123
+ });
124
+
125
+ it('serialises concurrent creations of two SPELLINGS of one slug — exactly one lands', async () => {
126
+ // Different lowercased names take different name-keyed locks; only a
127
+ // slug-keyed lock makes the twin check above hold under concurrency.
128
+ const results = await Promise.allSettled([
129
+ h.svc.createPlugin(USER, 'Growth Team'),
130
+ h.svc.createPlugin(USER, 'Growth-Team'),
131
+ ]);
132
+ const ok = results.filter((r) => r.status === 'fulfilled');
133
+ const failed = results.filter(
134
+ (r) => r.status === 'rejected' && (r.reason as { status?: number }).status === 409,
135
+ );
136
+ expect(ok).toHaveLength(1);
137
+ expect(failed).toHaveLength(1);
138
+ expect(await fs.readdir(path.join(h.dir, KB, 'Plugins'))).toHaveLength(1);
139
+ });
140
+
141
+ it('a loose file at the Plugins root claims no slug — "Slack Tool" is not its twin', async () => {
142
+ // Loose files are not plugins (the catalog skips them); only a real
143
+ // plugin FOLDER publishes a manifest identity.
144
+ await fs.mkdir(path.join(h.dir, KB, 'Plugins'), { recursive: true });
145
+ await fs.writeFile(path.join(h.dir, KB, 'Plugins', 'slack.tool'), 'id: slack\n', 'utf-8');
146
+ await expect(h.svc.createPlugin(USER, 'Slack Tool')).resolves.toEqual({
147
+ folder: 'Slack Tool',
148
+ created: true,
149
+ });
150
+ });
151
+
152
+ it('refuses names the filesystem or the model cannot carry with 422', async () => {
153
+ for (const bad of ['', ' ', 'a/b', 'a\\b', '.', '..', '.hidden', 'personal-anything', 'Personal Abc', 'a\u0000b', 'a\tb']) {
154
+ await expect(h.svc.createPlugin(USER, bad)).rejects.toBeInstanceOf(PluginProvisionError);
155
+ await expect(h.svc.createPlugin(USER, bad)).rejects.toMatchObject({ status: 422 });
156
+ }
157
+ // Nothing landed on disk for any of them.
158
+ await expect(fs.readdir(path.join(h.dir, KB, 'Plugins'))).rejects.toThrow();
159
+ });
160
+
161
+ it('serialises concurrent creations of one name in different casings — exactly one lands', async () => {
162
+ // On a case-sensitive filesystem `GTM` and `gtm` are different paths, so
163
+ // the wx writes alone would BOTH succeed; the per-name lock is what makes
164
+ // the case-insensitive uniqueness hold under concurrency.
165
+ const results = await Promise.allSettled([
166
+ h.svc.createPlugin(USER, 'GTM'),
167
+ h.svc.createPlugin(USER, 'gtm'),
168
+ ]);
169
+ const ok = results.filter((r) => r.status === 'fulfilled');
170
+ const refused = results.filter(
171
+ (r) => r.status === 'rejected' && (r.reason as PluginProvisionError).status === 409,
172
+ );
173
+ expect(ok).toHaveLength(1);
174
+ expect(refused).toHaveLength(1);
175
+ expect(await fs.readdir(path.join(h.dir, KB, 'Plugins'))).toHaveLength(1);
176
+ });
177
+
178
+ it('rolls the seeded file back when the commit fails, so a retry is not told "already exists"', async () => {
179
+ h.commits.runPendingCommit.mockRejectedValueOnce(new Error('push refused'));
180
+ await expect(h.svc.createPlugin(USER, 'GTM')).rejects.toThrow('push refused');
181
+ // The folder is gone again — the next attempt starts clean.
182
+ await expect(fs.stat(path.join(h.dir, KB, 'Plugins/GTM'))).rejects.toThrow();
183
+ await expect(h.svc.createPlugin(USER, 'GTM')).resolves.toEqual({ folder: 'GTM', created: true });
184
+ });
185
+ });
186
+
187
+ describe('PluginProvisionService.deletePlugin', () => {
188
+ let h: Awaited<ReturnType<typeof makeHarness>>;
189
+ beforeEach(async () => {
190
+ h = await makeHarness();
191
+ });
192
+
193
+ it('removes the whole folder in ONE folder-scoped commit, and drops the access cache', async () => {
194
+ await h.svc.createPlugin(USER, 'GTM');
195
+ // A plugin with content — the delete takes the skills with the folder.
196
+ await fs.mkdir(path.join(h.dir, KB, 'Plugins/GTM/outreach'), { recursive: true });
197
+ await fs.writeFile(path.join(h.dir, KB, 'Plugins/GTM/outreach/SKILL.md'), '# outreach\n');
198
+ h.commits.runPendingCommit.mockClear();
199
+ (h.accessControl.invalidate as ReturnType<typeof vi.fn>).mockClear();
200
+
201
+ await h.svc.deletePlugin(USER, 'GTM');
202
+
203
+ await expect(fs.stat(path.join(h.dir, KB, 'Plugins/GTM'))).rejects.toThrow();
204
+ // No parked remnant either — the commit landed, so the bytes may go.
205
+ expect(await fs.readdir(path.join(h.dir, KB, 'Plugins'))).toEqual([]);
206
+ // FOLDER-scoped (`git add -- <folder>` stages every deletion under it),
207
+ // inline, and `systemAuthorized` — the endpoint already authorized the
208
+ // delete (owner verdict), and the per-user push gate would re-read the
209
+ // very access.md this commit removes.
210
+ expect(h.commits.runPendingCommit).toHaveBeenCalledWith(
211
+ 'ws-main',
212
+ DEFAULT_BRANCH,
213
+ `${KB}/Plugins/GTM`,
214
+ USER,
215
+ { systemAuthorized: true },
216
+ );
217
+ expect(h.accessControl.invalidate).toHaveBeenCalledWith('ws-main');
218
+ expect(h.events.emit).toHaveBeenCalledWith(
219
+ expect.objectContaining({ kind: 'fs-tree-changed' }),
220
+ );
221
+ });
222
+
223
+ it('refuses an unknown name — and a casing mismatch, which is the same thing — with 404', async () => {
224
+ await h.svc.createPlugin(USER, 'GTM');
225
+ for (const name of ['Nope', 'gtm']) {
226
+ await expect(h.svc.deletePlugin(USER, name)).rejects.toMatchObject({ status: 404 });
227
+ }
228
+ expect(await fs.readdir(path.join(h.dir, KB, 'Plugins'))).toEqual(['GTM']);
229
+ });
230
+
231
+ it('never deletes a personal folder through the plugin door', async () => {
232
+ await h.svc.ensurePersonalPlugin(USER);
233
+ const folder = personalPluginFolderName(USER.id);
234
+ await expect(h.svc.deletePlugin(USER, folder)).rejects.toMatchObject({ status: 404 });
235
+ await expect(fs.stat(path.join(h.dir, KB, 'Plugins', folder))).resolves.toBeDefined();
236
+ });
237
+
238
+ it('puts the folder back, content intact, when the commit is refused', async () => {
239
+ await h.svc.createPlugin(USER, 'GTM');
240
+ await fs.mkdir(path.join(h.dir, KB, 'Plugins/GTM/outreach'), { recursive: true });
241
+ await fs.writeFile(path.join(h.dir, KB, 'Plugins/GTM/outreach/SKILL.md'), '# outreach\n');
242
+ h.commits.runPendingCommit.mockRejectedValueOnce(new Error('push refused'));
243
+
244
+ await expect(h.svc.deletePlugin(USER, 'GTM')).rejects.toThrow('push refused');
245
+
246
+ // A failed delete is a NO-OP: origin still carries the plugin, so the
247
+ // working tree must too — bytes included, not just the folder shell.
248
+ expect(
249
+ await fs.readFile(path.join(h.dir, KB, 'Plugins/GTM/outreach/SKILL.md'), 'utf-8'),
250
+ ).toBe('# outreach\n');
251
+ // And the retry goes through.
252
+ await expect(h.svc.deletePlugin(USER, 'GTM')).resolves.toBeUndefined();
253
+ await expect(fs.stat(path.join(h.dir, KB, 'Plugins/GTM'))).rejects.toThrow();
254
+ });
255
+ });
256
+
257
+ describe('PluginProvisionService.ensurePersonalPlugin', () => {
258
+ it('creates the private personal folder once, then reports it as existing', async () => {
259
+ const h = await makeHarness();
260
+ const folder = personalPluginFolderName(USER.id);
261
+
262
+ const first = await h.svc.ensurePersonalPlugin(USER);
263
+ expect(first).toEqual({ folder, created: true });
264
+ const accessMd = await fs.readFile(
265
+ path.join(h.dir, KB, 'Plugins', folder, 'access.md'),
266
+ 'utf-8',
267
+ );
268
+ // PRIVATE: no `everyone` self-grant anywhere — the file follows the
269
+ // folder chain, and the rules close that chain to the owner alone.
270
+ expect(accessMd).not.toContain('everyone');
271
+ for (const verb of ['read', 'write', 'owner']) {
272
+ expect(accessMd).toMatch(new RegExp(`${verb}:[\\s\\S]*Ali Vega <ali@example.com>`));
273
+ }
274
+
275
+ const second = await h.svc.ensurePersonalPlugin(USER);
276
+ expect(second).toEqual({ folder, created: false });
277
+ // Idempotent for real: one provision (access.md + plugin.json), one commit.
278
+ expect(h.writeFile).toHaveBeenCalledTimes(2);
279
+ expect(h.commits.runPendingCommit).toHaveBeenCalledTimes(1);
280
+ // A personal folder is a plugin too — its id is slugged from the folder,
281
+ // which is where a doubled separator would have produced an invalid name.
282
+ const manifest = JSON.parse(
283
+ await fs.readFile(path.join(h.dir, KB, 'Plugins', folder, 'plugin.json'), 'utf-8'),
284
+ );
285
+ expect(manifest.name).toMatch(/^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/);
286
+ });
287
+
288
+ it('two concurrent ensures both succeed — one creates, the other reports existing', async () => {
289
+ const h = await makeHarness();
290
+ const folder = personalPluginFolderName(USER.id);
291
+ const [a, b] = await Promise.all([
292
+ h.svc.ensurePersonalPlugin(USER),
293
+ h.svc.ensurePersonalPlugin(USER),
294
+ ]);
295
+ expect([a.created, b.created].sort()).toEqual([false, true]);
296
+ expect(a.folder).toBe(folder);
297
+ expect(b.folder).toBe(folder);
298
+ expect(h.commits.runPendingCommit).toHaveBeenCalledTimes(1);
299
+ });
300
+ });
301
+
302
+ describe('access.md templates', () => {
303
+ it('plugin template is discoverable, personal template is not — same creator grants in both', () => {
304
+ const plugin = pluginAccessMd(USER);
305
+ const personal = personalAccessMd(USER);
306
+ expect(plugin).toContain('everyone');
307
+ expect(personal).not.toContain('everyone');
308
+ for (const text of [plugin, personal]) {
309
+ expect(text).toContain('Ali Vega <ali@example.com>');
310
+ }
311
+ });
312
+ });