@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.
- package/LICENSE +202 -202
- package/THIRD-PARTY-NOTICES.md +428 -454
- package/dist/core/core-ports.d.ts +1 -1
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +51 -7
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +5 -3
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +30 -18
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +8 -7
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +1 -1
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +6 -6
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/access-declarations.d.ts +5 -5
- package/dist/modules/access/access-declarations.js +3 -3
- package/dist/modules/access/access-mutation.service.d.ts +3 -3
- package/dist/modules/access/access-mutation.service.d.ts.map +1 -1
- package/dist/modules/access/access-mutation.service.js +3 -3
- package/dist/modules/access/access-mutation.service.js.map +1 -1
- package/dist/modules/access/access-splice.js +4 -4
- package/dist/modules/access/access-splice.js.map +1 -1
- package/dist/modules/access/access.routes.js +19 -19
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/access/creator-access.d.ts +2 -2
- package/dist/modules/access/creator-access.js +5 -5
- package/dist/modules/access/creator-access.js.map +1 -1
- package/dist/modules/access/roles-admin.service.d.ts +18 -4
- package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
- package/dist/modules/access/roles-admin.service.js +14 -4
- package/dist/modules/access/roles-admin.service.js.map +1 -1
- package/dist/modules/code-mode/code-mode-names.d.ts +5 -13
- package/dist/modules/code-mode/code-mode-names.d.ts.map +1 -1
- package/dist/modules/code-mode/code-mode-names.js +5 -27
- package/dist/modules/code-mode/code-mode-names.js.map +1 -1
- package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -1
- package/dist/modules/code-mode/code-mode.tool.js +29 -7
- package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
- package/dist/modules/mcp/mcp.service.d.ts +11 -52
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.service.js +33 -395
- package/dist/modules/mcp/mcp.service.js.map +1 -1
- package/dist/modules/plugins/index.d.ts +7 -0
- package/dist/modules/plugins/index.d.ts.map +1 -0
- package/dist/modules/plugins/index.js +6 -0
- package/dist/modules/plugins/index.js.map +1 -0
- package/dist/modules/plugins/join-proposals.d.ts +53 -0
- package/dist/modules/plugins/join-proposals.d.ts.map +1 -0
- package/dist/modules/plugins/join-proposals.js +67 -0
- package/dist/modules/plugins/join-proposals.js.map +1 -0
- package/dist/modules/plugins/join-requests.service.d.ts +81 -0
- package/dist/modules/plugins/join-requests.service.d.ts.map +1 -0
- package/dist/modules/plugins/join-requests.service.js +135 -0
- package/dist/modules/plugins/join-requests.service.js.map +1 -0
- package/dist/modules/plugins/plugin-provision.service.d.ts +134 -0
- package/dist/modules/plugins/plugin-provision.service.d.ts.map +1 -0
- package/dist/modules/plugins/plugin-provision.service.js +344 -0
- package/dist/modules/plugins/plugin-provision.service.js.map +1 -0
- package/dist/modules/plugins/plugins.contract.d.ts +106 -0
- package/dist/modules/plugins/plugins.contract.d.ts.map +1 -0
- package/dist/modules/plugins/plugins.contract.js +36 -0
- package/dist/modules/plugins/plugins.contract.js.map +1 -0
- package/dist/modules/plugins/plugins.routes.d.ts +42 -0
- package/dist/modules/plugins/plugins.routes.d.ts.map +1 -0
- package/dist/modules/plugins/plugins.routes.js +379 -0
- package/dist/modules/plugins/plugins.routes.js.map +1 -0
- package/dist/modules/plugins/plugins.service.d.ts +60 -0
- package/dist/modules/plugins/plugins.service.d.ts.map +1 -0
- package/dist/modules/plugins/plugins.service.js +172 -0
- package/dist/modules/plugins/plugins.service.js.map +1 -0
- package/dist/modules/skills/pending-skills.service.d.ts +2 -2
- package/dist/modules/skills/pending-skills.service.js +7 -7
- package/dist/modules/skills/pending-skills.service.js.map +1 -1
- package/dist/modules/skills/skills.contract.d.ts +4 -4
- package/dist/modules/skills/skills.contract.d.ts.map +1 -1
- package/dist/modules/skills/skills.contract.js +1 -1
- package/dist/modules/skills/skills.service.js +5 -5
- package/dist/modules/skills/skills.service.js.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.d.ts +65 -0
- package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -0
- package/dist/modules/tool-manuals/mcp-json-discovery.js +276 -0
- package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -0
- package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts +92 -0
- package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -0
- package/dist/modules/tool-manuals/mcp-server-edit.service.js +328 -0
- package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -0
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +38 -12
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.contract.js +1 -1
- package/dist/modules/tool-manuals/tool-manuals.routes.d.ts +13 -2
- package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.routes.js +233 -2
- package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.js +74 -37
- package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.js +6 -3
- package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
- package/dist/modules/workflow/git/git.service.js +2 -2
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workspace/kb-seed.service.d.ts +2 -2
- package/dist/modules/workspace/kb-seed.service.d.ts.map +1 -1
- package/dist/modules/workspace/kb-seed.service.js +43 -10
- package/dist/modules/workspace/kb-seed.service.js.map +1 -1
- package/dist/modules/workspace/plugins-migration.d.ts +50 -0
- package/dist/modules/workspace/plugins-migration.d.ts.map +1 -0
- package/dist/modules/workspace/plugins-migration.js +379 -0
- package/dist/modules/workspace/plugins-migration.js.map +1 -0
- package/dist/modules/workspace/workspace.routes.js +3 -3
- package/dist/modules/workspace/workspace.routes.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +26 -0
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +83 -12
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/shared/kb-layout.test.js +3 -3
- package/dist/shared/kb-layout.test.js.map +1 -1
- package/dist/shared/utcp-namespace.d.ts +6 -27
- package/dist/shared/utcp-namespace.d.ts.map +1 -1
- package/dist/shared/utcp-namespace.js +6 -63
- package/dist/shared/utcp-namespace.js.map +1 -1
- package/dist/shared/variable-refs.d.ts +42 -0
- package/dist/shared/variable-refs.d.ts.map +1 -0
- package/dist/shared/variable-refs.js +60 -0
- package/dist/shared/variable-refs.js.map +1 -0
- package/kb-template/.bevelignore +1 -1
- package/kb-template/AGENTS.md +88 -35
- package/kb-template/KnowledgeBase/How to get started.md +10 -10
- package/kb-template/access.md +36 -36
- package/migrations/meta/0000_snapshot.json +1479 -1479
- package/package.json +5 -4
- package/src/assets.ts +25 -25
- package/src/core/core-ports.ts +106 -106
- package/src/core/create-core-server.ts +55 -9
- package/src/core/create-core-services.ts +40 -20
- package/src/index.ts +69 -69
- package/src/modules/access/__tests__/access-control.atref-batch.test.ts +98 -98
- package/src/modules/access/__tests__/access-declarations.test.ts +28 -28
- package/src/modules/access/__tests__/access-md-format.test.ts +18 -18
- package/src/modules/access/__tests__/access-mutation.service.test.ts +5 -5
- package/src/modules/access/__tests__/access-splice.test.ts +2 -2
- package/src/modules/access/__tests__/access.routes.overrides.test.ts +16 -16
- package/src/modules/access/__tests__/grant-sources.test.ts +12 -12
- package/src/modules/access/__tests__/roles-admin.service.test.ts +13 -1
- package/src/modules/access/access-control.interface.ts +8 -7
- package/src/modules/access/access-control.service.ts +7 -7
- package/src/modules/access/access-declarations.ts +5 -5
- package/src/modules/access/access-mutation.service.ts +3 -3
- package/src/modules/access/access-splice.ts +4 -4
- package/src/modules/access/access.routes.ts +20 -20
- package/src/modules/access/creator-access.ts +5 -5
- package/src/modules/access/roles-admin.service.ts +13 -2
- package/src/modules/admin/admin-access.routes.ts +29 -29
- package/src/modules/auth/__tests__/auth.routes.test.ts +91 -91
- package/src/modules/auth/__tests__/rate-limit.test.ts +36 -36
- package/src/modules/auth/rate-limit.ts +45 -45
- package/src/modules/code-mode/__tests__/code-mode.tool.test.ts +67 -0
- package/src/modules/code-mode/code-mode-names.ts +10 -36
- package/src/modules/code-mode/code-mode.tool.ts +27 -7
- package/src/modules/database/connection.ts +15 -15
- package/src/modules/database/schema.ts +11 -11
- package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +150 -150
- package/src/modules/mcp/mcp.service.ts +57 -435
- package/src/modules/{groups → plugins}/__tests__/join-proposals.test.ts +1 -1
- package/src/modules/{groups → plugins}/__tests__/join-requests.service.test.ts +7 -7
- package/src/modules/{groups/__tests__/group-index.service.test.ts → plugins/__tests__/plugin-index.service.test.ts} +41 -41
- package/src/modules/plugins/__tests__/plugin-provision.service.test.ts +312 -0
- package/src/modules/{groups/__tests__/groups.routes.test.ts → plugins/__tests__/plugins.routes.test.ts} +100 -100
- package/src/modules/plugins/index.ts +17 -0
- package/src/modules/{groups → plugins}/join-proposals.ts +2 -2
- package/src/modules/{groups → plugins}/join-requests.service.ts +8 -8
- package/src/modules/{groups/group-provision.service.ts → plugins/plugin-provision.service.ts} +139 -69
- package/src/modules/{groups/groups.contract.ts → plugins/plugins.contract.ts} +26 -26
- package/src/modules/{groups/groups.routes.ts → plugins/plugins.routes.ts} +102 -102
- package/src/modules/{groups/groups.service.ts → plugins/plugins.service.ts} +43 -43
- package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +143 -143
- package/src/modules/skills/__tests__/pending-skills.service.test.ts +14 -14
- package/src/modules/skills/__tests__/skills.service.test.ts +13 -13
- package/src/modules/skills/pending-skills.service.ts +7 -7
- package/src/modules/skills/skills.contract.ts +4 -4
- package/src/modules/skills/skills.service.ts +5 -5
- package/src/modules/tool-auth/llm-usage-meter.ts +19 -19
- package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +198 -0
- package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +346 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.archive.route.test.ts +101 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +3 -3
- package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +2 -2
- package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +27 -27
- package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +3 -3
- package/src/modules/tool-manuals/mcp-json-discovery.ts +328 -0
- package/src/modules/tool-manuals/mcp-server-edit.service.ts +434 -0
- package/src/modules/tool-manuals/tool-manuals.contract.ts +35 -12
- package/src/modules/tool-manuals/tool-manuals.routes.ts +222 -1
- package/src/modules/tool-manuals/tool-manuals.service.ts +82 -42
- package/src/modules/tool-manuals/tool-manuals.tools.ts +6 -3
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +1 -1
- package/src/modules/workflow/git/__tests__/branch-name.test.ts +3 -3
- package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +3 -3
- package/src/modules/workflow/git/__tests__/git.service.commitFile.test.ts +13 -8
- package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +1 -1
- package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +1 -1
- package/src/modules/workflow/git/git.service.ts +2 -2
- package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +1 -1
- package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +1 -1
- package/src/modules/workflow/workflow-hooks.ts +101 -101
- package/src/modules/workspace/__tests__/kb-seed.service.test.ts +81 -13
- package/src/modules/workspace/__tests__/plugins-migration.test.ts +427 -0
- package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +237 -237
- package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +236 -236
- package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +179 -179
- package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +320 -320
- package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +337 -337
- package/src/modules/workspace/__tests__/workspace.service.test.ts +116 -0
- package/src/modules/workspace/bevel-ignore.ts +66 -66
- package/src/modules/workspace/kb-seed.service.ts +38 -9
- package/src/modules/workspace/plugins-migration.ts +479 -0
- package/src/modules/workspace/session-sink.ts +25 -25
- package/src/modules/workspace/workspace.routes.ts +3 -3
- package/src/modules/workspace/workspace.service.ts +85 -14
- package/src/modules/workspace/workspace.tools.ts +922 -922
- package/src/shared/__tests__/join-request.test.ts +13 -13
- package/src/shared/__tests__/kb-layout.plugin.test.ts +45 -0
- package/src/shared/kb-layout.test.ts +3 -3
- package/src/shared/utcp-namespace.ts +10 -68
- package/src/shared/variable-refs.ts +64 -0
- package/src/modules/groups/__tests__/group-provision.service.test.ts +0 -247
- package/src/modules/groups/index.ts +0 -17
- package/src/shared/__tests__/kb-layout.group.test.ts +0 -45
- /package/kb-template/{Groups → Plugins}/.gitkeep +0 -0
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { isJoinBranchFor, joinBranchFor,
|
|
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
|
|
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
|
|
13
|
-
* is empty", so matching a branch to the WRONG
|
|
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
|
-
*
|
|
16
|
-
* cross
|
|
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
|
|
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
|
|
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
|
|
50
|
+
it('still yields a valid, matchable branch for a plugin with no alphanumerics', () => {
|
|
51
51
|
const branch = joinBranchFor('ali@bevel.software', '!!!');
|
|
52
|
-
expect(
|
|
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
|
|
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
|
|
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
|
|
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/
|
|
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
|
|
45
|
-
expect(ontologyOf('
|
|
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
|
-
*
|
|
3
|
-
* the
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|