@bevel-software/platform-core-backend 0.4.1 → 0.5.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/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 +3 -3
- 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
|
@@ -33,6 +33,37 @@ function redirectUriFor(publicBackendUrl: string): string {
|
|
|
33
33
|
return `${publicBackendUrl}/api/secrets/oauth/callback`;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Is this a SAME-ORIGIN path we're willing to bounce the browser back to after a
|
|
38
|
+
* sign-in? The callback builds `${publicFrontendUrl}${dest}#…`, so anything that
|
|
39
|
+
* survives this check gets concatenated onto our own origin — which makes every
|
|
40
|
+
* rule here an open-redirect rule, not a formatting preference:
|
|
41
|
+
* - must start with a single `/` — `//evil.com` is a protocol-relative URL and
|
|
42
|
+
* `https://evil.com` an absolute one; both would leave our origin entirely.
|
|
43
|
+
* - no `\` — WHATWG folds a backslash to a slash for http(s), so `/\evil.com`
|
|
44
|
+
* is `//evil.com` by another spelling.
|
|
45
|
+
* - no `#` — the callback appends its own fragment; a caller-supplied one would
|
|
46
|
+
* swallow the `#authorized=…`/`#error=…` the landing page reads.
|
|
47
|
+
* - no CR/LF — never let a caller-controlled string reach a `Location` header
|
|
48
|
+
* with a line break in it (response splitting).
|
|
49
|
+
* - ≤512 chars — a signed `state` rides in the provider's authorize URL, and
|
|
50
|
+
* some providers cap its length; this keeps the round-trip inside every
|
|
51
|
+
* budget without needing to know each provider's limit.
|
|
52
|
+
* Exported for the route tests: this predicate IS the security boundary, so it
|
|
53
|
+
* is pinned by a unit table rather than only exercised end-to-end.
|
|
54
|
+
*/
|
|
55
|
+
export function isSafeReturnPath(returnTo: unknown): returnTo is string {
|
|
56
|
+
return (
|
|
57
|
+
typeof returnTo === 'string' &&
|
|
58
|
+
returnTo.length <= 512 &&
|
|
59
|
+
returnTo.startsWith('/') &&
|
|
60
|
+
!returnTo.startsWith('//') &&
|
|
61
|
+
!returnTo.includes('\\') &&
|
|
62
|
+
!returnTo.includes('#') &&
|
|
63
|
+
!/[\r\n]/.test(returnTo)
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
36
67
|
/**
|
|
37
68
|
* Authenticated Secrets Vault CRUD + OAuth start. Every route is scoped to
|
|
38
69
|
* `req.userId` — secrets are private per user. Mounted behind the JWT middleware.
|
|
@@ -40,7 +71,14 @@ function redirectUriFor(publicBackendUrl: string): string {
|
|
|
40
71
|
export function createSecretsVaultRoutes(deps: SecretsVaultRoutesDeps): express.Router {
|
|
41
72
|
const { secretsVault, toolManualService, accessControl } = deps;
|
|
42
73
|
const router = express.Router();
|
|
43
|
-
|
|
74
|
+
// A FUNCTION, not a constant. Routers are constructed at boot, and on a
|
|
75
|
+
// deployment configured through the setup screen the branch model does not
|
|
76
|
+
// exist yet at that moment — `DEFAULT_BRANCH` is still ''. The live binding
|
|
77
|
+
// updates when setup applies the model, but only reads INSIDE a function
|
|
78
|
+
// body see it; a construction-time capture would keep handing an empty
|
|
79
|
+
// workspace id to the access resolver until the next restart ("Invalid
|
|
80
|
+
// workspace ID" on every /secrets/tools call).
|
|
81
|
+
const defaultWs = () => workspaceIdForBranch(DEFAULT_BRANCH);
|
|
44
82
|
|
|
45
83
|
// The vault key = the exact key UTCP looks the var up under (doubles underscores
|
|
46
84
|
// in the manual namespace), so storage and resolution agree for snake_case ids.
|
|
@@ -165,7 +203,7 @@ export function createSecretsVaultRoutes(deps: SecretsVaultRoutesDeps): express.
|
|
|
165
203
|
path: m.path,
|
|
166
204
|
type: m.type,
|
|
167
205
|
setup: m.setup ?? null,
|
|
168
|
-
canWrite: await accessControl.canWrite(defaultWs, email, m.path),
|
|
206
|
+
canWrite: await accessControl.canWrite(defaultWs(), email, m.path),
|
|
169
207
|
variables: (m.variables ?? []).map((v) => {
|
|
170
208
|
const key = varKey(m.name, v.name);
|
|
171
209
|
const st = statusByKey.get(key);
|
|
@@ -290,7 +328,7 @@ export function createSecretsVaultRoutes(deps: SecretsVaultRoutesDeps): express.
|
|
|
290
328
|
if (found.variable.scope !== 'admin') {
|
|
291
329
|
return void res.status(422).json({ error: 'This variable is set by each user, not the tool owner.' });
|
|
292
330
|
}
|
|
293
|
-
if (!(await accessControl.canWrite(defaultWs, email, found.manual.path))) {
|
|
331
|
+
if (!(await accessControl.canWrite(defaultWs(), email, found.manual.path))) {
|
|
294
332
|
return void res.status(403).json({ error: 'You need write access to this tool to set its shared secrets.' });
|
|
295
333
|
}
|
|
296
334
|
const body = req.body ?? {};
|
|
@@ -343,7 +381,7 @@ export function createSecretsVaultRoutes(deps: SecretsVaultRoutesDeps): express.
|
|
|
343
381
|
if (!found.variable.oauth) {
|
|
344
382
|
return void res.status(422).json({ error: 'This variable is not an OAuth sign-in.' });
|
|
345
383
|
}
|
|
346
|
-
if (!(await accessControl.canWrite(defaultWs, email, found.manual.path))) {
|
|
384
|
+
if (!(await accessControl.canWrite(defaultWs(), email, found.manual.path))) {
|
|
347
385
|
return void res.status(403).json({ error: 'You need write access to this tool to set its client secret.' });
|
|
348
386
|
}
|
|
349
387
|
const clientSecret = (req.body ?? {}).clientSecret;
|
|
@@ -369,7 +407,9 @@ export function createSecretsVaultRoutes(deps: SecretsVaultRoutesDeps): express.
|
|
|
369
407
|
|
|
370
408
|
// Start sign-in for a tool's OAuth-backed variable. Provisions the caller's row
|
|
371
409
|
// from the owner-set secret and returns the provider consent URL; the callback
|
|
372
|
-
// bounces back to
|
|
410
|
+
// bounces back to wherever the flow started (via `r` in the signed state) — an
|
|
411
|
+
// optional `{ returnTo }` body when the caller asks for a specific page, else
|
|
412
|
+
// /connect, which is where every caller came from before `returnTo` existed.
|
|
373
413
|
router.post('/secrets/tools/:slug/vars/:var/oauth/start', async (req, res) => {
|
|
374
414
|
const userId = req.userId;
|
|
375
415
|
const email = req.userEmail;
|
|
@@ -395,11 +435,17 @@ export function createSecretsVaultRoutes(deps: SecretsVaultRoutesDeps): express.
|
|
|
395
435
|
// on the next sign-in. The secret/clientId/addresses stay owner-pinned.
|
|
396
436
|
scopes: found.variable.oauth.scopes,
|
|
397
437
|
});
|
|
438
|
+
// The return path is signed INTO the state (HMAC-SHA256) rather than kept
|
|
439
|
+
// in a query param or a cookie: the callback arrives un-authenticated from
|
|
440
|
+
// the provider, so the state is the only thing it can trust. An absent or
|
|
441
|
+
// unsafe `returnTo` falls back to the legacy `'connect'` marker, which
|
|
442
|
+
// keeps a body-less start byte-identical to what it produced before.
|
|
443
|
+
const returnTo: unknown = (req.body ?? {}).returnTo;
|
|
398
444
|
const state = signState(deps.stateSecret, {
|
|
399
445
|
u: userId,
|
|
400
446
|
i: id,
|
|
401
447
|
n: randomBytes(8).toString('hex'),
|
|
402
|
-
r: 'connect',
|
|
448
|
+
r: isSafeReturnPath(returnTo) ? returnTo : 'connect',
|
|
403
449
|
});
|
|
404
450
|
const consentUrl = new URL(url);
|
|
405
451
|
consentUrl.searchParams.set('state', state);
|
|
@@ -416,7 +462,7 @@ export function createSecretsVaultRoutes(deps: SecretsVaultRoutesDeps): express.
|
|
|
416
462
|
try {
|
|
417
463
|
const found = await findManualVar(email, req.params.slug, req.params.var);
|
|
418
464
|
if (!found) return void res.status(404).json({ error: 'Tool or variable not found' });
|
|
419
|
-
if (!(await accessControl.canWrite(defaultWs, email, found.manual.path))) {
|
|
465
|
+
if (!(await accessControl.canWrite(defaultWs(), email, found.manual.path))) {
|
|
420
466
|
return void res.status(403).json({ error: 'You need write access to this tool to remove its shared secrets.' });
|
|
421
467
|
}
|
|
422
468
|
await secretsVault.removeShared(varKey(found.manual.name, found.variable.name));
|
|
@@ -456,16 +502,21 @@ export function createSecretsVaultPublicRoutes(deps: SecretsVaultRoutesDeps): ex
|
|
|
456
502
|
const code = typeof req.query.code === 'string' ? req.query.code : '';
|
|
457
503
|
const stateRaw = typeof req.query.state === 'string' ? req.query.state : '';
|
|
458
504
|
// The landing page depends on where the flow started (the `r` field in the
|
|
459
|
-
// signed state):
|
|
460
|
-
//
|
|
461
|
-
// fall back to /secrets.
|
|
505
|
+
// signed state): an explicit same-origin path returns there, the legacy
|
|
506
|
+
// `'connect'` marker to /connect, the standalone Secrets page to /secrets.
|
|
507
|
+
// Pre-verification errors can't know `r`, so they fall back to /secrets.
|
|
462
508
|
const back = (frag: string, dest = '/secrets') =>
|
|
463
509
|
res.redirect(`${deps.publicFrontendUrl}${dest}#${frag}`);
|
|
464
510
|
if (!code || !stateRaw) return void back(`error=${encodeURIComponent('Invalid OAuth callback.')}`);
|
|
465
511
|
|
|
466
512
|
const state = verifyState(deps.stateSecret, stateRaw);
|
|
467
513
|
if (!state) return void back(`error=${encodeURIComponent('OAuth state mismatch.')}`);
|
|
468
|
-
|
|
514
|
+
// Re-validated HERE even though `r` was validated on the way in and is
|
|
515
|
+
// HMAC-signed: this is the line that concatenates a stored string onto our
|
|
516
|
+
// public origin and hands it to the browser as a `Location`. Defense in
|
|
517
|
+
// depth — the check costs nothing, and it means an open redirect would need
|
|
518
|
+
// BOTH the signing key and a validation bug, not either one.
|
|
519
|
+
const dest = isSafeReturnPath(state.r) ? state.r : state.r === 'connect' ? '/connect' : '/secrets';
|
|
469
520
|
|
|
470
521
|
try {
|
|
471
522
|
await secretsVault.completeOAuth(state.u, state.i, code, redirectUriFor(deps.publicBackendUrl));
|
|
@@ -494,7 +545,12 @@ interface OAuthState {
|
|
|
494
545
|
i: string; // secret id
|
|
495
546
|
n: string; // nonce
|
|
496
547
|
iat: number; // issued-at (epoch ms)
|
|
497
|
-
|
|
548
|
+
/**
|
|
549
|
+
* Return path hint: a same-origin path (`isSafeReturnPath`) → land there; the
|
|
550
|
+
* legacy marker `'connect'` → /connect; anything else (incl. absent) →
|
|
551
|
+
* /secrets. Signed, so the callback can trust it — but re-validated anyway.
|
|
552
|
+
*/
|
|
553
|
+
r?: string;
|
|
498
554
|
}
|
|
499
555
|
|
|
500
556
|
const OAUTH_STATE_MAX_AGE_MS = 10 * 60_000;
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import {
|
|
4
|
+
DeploymentSettingsService,
|
|
5
|
+
SettingsValidationError,
|
|
6
|
+
CORE_SETTINGS,
|
|
7
|
+
} from '../deployment-settings.service.js';
|
|
8
|
+
import type { Database } from '../../database/connection.js';
|
|
9
|
+
|
|
10
|
+
const ENC_KEY = randomBytes(32).toString('base64');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* An in-memory stand-in for the one table this service owns. Enough of the
|
|
14
|
+
* drizzle surface to exercise load/save round-trips without a live Postgres —
|
|
15
|
+
* the SQL itself is one insert with an upsert target, which the migration
|
|
16
|
+
* covers.
|
|
17
|
+
*/
|
|
18
|
+
function makeDb() {
|
|
19
|
+
const rows: { key: string; value: string; encrypted: boolean }[] = [];
|
|
20
|
+
const db = {
|
|
21
|
+
select: () => ({ from: () => Promise.resolve(rows.map((r) => ({ ...r }))) }),
|
|
22
|
+
insert: () => ({
|
|
23
|
+
values: (v: { key: string; value: string; encrypted: boolean }) => ({
|
|
24
|
+
onConflictDoUpdate: () => {
|
|
25
|
+
const existing = rows.find((r) => r.key === v.key);
|
|
26
|
+
if (existing) Object.assign(existing, v);
|
|
27
|
+
else rows.push({ key: v.key, value: v.value, encrypted: v.encrypted });
|
|
28
|
+
return Promise.resolve();
|
|
29
|
+
},
|
|
30
|
+
}),
|
|
31
|
+
}),
|
|
32
|
+
delete: () => ({ where: () => Promise.resolve() }),
|
|
33
|
+
} as unknown as Database;
|
|
34
|
+
return { db, rows };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let saved: NodeJS.ProcessEnv;
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
saved = { ...process.env };
|
|
40
|
+
for (const def of CORE_SETTINGS) delete process.env[def.envVar];
|
|
41
|
+
delete process.env.GITHUB_TOKEN;
|
|
42
|
+
});
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
process.env = saved;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('DeploymentSettingsService — precedence', () => {
|
|
48
|
+
/**
|
|
49
|
+
* The rule the whole design rests on: a stored row is a FALLBACK for a
|
|
50
|
+
* variable nobody set, never an override of one they did. It is what lets an
|
|
51
|
+
* existing deployment adopt this table with no behaviour change, and what
|
|
52
|
+
* stops a value typed into a browser outranking the infrastructure config
|
|
53
|
+
* someone is reviewing in a repo.
|
|
54
|
+
*/
|
|
55
|
+
it('lets the environment win over a stored value', async () => {
|
|
56
|
+
const { db } = makeDb();
|
|
57
|
+
const settings = new DeploymentSettingsService(db, ENC_KEY);
|
|
58
|
+
await settings.save({ kbRepoUrl: 'https://example.com/stored.git' }, null);
|
|
59
|
+
|
|
60
|
+
process.env.KB_REPO_URL = 'https://example.com/from-env.git';
|
|
61
|
+
expect(settings.resolve('kbRepoUrl')).toBe('https://example.com/from-env.git');
|
|
62
|
+
expect(settings.sourceOf('kbRepoUrl')).toBe('env');
|
|
63
|
+
|
|
64
|
+
delete process.env.KB_REPO_URL;
|
|
65
|
+
expect(settings.resolve('kbRepoUrl')).toBe('https://example.com/stored.git');
|
|
66
|
+
expect(settings.sourceOf('kbRepoUrl')).toBe('stored');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Refused, not silently accepted. Storing it would write a row that can never
|
|
71
|
+
* take effect, and leave the screen implying it had.
|
|
72
|
+
*/
|
|
73
|
+
it('refuses to store a setting the environment already supplies', async () => {
|
|
74
|
+
process.env.KB_REPO_URL = 'https://example.com/from-env.git';
|
|
75
|
+
const { db } = makeDb();
|
|
76
|
+
const settings = new DeploymentSettingsService(db, ENC_KEY);
|
|
77
|
+
await expect(
|
|
78
|
+
settings.save({ kbRepoUrl: 'https://example.com/other.git' }, null),
|
|
79
|
+
).rejects.toBeInstanceOf(SettingsValidationError);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('DeploymentSettingsService — secrets', () => {
|
|
84
|
+
it('stores the token as ciphertext and reads it back', async () => {
|
|
85
|
+
const { db, rows } = makeDb();
|
|
86
|
+
const settings = new DeploymentSettingsService(db, ENC_KEY);
|
|
87
|
+
await settings.save({ gitToken: 'ghp_verysecret' }, null);
|
|
88
|
+
|
|
89
|
+
const row = rows.find((r) => r.key === 'gitToken');
|
|
90
|
+
expect(row?.encrypted).toBe(true);
|
|
91
|
+
expect(row?.value).not.toContain('ghp_verysecret');
|
|
92
|
+
|
|
93
|
+
// A fresh instance decrypts from the same rows — the round trip, not just
|
|
94
|
+
// the in-memory cache.
|
|
95
|
+
const reloaded = new DeploymentSettingsService(db, ENC_KEY);
|
|
96
|
+
await reloaded.load();
|
|
97
|
+
expect(reloaded.resolve('gitToken')).toBe('ghp_verysecret');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('never puts a secret value in what the screen is sent', async () => {
|
|
101
|
+
const { db } = makeDb();
|
|
102
|
+
const settings = new DeploymentSettingsService(db, ENC_KEY);
|
|
103
|
+
await settings.save({ gitToken: 'ghp_verysecret' }, null);
|
|
104
|
+
|
|
105
|
+
const described = settings.describe();
|
|
106
|
+
expect(JSON.stringify(described)).not.toContain('ghp_verysecret');
|
|
107
|
+
const token = described.find((s) => s.key === 'gitToken');
|
|
108
|
+
// Configured is all a client learns — enough to render "replace it?".
|
|
109
|
+
expect(token).toMatchObject({ secret: true, configured: true });
|
|
110
|
+
expect(token).not.toHaveProperty('value');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('publishes a stored token as GITHUB_TOKEN, which is what git reads', async () => {
|
|
114
|
+
const { db } = makeDb();
|
|
115
|
+
const settings = new DeploymentSettingsService(db, ENC_KEY);
|
|
116
|
+
await settings.save({ gitToken: 'ghp_fromsetup' }, null);
|
|
117
|
+
expect(process.env.GITHUB_TOKEN).toBe('ghp_fromsetup');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('does not overwrite a git token the environment supplied', async () => {
|
|
121
|
+
process.env.GIT_TOKEN = 'ghp_fromenv';
|
|
122
|
+
process.env.GITHUB_TOKEN = 'ghp_fromenv';
|
|
123
|
+
const { db } = makeDb();
|
|
124
|
+
const settings = new DeploymentSettingsService(db, ENC_KEY);
|
|
125
|
+
settings.syncGitTokenEnv();
|
|
126
|
+
expect(process.env.GITHUB_TOKEN).toBe('ghp_fromenv');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('refuses to store a secret with no encryption key rather than writing plaintext', async () => {
|
|
130
|
+
const { db } = makeDb();
|
|
131
|
+
const settings = new DeploymentSettingsService(db, '');
|
|
132
|
+
await expect(settings.save({ gitToken: 'ghp_verysecret' }, null)).rejects.toBeInstanceOf(
|
|
133
|
+
SettingsValidationError,
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* A rotated or mistyped key must not stop the server booting — the screen
|
|
139
|
+
* where it gets fixed is on the other side of that boot.
|
|
140
|
+
*/
|
|
141
|
+
it('survives an undecryptable row instead of failing to start', async () => {
|
|
142
|
+
const { db, rows } = makeDb();
|
|
143
|
+
rows.push({ key: 'gitToken', value: 'not-a-valid-blob', encrypted: true });
|
|
144
|
+
const settings = new DeploymentSettingsService(db, ENC_KEY);
|
|
145
|
+
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
146
|
+
await expect(settings.load()).resolves.toBeUndefined();
|
|
147
|
+
expect(settings.resolve('gitToken')).toBe('');
|
|
148
|
+
expect(spy).toHaveBeenCalled();
|
|
149
|
+
spy.mockRestore();
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('DeploymentSettingsService — validation', () => {
|
|
154
|
+
it('rejects the whole batch when one field is wrong', async () => {
|
|
155
|
+
const { db, rows } = makeDb();
|
|
156
|
+
const settings = new DeploymentSettingsService(db, ENC_KEY);
|
|
157
|
+
await expect(
|
|
158
|
+
settings.save({ kbRepoUrl: 'not-a-url', gitToken: 'ghp_ok' }, null),
|
|
159
|
+
).rejects.toBeInstanceOf(SettingsValidationError);
|
|
160
|
+
// Nothing written: a URL saved without its token is a deployment that
|
|
161
|
+
// fails at the first clone.
|
|
162
|
+
expect(rows).toHaveLength(0);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('rejects a non-https remote', async () => {
|
|
166
|
+
const { db } = makeDb();
|
|
167
|
+
const settings = new DeploymentSettingsService(db, ENC_KEY);
|
|
168
|
+
await expect(
|
|
169
|
+
settings.save({ kbRepoUrl: 'git@github.com:acme/kb.git' }, null),
|
|
170
|
+
).rejects.toThrow(/Invalid settings/);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('rejects a directory name that could escape the workspace', async () => {
|
|
174
|
+
const { db } = makeDb();
|
|
175
|
+
const settings = new DeploymentSettingsService(db, ENC_KEY);
|
|
176
|
+
await expect(settings.save({ kbDirName: '../elsewhere' }, null)).rejects.toThrow(
|
|
177
|
+
/Invalid settings/,
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('rejects a username that would break out of the credential-helper snippet', async () => {
|
|
182
|
+
const { db } = makeDb();
|
|
183
|
+
const settings = new DeploymentSettingsService(db, ENC_KEY);
|
|
184
|
+
await expect(settings.save({ gitUsername: 'x"; rm -rf /; #' }, null)).rejects.toThrow(
|
|
185
|
+
/Invalid settings/,
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* A blank field means "leave it alone". Treating it as a delete would let a
|
|
191
|
+
* stray Enter on a half-filled form unconfigure a running deployment.
|
|
192
|
+
*/
|
|
193
|
+
it('treats a blank field as no change, not as an erase', async () => {
|
|
194
|
+
const { db } = makeDb();
|
|
195
|
+
const settings = new DeploymentSettingsService(db, ENC_KEY);
|
|
196
|
+
await settings.save({ gitToken: 'ghp_keepme' }, null);
|
|
197
|
+
await settings.save({ gitToken: ' ' }, null);
|
|
198
|
+
expect(settings.resolve('gitToken')).toBe('ghp_keepme');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The branch pair is the one rule no per-field check can express: the default
|
|
203
|
+
* has to appear in the protected list, so each name is only valid in the
|
|
204
|
+
* other's company. If it were not checked, the pair would be rejected far
|
|
205
|
+
* later — by `configureBranchModel` at the NEXT BOOT, which is a deployment
|
|
206
|
+
* that saved successfully and then would not start.
|
|
207
|
+
*/
|
|
208
|
+
it('refuses a default branch that is not in the protected list', async () => {
|
|
209
|
+
const { db } = makeDb();
|
|
210
|
+
const settings = new DeploymentSettingsService(db, ENC_KEY);
|
|
211
|
+
await expect(
|
|
212
|
+
settings.save({ defaultBranch: 'main', protectedBranches: 'release' }, null),
|
|
213
|
+
).rejects.toBeInstanceOf(SettingsValidationError);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('accepts a pair that agrees', async () => {
|
|
217
|
+
const { db } = makeDb();
|
|
218
|
+
const settings = new DeploymentSettingsService(db, ENC_KEY);
|
|
219
|
+
await settings.save({ defaultBranch: 'main', protectedBranches: 'main, release' }, null);
|
|
220
|
+
expect(settings.resolve('defaultBranch')).toBe('main');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Judged on the model the save WOULD produce, not on the input: setting one
|
|
225
|
+
* half against an existing other half has to be checked against the result.
|
|
226
|
+
*/
|
|
227
|
+
it('checks a half-save against what is already in effect', async () => {
|
|
228
|
+
const { db } = makeDb();
|
|
229
|
+
const settings = new DeploymentSettingsService(db, ENC_KEY);
|
|
230
|
+
await settings.save({ defaultBranch: 'main', protectedBranches: 'main' }, null);
|
|
231
|
+
// Narrowing the protected list to one that excludes the standing default.
|
|
232
|
+
await expect(settings.save({ protectedBranches: 'release' }, null)).rejects.toBeInstanceOf(
|
|
233
|
+
SettingsValidationError,
|
|
234
|
+
);
|
|
235
|
+
// …and the other way: a default that the standing list does cover.
|
|
236
|
+
await settings.save({ protectedBranches: 'main, release' }, null);
|
|
237
|
+
await expect(settings.save({ defaultBranch: 'release' }, null)).resolves.toBeTruthy();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('seals the SSO client secret like the git token', async () => {
|
|
241
|
+
const { db, rows } = makeDb();
|
|
242
|
+
const settings = new DeploymentSettingsService(db, ENC_KEY);
|
|
243
|
+
await settings.save({ oidcClientSecret: 'sso-very-secret' }, null);
|
|
244
|
+
expect(rows.find((r) => r.key === 'oidcClientSecret')?.encrypted).toBe(true);
|
|
245
|
+
expect(JSON.stringify(settings.describe())).not.toContain('sso-very-secret');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('reports a restart only for a setting a running server cannot pick up', async () => {
|
|
249
|
+
const { db } = makeDb();
|
|
250
|
+
const settings = new DeploymentSettingsService(db, ENC_KEY);
|
|
251
|
+
const live = await settings.save({ kbRepoUrl: 'https://example.com/kb.git' }, null);
|
|
252
|
+
expect(live.restartRequired).toBe(false);
|
|
253
|
+
const staged = await settings.save({ kbDirName: 'company-brain' }, null);
|
|
254
|
+
expect(staged.restartRequired).toBe(true);
|
|
255
|
+
});
|
|
256
|
+
});
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import type { Server as HttpServer } from 'node:http';
|
|
2
|
+
import type { AddressInfo } from 'node:net';
|
|
3
|
+
import express from 'express';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { createSetupRoutes } from '../setup.routes.js';
|
|
6
|
+
import { DeploymentSettingsService } from '../deployment-settings.service.js';
|
|
7
|
+
import type { Database } from '../../database/connection.js';
|
|
8
|
+
import type { IAdminAccessService } from '../../admin/admin.interface.js';
|
|
9
|
+
|
|
10
|
+
const ENC_KEY = 'kToAi8FXWDpDn3A6yQ/60O39bv05N7XzVOIu/0CJrFc=';
|
|
11
|
+
|
|
12
|
+
let server: HttpServer | null = null;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* These suites clear the KB variables so `settings.resolve` falls through to
|
|
16
|
+
* the stored layer. They are RESTORED afterwards rather than left deleted: the
|
|
17
|
+
* repo `.env` is loaded into `process.env` by the vitest config, so a suite
|
|
18
|
+
* that runs later in the same worker would otherwise see a different
|
|
19
|
+
* environment than the one it was written against.
|
|
20
|
+
*/
|
|
21
|
+
const KB_ENV = ['KB_REPO_URL', 'GIT_TOKEN', 'GIT_USERNAME', 'KB_DIR_NAME'] as const;
|
|
22
|
+
let savedEnv: Partial<Record<(typeof KB_ENV)[number], string | undefined>> = {};
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
savedEnv = {};
|
|
26
|
+
for (const k of KB_ENV) {
|
|
27
|
+
savedEnv[k] = process.env[k];
|
|
28
|
+
delete process.env[k];
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
server?.close();
|
|
34
|
+
server = null;
|
|
35
|
+
for (const k of KB_ENV) {
|
|
36
|
+
const original = savedEnv[k];
|
|
37
|
+
if (original === undefined) delete process.env[k];
|
|
38
|
+
else process.env[k] = original;
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/** Mount the setup router on a throwaway port and hand back its base URL. */
|
|
43
|
+
function listen(isAdmin = true) {
|
|
44
|
+
const db = {
|
|
45
|
+
select: () => ({ from: () => Promise.resolve([]) }),
|
|
46
|
+
insert: () => ({ values: () => ({ onConflictDoUpdate: () => Promise.resolve() }) }),
|
|
47
|
+
delete: () => ({ where: () => Promise.resolve() }),
|
|
48
|
+
} as unknown as Database;
|
|
49
|
+
const settings = new DeploymentSettingsService(db, ENC_KEY);
|
|
50
|
+
const app = express();
|
|
51
|
+
app.use(express.json());
|
|
52
|
+
app.use((req, _res, next) => {
|
|
53
|
+
req.userEmail = 'root@example.com';
|
|
54
|
+
req.userId = 'user-1';
|
|
55
|
+
next();
|
|
56
|
+
});
|
|
57
|
+
app.use('/api', createSetupRoutes(settings, { isAdmin: async () => isAdmin } as IAdminAccessService));
|
|
58
|
+
server = app.listen(0);
|
|
59
|
+
const { port } = server.address() as AddressInfo;
|
|
60
|
+
return { base: `http://127.0.0.1:${port}`, settings };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const post = (base: string, path: string, body: unknown) =>
|
|
64
|
+
fetch(`${base}${path}`, {
|
|
65
|
+
method: 'POST',
|
|
66
|
+
headers: { 'content-type': 'application/json' },
|
|
67
|
+
body: JSON.stringify(body),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The connection test hands its input to `git`. Anything reaching that command
|
|
72
|
+
* unvalidated is argument injection — admin-only, but "admin" is not "may run
|
|
73
|
+
* arbitrary commands as the server process".
|
|
74
|
+
*/
|
|
75
|
+
describe('POST /setup/test-connection — what may reach git', () => {
|
|
76
|
+
it('refuses a value git would read as an option', async () => {
|
|
77
|
+
const { base } = listen();
|
|
78
|
+
const res = await post(base, '/api/setup/test-connection', {
|
|
79
|
+
kbRepoUrl: '--upload-pack=touch /tmp/pwned',
|
|
80
|
+
});
|
|
81
|
+
expect(res.status).toBe(400);
|
|
82
|
+
expect((await res.json()).ok).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
/** `ext::` is a git transport whose whole purpose is to run a command. */
|
|
86
|
+
it('refuses the ext:: transport', async () => {
|
|
87
|
+
const { base } = listen();
|
|
88
|
+
const res = await post(base, '/api/setup/test-connection', {
|
|
89
|
+
kbRepoUrl: 'ext::sh -c "touch /tmp/pwned"',
|
|
90
|
+
});
|
|
91
|
+
expect(res.status).toBe(400);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('refuses a non-https scheme', async () => {
|
|
95
|
+
const { base } = listen();
|
|
96
|
+
const res = await post(base, '/api/setup/test-connection', { kbRepoUrl: 'file:///etc/passwd' });
|
|
97
|
+
expect(res.status).toBe(400);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('refuses a username that would break out of the credential-helper snippet', async () => {
|
|
101
|
+
const { base } = listen();
|
|
102
|
+
const res = await post(base, '/api/setup/test-connection', {
|
|
103
|
+
kbRepoUrl: 'https://example.com/acme/kb.git',
|
|
104
|
+
gitUsername: 'x"; touch /tmp/pwned; #',
|
|
105
|
+
});
|
|
106
|
+
expect(res.status).toBe(400);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('is admins-only', async () => {
|
|
110
|
+
const { base } = listen(false);
|
|
111
|
+
const res = await post(base, '/api/setup/test-connection', {
|
|
112
|
+
kbRepoUrl: 'https://example.com/acme/kb.git',
|
|
113
|
+
});
|
|
114
|
+
expect(res.status).toBe(403);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The stored token is deliberately unreadable — `describe()` omits it — so an
|
|
120
|
+
* endpoint that would send it to a caller-named host hands it straight back.
|
|
121
|
+
*/
|
|
122
|
+
describe('POST /setup/test-connection — where the saved token may go', () => {
|
|
123
|
+
it('refuses to test a different repository with the saved token', async () => {
|
|
124
|
+
const { base, settings } = listen();
|
|
125
|
+
await settings.save(
|
|
126
|
+
{ kbRepoUrl: 'https://example.com/acme/kb.git', gitToken: 'ghp_verysecret' },
|
|
127
|
+
null,
|
|
128
|
+
);
|
|
129
|
+
const res = await post(base, '/api/setup/test-connection', {
|
|
130
|
+
kbRepoUrl: 'https://attacker.example/collector.git',
|
|
131
|
+
});
|
|
132
|
+
expect(res.status).toBe(400);
|
|
133
|
+
expect((await res.json()).error).toMatch(/access token for that repository/i);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('still lets an admin re-test the repository that IS configured', async () => {
|
|
137
|
+
const { base, settings } = listen();
|
|
138
|
+
await settings.save(
|
|
139
|
+
{ kbRepoUrl: 'https://127.0.0.1:1/acme/kb.git', gitToken: 'ghp_verysecret' },
|
|
140
|
+
null,
|
|
141
|
+
);
|
|
142
|
+
// No credentials in the body: the saved pair is used together, which is the
|
|
143
|
+
// "does the token I saved last week still work?" case.
|
|
144
|
+
const res = await post(base, '/api/setup/test-connection', {});
|
|
145
|
+
// Port 1 refuses instantly — reaching a connection error proves the request
|
|
146
|
+
// got past the guards and ran git, rather than being rejected up front.
|
|
147
|
+
expect(res.status).toBe(200);
|
|
148
|
+
expect((await res.json()).ok).toBe(false);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('accepts a different repository when the request brings its own token', async () => {
|
|
152
|
+
const { base, settings } = listen();
|
|
153
|
+
await settings.save(
|
|
154
|
+
{ kbRepoUrl: 'https://example.com/acme/kb.git', gitToken: 'ghp_verysecret' },
|
|
155
|
+
null,
|
|
156
|
+
);
|
|
157
|
+
const res = await post(base, '/api/setup/test-connection', {
|
|
158
|
+
kbRepoUrl: 'https://127.0.0.1:1/other/kb.git',
|
|
159
|
+
gitToken: 'ghp_its_own',
|
|
160
|
+
});
|
|
161
|
+
expect(res.status).toBe(200);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The arguments the route builds have to be ones git actually accepts —
|
|
167
|
+
* `--end-of-options` among them. A rejected FLAG and a refused CONNECTION look
|
|
168
|
+
* alike from the outside (`ok: false`), so this aims at a port nothing listens
|
|
169
|
+
* on: reaching a connection error proves git parsed the command line.
|
|
170
|
+
*/
|
|
171
|
+
describe('POST /setup/test-connection — the command git is given', () => {
|
|
172
|
+
it('builds a command git accepts', async () => {
|
|
173
|
+
const { base } = listen();
|
|
174
|
+
const res = await post(base, '/api/setup/test-connection', {
|
|
175
|
+
kbRepoUrl: 'https://127.0.0.1:1/acme/kb.git',
|
|
176
|
+
gitToken: 'ghp_whatever',
|
|
177
|
+
});
|
|
178
|
+
expect(res.status).toBe(200);
|
|
179
|
+
const body = await res.json();
|
|
180
|
+
expect(body.ok).toBe(false);
|
|
181
|
+
expect(body.error).not.toMatch(/unknown option|usage: git/i);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe('GET /setup/status', () => {
|
|
186
|
+
it('tells a non-admin whether setup is done, and nothing else', async () => {
|
|
187
|
+
const { base } = listen(false);
|
|
188
|
+
const res = await fetch(`${base}/api/setup/status`);
|
|
189
|
+
const body = await res.json();
|
|
190
|
+
expect(body).toEqual({ complete: false, isAdmin: false });
|
|
191
|
+
// No catalogue, so no variable names and no hint at what is missing.
|
|
192
|
+
expect(JSON.stringify(body)).not.toContain('KB_REPO_URL');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('never includes a stored secret, even for an admin', async () => {
|
|
196
|
+
const { base, settings } = listen();
|
|
197
|
+
await settings.save({ gitToken: 'ghp_verysecret' }, null);
|
|
198
|
+
const res = await fetch(`${base}/api/setup/status`);
|
|
199
|
+
expect(JSON.stringify(await res.json())).not.toContain('ghp_verysecret');
|
|
200
|
+
});
|
|
201
|
+
});
|