@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
|
@@ -5,7 +5,7 @@ import { logger } from '../../shared/logging.js';
|
|
|
5
5
|
const log = logger('access');
|
|
6
6
|
|
|
7
7
|
import { isAbsence, type ITreeWalker, type WalkListener } from '../../shared/fs.contract.js';
|
|
8
|
-
import { isGitTimeout, type IGitRunner } from '../../shared/git.contract.js';
|
|
8
|
+
import { assertOneSpecPerLine, isGitTimeout, type IGitRunner } from '../../shared/git.contract.js';
|
|
9
9
|
import { hashEmail } from '../../shared/email-identity.js';
|
|
10
10
|
import { NodeGitRunner } from '../workflow/git/node-git-runner.js';
|
|
11
11
|
import type { WorkspaceService } from '../workspace/workspace.service.js';
|
|
@@ -19,6 +19,7 @@ import type {
|
|
|
19
19
|
GrantSource,
|
|
20
20
|
GrantSources,
|
|
21
21
|
PathHolders,
|
|
22
|
+
PathVerbs,
|
|
22
23
|
ProspectiveHolders,
|
|
23
24
|
ResolvedPrincipal,
|
|
24
25
|
} from './access-control.interface.js';
|
|
@@ -31,7 +32,6 @@ import {
|
|
|
31
32
|
} from '@bevel-software/platform-shared';
|
|
32
33
|
import type { KbContext } from '../../shared/kb-context.js';
|
|
33
34
|
import { AccessConfigError, AccessUnreadableError } from '../access-model/access-errors.js';
|
|
34
|
-
import { WorkflowDomainError } from '../../shared/domain-errors.js';
|
|
35
35
|
import { synthesizePluginPrincipals } from '../access-model/plugin-principals.js';
|
|
36
36
|
import {
|
|
37
37
|
GROUPS_YAML,
|
|
@@ -81,6 +81,12 @@ async function catFileBatch(
|
|
|
81
81
|
): Promise<(string | null)[]> {
|
|
82
82
|
// Bytes, not text: every `<size>` in the header counts bytes, and the walk
|
|
83
83
|
// below has to step by that count through content that is not all ASCII.
|
|
84
|
+
// One spec per LINE is the protocol, and this walk depends on getting back
|
|
85
|
+
// exactly as many answers as it sent: a spec carrying a line break would
|
|
86
|
+
// insert an extra answer and shift every result after it, so each directory
|
|
87
|
+
// would be handed the NEXT one's `access.md`. Paths are refused a control
|
|
88
|
+
// character at the normaliser; this is the fence at the protocol.
|
|
89
|
+
assertOneSpecPerLine(specs);
|
|
84
90
|
const { stdout: out } = await runner.run(repoDir, ['cat-file', '--batch'], {
|
|
85
91
|
input: `${specs.join('\n')}\n`,
|
|
86
92
|
encoding: 'buffer',
|
|
@@ -951,6 +957,95 @@ function resolveDenialSourcesForVerb(
|
|
|
951
957
|
return out;
|
|
952
958
|
}
|
|
953
959
|
|
|
960
|
+
/**
|
|
961
|
+
* The model as it will be once `fromPath` has been moved or copied to
|
|
962
|
+
* `toPath`: every `access.md` the operation carries, keyed at the directory
|
|
963
|
+
* it lands in, since the rules a folder carries travel with its bytes.
|
|
964
|
+
*
|
|
965
|
+
* What travels is BYTES, and the name they land under decides what they
|
|
966
|
+
* govern — `landing` is the caller's answer to that (see
|
|
967
|
+
* `relocationSubject`), already parsed as the destination path:
|
|
968
|
+
*
|
|
969
|
+
* - a FOLDER takes every `access.md` at or under it, each re-keyed to the
|
|
970
|
+
* directory it lands in, and `landing` plays no part;
|
|
971
|
+
* - a FILE governs a directory exactly when it lands as that directory's
|
|
972
|
+
* `access.md`, whatever it was called before — a copy `copy_file` allows,
|
|
973
|
+
* and one that governs the destination folder the moment it lands. Its key
|
|
974
|
+
* is a PARENT directory, never a path under `fromPath`, which is why the
|
|
975
|
+
* prefix walk above cannot see it.
|
|
976
|
+
*
|
|
977
|
+
* The emptied side is the mirror: an `access.md` that MOVES stops governing
|
|
978
|
+
* the directory it left, whether or not it governs anything where it lands.
|
|
979
|
+
*
|
|
980
|
+
* PREVIEW ONLY. Nothing here reads disk and nothing here decides whether an
|
|
981
|
+
* operation may run — the gates resolve against the model as it IS. This
|
|
982
|
+
* answers the other question, the one a dry run asks: what will be true
|
|
983
|
+
* afterwards. Judging the destination as it stands today is what made a
|
|
984
|
+
* rename warn about losing the owner access the folder's own `access.md` was
|
|
985
|
+
* about to hand straight back.
|
|
986
|
+
*
|
|
987
|
+
* `sourceRemains` is the difference between a copy and a move: a copy leaves
|
|
988
|
+
* the rules where they were, a move takes them away. It changes the answer
|
|
989
|
+
* only when the destination resolves through a directory the source
|
|
990
|
+
* occupied; nothing else consults those keys.
|
|
991
|
+
*/
|
|
992
|
+
function withRelocatedAccessFiles(
|
|
993
|
+
model: AccessModel,
|
|
994
|
+
fromPath: string,
|
|
995
|
+
toPath: string,
|
|
996
|
+
opts: { sourceRemains: boolean; sourceIsFolder: boolean; landing?: AccessFile | null },
|
|
997
|
+
): AccessModel {
|
|
998
|
+
/** Directories the operation takes an `access.md` AWAY from. */
|
|
999
|
+
const emptied: string[] = [];
|
|
1000
|
+
/** Directories it leaves one IN, with the file as it will be read there. */
|
|
1001
|
+
const landing: { toDir: string; file: AccessFile }[] = [];
|
|
1002
|
+
if (opts.sourceIsFolder) {
|
|
1003
|
+
for (const [dir, file] of model.accessFilesByDir) {
|
|
1004
|
+
if (dir !== fromPath && !dir.startsWith(`${fromPath}/`)) continue;
|
|
1005
|
+
emptied.push(dir);
|
|
1006
|
+
landing.push({ toDir: `${toPath}${dir.slice(fromPath.length)}`, file });
|
|
1007
|
+
}
|
|
1008
|
+
} else {
|
|
1009
|
+
const fromDir = parentDirOf(fromPath);
|
|
1010
|
+
if (isAccessMdPath(fromPath) && model.accessFilesByDir.has(fromDir)) emptied.push(fromDir);
|
|
1011
|
+
if (opts.landing) landing.push({ toDir: parentDirOf(toPath), file: opts.landing });
|
|
1012
|
+
}
|
|
1013
|
+
if (emptied.length === 0 && landing.length === 0) return model;
|
|
1014
|
+
const accessFilesByDir = new Map(model.accessFilesByDir);
|
|
1015
|
+
if (!opts.sourceRemains) for (const dir of emptied) accessFilesByDir.delete(dir);
|
|
1016
|
+
for (const { toDir, file } of landing) {
|
|
1017
|
+
// A directory that already has an `access.md` keeps it. The operation
|
|
1018
|
+
// would be refused for landing on something that exists long before the
|
|
1019
|
+
// two files could meet, so the model must not invent a merge either.
|
|
1020
|
+
if (accessFilesByDir.has(toDir)) continue;
|
|
1021
|
+
accessFilesByDir.set(toDir, {
|
|
1022
|
+
...file,
|
|
1023
|
+
dir: toDir,
|
|
1024
|
+
path: toDir ? `${toDir}/access.md` : 'access.md',
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
return { ...model, accessFilesByDir };
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/** The directory a repo-relative file path sits in; `''` for the repo root. */
|
|
1031
|
+
function parentDirOf(relativePath: string): string {
|
|
1032
|
+
const cut = relativePath.lastIndexOf('/');
|
|
1033
|
+
return cut === -1 ? '' : relativePath.slice(0, cut);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* One file's own-entries, parsed as the path `asName` would be read.
|
|
1038
|
+
*
|
|
1039
|
+
* The same frontmatter means two different things by name: in an `access.md`
|
|
1040
|
+
* it is the FOLDER's rules (and the file governs itself only through the
|
|
1041
|
+
* body format — see {@link accessMdSelfEntries}), and anywhere else it is the
|
|
1042
|
+
* file's own. Which is why a preview must read the bytes as the name they
|
|
1043
|
+
* LAND under, not the one they are leaving.
|
|
1044
|
+
*/
|
|
1045
|
+
function ownEntriesFromText(text: string, asName: string): OwnEntries | null {
|
|
1046
|
+
return isAccessMdPath(asName) ? accessMdSelfEntries(text) : parseOwnAccessEntries(text);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
954
1049
|
/**
|
|
955
1050
|
* Build the repo-root → path chain of directory scopes that govern
|
|
956
1051
|
* `relativePath`, e.g. `Knowledge/Sales/Foo.md` →
|
|
@@ -1586,16 +1681,25 @@ export class AccessControlService implements IAccessControl {
|
|
|
1586
1681
|
}
|
|
1587
1682
|
|
|
1588
1683
|
/**
|
|
1589
|
-
* The read and write holders of one file at its current path and
|
|
1590
|
-
* it has not moved to yet (see the interface).
|
|
1684
|
+
* The read and write holders of one file or folder at its current path and
|
|
1685
|
+
* at a path it has not moved to yet (see the interface).
|
|
1686
|
+
*
|
|
1687
|
+
* A move changes nothing about what the thing itself carries — a file's
|
|
1688
|
+
* frontmatter and a folder's `access.md` files travel with the bytes — and
|
|
1689
|
+
* everything about the folder chain above it. So the hypothetical side is
|
|
1690
|
+
* the ordinary resolution at the destination path, over what travels:
|
|
1591
1691
|
*
|
|
1592
|
-
*
|
|
1593
|
-
*
|
|
1594
|
-
*
|
|
1595
|
-
*
|
|
1596
|
-
*
|
|
1597
|
-
*
|
|
1598
|
-
*
|
|
1692
|
+
* - a FILE's own-entries, read from the SOURCE (nothing sits at the
|
|
1693
|
+
* destination to read) and parsed as the name they LAND under, layered
|
|
1694
|
+
* over the destination's ancestors — the two sides can differ, because
|
|
1695
|
+
* the same frontmatter is a folder's rules in an `access.md` and the
|
|
1696
|
+
* file's own anywhere else;
|
|
1697
|
+
* - a FOLDER's `access.md` files, keyed at the directories they land in,
|
|
1698
|
+
* and a file landing as a directory's `access.md` at the directory it
|
|
1699
|
+
* lands in (`withRelocatedAccessFiles`). A folder has no frontmatter of
|
|
1700
|
+
* its own, so it passes none.
|
|
1701
|
+
*
|
|
1702
|
+
* One model load and one file read serve all four lookups.
|
|
1599
1703
|
*/
|
|
1600
1704
|
async prospectiveHolders(
|
|
1601
1705
|
workspaceId: string,
|
|
@@ -1604,31 +1708,64 @@ export class AccessControlService implements IAccessControl {
|
|
|
1604
1708
|
): Promise<ProspectiveHolders> {
|
|
1605
1709
|
const model = await this.loadModel(workspaceId);
|
|
1606
1710
|
const repoDir = await this.repoDir(workspaceId);
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
// Only absence is an answer here (see `isAbsence`): a probe that failed on
|
|
1614
|
-
// permissions or I/O does not say "this is a file", and folding it into
|
|
1615
|
-
// one would let the very case above through on an unreadable source.
|
|
1616
|
-
const fromStat = await fs.stat(path.join(repoDir, fromPath)).catch((err: unknown) => {
|
|
1617
|
-
if (isAbsence(err)) return null;
|
|
1618
|
-
throw err;
|
|
1711
|
+
const { isFolder, ownHere, ownThere, landing } =
|
|
1712
|
+
await this.relocationSubject(repoDir, fromPath, toPath);
|
|
1713
|
+
const after = withRelocatedAccessFiles(model, fromPath, toPath, {
|
|
1714
|
+
sourceRemains: false,
|
|
1715
|
+
sourceIsFolder: isFolder,
|
|
1716
|
+
landing,
|
|
1619
1717
|
});
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
400,
|
|
1624
|
-
);
|
|
1625
|
-
}
|
|
1626
|
-
const own = await this.readOwnEntries(repoDir, fromPath);
|
|
1627
|
-
const holdersAt = (relativePath: string): PathHolders => ({
|
|
1628
|
-
read: eligibleHoldersResolved(model, 'read', relativePath, own),
|
|
1629
|
-
write: eligibleHoldersResolved(model, 'write', relativePath, own),
|
|
1718
|
+
const holdersAt = (m: AccessModel, relativePath: string, own: OwnEntries | null): PathHolders => ({
|
|
1719
|
+
read: eligibleHoldersResolved(m, 'read', relativePath, own),
|
|
1720
|
+
write: eligibleHoldersResolved(m, 'write', relativePath, own),
|
|
1630
1721
|
});
|
|
1631
|
-
return {
|
|
1722
|
+
return {
|
|
1723
|
+
before: holdersAt(model, fromPath, ownHere),
|
|
1724
|
+
after: holdersAt(after, toPath, ownThere),
|
|
1725
|
+
};
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
/**
|
|
1729
|
+
* The caller's own verbs at `toPath` as they will be once `fromPath` has
|
|
1730
|
+
* been moved or copied there (see the interface).
|
|
1731
|
+
*
|
|
1732
|
+
* Identical to asking `canRead`/`canWrite`/`canDownload`/`canOwner` at
|
|
1733
|
+
* `toPath` whenever nothing travels — an ordinary file with no rules of its
|
|
1734
|
+
* own, or a folder with no `access.md` of its own — so the cases this was
|
|
1735
|
+
* not written for answer exactly as they always did.
|
|
1736
|
+
*
|
|
1737
|
+
* What travels is what `prospectiveHolders` says travels, and for the same
|
|
1738
|
+
* reason: the preview and the dialog's holder lists must not disagree about
|
|
1739
|
+
* one move. A file's own-entries come from the SOURCE, where the bytes
|
|
1740
|
+
* carrying them still are — asking the destination, which does not exist
|
|
1741
|
+
* yet, always answered null and so dropped the frontmatter a rename hands
|
|
1742
|
+
* straight back (requirement 6: what the caller has afterwards equals what
|
|
1743
|
+
* the preview said) — and they are parsed as the name they LAND under,
|
|
1744
|
+
* because that is the parse the gates will use a moment later.
|
|
1745
|
+
*/
|
|
1746
|
+
async previewAccessAfterRelocation(
|
|
1747
|
+
workspaceId: string,
|
|
1748
|
+
userEmail: string,
|
|
1749
|
+
fromPath: string,
|
|
1750
|
+
toPath: string,
|
|
1751
|
+
opts?: { sourceRemains?: boolean },
|
|
1752
|
+
): Promise<PathVerbs> {
|
|
1753
|
+
const repoDir = await this.repoDir(workspaceId);
|
|
1754
|
+
const { isFolder, ownThere: own, landing } =
|
|
1755
|
+
await this.relocationSubject(repoDir, fromPath, toPath);
|
|
1756
|
+
const model = withRelocatedAccessFiles(
|
|
1757
|
+
await this.loadModel(workspaceId),
|
|
1758
|
+
fromPath,
|
|
1759
|
+
toPath,
|
|
1760
|
+
{ sourceRemains: opts?.sourceRemains === true, sourceIsFolder: isFolder, landing },
|
|
1761
|
+
);
|
|
1762
|
+
const machineOwned = this.machineOwnedWriteRule(userEmail, toPath);
|
|
1763
|
+
return {
|
|
1764
|
+
read: canReadResolved(model, userEmail, toPath, own),
|
|
1765
|
+
write: machineOwned ?? hasPermissionResolved(model, 'write', userEmail, toPath, own),
|
|
1766
|
+
download: hasPermissionResolved(model, 'download', userEmail, toPath, own),
|
|
1767
|
+
owner: hasPermissionResolved(model, 'owner', userEmail, toPath, own),
|
|
1768
|
+
};
|
|
1632
1769
|
}
|
|
1633
1770
|
|
|
1634
1771
|
async eligibleWriterEmails(
|
|
@@ -2219,8 +2356,69 @@ export class AccessControlService implements IAccessControl {
|
|
|
2219
2356
|
} catch {
|
|
2220
2357
|
return null;
|
|
2221
2358
|
}
|
|
2222
|
-
|
|
2223
|
-
|
|
2359
|
+
return ownEntriesFromText(text, relativePath);
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
/**
|
|
2363
|
+
* What a move or a copy of `fromPath` to `toPath` CARRIES — the one disk
|
|
2364
|
+
* read both preview surfaces need:
|
|
2365
|
+
*
|
|
2366
|
+
* - `isFolder`: the source is a folder, so the `access.md` files under it
|
|
2367
|
+
* travel, keyed at the directories they land in;
|
|
2368
|
+
* - `ownHere` / `ownThere`: a file's own-entries as the SOURCE's name reads
|
|
2369
|
+
* them and as the DESTINATION's name will. They differ because the same
|
|
2370
|
+
* frontmatter means two things by name (see `ownEntriesFromText`), so
|
|
2371
|
+
* `Sales/access.md` copied to `Legal/rules-copy.md` arrives as an
|
|
2372
|
+
* ordinary file whose frontmatter is its own — reading it as the source's
|
|
2373
|
+
* name had the preview contradict the copy it was previewing;
|
|
2374
|
+
* - `landing`: the folder rules the bytes WILL be, when they land under the
|
|
2375
|
+
* name `access.md`. Parsed as the destination path, so the directory it
|
|
2376
|
+
* governs is already the right one. An `access.md` that will not parse
|
|
2377
|
+
* travels as nothing, as the loader treats it.
|
|
2378
|
+
*
|
|
2379
|
+
* `lstat`, not `stat`: a SYMLINK is none of this. `move_file` refuses a
|
|
2380
|
+
* symlink source outright, so a link to a directory must not be previewed
|
|
2381
|
+
* as a folder taking access files along, and nothing is read THROUGH the
|
|
2382
|
+
* link either — the link's target is not what would move.
|
|
2383
|
+
*
|
|
2384
|
+
* Only absence is an answer (see `isAbsence`): a probe that failed on
|
|
2385
|
+
* permissions or I/O does not say "this is a file", and reading a folder as
|
|
2386
|
+
* one would resolve it through frontmatter it does not have.
|
|
2387
|
+
*/
|
|
2388
|
+
private async relocationSubject(
|
|
2389
|
+
repoDir: string,
|
|
2390
|
+
fromPath: string,
|
|
2391
|
+
toPath: string,
|
|
2392
|
+
): Promise<{
|
|
2393
|
+
isFolder: boolean;
|
|
2394
|
+
ownHere: OwnEntries | null;
|
|
2395
|
+
ownThere: OwnEntries | null;
|
|
2396
|
+
landing: AccessFile | null;
|
|
2397
|
+
}> {
|
|
2398
|
+
const nothing = { isFolder: false, ownHere: null, ownThere: null, landing: null };
|
|
2399
|
+
const fromStat = await fs.lstat(path.join(repoDir, fromPath)).catch((err: unknown) => {
|
|
2400
|
+
if (isAbsence(err)) return null;
|
|
2401
|
+
throw err;
|
|
2402
|
+
});
|
|
2403
|
+
if (fromStat?.isSymbolicLink() === true) return nothing;
|
|
2404
|
+
if (fromStat?.isDirectory() === true) return { ...nothing, isFolder: true };
|
|
2405
|
+
// `roles.yaml` carries no own-entries wherever it sits, exactly as
|
|
2406
|
+
// `readOwnEntries` has it; a read that fails for any reason is no rules,
|
|
2407
|
+
// which is what the gates would say about the same file.
|
|
2408
|
+
if (fromPath === 'roles.yaml' || toPath === 'roles.yaml') return nothing;
|
|
2409
|
+
let text: string;
|
|
2410
|
+
try {
|
|
2411
|
+
text = await fs.readFile(path.join(repoDir, fromPath), 'utf-8');
|
|
2412
|
+
} catch {
|
|
2413
|
+
return nothing;
|
|
2414
|
+
}
|
|
2415
|
+
const parsed = isAccessMdPath(toPath) ? parseAccessFile(text, toPath) : null;
|
|
2416
|
+
return {
|
|
2417
|
+
isFolder: false,
|
|
2418
|
+
ownHere: ownEntriesFromText(text, fromPath),
|
|
2419
|
+
ownThere: ownEntriesFromText(text, toPath),
|
|
2420
|
+
landing: parsed?.ok === true ? parsed.file : null,
|
|
2421
|
+
};
|
|
2224
2422
|
}
|
|
2225
2423
|
|
|
2226
2424
|
/**
|
|
@@ -368,27 +368,25 @@ export function createAccessRoutes(
|
|
|
368
368
|
});
|
|
369
369
|
|
|
370
370
|
/**
|
|
371
|
-
* GET /api/workspace/:id/access/prospective?from=<file>&toDir=<folder>
|
|
371
|
+
* GET /api/workspace/:id/access/prospective?from=<file|folder>&toDir=<folder>
|
|
372
372
|
*
|
|
373
|
-
* Who can open and who can edit one file where it is, and where
|
|
374
|
-
* would put it — `{ before, after }`, each `{ read, write }` lists
|
|
375
|
-
* principals named as their grants name them. `toDir` is the
|
|
376
|
-
* FOLDER (`''` is the repo root); the
|
|
377
|
-
* derives the destination path itself rather than trusting a
|
|
373
|
+
* Who can open and who can edit one file OR FOLDER where it is, and where
|
|
374
|
+
* a move would put it — `{ before, after }`, each `{ read, write }` lists
|
|
375
|
+
* of principals named as their grants name them. `toDir` is the
|
|
376
|
+
* destination FOLDER (`''` is the repo root); the thing keeps its name, so
|
|
377
|
+
* the route derives the destination path itself rather than trusting a
|
|
378
|
+
* second one.
|
|
378
379
|
*
|
|
379
380
|
* The destination path does not exist yet, which is why this cannot be two
|
|
380
|
-
* calls to `GET /access`: the resolver is asked for a hypothetical,
|
|
381
|
-
* the file's own frontmatter
|
|
382
|
-
*
|
|
381
|
+
* calls to `GET /access`: the resolver is asked for a hypothetical, over
|
|
382
|
+
* what the move carries — a file's own frontmatter, or the `access.md`
|
|
383
|
+
* files inside a folder, at the paths they land on. Nothing is written and
|
|
384
|
+
* nothing is moved.
|
|
383
385
|
*
|
|
384
386
|
* Gated like the sibling `overrides` route rather than the permissive
|
|
385
|
-
* `GET /access`: the caller must resolve read on the
|
|
386
|
-
* lists name people, and someone who cannot see
|
|
387
|
+
* `GET /access`: the caller must resolve read on the thing being moved. The
|
|
388
|
+
* lists name people, and someone who cannot see it has no business
|
|
387
389
|
* learning who can.
|
|
388
|
-
*
|
|
389
|
-
* `from` must be a FILE. A folder carries its own `access.md` and governs
|
|
390
|
-
* everything under it, which is a different question; the resolver refuses
|
|
391
|
-
* one with a 400 rather than answering it as if it were a file.
|
|
392
390
|
*/
|
|
393
391
|
router.get('/workspace/:id/access/prospective', async (req, res) => {
|
|
394
392
|
const user = await requireUser(req, res);
|
|
@@ -31,11 +31,13 @@ async function serve(readPreamble: () => Promise<string | null>): Promise<string
|
|
|
31
31
|
looksLikeInternalToken: (t: string) => t.startsWith('bit_'),
|
|
32
32
|
verify: (t: string) => (t === INTERNAL_TOKEN ? { userId: 'u-internal' } : null),
|
|
33
33
|
} as unknown as InternalTokenService;
|
|
34
|
+
// Sessions and internal tokens are accepted only for an account that is on; these are.
|
|
34
35
|
const authService = {
|
|
35
|
-
|
|
36
|
+
resolveSession: async (t: string) => {
|
|
36
37
|
if (t !== BROWSER_JWT) throw new Error('bad jwt');
|
|
37
38
|
return { userId: 'u-browser' };
|
|
38
39
|
},
|
|
40
|
+
isActive: async () => true,
|
|
39
41
|
} as unknown as AuthService;
|
|
40
42
|
const app = express();
|
|
41
43
|
app.use('/api', createAgentInstructionsRoutes(createManualAuthMiddleware(externalKeys, internalTokens, authService), readPreamble));
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { Database } from '../../database/connection.js';
|
|
3
|
+
import type { CoreConfig } from '../../../core-config.js';
|
|
4
|
+
import { AuthService } from '../auth.service.js';
|
|
5
|
+
import {
|
|
6
|
+
AccountAdmissionRefusedError,
|
|
7
|
+
AccountChangeRefusedError,
|
|
8
|
+
AccountDeactivatedError,
|
|
9
|
+
AuthBackendError,
|
|
10
|
+
type AccountProvisionReason,
|
|
11
|
+
type IAccountAdmission,
|
|
12
|
+
} from '../account-admission.js';
|
|
13
|
+
import { hashPassword } from '../password-hash.js';
|
|
14
|
+
import { RECOVERY_BOT_EMAIL } from '../../workflow/recovery-bot.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Drizzle chain stub, as in `auth.service.test.ts`: each db.select() /
|
|
18
|
+
* insert() / update() consumes the next queued result; awaiting any point of
|
|
19
|
+
* the chain resolves it. Records what was inserted and set.
|
|
20
|
+
*/
|
|
21
|
+
function makeFakeDb(queue: unknown[]) {
|
|
22
|
+
const captured: { values: Record<string, unknown>[]; set: Record<string, unknown>[] } = { values: [], set: [] };
|
|
23
|
+
function nextChain(): unknown {
|
|
24
|
+
const result = queue.shift();
|
|
25
|
+
const chain: Record<string, unknown> = {};
|
|
26
|
+
const passthrough = (recorder?: (arg: Record<string, unknown>) => void) =>
|
|
27
|
+
vi.fn((arg?: unknown) => {
|
|
28
|
+
recorder?.(arg as Record<string, unknown>);
|
|
29
|
+
return chain;
|
|
30
|
+
});
|
|
31
|
+
Object.assign(chain, {
|
|
32
|
+
values: passthrough((a) => captured.values.push(a)),
|
|
33
|
+
set: passthrough((a) => captured.set.push(a)),
|
|
34
|
+
where: passthrough(),
|
|
35
|
+
limit: passthrough(),
|
|
36
|
+
from: passthrough(),
|
|
37
|
+
orderBy: passthrough(),
|
|
38
|
+
returning: passthrough(),
|
|
39
|
+
onConflictDoUpdate: passthrough(),
|
|
40
|
+
onConflictDoNothing: passthrough(),
|
|
41
|
+
then: (ok: (v: unknown) => unknown, ko: (e: unknown) => unknown) => Promise.resolve(result).then(ok, ko),
|
|
42
|
+
});
|
|
43
|
+
return chain;
|
|
44
|
+
}
|
|
45
|
+
const db = {
|
|
46
|
+
insert: vi.fn(() => nextChain()),
|
|
47
|
+
select: vi.fn(() => nextChain()),
|
|
48
|
+
update: vi.fn(() => nextChain()),
|
|
49
|
+
} as unknown as Database;
|
|
50
|
+
return { db, captured };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function makeConfig(over: Partial<CoreConfig> = {}): CoreConfig {
|
|
54
|
+
return { jwtSecret: 'test-jwt-secret', adminEmail: '', adminPassword: '', allowedEmailDomains: [], ...over } as CoreConfig;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const ROW = { id: 'user-1', email: 'alice@example.com', name: 'Alice', avatarUrl: null, onboardingDone: false, deactivatedAt: null };
|
|
58
|
+
const OFF = { ...ROW, deactivatedAt: new Date('2026-09-01') };
|
|
59
|
+
|
|
60
|
+
function recordingPort(verdict: Awaited<ReturnType<IAccountAdmission['canProvision']>>) {
|
|
61
|
+
const asked: Array<[string, AccountProvisionReason]> = [];
|
|
62
|
+
const port: IAccountAdmission = {
|
|
63
|
+
canProvision: async (email, reason) => {
|
|
64
|
+
asked.push([email, reason]);
|
|
65
|
+
return verdict;
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
return { port, asked };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe('signing in with a switched-off account', () => {
|
|
72
|
+
it('refuses a password sign-in once the password is proven, and says why', async () => {
|
|
73
|
+
const passwordHash = await hashPassword('a-long-enough-password');
|
|
74
|
+
const { db } = makeFakeDb([[{ ...OFF, passwordHash }]]);
|
|
75
|
+
const svc = new AuthService(db, makeConfig());
|
|
76
|
+
await expect(svc.loginWithPassword('alice@example.com', 'a-long-enough-password')).rejects.toBeInstanceOf(
|
|
77
|
+
AccountDeactivatedError,
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('gives a wrong password the same answer as for any account, so it reveals nothing', async () => {
|
|
82
|
+
const passwordHash = await hashPassword('a-long-enough-password');
|
|
83
|
+
const { db } = makeFakeDb([[{ ...OFF, passwordHash }]]);
|
|
84
|
+
const svc = new AuthService(db, makeConfig());
|
|
85
|
+
await expect(svc.loginWithPassword('alice@example.com', 'the-wrong-password')).rejects.toThrow('Invalid credentials');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('refuses a single sign-on sign-in, without issuing a session', async () => {
|
|
89
|
+
const { db } = makeFakeDb([[OFF]]);
|
|
90
|
+
const svc = new AuthService(db, makeConfig());
|
|
91
|
+
await expect(svc.loginWithSso('alice@example.com', 'Alice')).rejects.toBeInstanceOf(AccountDeactivatedError);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('refuses an embed identity', async () => {
|
|
95
|
+
const { db } = makeFakeDb([[OFF]]);
|
|
96
|
+
const svc = new AuthService(db, makeConfig());
|
|
97
|
+
await expect(svc.getOrCreateByEmail('alice@example.com')).rejects.toBeInstanceOf(AccountDeactivatedError);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('a session that outlives a deactivation', () => {
|
|
102
|
+
it('stops being accepted once the account is switched off', async () => {
|
|
103
|
+
const { db } = makeFakeDb([[ROW], [{ deactivatedAt: new Date() }]]);
|
|
104
|
+
const svc = new AuthService(db, makeConfig());
|
|
105
|
+
const { token } = await svc.loginWithSso('alice@example.com', 'Alice');
|
|
106
|
+
// The signature alone is still good…
|
|
107
|
+
expect(svc.verifyToken(token).userId).toBe(ROW.id);
|
|
108
|
+
// …but the session is not.
|
|
109
|
+
await expect(svc.resolveSession(token)).rejects.toBeInstanceOf(AccountDeactivatedError);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('is told apart from a refusal when the account cannot be looked up', async () => {
|
|
113
|
+
const { db } = makeFakeDb([[ROW]]);
|
|
114
|
+
const svc = new AuthService(db, makeConfig());
|
|
115
|
+
const { token } = await svc.loginWithSso('alice@example.com', 'Alice');
|
|
116
|
+
vi.mocked(db.select).mockImplementationOnce(() => {
|
|
117
|
+
throw new Error('db down');
|
|
118
|
+
});
|
|
119
|
+
await expect(svc.resolveSession(token)).rejects.toBeInstanceOf(AuthBackendError);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('is accepted while the account is on', async () => {
|
|
123
|
+
const { db } = makeFakeDb([[ROW], [{ deactivatedAt: null }]]);
|
|
124
|
+
const svc = new AuthService(db, makeConfig());
|
|
125
|
+
const { token } = await svc.loginWithSso('alice@example.com', 'Alice');
|
|
126
|
+
await expect(svc.resolveSession(token)).resolves.toEqual({ userId: ROW.id, email: ROW.email });
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('AuthService.isActive', () => {
|
|
131
|
+
it('reads once and keeps the answer briefly', async () => {
|
|
132
|
+
const { db } = makeFakeDb([[{ deactivatedAt: null }]]);
|
|
133
|
+
const svc = new AuthService(db, makeConfig());
|
|
134
|
+
expect(await svc.isActive('user-1')).toBe(true);
|
|
135
|
+
expect(await svc.isActive('user-1')).toBe(true);
|
|
136
|
+
expect(vi.mocked(db.select)).toHaveBeenCalledTimes(1);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('treats the deployment admin as on whatever its row says, so its rescue session works', async () => {
|
|
140
|
+
const config = makeConfig({ adminEmail: 'root@example.com', adminPassword: 'sup3r-secret' });
|
|
141
|
+
const { db } = makeFakeDb([[{ email: 'root@example.com', deactivatedAt: new Date() }]]);
|
|
142
|
+
expect(await new AuthService(db, config).isActive('root-id')).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('treats an account that no longer exists as off', async () => {
|
|
146
|
+
const { db } = makeFakeDb([[]]);
|
|
147
|
+
expect(await new AuthService(db, makeConfig()).isActive('gone')).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('forgets the cached answer when this process switches the account off', async () => {
|
|
151
|
+
const { db } = makeFakeDb([[{ deactivatedAt: null }], [ROW], [], [{ deactivatedAt: new Date() }]]);
|
|
152
|
+
const svc = new AuthService(db, makeConfig());
|
|
153
|
+
expect(await svc.isActive(ROW.id)).toBe(true);
|
|
154
|
+
expect(await svc.deactivate(ROW.id)).toBe(true);
|
|
155
|
+
expect(await svc.isActive(ROW.id)).toBe(false);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe('AuthService.deactivate', () => {
|
|
160
|
+
it('records when the account was switched off', async () => {
|
|
161
|
+
const { db, captured } = makeFakeDb([[ROW], []]);
|
|
162
|
+
expect(await new AuthService(db, makeConfig()).deactivate(ROW.id)).toBe(true);
|
|
163
|
+
expect(captured.set[0].deactivatedAt).toBeInstanceOf(Date);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('answers false for an account that does not exist', async () => {
|
|
167
|
+
const { db } = makeFakeDb([[]]);
|
|
168
|
+
expect(await new AuthService(db, makeConfig()).deactivate('nobody')).toBe(false);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('refuses the deployment admin, whose environment password is the way back in', async () => {
|
|
172
|
+
const config = makeConfig({ adminEmail: 'root@example.com', adminPassword: 'sup3r-secret' });
|
|
173
|
+
const { db } = makeFakeDb([[{ ...ROW, email: 'root@example.com' }]]);
|
|
174
|
+
await expect(new AuthService(db, config).deactivate(ROW.id)).rejects.toBeInstanceOf(AccountChangeRefusedError);
|
|
175
|
+
expect(vi.mocked(db.update)).not.toHaveBeenCalled();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('refuses the accounts the platform runs its own work as', async () => {
|
|
179
|
+
const { db } = makeFakeDb([[{ ...ROW, email: RECOVERY_BOT_EMAIL }]]);
|
|
180
|
+
await expect(new AuthService(db, makeConfig()).deactivate(ROW.id)).rejects.toThrow('platform itself');
|
|
181
|
+
expect(vi.mocked(db.update)).not.toHaveBeenCalled();
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe('AuthService.reactivate', () => {
|
|
186
|
+
it('asks the admission port, as for a new account, and switches it on when admitted', async () => {
|
|
187
|
+
const { port, asked } = recordingPort({ ok: true });
|
|
188
|
+
const { db, captured } = makeFakeDb([[OFF], []]);
|
|
189
|
+
expect(await new AuthService(db, makeConfig(), port).reactivate(ROW.id)).toBe(true);
|
|
190
|
+
expect(asked).toEqual([['alice@example.com', 'reactivate']]);
|
|
191
|
+
expect(captured.set[0].deactivatedAt).toBeNull();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("refuses with the port's words when there is no room, and changes nothing", async () => {
|
|
195
|
+
const { port } = recordingPort({ ok: false, message: 'All 3 seats are taken' });
|
|
196
|
+
const { db } = makeFakeDb([[OFF]]);
|
|
197
|
+
const svc = new AuthService(db, makeConfig(), port);
|
|
198
|
+
await expect(svc.reactivate(ROW.id)).rejects.toThrow('All 3 seats are taken');
|
|
199
|
+
expect(vi.mocked(db.update)).not.toHaveBeenCalled();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('does not ask about an account that is already on', async () => {
|
|
203
|
+
const { port, asked } = recordingPort({ ok: false, message: 'full' });
|
|
204
|
+
const { db } = makeFakeDb([[ROW]]);
|
|
205
|
+
expect(await new AuthService(db, makeConfig(), port).reactivate(ROW.id)).toBe(true);
|
|
206
|
+
expect(asked).toEqual([]);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe('a first sign-in the port would rather keep waiting', () => {
|
|
211
|
+
const waiting = { ok: false as const, message: 'No seat left; the admin can let you in', waitForAdmin: true };
|
|
212
|
+
|
|
213
|
+
it('puts the person on file switched off, and still refuses the sign-in', async () => {
|
|
214
|
+
const { port } = recordingPort(waiting);
|
|
215
|
+
const { db, captured } = makeFakeDb([[], []]);
|
|
216
|
+
const svc = new AuthService(db, makeConfig(), port);
|
|
217
|
+
const err = await svc.loginWithSso('new@example.com', 'New').catch((e: unknown) => e);
|
|
218
|
+
expect(err).toBeInstanceOf(AccountAdmissionRefusedError);
|
|
219
|
+
expect((err as AccountAdmissionRefusedError).waitingForAdmin).toBe(true);
|
|
220
|
+
expect((err as Error).message).toBe(waiting.message);
|
|
221
|
+
expect(captured.values).toHaveLength(1);
|
|
222
|
+
expect(captured.values[0]).toMatchObject({ email: 'new@example.com' });
|
|
223
|
+
expect(captured.values[0].deactivatedAt).toBeInstanceOf(Date);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('is a plain refusal when an admin creates the account', async () => {
|
|
227
|
+
const { port } = recordingPort(waiting);
|
|
228
|
+
const { db } = makeFakeDb([[]]);
|
|
229
|
+
const err = await new AuthService(db, makeConfig(), port)
|
|
230
|
+
.createAccount('new@example.com', 'New')
|
|
231
|
+
.catch((e: unknown) => e);
|
|
232
|
+
expect(err).toBeInstanceOf(AccountAdmissionRefusedError);
|
|
233
|
+
expect((err as AccountAdmissionRefusedError).waitingForAdmin).toBe(false);
|
|
234
|
+
expect(vi.mocked(db.insert)).not.toHaveBeenCalled();
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe('AuthService.createAccount without a password', () => {
|
|
239
|
+
it('makes an account for single sign-on, with no hash stored', async () => {
|
|
240
|
+
const { db, captured } = makeFakeDb([[{ ...ROW, passwordHash: null }]]);
|
|
241
|
+
const user = await new AuthService(db, makeConfig()).createAccount('Alice@Example.com', '');
|
|
242
|
+
expect(user.email).toBe('alice@example.com');
|
|
243
|
+
expect(captured.values[0]).toEqual({ email: 'alice@example.com', name: 'alice' });
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('asks the admission port, as any new account does', async () => {
|
|
247
|
+
const { port, asked } = recordingPort({ ok: true });
|
|
248
|
+
const { db } = makeFakeDb([[], [{ ...ROW, passwordHash: null }]]);
|
|
249
|
+
await new AuthService(db, makeConfig(), port).createAccount('alice@example.com', 'Alice');
|
|
250
|
+
expect(asked).toEqual([['alice@example.com', 'admin-create']]);
|
|
251
|
+
});
|
|
252
|
+
});
|