@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
@@ -1,19 +1,19 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { isJoinBranchFor, joinBranchFor, kebabGroupName } from '@bevel-software/platform-shared';
2
+ import { isJoinBranchFor, joinBranchFor, kebabPluginName } from '@bevel-software/platform-shared';
3
3
  import { assertValidBranchName } from '../../modules/workflow/git/branch-name.js';
4
4
 
5
5
  /**
6
6
  * The join-branch convention. Two properties matter, and they pull in
7
7
  * different directions:
8
8
  *
9
- * - UNIQUENESS (the write side): distinct requesters and distinct groups must
9
+ * - UNIQUENESS (the write side): distinct requesters and distinct plugins must
10
10
  * never share a branch, or one person's commit lands on another's request.
11
11
  * Both inputs the name is built from are lossy; the requester tag fixes it.
12
- * - EXACT GROUP IDENTITY (the read side): the settle decision is "the diff
13
- * is empty", so matching a branch to the WRONG group reads an unchanged
12
+ * - EXACT PLUGIN IDENTITY (the read side): the settle decision is "the diff
13
+ * is empty", so matching a branch to the WRONG plugin reads an unchanged
14
14
  * file, sees an empty diff, and destroys somebody else's request. The
15
- * group tag is recomputed from the exact name so shape-matching can never
16
- * cross groups; only the requester half matches by shape (the reader has
15
+ * plugin tag is recomputed from the exact name so shape-matching can never
16
+ * cross plugins; only the requester half matches by shape (the reader has
17
17
  * no email to recompute it from).
18
18
  */
19
19
  const TAG = '[0-9a-z]{7}';
@@ -37,19 +37,19 @@ describe('joinBranchFor', () => {
37
37
  );
38
38
  });
39
39
 
40
- it('separates groups whose names kebab to the same slug', () => {
40
+ it('separates plugins whose names kebab to the same slug', () => {
41
41
  expect(joinBranchFor('ali@bevel.software', 'Finance!')).not.toBe(
42
42
  joinBranchFor('ali@bevel.software', 'Finance'),
43
43
  );
44
- // Case is part of a group's identity (the folder name is case-sensitive).
44
+ // Case is part of a plugin's identity (the folder name is case-sensitive).
45
45
  expect(joinBranchFor('ali@bevel.software', 'finance')).not.toBe(
46
46
  joinBranchFor('ali@bevel.software', 'Finance'),
47
47
  );
48
48
  });
49
49
 
50
- it('still yields a valid, matchable branch for a group with no alphanumerics', () => {
50
+ it('still yields a valid, matchable branch for a plugin with no alphanumerics', () => {
51
51
  const branch = joinBranchFor('ali@bevel.software', '!!!');
52
- expect(kebabGroupName('!!!')).toBe('');
52
+ expect(kebabPluginName('!!!')).toBe('');
53
53
  expect(branch).toMatch(new RegExp(`^ali/join-${TAG}-${TAG}$`));
54
54
  expect(() => assertValidBranchName(branch)).not.toThrow();
55
55
  expect(isJoinBranchFor(branch, '!!!')).toBe(true);
@@ -62,16 +62,16 @@ describe('isJoinBranchFor', () => {
62
62
  expect(isJoinBranchFor(joinBranchFor('ali@other.com', 'GTM'), 'GTM')).toBe(true);
63
63
  });
64
64
 
65
- it('rejects a branch for a different group', () => {
65
+ it('rejects a branch for a different plugin', () => {
66
66
  expect(isJoinBranchFor(joinBranchFor('ali@bevel.software', 'Finance'), 'GTM')).toBe(false);
67
67
  });
68
68
 
69
- it('NEVER crosses slug-colliding groups — the destructive-settle regression', () => {
69
+ it('NEVER crosses slug-colliding plugins — the destructive-settle regression', () => {
70
70
  // `Finance!`, `finance` and `Finance` all slug to `finance`. A listing
71
71
  // for one that matched another's branch would read that branch's copy of
72
72
  // ITS OWN access.md (unchanged there), see an empty diff, and settle —
73
73
  // closing a change request and deleting a branch belonging to a
74
- // different group. The recomputed group tag is what forbids the match.
74
+ // different plugin. The recomputed plugin tag is what forbids the match.
75
75
  const forBang = joinBranchFor('ali@bevel.software', 'Finance!');
76
76
  expect(isJoinBranchFor(forBang, 'finance')).toBe(false);
77
77
  expect(isJoinBranchFor(forBang, 'Finance')).toBe(false);
@@ -0,0 +1,45 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { pluginOfPath, PLUGINS_DIR } from '@bevel-software/platform-shared';
3
+
4
+ /**
5
+ * `pluginOfPath` is the whole of the grouping contract — the sidebar, the
6
+ * catalog buckets and the access story all read a plugin off a path with it.
7
+ * The cases that matter are the ones where "has a plugin" and "is under the
8
+ * plugin root" come apart.
9
+ */
10
+ describe('pluginOfPath', () => {
11
+ it('reads the plugin from the Plugins root', () => {
12
+ expect(pluginOfPath(`${PLUGINS_DIR}/GTM/heyreach-campaign/SKILL.md`)).toBe('GTM');
13
+ expect(pluginOfPath(`${PLUGINS_DIR}/GTM/heyreach.tool`)).toBe('GTM');
14
+ expect(pluginOfPath(`${PLUGINS_DIR}/Engineering/review/architecture-review`)).toBe(
15
+ 'Engineering',
16
+ );
17
+ });
18
+
19
+ it('returns null for content directly under the root — it has no plugin folder', () => {
20
+ // The regression this guards: `Plugins/slack.tool` must NOT report a plugin
21
+ // called "slack.tool", and `Plugins/GTM` must not report itself.
22
+ expect(pluginOfPath(`${PLUGINS_DIR}/slack.tool`)).toBeNull();
23
+ expect(pluginOfPath(`${PLUGINS_DIR}/GTM`)).toBeNull();
24
+ expect(pluginOfPath(PLUGINS_DIR)).toBeNull();
25
+ });
26
+
27
+ it('returns null outside the plugin root', () => {
28
+ expect(pluginOfPath('KnowledgeBase/Product/Knowledge/Foo.md')).toBeNull();
29
+ expect(pluginOfPath('Data/Engineering/Knowledge/Bar.md')).toBeNull();
30
+ // The retired pre-merge roots are ordinary non-plugin folders now.
31
+ expect(pluginOfPath('Skills/GTM/heyreach-campaign')).toBeNull();
32
+ expect(pluginOfPath('Tools/NewsAgent/serper.tool')).toBeNull();
33
+ expect(pluginOfPath('access.md')).toBeNull();
34
+ expect(pluginOfPath('')).toBeNull();
35
+ });
36
+
37
+ it('is not fooled by a prefix match on the root name', () => {
38
+ expect(pluginOfPath('PluginsOld/GTM/x')).toBeNull();
39
+ });
40
+
41
+ it('tolerates leading and doubled separators', () => {
42
+ expect(pluginOfPath(`/${PLUGINS_DIR}/GTM/x`)).toBe('GTM');
43
+ expect(pluginOfPath(`${PLUGINS_DIR}//GTM//x`)).toBe('GTM');
44
+ });
45
+ });
@@ -17,7 +17,7 @@ describe('ontologyOf', () => {
17
17
  });
18
18
 
19
19
  it('treats a nested ontology directory as the full prefix before the marker', () => {
20
- expect(ontologyOf('KnowledgeBase/Group/Sub/Knowledge/x.md')).toBe('KnowledgeBase/Group/Sub');
20
+ expect(ontologyOf('KnowledgeBase/Plugin/Sub/Knowledge/x.md')).toBe('KnowledgeBase/Plugin/Sub');
21
21
  });
22
22
 
23
23
  it('returns null when the marker sits directly under KnowledgeBase/', () => {
@@ -41,8 +41,8 @@ describe('ontologyOf', () => {
41
41
  expect(ontologyOf('KnowledgeBase/Ops/Knowledge/x.md')).toBe('KnowledgeBase/Ops');
42
42
  });
43
43
 
44
- it('returns null for Groups and root config (neutral)', () => {
45
- expect(ontologyOf('Groups/some-skill/SKILL.md')).toBeNull();
44
+ it('returns null for Plugins and root config (neutral)', () => {
45
+ expect(ontologyOf('Plugins/some-skill/SKILL.md')).toBeNull();
46
46
  expect(ontologyOf('access.md')).toBeNull();
47
47
  expect(ontologyOf('roles.yaml')).toBeNull();
48
48
  });
@@ -1,21 +1,15 @@
1
1
  /**
2
- * The key UTCP uses to look up a manual's `${VAR}`. `@utcp/sdk` first SANITIZES
3
- * the manual name — every non-word char (`-`, `.`, spaces, …) becomes `_`
4
- * (`manualCallTemplate.name.replace(/[^\w]/g, "_")` at registration) — and then
5
- * namespaces variables under `<namespace>_<VAR>` where every `_` in the
6
- * sanitized namespace is DOUBLED (the variable substitutor), so `my_tool` + `KEY`
7
- * → `my__tool_KEY` and `my-tool` + `KEY` → the very same `my__tool_KEY`. Our
8
- * secret storage AND scope resolution must derive the exact same key. For an
9
- * alphanumeric name (no `_`, no punctuation) this reduces to `<name>_<VAR>`,
10
- * so plain tool names are unaffected.
2
+ * UTCP namespacing and the Bevel-hosted seeding rule now live in
3
+ * `@bevel-software/platform-mcp-core`: the local MCP server has to make the
4
+ * SAME bearer-leak decision this file documents, and a security rule that
5
+ * exists in two copies is a security rule that will eventually differ.
6
+ * Re-exported here because this is the path the rest of the package imports.
11
7
  */
12
- export function utcpNamespacePrefix(manualName: string): string {
13
- return `${manualName.replace(/[^\w]/g, '_').replace(/_/g, '__')}_`;
14
- }
15
-
16
- export function utcpNamespacedKey(manualName: string, varName: string): string {
17
- return `${utcpNamespacePrefix(manualName)}${varName}`;
18
- }
8
+ export {
9
+ utcpNamespacePrefix,
10
+ utcpNamespacedKey,
11
+ seedBevelHostedManualVars,
12
+ } from '@bevel-software/platform-mcp-core';
19
13
 
20
14
  /**
21
15
  * The synthetic per-user variable MCP OAuth auto-discovery hangs a remote
@@ -34,55 +28,3 @@ export const MCP_OAUTH_VAR = 'MCP_OAUTH';
34
28
  * connector creds under the shared namespace) can't drift.
35
29
  */
36
30
  export const INTERNAL_MANUAL_NAME = 'Bevel';
37
-
38
- /**
39
- * The origin template WE mint for every Bevel-hosted manual's discovery URL
40
- * (`${API_URL}/api/…`). `${API_URL}` expands to the loopback origin, so a
41
- * Bevel-hosted URL always has this exact string as its scheme+authority.
42
- */
43
- const LOOPBACK_ORIGIN_TEMPLATE = '${API_URL}';
44
-
45
- /**
46
- * True only when `${API_URL}` is the URL's ORIGIN (scheme+authority) — the shape
47
- * we ourselves produce for a Bevel-hosted manual. A third-party `.tool` author
48
- * can embed the literal `${API_URL}` anywhere in a path or query
49
- * (`http://evil.example/?x=${API_URL}`) but can NOT make it the authority without
50
- * pointing the request back at our own loopback, so anchoring the trust decision
51
- * to the origin is what a user `.tool`'s URL text cannot forge.
52
- */
53
- function isBevelHostedUrl(url: string): boolean {
54
- if (!url.startsWith(LOOPBACK_ORIGIN_TEMPLATE)) return false;
55
- // The char right after the origin must delimit the authority; anything else
56
- // (`${API_URL}.evil.com`, `${API_URL}evil`) is a different, untrusted host.
57
- const next = url.charAt(LOOPBACK_ORIGIN_TEMPLATE.length);
58
- return next === '' || next === '/' || next === '?' || next === '#';
59
- }
60
-
61
- /**
62
- * Seed the loopback discovery vars (`<ns>_API_URL` + `<ns>_CONNECTION_KEY`) for
63
- * every Bevel-HOSTED manual in `manuals` — one whose discovery `url` targets our
64
- * own backend with `${API_URL}` as its ORIGIN (the KB manual and inline `.tool`
65
- * sub-manuals). A third-party http/mcp `.tool` points at an arbitrary URL, so it
66
- * is deliberately NOT seeded: handing it `connectionKey` would leak the bearer to
67
- * that endpoint. Keying off the origin (not a bare `includes('${API_URL}')`) is
68
- * deliberate — an `http` `.tool`'s URL is author-controlled, so a substring match
69
- * would let `http://attacker/?x=${API_URL}` be seeded the bearer and exfiltrate
70
- * it. This is the exfiltration-safe seeding rule shared by the external MCP proxy
71
- * and the in-process agent factory, so the bearer-leak decision is written ONCE.
72
- */
73
- export function seedBevelHostedManualVars(
74
- manuals: readonly { name?: unknown; url?: unknown }[],
75
- loopbackBaseUrl: string,
76
- connectionKey: string,
77
- ): Record<string, string> {
78
- const variables: Record<string, string> = {};
79
- for (const m of manuals) {
80
- const name = typeof m.name === 'string' ? m.name : '';
81
- if (!name) continue;
82
- const url = (m as { url?: unknown }).url;
83
- if (typeof url !== 'string' || !isBevelHostedUrl(url)) continue;
84
- variables[utcpNamespacedKey(name, 'API_URL')] = loopbackBaseUrl;
85
- variables[utcpNamespacedKey(name, 'CONNECTION_KEY')] = connectionKey;
86
- }
87
- return variables;
88
- }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The ONE variable-reference grammar, shared by every boundary that has to
3
+ * decide "would the substitutor expand this?" — `.tool` parsing, `mcp.json`
4
+ * discovery, the server editor's header split, and the Groups→Plugins
5
+ * migration. The decision has credential stakes on both sides (a reference
6
+ * mis-read as prose is a `${VAR}` written into portable, world-readable
7
+ * `mcp.json`; prose mis-read as a reference is a header that stops working),
8
+ * so the classifiers must not each keep their own approximation of it.
9
+ */
10
+
11
+ /**
12
+ * The SDK substitutor's reference grammar, exactly: `${VAR}` or `$VAR`, names
13
+ * `[a-zA-Z0-9_]+`. Note a LEADING DIGIT is legal — `$5TOKEN` (and `$5` in
14
+ * prose) is a reference as far as substitution is concerned, and classifying
15
+ * it as anything else here would diverge from what actually expands.
16
+ */
17
+ export const VARIABLE_REFERENCE_RE = /\$\{([a-zA-Z0-9_]+)\}|\$([a-zA-Z0-9_]+)/g;
18
+
19
+ // `.test()` on a global regex is stateful (lastIndex persists across calls) —
20
+ // the predicate gets its own non-global compilation of the same source.
21
+ const VARIABLE_REFERENCE_ONCE = new RegExp(VARIABLE_REFERENCE_RE.source);
22
+
23
+ /** True when any substring of `text` is a substitutor reference. */
24
+ export function containsVariableReference(text: string): boolean {
25
+ return VARIABLE_REFERENCE_ONCE.test(text);
26
+ }
27
+
28
+ /**
29
+ * Variable names the platform seeds for its own (Bevel-hosted) manuals:
30
+ * `<ns>_API_URL` points a manual at the backend and `<ns>_CONNECTION_KEY`
31
+ * carries the platform bearer. User-declared variables may not take these
32
+ * names, and user content may not reference them (bare or namespaced).
33
+ */
34
+ export const RESERVED_VARIABLE_NAMES: readonly string[] = ['API_URL', 'CONNECTION_KEY'];
35
+
36
+ /**
37
+ * A NAME is reserved by SUFFIX, not by exact match: the substitutor looks a
38
+ * variable up under the manual's UTCP namespace first, so `${<ns>_CONNECTION_KEY}`
39
+ * resolves the very same seeded value the bare `${CONNECTION_KEY}` does.
40
+ * Suffix matching also refuses harmless-looking near-misses (`MY_API_URL`) —
41
+ * deliberately fail-closed.
42
+ */
43
+ export function isReservedVariableName(varName: string): boolean {
44
+ return RESERVED_VARIABLE_NAMES.some((reserved) => varName.endsWith(reserved));
45
+ }
46
+
47
+ /**
48
+ * The first reserved REFERENCE anywhere in `doc` (scanned as JSON text), or
49
+ * null. Built on the shared grammar above, so what counts as a reference here
50
+ * is exactly what counts everywhere else — `${ API_URL }` with spaces is not
51
+ * expandable, so it is a literal to every boundary, not reserved to one and
52
+ * portable to another.
53
+ */
54
+ export function findReservedVariableRef(doc: unknown): string | null {
55
+ const text = JSON.stringify(doc) ?? '';
56
+ // A fresh instance per scan: `matchAll` COPIES the source regex's
57
+ // `lastIndex` (spec), and the exported one is mutable module state — any
58
+ // future `.exec`/`.test` on it would make this scan start mid-string.
59
+ for (const match of text.matchAll(new RegExp(VARIABLE_REFERENCE_RE.source, VARIABLE_REFERENCE_RE.flags))) {
60
+ const varName = match[1] ?? match[2] ?? '';
61
+ if (isReservedVariableName(varName)) return match[0];
62
+ }
63
+ return null;
64
+ }
@@ -1,247 +0,0 @@
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, personalGroupFolderName } 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
- GroupProvisionError,
11
- GroupProvisionService,
12
- groupAccessMd,
13
- personalAccessMd,
14
- } from '../group-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 GroupProvisionService(workspaceService, commits, accessControl, KB, events);
51
- return { svc, dir, commits, accessControl, events, writeFile };
52
- }
53
-
54
- describe('GroupProvisionService.createGroup', () => {
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.createGroup(USER, 'GTM');
62
- expect(result).toEqual({ folder: 'GTM', created: true });
63
-
64
- const accessMd = await fs.readFile(path.join(h.dir, KB, 'Groups/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 group to.
78
- expect(h.commits.runPendingCommit).toHaveBeenCalledWith(
79
- 'ws-main',
80
- DEFAULT_BRANCH,
81
- `${KB}/Groups/GTM/access.md`,
82
- USER,
83
- { systemAuthorized: true },
84
- );
85
- expect(h.accessControl.invalidate).toHaveBeenCalledWith('ws-main');
86
- });
87
-
88
- it('refuses a taken name case-insensitively with 409', async () => {
89
- await h.svc.createGroup(USER, 'GTM');
90
- await expect(h.svc.createGroup(USER, 'gtm')).rejects.toMatchObject({ status: 409 });
91
- });
92
-
93
- it('refuses names the filesystem or the model cannot carry with 422', async () => {
94
- for (const bad of ['', ' ', 'a/b', 'a\\b', '.', '..', '.hidden', 'personal-anything', 'a\u0000b', 'a\tb']) {
95
- await expect(h.svc.createGroup(USER, bad)).rejects.toBeInstanceOf(GroupProvisionError);
96
- await expect(h.svc.createGroup(USER, bad)).rejects.toMatchObject({ status: 422 });
97
- }
98
- // Nothing landed on disk for any of them.
99
- await expect(fs.readdir(path.join(h.dir, KB, 'Groups'))).rejects.toThrow();
100
- });
101
-
102
- it('serialises concurrent creations of one name in different casings — exactly one lands', async () => {
103
- // On a case-sensitive filesystem `GTM` and `gtm` are different paths, so
104
- // the wx writes alone would BOTH succeed; the per-name lock is what makes
105
- // the case-insensitive uniqueness hold under concurrency.
106
- const results = await Promise.allSettled([
107
- h.svc.createGroup(USER, 'GTM'),
108
- h.svc.createGroup(USER, 'gtm'),
109
- ]);
110
- const ok = results.filter((r) => r.status === 'fulfilled');
111
- const refused = results.filter(
112
- (r) => r.status === 'rejected' && (r.reason as GroupProvisionError).status === 409,
113
- );
114
- expect(ok).toHaveLength(1);
115
- expect(refused).toHaveLength(1);
116
- expect(await fs.readdir(path.join(h.dir, KB, 'Groups'))).toHaveLength(1);
117
- });
118
-
119
- it('rolls the seeded file back when the commit fails, so a retry is not told "already exists"', async () => {
120
- h.commits.runPendingCommit.mockRejectedValueOnce(new Error('push refused'));
121
- await expect(h.svc.createGroup(USER, 'GTM')).rejects.toThrow('push refused');
122
- // The folder is gone again — the next attempt starts clean.
123
- await expect(fs.stat(path.join(h.dir, KB, 'Groups/GTM'))).rejects.toThrow();
124
- await expect(h.svc.createGroup(USER, 'GTM')).resolves.toEqual({ folder: 'GTM', created: true });
125
- });
126
- });
127
-
128
- describe('GroupProvisionService.deleteGroup', () => {
129
- let h: Awaited<ReturnType<typeof makeHarness>>;
130
- beforeEach(async () => {
131
- h = await makeHarness();
132
- });
133
-
134
- it('removes the whole folder in ONE folder-scoped commit, and drops the access cache', async () => {
135
- await h.svc.createGroup(USER, 'GTM');
136
- // A group with content — the delete takes the skills with the folder.
137
- await fs.mkdir(path.join(h.dir, KB, 'Groups/GTM/outreach'), { recursive: true });
138
- await fs.writeFile(path.join(h.dir, KB, 'Groups/GTM/outreach/SKILL.md'), '# outreach\n');
139
- h.commits.runPendingCommit.mockClear();
140
- (h.accessControl.invalidate as ReturnType<typeof vi.fn>).mockClear();
141
-
142
- await h.svc.deleteGroup(USER, 'GTM');
143
-
144
- await expect(fs.stat(path.join(h.dir, KB, 'Groups/GTM'))).rejects.toThrow();
145
- // No parked remnant either — the commit landed, so the bytes may go.
146
- expect(await fs.readdir(path.join(h.dir, KB, 'Groups'))).toEqual([]);
147
- // FOLDER-scoped (`git add -- <folder>` stages every deletion under it),
148
- // inline, and `systemAuthorized` — the endpoint already authorized the
149
- // delete (owner verdict), and the per-user push gate would re-read the
150
- // very access.md this commit removes.
151
- expect(h.commits.runPendingCommit).toHaveBeenCalledWith(
152
- 'ws-main',
153
- DEFAULT_BRANCH,
154
- `${KB}/Groups/GTM`,
155
- USER,
156
- { systemAuthorized: true },
157
- );
158
- expect(h.accessControl.invalidate).toHaveBeenCalledWith('ws-main');
159
- expect(h.events.emit).toHaveBeenCalledWith(
160
- expect.objectContaining({ kind: 'fs-tree-changed' }),
161
- );
162
- });
163
-
164
- it('refuses an unknown name — and a casing mismatch, which is the same thing — with 404', async () => {
165
- await h.svc.createGroup(USER, 'GTM');
166
- for (const name of ['Nope', 'gtm']) {
167
- await expect(h.svc.deleteGroup(USER, name)).rejects.toMatchObject({ status: 404 });
168
- }
169
- expect(await fs.readdir(path.join(h.dir, KB, 'Groups'))).toEqual(['GTM']);
170
- });
171
-
172
- it('never deletes a personal folder through the group door', async () => {
173
- await h.svc.ensurePersonalGroup(USER);
174
- const folder = personalGroupFolderName(USER.id);
175
- await expect(h.svc.deleteGroup(USER, folder)).rejects.toMatchObject({ status: 404 });
176
- await expect(fs.stat(path.join(h.dir, KB, 'Groups', folder))).resolves.toBeDefined();
177
- });
178
-
179
- it('puts the folder back, content intact, when the commit is refused', async () => {
180
- await h.svc.createGroup(USER, 'GTM');
181
- await fs.mkdir(path.join(h.dir, KB, 'Groups/GTM/outreach'), { recursive: true });
182
- await fs.writeFile(path.join(h.dir, KB, 'Groups/GTM/outreach/SKILL.md'), '# outreach\n');
183
- h.commits.runPendingCommit.mockRejectedValueOnce(new Error('push refused'));
184
-
185
- await expect(h.svc.deleteGroup(USER, 'GTM')).rejects.toThrow('push refused');
186
-
187
- // A failed delete is a NO-OP: origin still carries the group, so the
188
- // working tree must too — bytes included, not just the folder shell.
189
- expect(
190
- await fs.readFile(path.join(h.dir, KB, 'Groups/GTM/outreach/SKILL.md'), 'utf-8'),
191
- ).toBe('# outreach\n');
192
- // And the retry goes through.
193
- await expect(h.svc.deleteGroup(USER, 'GTM')).resolves.toBeUndefined();
194
- await expect(fs.stat(path.join(h.dir, KB, 'Groups/GTM'))).rejects.toThrow();
195
- });
196
- });
197
-
198
- describe('GroupProvisionService.ensurePersonalGroup', () => {
199
- it('creates the private personal folder once, then reports it as existing', async () => {
200
- const h = await makeHarness();
201
- const folder = personalGroupFolderName(USER.id);
202
-
203
- const first = await h.svc.ensurePersonalGroup(USER);
204
- expect(first).toEqual({ folder, created: true });
205
- const accessMd = await fs.readFile(
206
- path.join(h.dir, KB, 'Groups', folder, 'access.md'),
207
- 'utf-8',
208
- );
209
- // PRIVATE: no `everyone` self-grant anywhere — the file follows the
210
- // folder chain, and the rules close that chain to the owner alone.
211
- expect(accessMd).not.toContain('everyone');
212
- for (const verb of ['read', 'write', 'owner']) {
213
- expect(accessMd).toMatch(new RegExp(`${verb}:[\\s\\S]*Ali Vega <ali@example.com>`));
214
- }
215
-
216
- const second = await h.svc.ensurePersonalGroup(USER);
217
- expect(second).toEqual({ folder, created: false });
218
- // Idempotent for real: one write, one commit.
219
- expect(h.writeFile).toHaveBeenCalledTimes(1);
220
- expect(h.commits.runPendingCommit).toHaveBeenCalledTimes(1);
221
- });
222
-
223
- it('two concurrent ensures both succeed — one creates, the other reports existing', async () => {
224
- const h = await makeHarness();
225
- const folder = personalGroupFolderName(USER.id);
226
- const [a, b] = await Promise.all([
227
- h.svc.ensurePersonalGroup(USER),
228
- h.svc.ensurePersonalGroup(USER),
229
- ]);
230
- expect([a.created, b.created].sort()).toEqual([false, true]);
231
- expect(a.folder).toBe(folder);
232
- expect(b.folder).toBe(folder);
233
- expect(h.commits.runPendingCommit).toHaveBeenCalledTimes(1);
234
- });
235
- });
236
-
237
- describe('access.md templates', () => {
238
- it('group template is discoverable, personal template is not — same creator grants in both', () => {
239
- const group = groupAccessMd(USER);
240
- const personal = personalAccessMd(USER);
241
- expect(group).toContain('everyone');
242
- expect(personal).not.toContain('everyone');
243
- for (const text of [group, personal]) {
244
- expect(text).toContain('Ali Vega <ali@example.com>');
245
- }
246
- });
247
- });
@@ -1,17 +0,0 @@
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';
@@ -1,45 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { groupOfPath, GROUPS_DIR } from '@bevel-software/platform-shared';
3
-
4
- /**
5
- * `groupOfPath` is the whole of the grouping contract — the sidebar, the
6
- * catalog buckets and the access story all read a group off a path with it.
7
- * The cases that matter are the ones where "has a group" and "is under the
8
- * group root" come apart.
9
- */
10
- describe('groupOfPath', () => {
11
- it('reads the group from the Groups root', () => {
12
- expect(groupOfPath(`${GROUPS_DIR}/GTM/heyreach-campaign/SKILL.md`)).toBe('GTM');
13
- expect(groupOfPath(`${GROUPS_DIR}/GTM/heyreach.tool`)).toBe('GTM');
14
- expect(groupOfPath(`${GROUPS_DIR}/Engineering/review/architecture-review`)).toBe(
15
- 'Engineering',
16
- );
17
- });
18
-
19
- it('returns null for content directly under the root — it has no group folder', () => {
20
- // The regression this guards: `Groups/slack.tool` must NOT report a group
21
- // called "slack.tool", and `Groups/GTM` must not report itself.
22
- expect(groupOfPath(`${GROUPS_DIR}/slack.tool`)).toBeNull();
23
- expect(groupOfPath(`${GROUPS_DIR}/GTM`)).toBeNull();
24
- expect(groupOfPath(GROUPS_DIR)).toBeNull();
25
- });
26
-
27
- it('returns null outside the group root', () => {
28
- expect(groupOfPath('KnowledgeBase/Product/Knowledge/Foo.md')).toBeNull();
29
- expect(groupOfPath('Data/Engineering/Knowledge/Bar.md')).toBeNull();
30
- // The retired pre-merge roots are ordinary non-group folders now.
31
- expect(groupOfPath('Skills/GTM/heyreach-campaign')).toBeNull();
32
- expect(groupOfPath('Tools/NewsAgent/serper.tool')).toBeNull();
33
- expect(groupOfPath('access.md')).toBeNull();
34
- expect(groupOfPath('')).toBeNull();
35
- });
36
-
37
- it('is not fooled by a prefix match on the root name', () => {
38
- expect(groupOfPath('GroupsOld/GTM/x')).toBeNull();
39
- });
40
-
41
- it('tolerates leading and doubled separators', () => {
42
- expect(groupOfPath(`/${GROUPS_DIR}/GTM/x`)).toBe('GTM');
43
- expect(groupOfPath(`${GROUPS_DIR}//GTM//x`)).toBe('GTM');
44
- });
45
- });
File without changes