@bevel-software/platform-core-backend 0.22.0 → 0.23.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 +11 -1
- package/dist/core/core-ports.d.ts.map +1 -1
- package/dist/core/core-ports.js.map +1 -1
- package/dist/core/create-core-server.d.ts +3 -3
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +30 -11
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +9 -2
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +112 -23
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core/lifecycle.d.ts +34 -1
- package/dist/core/lifecycle.d.ts.map +1 -1
- package/dist/core/lifecycle.js +89 -13
- package/dist/core/lifecycle.js.map +1 -1
- package/dist/core-config.d.ts +0 -12
- package/dist/core-config.d.ts.map +1 -1
- package/dist/core-config.js +11 -13
- package/dist/core-config.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +42 -12
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +67 -10
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +214 -35
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/access.routes.d.ts.map +1 -1
- package/dist/modules/access/access.routes.js +13 -15
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/auth/account-admission.d.ts +58 -7
- package/dist/modules/auth/account-admission.d.ts.map +1 -1
- package/dist/modules/auth/account-admission.js +44 -1
- package/dist/modules/auth/account-admission.js.map +1 -1
- package/dist/modules/auth/account.routes.d.ts +1 -1
- package/dist/modules/auth/account.routes.d.ts.map +1 -1
- package/dist/modules/auth/account.routes.js +61 -5
- package/dist/modules/auth/account.routes.js.map +1 -1
- package/dist/modules/auth/auth.middleware.d.ts +1 -1
- package/dist/modules/auth/auth.middleware.d.ts.map +1 -1
- package/dist/modules/auth/auth.middleware.js +18 -7
- package/dist/modules/auth/auth.middleware.js.map +1 -1
- package/dist/modules/auth/auth.routes.d.ts.map +1 -1
- package/dist/modules/auth/auth.routes.js +8 -0
- package/dist/modules/auth/auth.routes.js.map +1 -1
- package/dist/modules/auth/auth.service.d.ts +70 -2
- package/dist/modules/auth/auth.service.d.ts.map +1 -1
- package/dist/modules/auth/auth.service.js +176 -7
- package/dist/modules/auth/auth.service.js.map +1 -1
- package/dist/modules/auth/oidc-auth-provider.d.ts.map +1 -1
- package/dist/modules/auth/oidc-auth-provider.js +7 -2
- package/dist/modules/auth/oidc-auth-provider.js.map +1 -1
- package/dist/modules/database/core-schema.d.ts +41 -24
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +35 -27
- package/dist/modules/database/core-schema.js.map +1 -1
- package/dist/modules/kb-fs/branch-name.d.ts.map +1 -1
- package/dist/modules/kb-fs/branch-name.js +12 -2
- package/dist/modules/kb-fs/branch-name.js.map +1 -1
- package/dist/modules/kb-fs/repo-path.d.ts +11 -16
- package/dist/modules/kb-fs/repo-path.d.ts.map +1 -1
- package/dist/modules/kb-fs/repo-path.js +79 -0
- package/dist/modules/kb-fs/repo-path.js.map +1 -1
- package/dist/modules/kb-sync/kb-sync.routes.d.ts +2 -2
- package/dist/modules/kb-sync/kb-sync.routes.d.ts.map +1 -1
- package/dist/modules/kb-sync/kb-sync.routes.js +14 -3
- package/dist/modules/kb-sync/kb-sync.routes.js.map +1 -1
- package/dist/modules/kb-sync/sync-auth.d.ts +3 -1
- package/dist/modules/kb-sync/sync-auth.d.ts.map +1 -1
- package/dist/modules/kb-sync/sync-auth.js +1 -1
- package/dist/modules/kb-sync/sync-auth.js.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.js +21 -6
- package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
- package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
- package/dist/modules/mcp/oauth/bevel-oauth-provider.js +3 -2
- package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
- package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
- package/dist/modules/settings/deployment-settings.service.js +8 -3
- package/dist/modules/settings/deployment-settings.service.js.map +1 -1
- package/dist/modules/settings/setup.routes.d.ts +52 -1
- package/dist/modules/settings/setup.routes.d.ts.map +1 -1
- package/dist/modules/settings/setup.routes.js +207 -19
- package/dist/modules/settings/setup.routes.js.map +1 -1
- package/dist/modules/skills/skills.contract.d.ts +90 -18
- package/dist/modules/skills/skills.contract.d.ts.map +1 -1
- package/dist/modules/skills/skills.contract.js +4 -1
- package/dist/modules/skills/skills.contract.js.map +1 -1
- package/dist/modules/skills/skills.service.d.ts +98 -9
- package/dist/modules/skills/skills.service.d.ts.map +1 -1
- package/dist/modules/skills/skills.service.js +239 -37
- package/dist/modules/skills/skills.service.js.map +1 -1
- package/dist/modules/skills/skills.tools.d.ts +7 -0
- package/dist/modules/skills/skills.tools.d.ts.map +1 -1
- package/dist/modules/skills/skills.tools.js +71 -7
- package/dist/modules/skills/skills.tools.js.map +1 -1
- package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
- package/dist/modules/tool-auth/external-api-key.service.js +4 -2
- package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
- package/dist/modules/tool-auth/internal-token.service.d.ts +3 -3
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts +16 -7
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
- package/dist/modules/tool-auth/tool-auth.middleware.js +34 -12
- package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
- package/dist/modules/tool-helpers/tool-handler.d.ts +2 -1
- package/dist/modules/tool-helpers/tool-handler.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool-handler.js +25 -4
- package/dist/modules/tool-helpers/tool-handler.js.map +1 -1
- package/dist/modules/tool-helpers/tool.contract.d.ts +3 -2
- package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
- package/dist/modules/tool-helpers/validate-token.js +1 -1
- package/dist/modules/tool-helpers/validate-token.js.map +1 -1
- package/dist/modules/workflow/agent-tools/change-request-summary.d.ts +98 -0
- package/dist/modules/workflow/agent-tools/change-request-summary.d.ts.map +1 -0
- package/dist/modules/workflow/agent-tools/change-request-summary.js +81 -0
- package/dist/modules/workflow/agent-tools/change-request-summary.js.map +1 -0
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +75 -35
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workflow/file-lock.service.d.ts +24 -0
- package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
- package/dist/modules/workflow/file-lock.service.js +30 -0
- package/dist/modules/workflow/file-lock.service.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +69 -0
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +153 -2
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/pending-commits.service.d.ts +38 -0
- package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
- package/dist/modules/workflow/pending-commits.service.js +55 -0
- package/dist/modules/workflow/pending-commits.service.js.map +1 -1
- package/dist/modules/workflow/workflow-hooks.d.ts +54 -32
- package/dist/modules/workflow/workflow-hooks.d.ts.map +1 -1
- package/dist/modules/workflow/workflow-hooks.js +16 -1
- package/dist/modules/workflow/workflow-hooks.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +60 -6
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +111 -4
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/agent-access.gate.d.ts +94 -0
- package/dist/modules/workspace/agent-access.gate.d.ts.map +1 -0
- package/dist/modules/workspace/agent-access.gate.js +123 -0
- package/dist/modules/workspace/agent-access.gate.js.map +1 -0
- package/dist/modules/workspace/routine-write-policy.d.ts +5 -6
- package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
- package/dist/modules/workspace/routine-write-policy.js +5 -6
- package/dist/modules/workspace/routine-write-policy.js.map +1 -1
- package/dist/modules/workspace/session-sink.d.ts +5 -5
- package/dist/modules/workspace/set-aside-clone.d.ts +46 -0
- package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -0
- package/dist/modules/workspace/set-aside-clone.js +92 -0
- package/dist/modules/workspace/set-aside-clone.js.map +1 -0
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts +59 -9
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
- package/dist/modules/workspace/startup/kb-startup-runner.js +65 -24
- package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
- package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.routes.js +92 -3
- package/dist/modules/workspace/workspace.routes.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +115 -5
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +255 -25
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts +2 -2
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +309 -115
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/modules/write-access/write-access.d.ts +60 -0
- package/dist/modules/write-access/write-access.d.ts.map +1 -0
- package/dist/modules/write-access/write-access.js +129 -0
- package/dist/modules/write-access/write-access.js.map +1 -0
- package/dist/shared/domain-errors.d.ts +25 -0
- package/dist/shared/domain-errors.d.ts.map +1 -1
- package/dist/shared/domain-errors.js +28 -0
- package/dist/shared/domain-errors.js.map +1 -1
- package/dist/shared/git.contract.d.ts +20 -0
- package/dist/shared/git.contract.d.ts.map +1 -1
- package/dist/shared/git.contract.js +26 -0
- package/dist/shared/git.contract.js.map +1 -1
- package/dist/tenancy/static-tenant-source.d.ts +0 -1
- package/dist/tenancy/static-tenant-source.d.ts.map +1 -1
- package/dist/tenancy/static-tenant-source.js +0 -2
- package/dist/tenancy/static-tenant-source.js.map +1 -1
- package/migrations/0014_change_request_closed_reason.sql +1 -0
- package/migrations/0015_account_deactivation.sql +3 -0
- package/migrations/meta/0014_snapshot.json +2265 -0
- package/migrations/meta/0015_snapshot.json +2271 -0
- package/migrations/meta/_journal.json +14 -0
- package/package.json +3 -3
- package/src/__tests__/kb-layout-config.test.ts +0 -2
- package/src/__tests__/retired-settings.test.ts +96 -0
- package/src/core/__tests__/gated-boot.test.ts +132 -0
- package/src/core/__tests__/lifecycle.test.ts +170 -1
- package/src/core/__tests__/set-aside-root-is-one-place.test.ts +63 -0
- package/src/core/core-ports.ts +11 -1
- package/src/core/create-core-server.ts +32 -14
- package/src/core/create-core-services.ts +129 -23
- package/src/core/lifecycle.ts +98 -13
- package/src/core-config.ts +13 -14
- package/src/index.ts +12 -1
- package/src/modules/access/__tests__/access-control.preview-relocation.test.ts +385 -0
- package/src/modules/access/__tests__/access-control.prospective.test.ts +94 -18
- package/src/modules/access/__tests__/access.routes.prospective.test.ts +6 -7
- package/src/modules/access/access-control.interface.ts +43 -12
- package/src/modules/access/access-control.service.ts +234 -36
- package/src/modules/access/access.routes.ts +13 -15
- package/src/modules/agent-instructions/__tests__/agent-instructions.route.test.ts +3 -1
- package/src/modules/auth/__tests__/account-deactivation.test.ts +252 -0
- package/src/modules/auth/__tests__/account.routes.test.ts +81 -3
- package/src/modules/auth/__tests__/auth.middleware.test.ts +45 -22
- package/src/modules/auth/__tests__/auth.service.test.ts +1 -1
- package/src/modules/auth/account-admission.ts +78 -9
- package/src/modules/auth/account.routes.ts +63 -7
- package/src/modules/auth/auth.middleware.ts +19 -8
- package/src/modules/auth/auth.routes.ts +8 -0
- package/src/modules/auth/auth.service.ts +179 -6
- package/src/modules/auth/oidc-auth-provider.ts +7 -2
- package/src/modules/database/core-schema.ts +35 -27
- package/src/modules/kb-fs/__tests__/branch-name.test.ts +10 -0
- package/src/modules/kb-fs/__tests__/repo-path.test.ts +123 -0
- package/src/modules/kb-fs/branch-name.ts +14 -1
- package/src/modules/kb-fs/repo-path.ts +85 -0
- package/src/modules/kb-sync/__tests__/kb-sync.routes.test.ts +26 -1
- package/src/modules/kb-sync/kb-sync.routes.ts +14 -4
- package/src/modules/kb-sync/sync-auth.ts +2 -2
- package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +60 -3
- package/src/modules/mcp/__tests__/mcp.service.test.ts +68 -6
- package/src/modules/mcp/mcp-auth.middleware.ts +20 -6
- package/src/modules/mcp/oauth/bevel-oauth-provider.ts +3 -1
- package/src/modules/plugins/__tests__/plugins.tools.test.ts +2 -1
- package/src/modules/settings/__tests__/deployment-settings.service.test.ts +16 -0
- package/src/modules/settings/__tests__/setup.routes.git-mode.test.ts +91 -58
- package/src/modules/settings/__tests__/setup.routes.github-app.test.ts +25 -8
- package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +14 -11
- package/src/modules/settings/__tests__/setup.routes.repository-change.test.ts +390 -0
- package/src/modules/settings/__tests__/setup.routes.test.ts +3 -0
- package/src/modules/settings/deployment-settings.service.ts +8 -3
- package/src/modules/settings/setup.routes.ts +263 -19
- package/src/modules/skills/__tests__/allowed-tools-warn.tools.test.ts +2 -1
- package/src/modules/skills/__tests__/branch-skills.tools.test.ts +218 -0
- package/src/modules/skills/__tests__/skills.service.test.ts +229 -5
- package/src/modules/skills/skills.contract.ts +91 -18
- package/src/modules/skills/skills.service.ts +278 -41
- package/src/modules/skills/skills.tools.ts +80 -8
- package/src/modules/tool-auth/__tests__/manual-auth.middleware.test.ts +14 -1
- package/src/modules/tool-auth/external-api-key.service.ts +4 -2
- package/src/modules/tool-auth/internal-token.service.ts +3 -3
- package/src/modules/tool-auth/tool-auth.middleware.ts +34 -11
- package/src/modules/tool-helpers/__tests__/agent-roles-write.test.ts +5 -3
- package/src/modules/tool-helpers/__tests__/phase4-tools.test.ts +3 -3
- package/src/modules/tool-helpers/__tests__/validate-token.test.ts +11 -1
- package/src/modules/tool-helpers/tool-handler.ts +24 -4
- package/src/modules/tool-helpers/tool.contract.ts +3 -2
- package/src/modules/tool-helpers/validate-token.ts +1 -1
- package/src/modules/workflow/__tests__/pending-commits.repository-replaced.test.ts +111 -0
- package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +25 -0
- package/src/modules/workflow/__tests__/workflow.service.repository-replaced.test.ts +249 -0
- package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +264 -6
- package/src/modules/workflow/agent-tools/change-request-summary.ts +182 -0
- package/src/modules/workflow/agent-tools/workflow.tools.ts +86 -35
- package/src/modules/workflow/file-lock.service.ts +31 -0
- package/src/modules/workflow/git/__tests__/git.service.fileBytesAtCommit.test.ts +260 -0
- package/src/modules/workflow/git/git.service.ts +168 -0
- package/src/modules/workflow/pending-commits.service.ts +59 -1
- package/src/modules/workflow/workflow-hooks.ts +64 -26
- package/src/modules/workflow/workflow.service.ts +121 -4
- package/src/modules/workspace/__tests__/agent-access.gate.test.ts +208 -0
- package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +384 -0
- package/src/modules/workspace/__tests__/file-stat-access.test.ts +2 -1
- package/src/modules/workspace/__tests__/git-internals.security.test.ts +2 -1
- package/src/modules/workspace/__tests__/set-aside-clone.test.ts +52 -0
- package/src/modules/workspace/__tests__/workspace.routes.at-ref.test.ts +305 -0
- package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +2 -0
- package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +147 -0
- package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +326 -0
- package/src/modules/workspace/__tests__/workspace.service.test.ts +20 -9
- package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +2 -3
- package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +2 -3
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +685 -14
- package/src/modules/workspace/agent-access.gate.ts +164 -0
- package/src/modules/workspace/routine-write-policy.ts +5 -6
- package/src/modules/workspace/session-sink.ts +5 -5
- package/src/modules/workspace/set-aside-clone.ts +96 -0
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +86 -0
- package/src/modules/workspace/startup/kb-startup-runner.ts +115 -34
- package/src/modules/workspace/workspace.routes.ts +95 -3
- package/src/modules/workspace/workspace.service.ts +256 -24
- package/src/modules/workspace/workspace.tools.ts +338 -119
- package/src/modules/write-access/__tests__/write-access.test.ts +248 -0
- package/src/modules/write-access/write-access.ts +153 -0
- package/src/shared/domain-errors.ts +31 -0
- package/src/shared/git.contract.ts +27 -0
- package/src/tenancy/__tests__/static-tenant-source.test.ts +0 -1
- package/src/tenancy/static-tenant-source.ts +0 -3
- package/dist/modules/workflow/session-ontology.policy.d.ts +0 -52
- package/dist/modules/workflow/session-ontology.policy.d.ts.map +0 -1
- package/dist/modules/workflow/session-ontology.policy.js +0 -62
- package/dist/modules/workflow/session-ontology.policy.js.map +0 -1
- package/dist/modules/workflow/session-ontology.service.d.ts +0 -105
- package/dist/modules/workflow/session-ontology.service.d.ts.map +0 -1
- package/dist/modules/workflow/session-ontology.service.js +0 -147
- package/dist/modules/workflow/session-ontology.service.js.map +0 -1
- package/dist/modules/workspace/session-ontology.gate.d.ts +0 -114
- package/dist/modules/workspace/session-ontology.gate.d.ts.map +0 -1
- package/dist/modules/workspace/session-ontology.gate.js +0 -161
- package/dist/modules/workspace/session-ontology.gate.js.map +0 -1
- package/dist/shared/kb-layout.d.ts +0 -39
- package/dist/shared/kb-layout.d.ts.map +0 -1
- package/dist/shared/kb-layout.js +0 -103
- package/dist/shared/kb-layout.js.map +0 -1
- package/dist/shared/kb-layout.test.d.ts +0 -2
- package/dist/shared/kb-layout.test.d.ts.map +0 -1
- package/dist/shared/kb-layout.test.js +0 -75
- package/dist/shared/kb-layout.test.js.map +0 -1
- package/src/modules/workflow/__tests__/session-ontology.policy.test.ts +0 -62
- package/src/modules/workflow/__tests__/session-ontology.service.test.ts +0 -201
- package/src/modules/workflow/session-ontology.policy.ts +0 -70
- package/src/modules/workflow/session-ontology.service.ts +0 -183
- package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +0 -239
- package/src/modules/workspace/session-ontology.gate.ts +0 -191
- package/src/shared/kb-layout.test.ts +0 -98
- package/src/shared/kb-layout.ts +0 -102
|
@@ -4,6 +4,7 @@ import { logger } from '../../shared/logging.js';
|
|
|
4
4
|
const log = logger('tools');
|
|
5
5
|
import type { IExternalApiKeyService } from './external-api-key.interface.js';
|
|
6
6
|
import type { AuthService } from '../auth/auth.service.js';
|
|
7
|
+
import { ACCOUNT_DEACTIVATED_MESSAGE, AuthBackendError } from '../auth/account-admission.js';
|
|
7
8
|
import { InternalTokenService } from './internal-token.service.js';
|
|
8
9
|
import { INVALID_CONNECTION_KEY_CHALLENGE, INVALID_CONNECTION_KEY_MESSAGE } from './connection-key-rejection.js';
|
|
9
10
|
|
|
@@ -79,10 +80,10 @@ export const requireInternalSource: RequestHandler = (req, res, next) => {
|
|
|
79
80
|
|
|
80
81
|
/**
|
|
81
82
|
* Mirror of `requireInternalSource` for surfaces that must admit ONLY external
|
|
82
|
-
* callers. `start_session` mints a fresh chat thread as the caller's
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
83
|
+
* callers. `start_session` mints a fresh chat thread as the caller's session
|
|
84
|
+
* id; an internal (in-process agent) token already carries its run's
|
|
85
|
+
* `sessionId`, so letting it mint a new thread mid-run would silently split
|
|
86
|
+
* one conversation in two.
|
|
86
87
|
*
|
|
87
88
|
* "External" is decided by the VERIFIER, not by which credential shape
|
|
88
89
|
* authenticated: a `bevel_…` connection key resolves external, and so does an
|
|
@@ -122,6 +123,15 @@ export type VerifyResult =
|
|
|
122
123
|
export function createTokenVerifier(
|
|
123
124
|
externalApiKeyService: IExternalApiKeyService,
|
|
124
125
|
internalTokenService: InternalTokenService,
|
|
126
|
+
/**
|
|
127
|
+
* Whether the account an internal token was minted for is still on. An
|
|
128
|
+
* internal token carries only a user id and lives up to an hour (longer
|
|
129
|
+
* for the MCP loopback), so without this a switched-off account's running
|
|
130
|
+
* agent would keep working until it expired. Connection keys need no
|
|
131
|
+
* second look: their lookup reads the account. Every deployment passes
|
|
132
|
+
* it; absent, internal tokens are judged on their signature alone.
|
|
133
|
+
*/
|
|
134
|
+
accounts?: Pick<AuthService, 'isActive'>,
|
|
125
135
|
): (token: string | undefined) => Promise<VerifyResult> {
|
|
126
136
|
const missing = { ok: false as const, status: 401, message: 'A connection key or internal token is required' };
|
|
127
137
|
return async (token) => {
|
|
@@ -136,6 +146,9 @@ export function createTokenVerifier(
|
|
|
136
146
|
if (internalTokenService.looksLikeInternalToken(token)) {
|
|
137
147
|
const claim = internalTokenService.verify(token);
|
|
138
148
|
if (!claim) return { ok: false, status: 401, message: 'Invalid or expired internal token' };
|
|
149
|
+
if (accounts && !(await accounts.isActive(claim.userId))) {
|
|
150
|
+
return { ok: false, status: 401, message: ACCOUNT_DEACTIVATED_MESSAGE };
|
|
151
|
+
}
|
|
139
152
|
return claim.externalProxy
|
|
140
153
|
? {
|
|
141
154
|
ok: true,
|
|
@@ -170,8 +183,9 @@ export function createTokenVerifier(
|
|
|
170
183
|
export function createToolAuthMiddleware(
|
|
171
184
|
externalApiKeyService: IExternalApiKeyService,
|
|
172
185
|
internalTokenService: InternalTokenService,
|
|
186
|
+
accounts?: Pick<AuthService, 'isActive'>,
|
|
173
187
|
): RequestHandler {
|
|
174
|
-
const verify = createTokenVerifier(externalApiKeyService, internalTokenService);
|
|
188
|
+
const verify = createTokenVerifier(externalApiKeyService, internalTokenService, accounts);
|
|
175
189
|
return async (req: Request, res: Response, next: NextFunction) => {
|
|
176
190
|
const header = req.headers.authorization;
|
|
177
191
|
if (!header || !header.toLowerCase().startsWith('bearer ')) {
|
|
@@ -210,9 +224,9 @@ export function createToolAuthMiddleware(
|
|
|
210
224
|
export function createManualAuthMiddleware(
|
|
211
225
|
externalApiKeyService: IExternalApiKeyService,
|
|
212
226
|
internalTokenService: InternalTokenService,
|
|
213
|
-
authService: AuthService,
|
|
227
|
+
authService: Pick<AuthService, 'isActive' | 'resolveSession'>,
|
|
214
228
|
): RequestHandler {
|
|
215
|
-
const verify = createTokenVerifier(externalApiKeyService, internalTokenService);
|
|
229
|
+
const verify = createTokenVerifier(externalApiKeyService, internalTokenService, authService);
|
|
216
230
|
return async (req: Request, res: Response, next: NextFunction) => {
|
|
217
231
|
const header = req.headers.authorization;
|
|
218
232
|
if (!header || !header.toLowerCase().startsWith('bearer ')) {
|
|
@@ -241,13 +255,22 @@ export function createManualAuthMiddleware(
|
|
|
241
255
|
}
|
|
242
256
|
|
|
243
257
|
// Otherwise treat the bearer as a browser session JWT.
|
|
258
|
+
let userId: string;
|
|
244
259
|
try {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
260
|
+
({ userId } = await authService.resolveSession(token));
|
|
261
|
+
} catch (err) {
|
|
262
|
+
// The account could not be looked up: an outage, not a verdict on the
|
|
263
|
+
// session, and a 401 would sign a valid caller out over it.
|
|
264
|
+
if (err instanceof AuthBackendError) {
|
|
265
|
+
log.error('session account lookup failed:', { err: err.cause });
|
|
266
|
+
res.status(500).json({ error: 'Authentication backend unavailable' });
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
249
269
|
res.setHeader('WWW-Authenticate', WWW_AUTH);
|
|
250
270
|
res.status(401).json({ error: 'Invalid or expired token' });
|
|
271
|
+
return;
|
|
251
272
|
}
|
|
273
|
+
req.toolAuth = { source: 'session', userId, scope: 'write' };
|
|
274
|
+
next();
|
|
252
275
|
};
|
|
253
276
|
}
|
|
@@ -12,6 +12,7 @@ import { createToolHandlerFactory } from '../tool-handler.js';
|
|
|
12
12
|
import { registerWorkspaceTools } from '../../workspace/workspace.tools.js';
|
|
13
13
|
import { RoutineWritePolicyService } from '../../workspace/routine-write-policy.js';
|
|
14
14
|
import { WorkflowHooks } from '../../workflow/workflow-hooks.js';
|
|
15
|
+
import { ToolDescriptionNotes } from '../../workspace/agent-access.gate.js';
|
|
15
16
|
import { SpillStore } from '../../workspace/spill-store.js';
|
|
16
17
|
import { DocExtractService } from '../../workspace/file-readers/doc-extract.service.js';
|
|
17
18
|
import { NEW_ROLE_GUIDANCE } from '../../access-model/roles-yaml-guard.js';
|
|
@@ -68,13 +69,14 @@ async function start(): Promise<string> {
|
|
|
68
69
|
canOwner: async () => true,
|
|
69
70
|
canWriteBatchAtRef: async () => null,
|
|
70
71
|
canReadBatch: async (_w: string, _u: string, paths: string[]) => new Map(paths.map((p) => [p, true])),
|
|
72
|
+
// The `after` half of a move's or copy's preview — allowing everything, like the gates above.
|
|
73
|
+
previewAccessAfterRelocation: async () => ({ read: true, write: true, download: true, owner: true }),
|
|
71
74
|
} as never;
|
|
72
75
|
registerWorkspaceTools(registry, router, toolAuth, createToolHandlerFactory(resolve), new SpillStore(path.join(os.tmpdir(), 'bevel-test-spills')), new DocExtractService(docCache), allowAll, testKbContext({ kbDirName: KB }), {
|
|
73
|
-
|
|
74
|
-
enabled: false, // ontology boundary not under test here
|
|
75
|
-
kb: testKbContext({ kbDirName: KB }),
|
|
76
|
+
// No hook registered: the agent-access gate refuses nothing here.
|
|
76
77
|
recoveryBotEmail: 'recovery-bot@bevel.local',
|
|
77
78
|
hooks: new WorkflowHooks(),
|
|
79
|
+
notes: new ToolDescriptionNotes(),
|
|
78
80
|
}, new RoutineWritePolicyService(), {} as never);
|
|
79
81
|
const app = express();
|
|
80
82
|
app.use(express.json());
|
|
@@ -10,6 +10,7 @@ import { createManualRoutes } from '../../tool-registry/manual.routes.js';
|
|
|
10
10
|
import { registerWorkspaceTools } from '../../workspace/workspace.tools.js';
|
|
11
11
|
import { RoutineWritePolicyService } from '../../workspace/routine-write-policy.js';
|
|
12
12
|
import { WorkflowHooks } from '../../workflow/workflow-hooks.js';
|
|
13
|
+
import { ToolDescriptionNotes } from '../../workspace/agent-access.gate.js';
|
|
13
14
|
import { SpillStore } from '../../workspace/spill-store.js';
|
|
14
15
|
import { DocExtractService } from '../../workspace/file-readers/doc-extract.service.js';
|
|
15
16
|
import { testKbContext } from '../../../__tests__/kb-context.js';
|
|
@@ -65,11 +66,10 @@ async function start(): Promise<string> {
|
|
|
65
66
|
// and their web_search / submit_feedback / cite_kb_node cases — moved to
|
|
66
67
|
// the enterprise repo with their modules; core registers only core tools.
|
|
67
68
|
registerWorkspaceTools(registry, router, toolAuth, th, new SpillStore('/tmp/bevel-test-spills'), new DocExtractService('/tmp/bevel-test-doc-extract'), accessControl, testKbContext(), {
|
|
68
|
-
|
|
69
|
-
enabled: false, // ontology boundary not under test here
|
|
70
|
-
kb: testKbContext(),
|
|
69
|
+
// No hook registered: the agent-access gate refuses nothing here.
|
|
71
70
|
recoveryBotEmail: 'recovery-bot@bevel.local',
|
|
72
71
|
hooks: new WorkflowHooks(),
|
|
72
|
+
notes: new ToolDescriptionNotes(),
|
|
73
73
|
}, new RoutineWritePolicyService(), {} as never /* sessionSink — start_session not exercised here */);
|
|
74
74
|
router.use(createManualRoutes(registry, toolAuth));
|
|
75
75
|
|
|
@@ -18,7 +18,11 @@ const externalApiKeyService = {
|
|
|
18
18
|
verifyAndLoadToken: async (t: string) =>
|
|
19
19
|
t === 'bevel_ok' ? { user: { id: 'u-ext', email: 'e@x', name: 'Ext' }, tokenId: 'tk-1' } : null,
|
|
20
20
|
} as never;
|
|
21
|
-
|
|
21
|
+
// Every account is on except `u-off`, which an admin switched off.
|
|
22
|
+
const authService = {
|
|
23
|
+
getUserById: async (id: string) => ({ id, email: 'e@x', name: 'N' }),
|
|
24
|
+
isActive: async (id: string) => id !== 'u-off',
|
|
25
|
+
} as never;
|
|
22
26
|
|
|
23
27
|
let tempDir = '';
|
|
24
28
|
const workspaceService = {
|
|
@@ -66,6 +70,12 @@ describe('validateToken (tool-author SDK entry)', () => {
|
|
|
66
70
|
expect((await fs.readFile('a.md')).toString()).toBe('hi');
|
|
67
71
|
});
|
|
68
72
|
|
|
73
|
+
it('throws a 401 ToolError for a valid internal token whose account was switched off', async () => {
|
|
74
|
+
const err = await validateToken(internalToken.mint({ userId: 'u-off' })).catch((e) => e);
|
|
75
|
+
expect(hasHttpStatus(err) && err.status).toBe(401);
|
|
76
|
+
expect((err as Error).message).toContain('switched off');
|
|
77
|
+
});
|
|
78
|
+
|
|
69
79
|
it('throws a 401 ToolError on a bad token and an empty token', async () => {
|
|
70
80
|
for (const bad of ['garbage', 'bevel_nope', '', undefined]) {
|
|
71
81
|
const err = await validateToken(bad as string).catch((e) => e);
|
|
@@ -6,6 +6,7 @@ import { hasHttpStatus, ToolError, type ToolHandler } from './tool.contract.js';
|
|
|
6
6
|
import { WorkflowDomainError } from '../../shared/domain-errors.js';
|
|
7
7
|
import { domainErrorBody } from '../../shared/http-errors.js';
|
|
8
8
|
import type { ResolveToolContext } from './tool-context.js';
|
|
9
|
+
import { alwaysWritable, READ_ONLY_CODE, refuseWriteTool, type IWriteAccess } from '../write-access/write-access.js';
|
|
9
10
|
import '../tool-auth/tool-auth.middleware.js'; // Express Request.toolAuth augmentation
|
|
10
11
|
|
|
11
12
|
function isAsyncIterable(v: unknown): v is AsyncIterable<unknown> {
|
|
@@ -29,7 +30,7 @@ export interface ToolHandlerOptions {
|
|
|
29
30
|
* The handler receives `req.body` as the flat args (UTCP's `body_field` already
|
|
30
31
|
* delivered the inner body as the request body).
|
|
31
32
|
*/
|
|
32
|
-
export function createToolHandlerFactory(resolve: ResolveToolContext) {
|
|
33
|
+
export function createToolHandlerFactory(resolve: ResolveToolContext, writeAccess: IWriteAccess = alwaysWritable) {
|
|
33
34
|
return function toolHandler(handler: ToolHandler, opts: ToolHandlerOptions = {}) {
|
|
34
35
|
return async (req: Request, res: Response): Promise<void> => {
|
|
35
36
|
const auth = req.toolAuth;
|
|
@@ -41,10 +42,29 @@ export function createToolHandlerFactory(resolve: ResolveToolContext) {
|
|
|
41
42
|
res.status(403).json({ error: 'This tool requires write access.' });
|
|
42
43
|
return;
|
|
43
44
|
}
|
|
45
|
+
// Before anything is awaited: a client that goes away during the
|
|
46
|
+
// write-access check below must still abort the call.
|
|
47
|
+
//
|
|
48
|
+
// Asked of the RESPONSE. The request closes as soon as its body has
|
|
49
|
+
// been read, with the client still there and waiting, so its `close`
|
|
50
|
+
// says nothing about the client; the response closes when it has been
|
|
51
|
+
// sent or the connection is gone, and `writableEnded` tells the two
|
|
52
|
+
// apart.
|
|
44
53
|
const abort = new AbortController();
|
|
45
|
-
|
|
54
|
+
res.on('close', () => {
|
|
46
55
|
if (!res.writableEnded) abort.abort();
|
|
47
56
|
});
|
|
57
|
+
// The tool layer's half of the read-only gate: the HTTP gate lets every
|
|
58
|
+
// tool call through, since only here is a write tool told from a read.
|
|
59
|
+
if (opts.write) {
|
|
60
|
+
const refusal = await refuseWriteTool(writeAccess);
|
|
61
|
+
if (refusal !== null) {
|
|
62
|
+
res.status(403).json({ error: refusal, code: READ_ONLY_CODE });
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
// The client left while the verdict was awaited: nobody to answer.
|
|
66
|
+
if (abort.signal.aborted) return;
|
|
67
|
+
}
|
|
48
68
|
const body: unknown = req.body;
|
|
49
69
|
if (body !== undefined && body !== null && (typeof body !== 'object' || Array.isArray(body))) {
|
|
50
70
|
res.status(400).json({ error: 'Request body must be a JSON object.' });
|
|
@@ -53,8 +73,8 @@ export function createToolHandlerFactory(resolve: ResolveToolContext) {
|
|
|
53
73
|
const args = (body ?? {}) as Record<string, unknown>;
|
|
54
74
|
// `sessionId` rides the tool body like `branch` does: the external MCP
|
|
55
75
|
// proxy injects it (ask-tool continuity convention) and the in-process
|
|
56
|
-
// agent passes its thread id. Surfaced on the context so the
|
|
57
|
-
// gate can
|
|
76
|
+
// agent passes its thread id. Surfaced on the context so the
|
|
77
|
+
// agent-access gate can name the run without each handler re-reading args.
|
|
58
78
|
const sessionId = typeof args.sessionId === 'string' && args.sessionId.length > 0
|
|
59
79
|
? args.sessionId
|
|
60
80
|
: undefined;
|
|
@@ -34,8 +34,9 @@ export interface ToolContext {
|
|
|
34
34
|
* The agent run / conversation id this call belongs to (the agent thread id).
|
|
35
35
|
* Supplied as a `sessionId` field on the tool's input body — the external MCP
|
|
36
36
|
* proxy injects it via the `ask`-tool continuity convention, and the
|
|
37
|
-
* in-process agent passes its thread id.
|
|
38
|
-
*
|
|
37
|
+
* in-process agent passes its thread id. Handed to the agent-access hooks,
|
|
38
|
+
* which is how a deployment scopes a rule to one conversation. Absent for
|
|
39
|
+
* non-agent or unthreaded calls.
|
|
39
40
|
*/
|
|
40
41
|
sessionId?: string;
|
|
41
42
|
/**
|
|
@@ -35,7 +35,7 @@ export type ValidateToken = (
|
|
|
35
35
|
) => Promise<ToolContext>;
|
|
36
36
|
|
|
37
37
|
export function createToolValidator(deps: ToolValidatorDeps): ValidateToken {
|
|
38
|
-
const verify = createTokenVerifier(deps.externalApiKeyService, deps.internalTokenService);
|
|
38
|
+
const verify = createTokenVerifier(deps.externalApiKeyService, deps.internalTokenService, deps.authService);
|
|
39
39
|
const resolve = createToolContextResolver(deps);
|
|
40
40
|
return async (token, signal) => {
|
|
41
41
|
let result;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { SQL } from 'drizzle-orm';
|
|
3
|
+
import { PgDialect } from 'drizzle-orm/pg-core';
|
|
4
|
+
import { PendingCommitsService } from '../pending-commits.service.js';
|
|
5
|
+
import type { Database } from '../../database/connection.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Commits still queued for a branch when the knowledge-base repository is
|
|
9
|
+
* REPLACED.
|
|
10
|
+
*
|
|
11
|
+
* A release enqueues the bytes and only then drops its lock, so the moment the
|
|
12
|
+
* locks on a branch are dropped there may be rows already waiting — written
|
|
13
|
+
* against a working copy of the repository that is gone. Left `pending`, the
|
|
14
|
+
* worker picks them up after the re-clone and commits them onto a same-named
|
|
15
|
+
* branch of a DIFFERENT repository: somebody else's file, overwritten by a
|
|
16
|
+
* change nobody made to it.
|
|
17
|
+
*
|
|
18
|
+
* So they are escalated, never dropped: the bytes stay in the row, the reason
|
|
19
|
+
* is on it, and a person decides.
|
|
20
|
+
*/
|
|
21
|
+
function makeFakeDb(moved: Array<{ id: string }>) {
|
|
22
|
+
const sets: Record<string, unknown>[] = [];
|
|
23
|
+
const db = {
|
|
24
|
+
update: vi.fn(() => ({
|
|
25
|
+
set: (values: Record<string, unknown>) => {
|
|
26
|
+
sets.push(values);
|
|
27
|
+
return { where: () => ({ returning: async () => moved }) };
|
|
28
|
+
},
|
|
29
|
+
})),
|
|
30
|
+
// Queued bytes are never thrown away over this.
|
|
31
|
+
delete: () => {
|
|
32
|
+
throw new Error('a queued commit must never be deleted');
|
|
33
|
+
},
|
|
34
|
+
} as unknown as Database;
|
|
35
|
+
return { db, sets };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('PendingCommitsService.markNeedsAttentionOnBranch', () => {
|
|
39
|
+
it('escalates every queued commit on the branch, with the reason, and answers how many', async () => {
|
|
40
|
+
const { db, sets } = makeFakeDb([{ id: 'a' }, { id: 'b' }]);
|
|
41
|
+
const svc = new PendingCommitsService(db);
|
|
42
|
+
|
|
43
|
+
await expect(
|
|
44
|
+
svc.markNeedsAttentionOnBranch('alice/draft', 'The repository was replaced.'),
|
|
45
|
+
).resolves.toBe(2);
|
|
46
|
+
|
|
47
|
+
expect(sets).toHaveLength(1);
|
|
48
|
+
// `needs_attention` is the status the admin surface reads — the row is out
|
|
49
|
+
// of the worker's reach and in front of a person.
|
|
50
|
+
expect(sets[0]?.status).toBe('needs_attention');
|
|
51
|
+
expect(sets[0]?.lastError).toBe('The repository was replaced.');
|
|
52
|
+
expect(sets[0]?.lastAttemptedAt).toBeInstanceOf(Date);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('answers zero when nothing was queued for that branch', async () => {
|
|
56
|
+
const { db } = makeFakeDb([]);
|
|
57
|
+
const svc = new PendingCommitsService(db);
|
|
58
|
+
await expect(svc.markNeedsAttentionOnBranch('quiet/branch', 'whatever')).resolves.toBe(0);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The same, keyed on the WORKING COPY that was set aside. Closing change
|
|
64
|
+
* requests covers their branches and nothing else; a commit queued on the
|
|
65
|
+
* default branch was written against the repository that was left all the
|
|
66
|
+
* same, and the path it names is about to hold a clone of another one.
|
|
67
|
+
*/
|
|
68
|
+
describe('PendingCommitsService.markNeedsAttentionInWorkspace', () => {
|
|
69
|
+
it('escalates every queued commit for the working copy, with the reason, and answers how many', async () => {
|
|
70
|
+
const { db, sets } = makeFakeDb([{ id: 'a' }, { id: 'b' }, { id: 'c' }]);
|
|
71
|
+
const svc = new PendingCommitsService(db);
|
|
72
|
+
|
|
73
|
+
await expect(svc.markNeedsAttentionInWorkspace('main', 'The repository was replaced.')).resolves.toBe(3);
|
|
74
|
+
|
|
75
|
+
expect(sets).toHaveLength(1);
|
|
76
|
+
expect(sets[0]?.status).toBe('needs_attention');
|
|
77
|
+
expect(sets[0]?.lastError).toBe('The repository was replaced.');
|
|
78
|
+
expect(sets[0]?.lastAttemptedAt).toBeInstanceOf(Date);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Rows are stored under the encoded id, whichever spelling the caller
|
|
83
|
+
* enqueued with. The predicate is rendered and read, since the stub does
|
|
84
|
+
* not evaluate it: the id it compares must be the stored spelling, and it
|
|
85
|
+
* must match only the two statuses a worker could still write, `pending`
|
|
86
|
+
* and `running`.
|
|
87
|
+
*/
|
|
88
|
+
it('looks for the working copy under the id the queue stores, and only for commits still queued', async () => {
|
|
89
|
+
const wheres: SQL[] = [];
|
|
90
|
+
const db = {
|
|
91
|
+
update: () => ({
|
|
92
|
+
set: () => ({
|
|
93
|
+
where: (predicate: SQL) => {
|
|
94
|
+
wheres.push(predicate);
|
|
95
|
+
return { returning: async () => [] };
|
|
96
|
+
},
|
|
97
|
+
}),
|
|
98
|
+
}),
|
|
99
|
+
} as unknown as Database;
|
|
100
|
+
await new PendingCommitsService(db).markNeedsAttentionInWorkspace('alice/feature', 'whatever');
|
|
101
|
+
|
|
102
|
+
const { sql: text, params } = new PgDialect().sqlToQuery(wheres[0]!);
|
|
103
|
+
expect(text).toContain('"workspace_id" =');
|
|
104
|
+
expect(params).toEqual(['alice%2Ffeature', 'pending', 'running']);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('answers zero when nothing was queued for it', async () => {
|
|
108
|
+
const { db } = makeFakeDb([]);
|
|
109
|
+
await expect(new PendingCommitsService(db).markNeedsAttentionInWorkspace('main', 'whatever')).resolves.toBe(0);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -8,6 +8,10 @@ import type { AuthService } from '../../auth/auth.service.js';
|
|
|
8
8
|
import type { WorkspaceService } from '../../workspace/workspace.service.js';
|
|
9
9
|
import type { WorkflowEventBus } from '../event-bus.js';
|
|
10
10
|
import { createWorkflowRoutes } from '../workflow.routes.js';
|
|
11
|
+
import {
|
|
12
|
+
VersionNotOnBranchError,
|
|
13
|
+
VERSION_NOT_ON_BRANCH_MESSAGE,
|
|
14
|
+
} from '../../../shared/domain-errors.js';
|
|
11
15
|
|
|
12
16
|
// A file's history is its content with a time axis: the same default-deny
|
|
13
17
|
// read model that hides a file must hide its commit list, its diffs, and its
|
|
@@ -129,6 +133,27 @@ describe('history routes enforce the read model', () => {
|
|
|
129
133
|
expect((await get(`/compare-file?from=a&to=b&path=${OPEN}`)).status).toBe(403);
|
|
130
134
|
});
|
|
131
135
|
|
|
136
|
+
it.each([
|
|
137
|
+
['/show-file?sha=abc1234&path=%s', 'showFileAtChange'],
|
|
138
|
+
['/file-at-change?sha=abc1234&path=%s', 'fileAtChange'],
|
|
139
|
+
] as const)(
|
|
140
|
+
'%s: a save off this branch is refused with the history message',
|
|
141
|
+
async (route, method) => {
|
|
142
|
+
// The service raises it (the git layer knows the branch); the route has
|
|
143
|
+
// to pass the typed refusal through with its own status and sentence,
|
|
144
|
+
// because a reader who is told "not found" about a file that is plainly
|
|
145
|
+
// there learns the wrong thing. The bytes route carries the same rule,
|
|
146
|
+
// so neither can be read around through the other.
|
|
147
|
+
h = await makeHarness();
|
|
148
|
+
h.workflow[method].mockRejectedValueOnce(new VersionNotOnBranchError());
|
|
149
|
+
const res = await get(route.replace('%s', OPEN));
|
|
150
|
+
expect(res.status).toBe(404);
|
|
151
|
+
expect(((await res.json()) as { error: string }).error).toBe(
|
|
152
|
+
VERSION_NOT_ON_BRANCH_MESSAGE,
|
|
153
|
+
);
|
|
154
|
+
},
|
|
155
|
+
);
|
|
156
|
+
|
|
132
157
|
it('an access-model error fails closed, not open', async () => {
|
|
133
158
|
h = await makeHarness();
|
|
134
159
|
h.canRead.mockRejectedValueOnce(new Error('access tree unreadable'));
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { testKbContext } from '../../../__tests__/kb-context.js';
|
|
3
|
+
import type { GitService } from '../git/git.service.js';
|
|
4
|
+
import type { PullRequestService } from '../git/pull-request.service.js';
|
|
5
|
+
import type { IReviewWorkflowService } from '../review-workflow/review-workflow.interface.js';
|
|
6
|
+
import type { WorkspaceService } from '../../workspace/workspace.service.js';
|
|
7
|
+
import type { IAccessControl } from '../../access/access-control.interface.js';
|
|
8
|
+
import type { FileLockService } from '../file-lock.service.js';
|
|
9
|
+
import type { PendingCommitsService } from '../pending-commits.service.js';
|
|
10
|
+
import type { Database } from '../../database/connection.js';
|
|
11
|
+
import { WorkflowService } from '../workflow.service.js';
|
|
12
|
+
import { openChangeGate } from '../../../__tests__/open-change-gate.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The CLOSE half of the admin's choice when the knowledge-base repository is
|
|
16
|
+
* replaced: the requests point at branches that are not in the new
|
|
17
|
+
* repository, so they are closed — with a reason, releasing the locks held on
|
|
18
|
+
* those branches and taking any commit still queued for them off the worker,
|
|
19
|
+
* and without deleting a single row.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
interface ClosedRow {
|
|
23
|
+
number: number;
|
|
24
|
+
sourceBranch: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* DB stub shaped like the real close: ONE `update … where state = 'open'
|
|
29
|
+
* returning` per pass, answering with the rows that pass actually closed.
|
|
30
|
+
*
|
|
31
|
+
* `passes` is therefore what the statement RETURNS, pass by pass — which is
|
|
32
|
+
* how a request already closed by a merge or a withdrawal is expressed (it is
|
|
33
|
+
* simply not in the returned set) and how a request opened BETWEEN passes is
|
|
34
|
+
* expressed (it turns up in a later one). A pass past the end answers nothing,
|
|
35
|
+
* which is what stops the loop.
|
|
36
|
+
*/
|
|
37
|
+
function makeDb(passes: ClosedRow[][], sets: Record<string, unknown>[], openCount: number) {
|
|
38
|
+
let pass = 0;
|
|
39
|
+
const db = {
|
|
40
|
+
// The count is a SQL aggregate: one row, one integer.
|
|
41
|
+
select: () => ({ from: () => ({ where: async () => [{ open: openCount }] }) }),
|
|
42
|
+
update: () => ({
|
|
43
|
+
set: (values: Record<string, unknown>) => {
|
|
44
|
+
sets.push(values);
|
|
45
|
+
return { where: () => ({ returning: async () => passes[pass++] ?? [] }) };
|
|
46
|
+
},
|
|
47
|
+
}),
|
|
48
|
+
// Nothing here may ever delete a change request.
|
|
49
|
+
delete: () => {
|
|
50
|
+
throw new Error('a change request must never be deleted');
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
return db as unknown as Database;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function makeService(passes: ClosedRow[][], openCount = passes.flat().length) {
|
|
57
|
+
const sets: Record<string, unknown>[] = [];
|
|
58
|
+
const unlocked: string[] = [];
|
|
59
|
+
const dequeued: string[] = [];
|
|
60
|
+
const invalidated: number[] = [];
|
|
61
|
+
const emitted: unknown[] = [];
|
|
62
|
+
const fileLocks = {
|
|
63
|
+
releaseAllOnBranch: vi.fn(async (branch: string) => {
|
|
64
|
+
unlocked.push(branch);
|
|
65
|
+
return 2;
|
|
66
|
+
}),
|
|
67
|
+
} as unknown as FileLockService;
|
|
68
|
+
const pendingCommits = {
|
|
69
|
+
markNeedsAttentionOnBranch: vi.fn(async (branch: string) => {
|
|
70
|
+
dequeued.push(branch);
|
|
71
|
+
return 1;
|
|
72
|
+
}),
|
|
73
|
+
} as unknown as PendingCommitsService;
|
|
74
|
+
const prs = {
|
|
75
|
+
invalidateDetailCache: vi.fn((n: number) => invalidated.push(n)),
|
|
76
|
+
} as unknown as PullRequestService;
|
|
77
|
+
const svc = new WorkflowService(
|
|
78
|
+
makeDb(passes, sets, openCount),
|
|
79
|
+
{} as GitService,
|
|
80
|
+
prs,
|
|
81
|
+
{} as IReviewWorkflowService,
|
|
82
|
+
{} as WorkspaceService,
|
|
83
|
+
{} as IAccessControl,
|
|
84
|
+
fileLocks,
|
|
85
|
+
pendingCommits,
|
|
86
|
+
testKbContext(),
|
|
87
|
+
openChangeGate(),
|
|
88
|
+
);
|
|
89
|
+
(svc as unknown as { events?: { emit(e: unknown): void } }).events = {
|
|
90
|
+
emit: (e: unknown) => emitted.push(e),
|
|
91
|
+
};
|
|
92
|
+
return { svc, sets, unlocked, dequeued, invalidated, emitted, fileLocks, pendingCommits };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
describe('closeOpenChangeRequestsAsRepositoryReplaced', () => {
|
|
96
|
+
it('closes every open request with the reason, and deletes nothing', async () => {
|
|
97
|
+
const { svc, sets, invalidated, emitted } = makeService([
|
|
98
|
+
[
|
|
99
|
+
{ number: 7, sourceBranch: 'alice/draft' },
|
|
100
|
+
{ number: 9, sourceBranch: 'bob/draft' },
|
|
101
|
+
],
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
await expect(svc.closeOpenChangeRequestsAsRepositoryReplaced()).resolves.toBe(2);
|
|
105
|
+
|
|
106
|
+
// ONE statement closed both: a read-then-write loop would have missed any
|
|
107
|
+
// request opened while it ran.
|
|
108
|
+
expect(sets).toHaveLength(2); // the closing pass, then the pass that found nothing
|
|
109
|
+
expect(sets[0]?.state).toBe('closed');
|
|
110
|
+
// The reason outlives the admin who chose it — that is the whole point
|
|
111
|
+
// of a column rather than a log line.
|
|
112
|
+
expect(sets[0]?.closedReason).toBe('repository-replaced');
|
|
113
|
+
expect(sets[0]?.closedAt).toBeInstanceOf(Date);
|
|
114
|
+
expect(invalidated).toEqual([7, 9]);
|
|
115
|
+
expect(emitted).toEqual([
|
|
116
|
+
{ kind: 'change-request-rejected', number: 7 },
|
|
117
|
+
{ kind: 'change-request-rejected', number: 9 },
|
|
118
|
+
]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('closes a request opened while it was closing the others', async () => {
|
|
122
|
+
// The deployment is still live — the address has not been saved yet — so
|
|
123
|
+
// `POST /change-requests` is still being served. The first pass closed two;
|
|
124
|
+
// #11 arrived after it and is closed by the next one.
|
|
125
|
+
const { svc, invalidated } = makeService([
|
|
126
|
+
[
|
|
127
|
+
{ number: 7, sourceBranch: 'alice/draft' },
|
|
128
|
+
{ number: 9, sourceBranch: 'bob/draft' },
|
|
129
|
+
],
|
|
130
|
+
[{ number: 11, sourceBranch: 'carol/draft' }],
|
|
131
|
+
]);
|
|
132
|
+
|
|
133
|
+
await expect(svc.closeOpenChangeRequestsAsRepositoryReplaced()).resolves.toBe(3);
|
|
134
|
+
expect(invalidated).toEqual([7, 9, 11]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('gives up after a bounded number of passes rather than holding the save open', async () => {
|
|
138
|
+
// A client opening requests in a loop must not be able to keep the save
|
|
139
|
+
// from ever returning. What survives the last pass was opened after the
|
|
140
|
+
// repository was replaced, and the deleted-branch sweep owns it.
|
|
141
|
+
const endless = Array.from({ length: 50 }, (_, i) => [
|
|
142
|
+
{ number: i + 1, sourceBranch: `writer-${i}/draft` },
|
|
143
|
+
]);
|
|
144
|
+
const { svc } = makeService(endless, 50);
|
|
145
|
+
const closed = await svc.closeOpenChangeRequestsAsRepositoryReplaced();
|
|
146
|
+
expect(closed).toBeGreaterThan(0);
|
|
147
|
+
expect(closed).toBeLessThan(50);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('skips a request a merge or a withdrawal closed first', async () => {
|
|
151
|
+
// The guard is the statement's own `where state = 'open'`: #9 was closed by
|
|
152
|
+
// somebody else between the count and this call, so it is not in the
|
|
153
|
+
// returned set — and nothing is done on its behalf.
|
|
154
|
+
const { svc, unlocked, dequeued, invalidated, emitted } = makeService(
|
|
155
|
+
[[{ number: 7, sourceBranch: 'alice/draft' }]],
|
|
156
|
+
2,
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
await expect(svc.closeOpenChangeRequestsAsRepositoryReplaced()).resolves.toBe(1);
|
|
160
|
+
|
|
161
|
+
expect(unlocked).toEqual(['alice/draft']);
|
|
162
|
+
expect(dequeued).toEqual(['alice/draft']);
|
|
163
|
+
expect(invalidated).toEqual([7]);
|
|
164
|
+
expect(emitted).toEqual([{ kind: 'change-request-rejected', number: 7 }]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('releases the file locks held on the branches it closes', async () => {
|
|
168
|
+
const { svc, unlocked } = makeService([
|
|
169
|
+
[
|
|
170
|
+
{ number: 7, sourceBranch: 'alice/draft' },
|
|
171
|
+
{ number: 9, sourceBranch: 'bob/draft' },
|
|
172
|
+
],
|
|
173
|
+
]);
|
|
174
|
+
|
|
175
|
+
await svc.closeOpenChangeRequestsAsRepositoryReplaced();
|
|
176
|
+
|
|
177
|
+
// Nobody can publish those bytes or release the lock by finishing: the
|
|
178
|
+
// branch belongs to a repository this deployment no longer has.
|
|
179
|
+
expect(unlocked).toEqual(['alice/draft', 'bob/draft']);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('takes the commits already queued for those branches off the worker', async () => {
|
|
183
|
+
const { svc, dequeued, pendingCommits } = makeService([
|
|
184
|
+
[{ number: 7, sourceBranch: 'alice/draft' }],
|
|
185
|
+
]);
|
|
186
|
+
|
|
187
|
+
await svc.closeOpenChangeRequestsAsRepositoryReplaced();
|
|
188
|
+
|
|
189
|
+
// A release ENQUEUES the bytes and only then drops its lock, so dropping
|
|
190
|
+
// the locks can leave rows waiting. Left pending, the worker would commit
|
|
191
|
+
// them onto a same-named branch of a different repository after the
|
|
192
|
+
// re-clone.
|
|
193
|
+
expect(dequeued).toEqual(['alice/draft']);
|
|
194
|
+
expect(vi.mocked(pendingCommits.markNeedsAttentionOnBranch).mock.calls[0]?.[1]).toMatch(
|
|
195
|
+
/repository was replaced/,
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('still closes the requests when a lock release fails', async () => {
|
|
200
|
+
const { svc, fileLocks } = makeService([[{ number: 7, sourceBranch: 'alice/draft' }]]);
|
|
201
|
+
vi.mocked(fileLocks.releaseAllOnBranch).mockRejectedValueOnce(new Error('lock table is down'));
|
|
202
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
203
|
+
try {
|
|
204
|
+
// The close is the decision the admin made; a lock expires on its own
|
|
205
|
+
// within the minute, so it is not worth half-closing the set over.
|
|
206
|
+
await expect(svc.closeOpenChangeRequestsAsRepositoryReplaced()).resolves.toBe(1);
|
|
207
|
+
} finally {
|
|
208
|
+
vi.restoreAllMocks();
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('still closes the requests when the queued commits cannot be set aside', async () => {
|
|
213
|
+
const { svc, pendingCommits, unlocked } = makeService([
|
|
214
|
+
[{ number: 7, sourceBranch: 'alice/draft' }],
|
|
215
|
+
]);
|
|
216
|
+
vi.mocked(pendingCommits.markNeedsAttentionOnBranch).mockRejectedValueOnce(
|
|
217
|
+
new Error('pending_commits is down'),
|
|
218
|
+
);
|
|
219
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
220
|
+
try {
|
|
221
|
+
await expect(svc.closeOpenChangeRequestsAsRepositoryReplaced()).resolves.toBe(1);
|
|
222
|
+
expect(unlocked).toEqual(['alice/draft']);
|
|
223
|
+
} finally {
|
|
224
|
+
vi.restoreAllMocks();
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('closes nothing, and asks nothing of the lock table, when no request is open', async () => {
|
|
229
|
+
const { svc, unlocked, dequeued, emitted } = makeService([]);
|
|
230
|
+
await expect(svc.closeOpenChangeRequestsAsRepositoryReplaced()).resolves.toBe(0);
|
|
231
|
+
expect(unlocked).toEqual([]);
|
|
232
|
+
expect(dequeued).toEqual([]);
|
|
233
|
+
expect(emitted).toEqual([]);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe('countOpenChangeRequests', () => {
|
|
238
|
+
it('answers how many are open, so the confirmation can say what it is about', async () => {
|
|
239
|
+
const { svc } = makeService([], 2);
|
|
240
|
+
// A SQL aggregate: the question is an integer, and materializing one row
|
|
241
|
+
// per open request to answer it would scale with the wrong thing.
|
|
242
|
+
await expect(svc.countOpenChangeRequests()).resolves.toBe(2);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('answers zero when the aggregate comes back empty', async () => {
|
|
246
|
+
const { svc } = makeService([], 0);
|
|
247
|
+
await expect(svc.countOpenChangeRequests()).resolves.toBe(0);
|
|
248
|
+
});
|
|
249
|
+
});
|