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