@bevel-software/platform-core-backend 0.7.5 → 0.9.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 +8 -1
- package/dist/core/core-ports.d.ts.map +1 -1
- package/dist/core/core-ports.js.map +1 -1
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +75 -18
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +12 -5
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +69 -36
- 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.d.ts +3 -1
- package/dist/modules/access/access.routes.d.ts.map +1 -1
- package/dist/modules/access/access.routes.js +22 -20
- 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/render-roles-yaml.d.ts +22 -0
- package/dist/modules/access/render-roles-yaml.d.ts.map +1 -0
- package/dist/modules/access/render-roles-yaml.js +56 -0
- package/dist/modules/access/render-roles-yaml.js.map +1 -0
- package/dist/modules/access/roles-admin.service.d.ts +33 -5
- package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
- package/dist/modules/access/roles-admin.service.js +44 -19
- 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/settings/setup.routes.d.ts +10 -1
- package/dist/modules/settings/setup.routes.d.ts.map +1 -1
- package/dist/modules/settings/setup.routes.js +111 -6
- package/dist/modules/settings/setup.routes.js.map +1 -1
- 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/startup/kb-git.d.ts +23 -0
- package/dist/modules/workspace/startup/kb-git.d.ts.map +1 -0
- package/dist/modules/workspace/startup/kb-git.js +86 -0
- package/dist/modules/workspace/startup/kb-git.js.map +1 -0
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts +74 -0
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -0
- package/dist/modules/workspace/startup/kb-startup-runner.js +528 -0
- package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -0
- package/dist/modules/workspace/startup/on-server-start.d.ts +105 -0
- package/dist/modules/workspace/startup/on-server-start.d.ts.map +1 -0
- package/dist/modules/workspace/startup/on-server-start.js +21 -0
- package/dist/modules/workspace/startup/on-server-start.js.map +1 -0
- package/dist/modules/workspace/startup/steps/groups-to-plugins.step.d.ts +46 -0
- package/dist/modules/workspace/startup/steps/groups-to-plugins.step.d.ts.map +1 -0
- package/dist/modules/workspace/startup/steps/groups-to-plugins.step.js +492 -0
- package/dist/modules/workspace/startup/steps/groups-to-plugins.step.js.map +1 -0
- package/dist/modules/workspace/startup/steps/roles-yaml.step.d.ts +23 -0
- package/dist/modules/workspace/startup/steps/roles-yaml.step.d.ts.map +1 -0
- package/dist/modules/workspace/startup/steps/roles-yaml.step.js +69 -0
- package/dist/modules/workspace/startup/steps/roles-yaml.step.js.map +1 -0
- package/dist/modules/workspace/startup/steps/seed-tree.d.ts +17 -0
- package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -0
- package/dist/modules/workspace/startup/steps/seed-tree.js +109 -0
- package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -0
- package/dist/modules/workspace/startup/steps/template-files.step.d.ts +103 -0
- package/dist/modules/workspace/startup/steps/template-files.step.d.ts.map +1 -0
- package/dist/modules/workspace/startup/steps/template-files.step.js +337 -0
- package/dist/modules/workspace/startup/steps/template-files.step.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 +0 -9
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +2 -30
- 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/kb-template/gitignore.template +16 -0
- 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 +113 -106
- package/src/core/create-core-server.ts +85 -20
- package/src/core/create-core-services.ts +83 -42
- 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 +36 -2
- 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 +23 -20
- package/src/modules/access/creator-access.ts +5 -5
- package/src/modules/access/render-roles-yaml.ts +65 -0
- package/src/modules/access/roles-admin.service.ts +45 -16
- 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/settings/__tests__/setup.routes.test.ts +124 -3
- package/src/modules/settings/setup.routes.ts +112 -5
- 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__/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 +27 -0
- package/src/modules/workspace/bevel-ignore.ts +66 -66
- package/src/modules/workspace/session-sink.ts +25 -25
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +495 -0
- package/src/modules/workspace/startup/kb-git.ts +94 -0
- package/src/modules/workspace/startup/kb-startup-runner.ts +597 -0
- package/src/modules/workspace/startup/on-server-start.ts +97 -0
- package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +636 -0
- package/src/modules/workspace/startup/steps/groups-to-plugins.step.ts +561 -0
- package/src/modules/workspace/startup/steps/roles-yaml.step.ts +71 -0
- package/src/modules/workspace/startup/steps/seed-tree.ts +115 -0
- package/src/modules/workspace/startup/steps/template-files.step.ts +360 -0
- package/src/modules/workspace/workspace.routes.ts +3 -3
- package/src/modules/workspace/workspace.service.ts +2 -35
- 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/modules/workspace/__tests__/kb-seed.service.test.ts +0 -444
- package/src/modules/workspace/kb-seed.interface.ts +0 -36
- package/src/modules/workspace/kb-seed.service.ts +0 -555
- package/src/shared/__tests__/kb-layout.group.test.ts +0 -45
- /package/kb-template/{Groups → Plugins}/.gitkeep +0 -0
|
@@ -3,9 +3,9 @@ import fs from 'node:fs/promises';
|
|
|
3
3
|
import type { Dirent } from 'node:fs';
|
|
4
4
|
import {
|
|
5
5
|
DEFAULT_BRANCH,
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
PLUGINS_DIR,
|
|
7
|
+
pluginOfPath,
|
|
8
|
+
isPersonalPluginFolder,
|
|
9
9
|
} from '@bevel-software/platform-shared';
|
|
10
10
|
import type { WorkspaceService } from '../workspace/workspace.service.js';
|
|
11
11
|
import { workspaceIdForBranch } from '../workspace/workspace.service.js';
|
|
@@ -13,37 +13,37 @@ import type { IAccessControl } from '../access/access-control.interface.js';
|
|
|
13
13
|
import type { ISkillService } from '../skills/skills.contract.js';
|
|
14
14
|
import type { IToolManualService } from '../tool-manuals/tool-manuals.contract.js';
|
|
15
15
|
import { TtlCache } from '../../shared/ttl-cache.js';
|
|
16
|
-
import type {
|
|
16
|
+
import type { PluginCatalogEntry, IPluginIndexService } from './plugins.contract.js';
|
|
17
17
|
|
|
18
18
|
const CACHE_TTL_MS = 60_000;
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
* The
|
|
21
|
+
* The plugin index: every plugin folder in the default-branch KB, with its
|
|
22
22
|
* caller-independent totals and access principals.
|
|
23
23
|
*
|
|
24
24
|
* Two decisions worth keeping straight:
|
|
25
25
|
*
|
|
26
|
-
* - **Existence is an `access.md`, counting is not.** A
|
|
26
|
+
* - **Existence is an `access.md`, counting is not.** A plugin EXISTS because
|
|
27
27
|
* its folder carries an `access.md` — the file the provisioning endpoint
|
|
28
28
|
* seeds and the one every per-caller verdict (member / manager /
|
|
29
|
-
* discoverable) resolves against. A bare directory under `
|
|
30
|
-
* a
|
|
29
|
+
* discoverable) resolves against. A bare directory under `Plugins/` is NOT
|
|
30
|
+
* a plugin: git cannot record an empty folder, so deleting a plugin's files
|
|
31
31
|
* leaves its directory behind on live checkouts, and enumerating by
|
|
32
32
|
* directory would resurrect every such ghost. Counts come from the
|
|
33
33
|
* already-cached global catalogs — `skillService.listSkills(undefined)` and
|
|
34
|
-
* `toolManualService.listAllSummaries()` — bucketed by `
|
|
34
|
+
* `toolManualService.listAllSummaries()` — bucketed by `pluginOfPath`. A
|
|
35
35
|
* second `walkFiles` pass would re-read the same tree and could disagree
|
|
36
36
|
* with the catalogs about what counts as a skill.
|
|
37
|
-
* - **Loose files are not
|
|
37
|
+
* - **Loose files are not plugins.** `Plugins/slack.tool` sits directly under
|
|
38
38
|
* the root, so it is a file, not a directory — the same ≥3-segment rule
|
|
39
|
-
* `
|
|
39
|
+
* `pluginOfPath` applies, arrived at from the other side.
|
|
40
40
|
*
|
|
41
41
|
* Cached for {@link CACHE_TTL_MS} and dropped by `invalidate()` from the
|
|
42
42
|
* file-change subscriber, so a grant committed on the default branch is
|
|
43
43
|
* reflected within one round-trip rather than one TTL.
|
|
44
44
|
*/
|
|
45
|
-
export class
|
|
46
|
-
private readonly cache: TtlCache<
|
|
45
|
+
export class PluginIndexService implements IPluginIndexService {
|
|
46
|
+
private readonly cache: TtlCache<PluginCatalogEntry[]>;
|
|
47
47
|
|
|
48
48
|
constructor(
|
|
49
49
|
private readonly workspaceService: WorkspaceService,
|
|
@@ -60,13 +60,13 @@ export class GroupIndexService implements IGroupIndexService {
|
|
|
60
60
|
this.cache.invalidate();
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
async catalog(): Promise<
|
|
63
|
+
async catalog(): Promise<PluginCatalogEntry[]> {
|
|
64
64
|
const cached = this.cache.get();
|
|
65
65
|
if (cached) return cached;
|
|
66
66
|
const entries = await this.build();
|
|
67
|
-
// A failed scan and a KB with genuinely no
|
|
67
|
+
// A failed scan and a KB with genuinely no plugin folders both serve `[]`,
|
|
68
68
|
// but only the second is a fact worth holding for the TTL. Caching the
|
|
69
|
-
// failure would hide every
|
|
69
|
+
// failure would hide every plugin from every user for the full 60s AFTER
|
|
70
70
|
// the cause is gone — a clone mid-creation, a readdir that raced a
|
|
71
71
|
// checkout — and `invalidate()` only fires on a file-change event that may
|
|
72
72
|
// never arrive in that window. So a degraded read is served, not stored,
|
|
@@ -80,16 +80,16 @@ export class GroupIndexService implements IGroupIndexService {
|
|
|
80
80
|
|
|
81
81
|
/**
|
|
82
82
|
* Degrades on ANY failure rather than throwing. The Library must never break
|
|
83
|
-
* because a
|
|
83
|
+
* because a plugin folder can't be read — same philosophy as the skill and
|
|
84
84
|
* tool-manual scanners, and the reason the route's 500 is reserved for the
|
|
85
85
|
* per-caller half.
|
|
86
86
|
*
|
|
87
87
|
* `null` is that degraded case and is deliberately NOT the same value as an
|
|
88
|
-
* empty array: a KB with no
|
|
88
|
+
* empty array: a KB with no plugin folders returns `[]`, which is true and
|
|
89
89
|
* cacheable, while a failure returns `null`, which `catalog()` serves as `[]`
|
|
90
90
|
* without storing it.
|
|
91
91
|
*/
|
|
92
|
-
private async build(): Promise<
|
|
92
|
+
private async build(): Promise<PluginCatalogEntry[] | null> {
|
|
93
93
|
try {
|
|
94
94
|
const wsId = (await this.workspaceService.getOrCreateForBranch(DEFAULT_BRANCH)).id;
|
|
95
95
|
const kbRoot = path.join(await this.workspaceService.getWorkspacePath(wsId), this.kbDirName);
|
|
@@ -102,10 +102,10 @@ export class GroupIndexService implements IGroupIndexService {
|
|
|
102
102
|
this.countTools(),
|
|
103
103
|
]);
|
|
104
104
|
|
|
105
|
-
const entries:
|
|
106
|
-
for (const [name,
|
|
107
|
-
// One folder, one access boundary — the folder IS the
|
|
108
|
-
const [primary] =
|
|
105
|
+
const entries: PluginCatalogEntry[] = [];
|
|
106
|
+
for (const [name, pluginFolders] of folders) {
|
|
107
|
+
// One folder, one access boundary — the folder IS the plugin.
|
|
108
|
+
const [primary] = pluginFolders;
|
|
109
109
|
const [owners, writers, readers] = await Promise.all([
|
|
110
110
|
this.accessControl.eligibleOwners(wsId, primary),
|
|
111
111
|
this.accessControl.eligibleWriters(wsId, primary),
|
|
@@ -113,7 +113,7 @@ export class GroupIndexService implements IGroupIndexService {
|
|
|
113
113
|
]);
|
|
114
114
|
entries.push({
|
|
115
115
|
name,
|
|
116
|
-
folders:
|
|
116
|
+
folders: pluginFolders,
|
|
117
117
|
skillCount: skillCounts.get(name) ?? 0,
|
|
118
118
|
toolCount: toolCounts.get(name) ?? 0,
|
|
119
119
|
owners,
|
|
@@ -124,70 +124,70 @@ export class GroupIndexService implements IGroupIndexService {
|
|
|
124
124
|
return entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
125
125
|
} catch (err) {
|
|
126
126
|
console.warn(
|
|
127
|
-
`[
|
|
127
|
+
`[plugins] plugin index unavailable: ${err instanceof Error ? err.message : String(err)}`,
|
|
128
128
|
);
|
|
129
129
|
return null;
|
|
130
130
|
}
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
/** name → repo-relative
|
|
133
|
+
/** name → repo-relative plugin folder (always a single-element list). */
|
|
134
134
|
private async scanFolders(kbRoot: string): Promise<Map<string, string[]>> {
|
|
135
135
|
const byName = new Map<string, string[]>();
|
|
136
136
|
let children: Dirent[];
|
|
137
137
|
try {
|
|
138
|
-
children = await fs.readdir(path.join(kbRoot,
|
|
138
|
+
children = await fs.readdir(path.join(kbRoot, PLUGINS_DIR), { withFileTypes: true });
|
|
139
139
|
} catch {
|
|
140
|
-
return byName; // a KB without a `
|
|
140
|
+
return byName; // a KB without a `Plugins/` root simply has no plugins
|
|
141
141
|
}
|
|
142
142
|
const candidates = children.filter(
|
|
143
143
|
(child) =>
|
|
144
144
|
child.isDirectory() &&
|
|
145
145
|
!child.name.startsWith('.') &&
|
|
146
|
-
// Personal folders live under
|
|
146
|
+
// Personal folders live under Plugins/ but are not plugins: one exists
|
|
147
147
|
// per person, private by construction, and listing them would put a
|
|
148
148
|
// locked row per employee in everyone's index.
|
|
149
|
-
!
|
|
149
|
+
!isPersonalPluginFolder(child.name),
|
|
150
150
|
);
|
|
151
|
-
// A
|
|
151
|
+
// A plugin exists exactly when its folder carries an `access.md` (see the
|
|
152
152
|
// class doc) — stat that file, don't trust the directory.
|
|
153
153
|
const verdicts = await Promise.all(
|
|
154
154
|
candidates.map(async (child) => {
|
|
155
155
|
try {
|
|
156
|
-
return (await fs.stat(path.join(kbRoot,
|
|
156
|
+
return (await fs.stat(path.join(kbRoot, PLUGINS_DIR, child.name, 'access.md'))).isFile();
|
|
157
157
|
} catch {
|
|
158
158
|
return false;
|
|
159
159
|
}
|
|
160
160
|
}),
|
|
161
161
|
);
|
|
162
162
|
candidates.forEach((child, i) => {
|
|
163
|
-
if (verdicts[i]) byName.set(child.name, [`${
|
|
163
|
+
if (verdicts[i]) byName.set(child.name, [`${PLUGINS_DIR}/${child.name}`]);
|
|
164
164
|
});
|
|
165
165
|
return byName;
|
|
166
166
|
}
|
|
167
167
|
|
|
168
168
|
private async countSkills(): Promise<Map<string, number>> {
|
|
169
169
|
// `undefined` is the documented GLOBAL, unfiltered mode — counts are a
|
|
170
|
-
// property of the
|
|
171
|
-
return
|
|
170
|
+
// property of the plugin, not of who is asking.
|
|
171
|
+
return bucketByPlugin(await this.skillService.listSkills(undefined));
|
|
172
172
|
}
|
|
173
173
|
|
|
174
174
|
private async countTools(): Promise<Map<string, number>> {
|
|
175
|
-
return
|
|
175
|
+
return bucketByPlugin(await this.toolManualService.listAllSummaries());
|
|
176
176
|
}
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
-
/** Count items per
|
|
180
|
-
function
|
|
179
|
+
/** Count items per plugin folder name; ungrouped items (`null`) count nowhere. */
|
|
180
|
+
function bucketByPlugin(items: { path: string }[]): Map<string, number> {
|
|
181
181
|
const counts = new Map<string, number>();
|
|
182
182
|
for (const item of items) {
|
|
183
|
-
const
|
|
184
|
-
if (
|
|
185
|
-
counts.set(
|
|
183
|
+
const plugin = pluginOfPath(item.path);
|
|
184
|
+
if (plugin === null) continue;
|
|
185
|
+
counts.set(plugin, (counts.get(plugin) ?? 0) + 1);
|
|
186
186
|
}
|
|
187
187
|
return counts;
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
-
/** The default-branch workspace id every
|
|
191
|
-
export function
|
|
190
|
+
/** The default-branch workspace id every plugin resolution runs against. */
|
|
191
|
+
export function pluginsWorkspaceId(): string {
|
|
192
192
|
return workspaceIdForBranch(DEFAULT_BRANCH);
|
|
193
193
|
}
|
|
@@ -1,143 +1,143 @@
|
|
|
1
|
-
import type { Server as HttpServer } from 'node:http';
|
|
2
|
-
import express from 'express';
|
|
3
|
-
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
-
import { createSecretsVaultRoutes } from '../secrets-vault.routes.js';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* THE OWNER GATE: a tool's shared (admin) secrets — plain values and OAuth
|
|
8
|
-
* client secrets — are settable by whoever can WRITE that `.tool` FILE (its
|
|
9
|
-
* frontmatter `write:`/`owner:` verbs + the access.md chain), and by no one
|
|
10
|
-
* else. There is deliberately NO platform-role check here: a non-Admin who
|
|
11
|
-
* manages a `.tool` configures it; an Admin who can't write the file doesn't.
|
|
12
|
-
* The per-file resolution itself (frontmatter verbs on `.tool` files) is
|
|
13
|
-
* covered in access-control.service.test.ts — this locks the route gate to
|
|
14
|
-
* `canWrite(path)` so no role-based shortcut regresses it.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
const TOOL_PATH = 'Tools/weather.tool';
|
|
18
|
-
const WRITER = 'writer@x.com';
|
|
19
|
-
const READER = 'reader@x.com';
|
|
20
|
-
|
|
21
|
-
const toolManualService = {
|
|
22
|
-
listAccessible: async () => [
|
|
23
|
-
{
|
|
24
|
-
slug: 'weather',
|
|
25
|
-
name: 'weather',
|
|
26
|
-
path: TOOL_PATH,
|
|
27
|
-
type: 'mcp' as const,
|
|
28
|
-
variables: [
|
|
29
|
-
{ name: 'SHARED_KEY', scope: 'admin' as const, label: 'Org key' },
|
|
30
|
-
{
|
|
31
|
-
name: 'SIGNIN',
|
|
32
|
-
scope: 'user' as const,
|
|
33
|
-
label: 'Weather sign-in',
|
|
34
|
-
oauth: {
|
|
35
|
-
authorizationUrl: 'https://auth.example.com/authorize',
|
|
36
|
-
tokenUrl: 'https://auth.example.com/token',
|
|
37
|
-
clientId: 'client-1',
|
|
38
|
-
},
|
|
39
|
-
},
|
|
40
|
-
],
|
|
41
|
-
},
|
|
42
|
-
],
|
|
43
|
-
} as unknown as Parameters<typeof createSecretsVaultRoutes>[0]['toolManualService'];
|
|
44
|
-
|
|
45
|
-
const putSharedStatic = vi.fn(async () => ({ id: 's1' }));
|
|
46
|
-
const putSharedOAuthClientSecret = vi.fn(async () => {});
|
|
47
|
-
const secretsVault = {
|
|
48
|
-
putSharedStatic,
|
|
49
|
-
putSharedOAuthClientSecret,
|
|
50
|
-
} as unknown as Parameters<typeof createSecretsVaultRoutes>[0]['secretsVault'];
|
|
51
|
-
|
|
52
|
-
// Per-FILE write: only WRITER may write THIS tool's path. No role concept at all.
|
|
53
|
-
const accessControl = {
|
|
54
|
-
canRead: async () => true,
|
|
55
|
-
canWrite: async (_ws: string, email: string, path: string) => email === WRITER && path === TOOL_PATH,
|
|
56
|
-
} as unknown as Parameters<typeof createSecretsVaultRoutes>[0]['accessControl'];
|
|
57
|
-
|
|
58
|
-
let httpServer: HttpServer | undefined;
|
|
59
|
-
|
|
60
|
-
async function baseUrlAs(email: string): Promise<string> {
|
|
61
|
-
const app = express();
|
|
62
|
-
app.use(express.json());
|
|
63
|
-
app.use((req, _res, next) => {
|
|
64
|
-
req.userId = `id-${email}`;
|
|
65
|
-
req.userEmail = email;
|
|
66
|
-
next();
|
|
67
|
-
});
|
|
68
|
-
app.use(
|
|
69
|
-
'/api',
|
|
70
|
-
createSecretsVaultRoutes({
|
|
71
|
-
secretsVault,
|
|
72
|
-
toolManualService,
|
|
73
|
-
accessControl,
|
|
74
|
-
stateSecret: 'test-secret',
|
|
75
|
-
publicBackendUrl: 'http://localhost:3000',
|
|
76
|
-
publicFrontendUrl: 'http://localhost:5173',
|
|
77
|
-
}),
|
|
78
|
-
);
|
|
79
|
-
httpServer = await new Promise<HttpServer>((resolve) => {
|
|
80
|
-
const s = app.listen(0, () => resolve(s));
|
|
81
|
-
});
|
|
82
|
-
const port = (httpServer.address() as { port: number }).port;
|
|
83
|
-
return `http://127.0.0.1:${port}`;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
afterEach(async () => {
|
|
87
|
-
if (httpServer) await new Promise<void>((r) => httpServer!.close(() => r()));
|
|
88
|
-
httpServer = undefined;
|
|
89
|
-
putSharedStatic.mockClear();
|
|
90
|
-
putSharedOAuthClientSecret.mockClear();
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
describe('tool owner gate — shared config requires WRITE on the `.tool` file', () => {
|
|
94
|
-
it('a writer of the file sets the shared admin value', async () => {
|
|
95
|
-
const base = await baseUrlAs(WRITER);
|
|
96
|
-
const res = await fetch(`${base}/api/secrets/tools/weather/vars/SHARED_KEY/admin`, {
|
|
97
|
-
method: 'PUT',
|
|
98
|
-
headers: { 'Content-Type': 'application/json' },
|
|
99
|
-
body: JSON.stringify({ value: 'k-123' }),
|
|
100
|
-
});
|
|
101
|
-
expect(res.status).toBe(201);
|
|
102
|
-
expect(putSharedStatic).toHaveBeenCalledWith(expect.objectContaining({ key: 'weather_SHARED_KEY' }));
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it('a non-writer is refused (403), even though they can READ the tool', async () => {
|
|
106
|
-
const base = await baseUrlAs(READER);
|
|
107
|
-
const res = await fetch(`${base}/api/secrets/tools/weather/vars/SHARED_KEY/admin`, {
|
|
108
|
-
method: 'PUT',
|
|
109
|
-
headers: { 'Content-Type': 'application/json' },
|
|
110
|
-
body: JSON.stringify({ value: 'k-123' }),
|
|
111
|
-
});
|
|
112
|
-
expect(res.status).toBe(403);
|
|
113
|
-
expect(putSharedStatic).not.toHaveBeenCalled();
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it('a writer of the file sets the OAuth client secret, pinned to the declared provider', async () => {
|
|
117
|
-
const base = await baseUrlAs(WRITER);
|
|
118
|
-
const res = await fetch(`${base}/api/secrets/tools/weather/vars/SIGNIN/oauth/admin`, {
|
|
119
|
-
method: 'PUT',
|
|
120
|
-
headers: { 'Content-Type': 'application/json' },
|
|
121
|
-
body: JSON.stringify({ clientSecret: 'cs-123' }),
|
|
122
|
-
});
|
|
123
|
-
expect(res.status).toBe(201);
|
|
124
|
-
expect(putSharedOAuthClientSecret).toHaveBeenCalledWith(
|
|
125
|
-
expect.objectContaining({
|
|
126
|
-
key: 'weather_SIGNIN',
|
|
127
|
-
clientSecret: 'cs-123',
|
|
128
|
-
provider: expect.objectContaining({ clientId: 'client-1' }),
|
|
129
|
-
}),
|
|
130
|
-
);
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
it('a non-writer cannot set the client secret (403)', async () => {
|
|
134
|
-
const base = await baseUrlAs(READER);
|
|
135
|
-
const res = await fetch(`${base}/api/secrets/tools/weather/vars/SIGNIN/oauth/admin`, {
|
|
136
|
-
method: 'PUT',
|
|
137
|
-
headers: { 'Content-Type': 'application/json' },
|
|
138
|
-
body: JSON.stringify({ clientSecret: 'cs-123' }),
|
|
139
|
-
});
|
|
140
|
-
expect(res.status).toBe(403);
|
|
141
|
-
expect(putSharedOAuthClientSecret).not.toHaveBeenCalled();
|
|
142
|
-
});
|
|
143
|
-
});
|
|
1
|
+
import type { Server as HttpServer } from 'node:http';
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { createSecretsVaultRoutes } from '../secrets-vault.routes.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* THE OWNER GATE: a tool's shared (admin) secrets — plain values and OAuth
|
|
8
|
+
* client secrets — are settable by whoever can WRITE that `.tool` FILE (its
|
|
9
|
+
* frontmatter `write:`/`owner:` verbs + the access.md chain), and by no one
|
|
10
|
+
* else. There is deliberately NO platform-role check here: a non-Admin who
|
|
11
|
+
* manages a `.tool` configures it; an Admin who can't write the file doesn't.
|
|
12
|
+
* The per-file resolution itself (frontmatter verbs on `.tool` files) is
|
|
13
|
+
* covered in access-control.service.test.ts — this locks the route gate to
|
|
14
|
+
* `canWrite(path)` so no role-based shortcut regresses it.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const TOOL_PATH = 'Tools/weather.tool';
|
|
18
|
+
const WRITER = 'writer@x.com';
|
|
19
|
+
const READER = 'reader@x.com';
|
|
20
|
+
|
|
21
|
+
const toolManualService = {
|
|
22
|
+
listAccessible: async () => [
|
|
23
|
+
{
|
|
24
|
+
slug: 'weather',
|
|
25
|
+
name: 'weather',
|
|
26
|
+
path: TOOL_PATH,
|
|
27
|
+
type: 'mcp' as const,
|
|
28
|
+
variables: [
|
|
29
|
+
{ name: 'SHARED_KEY', scope: 'admin' as const, label: 'Org key' },
|
|
30
|
+
{
|
|
31
|
+
name: 'SIGNIN',
|
|
32
|
+
scope: 'user' as const,
|
|
33
|
+
label: 'Weather sign-in',
|
|
34
|
+
oauth: {
|
|
35
|
+
authorizationUrl: 'https://auth.example.com/authorize',
|
|
36
|
+
tokenUrl: 'https://auth.example.com/token',
|
|
37
|
+
clientId: 'client-1',
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
} as unknown as Parameters<typeof createSecretsVaultRoutes>[0]['toolManualService'];
|
|
44
|
+
|
|
45
|
+
const putSharedStatic = vi.fn(async () => ({ id: 's1' }));
|
|
46
|
+
const putSharedOAuthClientSecret = vi.fn(async () => {});
|
|
47
|
+
const secretsVault = {
|
|
48
|
+
putSharedStatic,
|
|
49
|
+
putSharedOAuthClientSecret,
|
|
50
|
+
} as unknown as Parameters<typeof createSecretsVaultRoutes>[0]['secretsVault'];
|
|
51
|
+
|
|
52
|
+
// Per-FILE write: only WRITER may write THIS tool's path. No role concept at all.
|
|
53
|
+
const accessControl = {
|
|
54
|
+
canRead: async () => true,
|
|
55
|
+
canWrite: async (_ws: string, email: string, path: string) => email === WRITER && path === TOOL_PATH,
|
|
56
|
+
} as unknown as Parameters<typeof createSecretsVaultRoutes>[0]['accessControl'];
|
|
57
|
+
|
|
58
|
+
let httpServer: HttpServer | undefined;
|
|
59
|
+
|
|
60
|
+
async function baseUrlAs(email: string): Promise<string> {
|
|
61
|
+
const app = express();
|
|
62
|
+
app.use(express.json());
|
|
63
|
+
app.use((req, _res, next) => {
|
|
64
|
+
req.userId = `id-${email}`;
|
|
65
|
+
req.userEmail = email;
|
|
66
|
+
next();
|
|
67
|
+
});
|
|
68
|
+
app.use(
|
|
69
|
+
'/api',
|
|
70
|
+
createSecretsVaultRoutes({
|
|
71
|
+
secretsVault,
|
|
72
|
+
toolManualService,
|
|
73
|
+
accessControl,
|
|
74
|
+
stateSecret: 'test-secret',
|
|
75
|
+
publicBackendUrl: 'http://localhost:3000',
|
|
76
|
+
publicFrontendUrl: 'http://localhost:5173',
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
httpServer = await new Promise<HttpServer>((resolve) => {
|
|
80
|
+
const s = app.listen(0, () => resolve(s));
|
|
81
|
+
});
|
|
82
|
+
const port = (httpServer.address() as { port: number }).port;
|
|
83
|
+
return `http://127.0.0.1:${port}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
afterEach(async () => {
|
|
87
|
+
if (httpServer) await new Promise<void>((r) => httpServer!.close(() => r()));
|
|
88
|
+
httpServer = undefined;
|
|
89
|
+
putSharedStatic.mockClear();
|
|
90
|
+
putSharedOAuthClientSecret.mockClear();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('tool owner gate — shared config requires WRITE on the `.tool` file', () => {
|
|
94
|
+
it('a writer of the file sets the shared admin value', async () => {
|
|
95
|
+
const base = await baseUrlAs(WRITER);
|
|
96
|
+
const res = await fetch(`${base}/api/secrets/tools/weather/vars/SHARED_KEY/admin`, {
|
|
97
|
+
method: 'PUT',
|
|
98
|
+
headers: { 'Content-Type': 'application/json' },
|
|
99
|
+
body: JSON.stringify({ value: 'k-123' }),
|
|
100
|
+
});
|
|
101
|
+
expect(res.status).toBe(201);
|
|
102
|
+
expect(putSharedStatic).toHaveBeenCalledWith(expect.objectContaining({ key: 'weather_SHARED_KEY' }));
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('a non-writer is refused (403), even though they can READ the tool', async () => {
|
|
106
|
+
const base = await baseUrlAs(READER);
|
|
107
|
+
const res = await fetch(`${base}/api/secrets/tools/weather/vars/SHARED_KEY/admin`, {
|
|
108
|
+
method: 'PUT',
|
|
109
|
+
headers: { 'Content-Type': 'application/json' },
|
|
110
|
+
body: JSON.stringify({ value: 'k-123' }),
|
|
111
|
+
});
|
|
112
|
+
expect(res.status).toBe(403);
|
|
113
|
+
expect(putSharedStatic).not.toHaveBeenCalled();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('a writer of the file sets the OAuth client secret, pinned to the declared provider', async () => {
|
|
117
|
+
const base = await baseUrlAs(WRITER);
|
|
118
|
+
const res = await fetch(`${base}/api/secrets/tools/weather/vars/SIGNIN/oauth/admin`, {
|
|
119
|
+
method: 'PUT',
|
|
120
|
+
headers: { 'Content-Type': 'application/json' },
|
|
121
|
+
body: JSON.stringify({ clientSecret: 'cs-123' }),
|
|
122
|
+
});
|
|
123
|
+
expect(res.status).toBe(201);
|
|
124
|
+
expect(putSharedOAuthClientSecret).toHaveBeenCalledWith(
|
|
125
|
+
expect.objectContaining({
|
|
126
|
+
key: 'weather_SIGNIN',
|
|
127
|
+
clientSecret: 'cs-123',
|
|
128
|
+
provider: expect.objectContaining({ clientId: 'client-1' }),
|
|
129
|
+
}),
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('a non-writer cannot set the client secret (403)', async () => {
|
|
134
|
+
const base = await baseUrlAs(READER);
|
|
135
|
+
const res = await fetch(`${base}/api/secrets/tools/weather/vars/SIGNIN/oauth/admin`, {
|
|
136
|
+
method: 'PUT',
|
|
137
|
+
headers: { 'Content-Type': 'application/json' },
|
|
138
|
+
body: JSON.stringify({ clientSecret: 'cs-123' }),
|
|
139
|
+
});
|
|
140
|
+
expect(res.status).toBe(403);
|
|
141
|
+
expect(putSharedOAuthClientSecret).not.toHaveBeenCalled();
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -18,7 +18,7 @@ let server: HttpServer | null = null;
|
|
|
18
18
|
* that runs later in the same worker would otherwise see a different
|
|
19
19
|
* environment than the one it was written against.
|
|
20
20
|
*/
|
|
21
|
-
const KB_ENV = ['KB_REPO_URL', 'GIT_TOKEN', 'GIT_USERNAME', 'KB_DIR_NAME'] as const;
|
|
21
|
+
const KB_ENV = ['KB_REPO_URL', 'GIT_TOKEN', 'GIT_USERNAME', 'KB_DIR_NAME', 'GITHUB_TOKEN'] as const;
|
|
22
22
|
let savedEnv: Partial<Record<(typeof KB_ENV)[number], string | undefined>> = {};
|
|
23
23
|
|
|
24
24
|
beforeEach(() => {
|
|
@@ -40,7 +40,7 @@ afterEach(() => {
|
|
|
40
40
|
});
|
|
41
41
|
|
|
42
42
|
/** Mount the setup router on a throwaway port and hand back its base URL. */
|
|
43
|
-
function listen(isAdmin = true) {
|
|
43
|
+
function listen(isAdmin = true, runAll: () => Promise<void> = async () => {}) {
|
|
44
44
|
const db = {
|
|
45
45
|
select: () => ({ from: () => Promise.resolve([]) }),
|
|
46
46
|
insert: () => ({ values: () => ({ onConflictDoUpdate: () => Promise.resolve() }) }),
|
|
@@ -54,7 +54,16 @@ function listen(isAdmin = true) {
|
|
|
54
54
|
req.userId = 'user-1';
|
|
55
55
|
next();
|
|
56
56
|
});
|
|
57
|
-
app.use(
|
|
57
|
+
app.use(
|
|
58
|
+
'/api',
|
|
59
|
+
createSetupRoutes(
|
|
60
|
+
settings,
|
|
61
|
+
{ isAdmin: async () => isAdmin } as IAdminAccessService,
|
|
62
|
+
// Default no-op: most suites never complete setup, so the runner is
|
|
63
|
+
// never reached. The completion-transition suite passes its own spy.
|
|
64
|
+
{ runAll },
|
|
65
|
+
),
|
|
66
|
+
);
|
|
58
67
|
server = app.listen(0);
|
|
59
68
|
const { port } = server.address() as AddressInfo;
|
|
60
69
|
return { base: `http://127.0.0.1:${port}`, settings };
|
|
@@ -182,6 +191,118 @@ describe('POST /setup/test-connection — the command git is given', () => {
|
|
|
182
191
|
});
|
|
183
192
|
});
|
|
184
193
|
|
|
194
|
+
/**
|
|
195
|
+
* The KB startup phase's SECOND quiet moment: the save that completes setup.
|
|
196
|
+
* Completion is a false→true TRANSITION (whichever field arrives last, on
|
|
197
|
+
* whichever save), and a failed setup-time run keeps the deployment gated —
|
|
198
|
+
* settings saved, KB uninitialized — until a retry succeeds.
|
|
199
|
+
*
|
|
200
|
+
* The branch model rides in from the environment the vitest config pins
|
|
201
|
+
* (DEFAULT_BRANCH / PROTECTED_BRANCHES) and is configured by test-setup, so
|
|
202
|
+
* completing setup here means storing the repository URL and token — exactly
|
|
203
|
+
* the "branch model configured on an earlier save" gap.
|
|
204
|
+
*/
|
|
205
|
+
describe('POST /setup/settings — the completion transition and the KB startup phase', () => {
|
|
206
|
+
const completing = { kbRepoUrl: 'https://example.com/acme/kb.git', gitToken: 'ghp_x' };
|
|
207
|
+
|
|
208
|
+
it('runs the phase exactly once: on the save that completes setup, not before, not after', async () => {
|
|
209
|
+
let runs = 0;
|
|
210
|
+
const { base } = listen(true, async () => {
|
|
211
|
+
runs++;
|
|
212
|
+
});
|
|
213
|
+
// First save: an incomplete configuration — no phase.
|
|
214
|
+
let res = await post(base, '/api/setup/settings', { settings: { gitUsername: 'x-access-token' } });
|
|
215
|
+
expect(res.status).toBe(200);
|
|
216
|
+
expect(runs).toBe(0);
|
|
217
|
+
// Second save completes setup — the transition runs the phase.
|
|
218
|
+
res = await post(base, '/api/setup/settings', { settings: completing });
|
|
219
|
+
expect(res.status).toBe(200);
|
|
220
|
+
expect((await res.json()).complete).toBe(true);
|
|
221
|
+
expect(runs).toBe(1);
|
|
222
|
+
// A re-save of a complete, healthy setup is NOT a quiet moment.
|
|
223
|
+
res = await post(base, '/api/setup/settings', { settings: { gitUsername: 'x-access-token' } });
|
|
224
|
+
expect(res.status).toBe(200);
|
|
225
|
+
expect(runs).toBe(1);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('keeps setup gated after a failed run; a re-save retries, and success clears the gate', async () => {
|
|
229
|
+
const consoleError = console.error;
|
|
230
|
+
console.error = () => {};
|
|
231
|
+
try {
|
|
232
|
+
let attempts = 0;
|
|
233
|
+
let fail = true;
|
|
234
|
+
const { base } = listen(true, async () => {
|
|
235
|
+
attempts++;
|
|
236
|
+
if (fail) throw new Error('remote said no');
|
|
237
|
+
});
|
|
238
|
+
const res = await post(base, '/api/setup/settings', { settings: completing });
|
|
239
|
+
expect(res.status).toBe(500);
|
|
240
|
+
expect((await res.json()).error).toMatch(/could not be initialized/i);
|
|
241
|
+
expect(attempts).toBe(1);
|
|
242
|
+
// The gate stays shut: status reports incomplete, with the admin's hint.
|
|
243
|
+
let status = await (await fetch(`${base}/api/setup/status`)).json();
|
|
244
|
+
expect(status.complete).toBe(false);
|
|
245
|
+
expect(status.kbInitError).toMatch(/remote said no/);
|
|
246
|
+
// Any save while the failure stands retries the phase.
|
|
247
|
+
fail = false;
|
|
248
|
+
const retry = await post(base, '/api/setup/settings', {
|
|
249
|
+
settings: { gitUsername: 'x-access-token' },
|
|
250
|
+
});
|
|
251
|
+
expect(retry.status).toBe(200);
|
|
252
|
+
expect(attempts).toBe(2);
|
|
253
|
+
expect((await retry.json()).complete).toBe(true);
|
|
254
|
+
status = await (await fetch(`${base}/api/setup/status`)).json();
|
|
255
|
+
expect(status.complete).toBe(true);
|
|
256
|
+
expect(status.kbInitError).toBeUndefined();
|
|
257
|
+
} finally {
|
|
258
|
+
console.error = consoleError;
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('keeps the gate shut while the phase runs, and a save landing mid-run waits the phase out', async () => {
|
|
263
|
+
let runs = 0;
|
|
264
|
+
let release!: () => void;
|
|
265
|
+
const running = new Promise<void>((r) => (release = r));
|
|
266
|
+
const { base } = listen(true, async () => {
|
|
267
|
+
runs++;
|
|
268
|
+
await running;
|
|
269
|
+
});
|
|
270
|
+
// The completing save blocks inside the phase...
|
|
271
|
+
const first = post(base, '/api/setup/settings', { settings: completing });
|
|
272
|
+
// release() in a finally: a mid-test assertion failure must still unblock
|
|
273
|
+
// the phase, or `first`/`second` hang pending until the vitest timeout.
|
|
274
|
+
let second!: Promise<Response>;
|
|
275
|
+
try {
|
|
276
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
277
|
+
// ...during which the settings read complete but the GATE must not open —
|
|
278
|
+
// the phase is still mutating branch trees.
|
|
279
|
+
const status = await (await fetch(`${base}/api/setup/status`)).json();
|
|
280
|
+
expect(status.complete).toBe(false);
|
|
281
|
+
// A save landing mid-run is HELD until the phase settles: the runner
|
|
282
|
+
// reads its configuration through live getters, and a save applied under
|
|
283
|
+
// it would split the run across two configurations. It must neither
|
|
284
|
+
// resolve early nor start a second run over the same trees.
|
|
285
|
+
let secondSettled = false;
|
|
286
|
+
second = post(base, '/api/setup/settings', {
|
|
287
|
+
settings: { gitUsername: 'x-access-token' },
|
|
288
|
+
}).then((r) => {
|
|
289
|
+
secondSettled = true;
|
|
290
|
+
return r;
|
|
291
|
+
});
|
|
292
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
293
|
+
expect(secondSettled).toBe(false);
|
|
294
|
+
expect(runs).toBe(1);
|
|
295
|
+
} finally {
|
|
296
|
+
release();
|
|
297
|
+
}
|
|
298
|
+
const [res1, res2] = await Promise.all([first, second]);
|
|
299
|
+
expect(res1.status).toBe(200);
|
|
300
|
+
expect(res2.status).toBe(200);
|
|
301
|
+
expect(runs).toBe(1);
|
|
302
|
+
expect((await (await fetch(`${base}/api/setup/status`)).json()).complete).toBe(true);
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
185
306
|
describe('GET /setup/status', () => {
|
|
186
307
|
it('tells a non-admin whether setup is done, and nothing else', async () => {
|
|
187
308
|
const { base } = listen(false);
|