@wardby/cli 0.2.1 → 0.3.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/.env.example +34 -4
- package/README.md +41 -4
- package/dist/claude-coding-worker/driver.d.ts +6 -1
- package/dist/claude-coding-worker/driver.js +27 -1
- package/dist/claude-coding-worker/main.js +2 -0
- package/dist/claude-coding-worker/tool-socket.d.ts +9 -0
- package/dist/claude-coding-worker/tool-socket.js +26 -0
- package/dist/cli-help.d.ts +1 -1
- package/dist/cli-help.js +13 -4
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +219 -29
- package/dist/coding/collect-exclude.d.ts +25 -0
- package/dist/coding/collect-exclude.js +76 -0
- package/dist/coding/profile.d.ts +67 -22
- package/dist/coding/profile.js +58 -25
- package/dist/coding/protected-path-wording.d.ts +24 -0
- package/dist/coding/protected-path-wording.js +55 -0
- package/dist/coding/protected-paths.d.ts +31 -0
- package/dist/coding/protected-paths.js +48 -0
- package/dist/coding/protocol.d.ts +75 -4
- package/dist/coding/protocol.js +107 -7
- package/dist/coding/registry/adapters.d.ts +2 -0
- package/dist/coding/registry/adapters.js +9 -0
- package/dist/coding/registry/allowlist.d.ts +9 -0
- package/dist/coding/registry/allowlist.js +28 -0
- package/dist/coding/registry/json-scan.d.ts +55 -0
- package/dist/coding/registry/json-scan.js +191 -0
- package/dist/coding/registry/lockfiles.d.ts +6 -0
- package/dist/coding/registry/lockfiles.js +89 -0
- package/dist/coding/registry/npm-lockfile.d.ts +9 -0
- package/dist/coding/registry/npm-lockfile.js +73 -0
- package/dist/coding/registry/npm-plan.d.ts +29 -0
- package/dist/coding/registry/npm-plan.js +289 -0
- package/dist/coding/registry/npm.d.ts +7 -0
- package/dist/coding/registry/npm.js +252 -0
- package/dist/coding/registry/pypi.d.ts +4 -0
- package/dist/coding/registry/pypi.js +210 -0
- package/dist/coding/registry/report.d.ts +37 -0
- package/dist/coding/registry/report.js +31 -0
- package/dist/coding/registry/token.d.ts +4 -0
- package/dist/coding/registry/token.js +7 -0
- package/dist/coding/registry/types.d.ts +286 -0
- package/dist/coding/registry/types.js +19 -0
- package/dist/coding/registry/worker-config.d.ts +14 -0
- package/dist/coding/registry/worker-config.js +31 -0
- package/dist/coding/services/builtins.d.ts +22 -0
- package/dist/coding/services/builtins.js +85 -0
- package/dist/coding/services/catalog.d.ts +443 -0
- package/dist/coding/services/catalog.js +159 -0
- package/dist/coding/services/declaration.d.ts +11 -0
- package/dist/coding/services/declaration.js +114 -0
- package/dist/coding/services/note.d.ts +8 -0
- package/dist/coding/services/note.js +15 -0
- package/dist/coding/services/resolve.d.ts +25 -0
- package/dist/coding/services/resolve.js +50 -0
- package/dist/coding/services/wording.d.ts +25 -0
- package/dist/coding/services/wording.js +70 -0
- package/dist/coding-proxy/main.js +24 -4
- package/dist/coding-worker/artifact.d.ts +6 -0
- package/dist/coding-worker/debug-trace.d.ts +37 -0
- package/dist/coding-worker/debug-trace.js +117 -0
- package/dist/coding-worker/driver.d.ts +13 -1
- package/dist/coding-worker/driver.js +102 -13
- package/dist/coding-worker/errors.js +8 -0
- package/dist/coding-worker/main.js +10 -2
- package/dist/coding-worker/sdk.d.ts +2 -2
- package/dist/coding-worker/sdk.js +7 -2
- package/dist/coding-worker/types.d.ts +3 -0
- package/dist/config/providers.d.ts +41 -0
- package/dist/config/providers.js +66 -0
- package/dist/core/budget-groups.d.ts +108 -5
- package/dist/core/budget-groups.js +125 -19
- package/dist/core/budget-wording.d.ts +22 -0
- package/dist/core/budget-wording.js +55 -0
- package/dist/core/datastores.js +18 -2
- package/dist/core/db.d.ts +5 -1
- package/dist/core/db.js +8 -2
- package/dist/core/dispatch.d.ts +33 -6
- package/dist/core/dispatch.js +260 -43
- package/dist/core/engine-native.js +27 -10
- package/dist/core/grants.d.ts +86 -0
- package/dist/core/grants.js +126 -0
- package/dist/core/host-events.d.ts +49 -0
- package/dist/core/host-events.js +257 -0
- package/dist/core/host-identity-links.d.ts +54 -0
- package/dist/core/host-identity-links.js +189 -0
- package/dist/core/host-status.d.ts +61 -0
- package/dist/core/host-status.js +211 -0
- package/dist/core/in-flight-runs.d.ts +8 -0
- package/dist/core/in-flight-runs.js +56 -0
- package/dist/core/provider-wording.d.ts +12 -0
- package/dist/core/provider-wording.js +44 -0
- package/dist/core/reconciler.d.ts +38 -2
- package/dist/core/reconciler.js +86 -2
- package/dist/core/repo-access.d.ts +99 -0
- package/dist/core/repo-access.js +136 -0
- package/dist/core/review-host-checks.d.ts +11 -0
- package/dist/core/review-host-checks.js +41 -0
- package/dist/core/review-host-tools.d.ts +47 -0
- package/dist/core/review-host-tools.js +347 -0
- package/dist/core/run-heartbeat.d.ts +27 -0
- package/dist/core/run-heartbeat.js +54 -0
- package/dist/core/runner.d.ts +30 -8
- package/dist/core/runner.js +237 -40
- package/dist/core/secrets.d.ts +11 -2
- package/dist/core/secrets.js +25 -6
- package/dist/core/subagent-memory-tools.d.ts +1 -1
- package/dist/core/subagent-memory-tools.js +16 -3
- package/dist/core/tool-admin.d.ts +81 -0
- package/dist/core/tool-admin.js +129 -0
- package/dist/core/tool-names.d.ts +42 -0
- package/dist/core/tool-names.js +64 -0
- package/dist/core/untrusted-content.d.ts +32 -0
- package/dist/core/untrusted-content.js +72 -0
- package/dist/core/webhooks.d.ts +8 -1
- package/dist/core/webhooks.js +23 -2
- package/dist/generated/prisma/browser.d.ts +100 -0
- package/dist/generated/prisma/client.d.ts +100 -0
- package/dist/generated/prisma/commonInputTypes.d.ts +30 -0
- package/dist/generated/prisma/enums.d.ts +6 -0
- package/dist/generated/prisma/enums.js +6 -1
- package/dist/generated/prisma/internal/class.d.ts +143 -0
- package/dist/generated/prisma/internal/class.js +4 -4
- package/dist/generated/prisma/internal/prismaNamespace.d.ts +1746 -577
- package/dist/generated/prisma/internal/prismaNamespace.js +179 -6
- package/dist/generated/prisma/internal/prismaNamespaceBrowser.d.ts +186 -0
- package/dist/generated/prisma/internal/prismaNamespaceBrowser.js +179 -6
- package/dist/generated/prisma/models/Agent.d.ts +294 -1
- package/dist/generated/prisma/models/AgentRepository.d.ts +1425 -0
- package/dist/generated/prisma/models/AgentRepository.js +1 -0
- package/dist/generated/prisma/models/AgentTool.d.ts +95 -1
- package/dist/generated/prisma/models/AuthUser.d.ts +56 -1
- package/dist/generated/prisma/models/CodingAgentProfile.d.ts +258 -7
- package/dist/generated/prisma/models/CodingProxySession.d.ts +123 -2
- package/dist/generated/prisma/models/CodingRun.d.ts +1276 -95
- package/dist/generated/prisma/models/CodingService.d.ts +1348 -0
- package/dist/generated/prisma/models/CodingService.js +1 -0
- package/dist/generated/prisma/models/HostEventDelivery.d.ts +946 -0
- package/dist/generated/prisma/models/HostEventDelivery.js +1 -0
- package/dist/generated/prisma/models/HostIdentity.d.ts +1232 -0
- package/dist/generated/prisma/models/HostIdentity.js +1 -0
- package/dist/generated/prisma/models/HostIdentityLinkRequest.d.ts +1473 -0
- package/dist/generated/prisma/models/HostIdentityLinkRequest.js +1 -0
- package/dist/generated/prisma/models/Principal.d.ts +455 -0
- package/dist/generated/prisma/models/RegistryAllowance.d.ts +1148 -0
- package/dist/generated/prisma/models/RegistryAllowance.js +1 -0
- package/dist/generated/prisma/models/RegistryApprovedVersion.d.ts +1219 -0
- package/dist/generated/prisma/models/RegistryApprovedVersion.js +1 -0
- package/dist/generated/prisma/models/RegistryFetch.d.ts +1428 -0
- package/dist/generated/prisma/models/RegistryFetch.js +1 -0
- package/dist/generated/prisma/models/RegistryPlanRefusal.d.ts +1294 -0
- package/dist/generated/prisma/models/RegistryPlanRefusal.js +1 -0
- package/dist/generated/prisma/models/RegistryVersionFact.d.ts +1085 -0
- package/dist/generated/prisma/models/RegistryVersionFact.js +1 -0
- package/dist/generated/prisma/models/ResourceGrant.d.ts +1437 -0
- package/dist/generated/prisma/models/ResourceGrant.js +1 -0
- package/dist/generated/prisma/models/Run.d.ts +389 -1
- package/dist/generated/prisma/models/RunHostCheck.d.ts +1239 -0
- package/dist/generated/prisma/models/RunHostCheck.js +1 -0
- package/dist/generated/prisma/models/RunHostStatus.d.ts +1315 -0
- package/dist/generated/prisma/models/RunHostStatus.js +1 -0
- package/dist/generated/prisma/models/Tool.d.ts +15 -3
- package/dist/generated/prisma/models.d.ts +13 -0
- package/dist/help/build.d.ts +1 -0
- package/dist/help/build.js +9 -0
- package/dist/help/catalog.d.ts +24 -0
- package/dist/help/catalog.js +160 -0
- package/dist/help/cli.d.ts +2 -0
- package/dist/help/cli.js +65 -0
- package/dist/help/runtime.d.ts +3 -0
- package/dist/help/runtime.js +44 -0
- package/dist/help/search.d.ts +9 -0
- package/dist/help/search.js +104 -0
- package/dist/help-index.json +660 -0
- package/dist/import/cli-args.js +3 -2
- package/dist/import/create.d.ts +5 -0
- package/dist/import/create.js +67 -13
- package/dist/import/index.js +19 -9
- package/dist/import/neutral-schema.d.ts +11 -11
- package/dist/mcp/auth/access.d.ts +70 -0
- package/dist/mcp/auth/access.js +58 -0
- package/dist/mcp/auth/grants-cli.d.ts +149 -0
- package/dist/mcp/auth/grants-cli.js +518 -0
- package/dist/mcp/auth/host-account-cli.d.ts +2 -0
- package/dist/mcp/auth/host-account-cli.js +47 -0
- package/dist/mcp/auth/ownership.d.ts +26 -52
- package/dist/mcp/auth/ownership.js +19 -14
- package/dist/mcp/auth/repo-authorization.d.ts +22 -0
- package/dist/mcp/auth/repo-authorization.js +51 -0
- package/dist/mcp/auth/resource-server.d.ts +31 -2
- package/dist/mcp/auth/resource-server.js +84 -3
- package/dist/mcp/auth/self-hosted/browser.js +2 -2
- package/dist/mcp/auth/self-hosted/cli.js +47 -13
- package/dist/mcp/auth/self-hosted/credentials.d.ts +20 -2
- package/dist/mcp/auth/self-hosted/credentials.js +62 -4
- package/dist/mcp/auth/self-hosted/session.d.ts +2 -1
- package/dist/mcp/context.d.ts +27 -1
- package/dist/mcp/errors.d.ts +25 -6
- package/dist/mcp/errors.js +98 -0
- package/dist/mcp/host-events/github-ingress.d.ts +36 -0
- package/dist/mcp/host-events/github-ingress.js +92 -0
- package/dist/mcp/host-events/github-user-callback.d.ts +18 -0
- package/dist/mcp/host-events/github-user-callback.js +50 -0
- package/dist/mcp/index.d.ts +8 -1
- package/dist/mcp/index.js +106 -16
- package/dist/mcp/server.js +27 -9
- package/dist/mcp/tools/agents.js +446 -48
- package/dist/mcp/tools/budget-groups.js +3 -3
- package/dist/mcp/tools/datastore.js +22 -15
- package/dist/mcp/tools/grants.d.ts +2 -0
- package/dist/mcp/tools/grants.js +239 -0
- package/dist/mcp/tools/help.d.ts +5 -0
- package/dist/mcp/tools/help.js +67 -0
- package/dist/mcp/tools/host-accounts.d.ts +2 -0
- package/dist/mcp/tools/host-accounts.js +114 -0
- package/dist/mcp/tools/memory.d.ts +7 -1
- package/dist/mcp/tools/memory.js +5 -5
- package/dist/mcp/tools/repositories.d.ts +2 -0
- package/dist/mcp/tools/repositories.js +181 -0
- package/dist/mcp/tools/runs.d.ts +6 -0
- package/dist/mcp/tools/runs.js +60 -6
- package/dist/mcp/tools/scheduling.js +3 -6
- package/dist/mcp/tools/secrets.js +18 -6
- package/dist/mcp/tools/services.d.ts +2 -0
- package/dist/mcp/tools/services.js +222 -0
- package/dist/mcp/tools/subagents.js +52 -16
- package/dist/mcp/tools/tools.d.ts +41 -0
- package/dist/mcp/tools/tools.js +201 -40
- package/dist/mcp/tools/trigger.js +36 -6
- package/dist/mcp/tools/webhooks.js +15 -4
- package/dist/mcp/transport/streamable-http.d.ts +10 -0
- package/dist/mcp/transport/streamable-http.js +32 -2
- package/dist/providers/auth/delegating.d.ts +19 -0
- package/dist/providers/auth/delegating.js +71 -0
- package/dist/providers/auth/index.d.ts +2 -0
- package/dist/providers/auth/index.js +11 -0
- package/dist/providers/auth/self-hosted.d.ts +2 -1
- package/dist/providers/auth/self-hosted.js +16 -4
- package/dist/providers/auth/types.d.ts +6 -0
- package/dist/providers/coding-proxy/memory-ledger.d.ts +3 -0
- package/dist/providers/coding-proxy/memory-ledger.js +21 -2
- package/dist/providers/coding-proxy/metering.d.ts +4 -0
- package/dist/providers/coding-proxy/metering.js +28 -2
- package/dist/providers/coding-proxy/prisma-ledger.d.ts +17 -0
- package/dist/providers/coding-proxy/prisma-ledger.js +50 -5
- package/dist/providers/coding-proxy/proxy.d.ts +13 -0
- package/dist/providers/coding-proxy/proxy.js +479 -18
- package/dist/providers/coding-proxy/registry/audit.d.ts +131 -0
- package/dist/providers/coding-proxy/registry/audit.js +380 -0
- package/dist/providers/coding-proxy/registry/bounded-fetch.d.ts +16 -0
- package/dist/providers/coding-proxy/registry/bounded-fetch.js +77 -0
- package/dist/providers/coding-proxy/registry/plan.d.ts +58 -0
- package/dist/providers/coding-proxy/registry/plan.js +304 -0
- package/dist/providers/coding-proxy/registry/prisma-store.d.ts +34 -0
- package/dist/providers/coding-proxy/registry/prisma-store.js +151 -0
- package/dist/providers/coding-proxy/registry/service.d.ts +213 -0
- package/dist/providers/coding-proxy/registry/service.js +1137 -0
- package/dist/providers/coding-proxy/registry/store.d.ts +127 -0
- package/dist/providers/coding-proxy/registry/store.js +70 -0
- package/dist/providers/coding-proxy/runtime.d.ts +17 -0
- package/dist/providers/coding-proxy/runtime.js +63 -0
- package/dist/providers/coding-proxy/secure-fetch.js +0 -1
- package/dist/providers/coding-proxy/server.d.ts +11 -0
- package/dist/providers/coding-proxy/server.js +163 -0
- package/dist/providers/coding-proxy/types.d.ts +15 -0
- package/dist/providers/engine/types.d.ts +10 -0
- package/dist/providers/executor/build.d.ts +2 -2
- package/dist/providers/executor/composition.d.ts +3 -0
- package/dist/providers/executor/composition.js +19 -0
- package/dist/providers/executor/container.d.ts +105 -3
- package/dist/providers/executor/container.js +296 -20
- package/dist/providers/executor/dbos.d.ts +2 -3
- package/dist/providers/executor/in-process.d.ts +2 -3
- package/dist/providers/executor/routing.d.ts +7 -0
- package/dist/providers/executor/routing.js +13 -0
- package/dist/providers/executor/types.d.ts +22 -0
- package/dist/providers/jobs/claude-tool-setup.d.ts +23 -0
- package/dist/providers/jobs/claude-tool-setup.js +50 -0
- package/dist/providers/jobs/collect-prune.d.ts +7 -0
- package/dist/providers/jobs/collect-prune.js +27 -0
- package/dist/providers/jobs/docker-isolation.d.ts +48 -3
- package/dist/providers/jobs/docker-isolation.js +213 -22
- package/dist/providers/jobs/docker-services.d.ts +35 -0
- package/dist/providers/jobs/docker-services.js +191 -0
- package/dist/providers/jobs/docker.d.ts +49 -2
- package/dist/providers/jobs/docker.js +279 -32
- package/dist/providers/jobs/fake-kubernetes-api.d.ts +1 -0
- package/dist/providers/jobs/fake-kubernetes-api.js +12 -3
- package/dist/providers/jobs/kubernetes-isolation.d.ts +17 -2
- package/dist/providers/jobs/kubernetes-isolation.js +238 -57
- package/dist/providers/jobs/kubernetes-platform.d.ts +10 -4
- package/dist/providers/jobs/kubernetes-platform.js +11 -5
- package/dist/providers/jobs/kubernetes-preflight.js +3 -0
- package/dist/providers/jobs/kubernetes.d.ts +21 -2
- package/dist/providers/jobs/kubernetes.js +112 -21
- package/dist/providers/jobs/types.d.ts +18 -0
- package/dist/providers/llm/anthropic.js +6 -2
- package/dist/providers/llm/bedrock.js +2 -1
- package/dist/providers/llm/claude-messages.d.ts +5 -1
- package/dist/providers/llm/claude-messages.js +1 -0
- package/dist/providers/llm/claude-provider.d.ts +3 -1
- package/dist/providers/llm/claude-provider.js +5 -1
- package/dist/providers/llm/index.d.ts +1 -1
- package/dist/providers/llm/index.js +1 -1
- package/dist/providers/llm/pricing-anthropic.d.ts +10 -0
- package/dist/providers/llm/pricing-anthropic.js +32 -14
- package/dist/providers/llm/pricing-bedrock-claude.d.ts +8 -0
- package/dist/providers/llm/pricing-bedrock-claude.js +9 -0
- package/dist/providers/llm/routing.d.ts +10 -1
- package/dist/providers/llm/routing.js +18 -0
- package/dist/providers/llm/types.d.ts +12 -0
- package/dist/providers/llm/types.js +8 -1
- package/dist/providers/review-host/diff-lines.d.ts +16 -0
- package/dist/providers/review-host/diff-lines.js +59 -0
- package/dist/providers/review-host/github-events.d.ts +6 -0
- package/dist/providers/review-host/github-events.js +180 -0
- package/dist/providers/review-host/github-user-auth.d.ts +38 -0
- package/dist/providers/review-host/github-user-auth.js +128 -0
- package/dist/providers/review-host/github.d.ts +57 -0
- package/dist/providers/review-host/github.js +567 -0
- package/dist/providers/review-host/index.d.ts +12 -0
- package/dist/providers/review-host/index.js +30 -0
- package/dist/providers/review-host/review-format.d.ts +19 -0
- package/dist/providers/review-host/review-format.js +59 -0
- package/dist/providers/review-host/types.d.ts +284 -0
- package/dist/providers/review-host/types.js +24 -0
- package/dist/providers/vcs/git.d.ts +16 -6
- package/dist/providers/vcs/git.js +77 -13
- package/dist/providers/vcs/github.d.ts +74 -3
- package/dist/providers/vcs/github.js +195 -15
- package/dist/providers/vcs/types.d.ts +42 -4
- package/dist/quickstart/index.js +13 -2
- package/dist/sandbox/fetch-policy.d.ts +20 -2
- package/dist/sandbox/fetch-policy.js +64 -3
- package/dist/sandbox/host-functions.d.ts +9 -1
- package/dist/sandbox/host-functions.js +12 -5
- package/dist/serve.js +1 -1
- package/dist/wardby-bin.js +6 -0
- package/docs/README.md +30 -0
- package/docs/architecture-runtime.md +90 -0
- package/docs/assets/brand/wardby-icon-512.png +0 -0
- package/docs/assets/brand/wardby-icon.svg +16 -0
- package/docs/assets/brand/wardby-mascot-profile-512.png +0 -0
- package/docs/assets/brand/wardby-mascot.png +0 -0
- package/docs/assets/brand/wardby-mascot.svg +5 -0
- package/docs/assets/wardby-workflow.svg +106 -0
- package/docs/code-review-agents.md +481 -0
- package/docs/coding-agent-setup.md +172 -0
- package/docs/coding-packages.md +455 -0
- package/docs/coding-services.md +300 -0
- package/docs/coding-worker-byo-images.md +98 -0
- package/docs/coding-worker-isolation.md +1061 -0
- package/docs/getting-started-gke.md +615 -0
- package/docs/getting-started-identity-provider.md +308 -0
- package/docs/getting-started.md +133 -0
- package/docs/observability.md +53 -0
- package/docs/release-verification.md +66 -0
- package/docs/security-deployment.md +657 -0
- package/help/code-review-agents.md +31 -0
- package/help/coding-packages.md +31 -0
- package/help/coding-services.md +71 -0
- package/help/creating-agents.md +68 -0
- package/help/deploy-gke.md +39 -0
- package/help/deployment-targets.md +39 -0
- package/help/errors/budget-group-exhausted.md +25 -0
- package/help/errors/docker-isolation-unsupported.md +26 -0
- package/help/errors/protected-path.md +45 -0
- package/help/errors/repo-access.md +27 -0
- package/help/errors/service-declaration-invalid.md +27 -0
- package/help/errors/service-declaration-unavailable.md +25 -0
- package/help/errors/service-launcher-unsupported.md +30 -0
- package/help/errors/service-not-allowed.md +27 -0
- package/help/errors/service-unknown.md +23 -0
- package/help/errors/service-unready.md +49 -0
- package/help/getting-started.md +31 -0
- package/help/github.md +30 -0
- package/help/identity-and-access.md +39 -0
- package/help/mcp.md +30 -0
- package/help/native-capabilities.md +30 -0
- package/help/observability.md +41 -0
- package/help/operating-agents.md +30 -0
- package/help/security.md +27 -0
- package/help/troubleshooting/budgets.md +26 -0
- package/help/troubleshooting/coding-workers.md +47 -0
- package/help/troubleshooting/repository-access.md +27 -0
- package/package.json +12 -3
- package/prisma/migrations/20260925010000_coding_collect_exclude/migration.sql +8 -0
- package/prisma/migrations/20260925015000_allowed_egress_default/migration.sql +8 -0
- package/prisma/migrations/20260925020000_coding_package_registry/migration.sql +55 -0
- package/prisma/migrations/20260925030000_tool_name_per_owner/migration.sql +11 -0
- package/prisma/migrations/20260926010000_run_trigger_host_event/migration.sql +7 -0
- package/prisma/migrations/20260926020000_code_review_hosts/migration.sql +53 -0
- package/prisma/migrations/20260926030000_auth_user_roles/migration.sql +9 -0
- package/prisma/migrations/20260926040000_agent_effort/migration.sql +6 -0
- package/prisma/migrations/20260926050000_registry_lockfile_plan/migration.sql +32 -0
- package/prisma/migrations/20260926050000_repo_access_authorization/migration.sql +82 -0
- package/prisma/migrations/20260926060000_registry_plan_refusal/migration.sql +19 -0
- package/prisma/migrations/20260926100000_registry_plan_refusal_published_at/migration.sql +5 -0
- package/prisma/migrations/20260926190000_run_host_status/migration.sql +18 -0
- package/prisma/migrations/20260926210000_run_host_status_at_dispatch/migration.sql +5 -0
- package/prisma/migrations/20260927010000_resource_grants/migration.sql +72 -0
- package/prisma/migrations/20260927020000_proxy_session_budget_exhausted/migration.sql +3 -0
- package/prisma/migrations/20260927030000_coding_debug_trace/migration.sql +4 -0
- package/prisma/migrations/20260927040000_proxy_session_upstream_failure/migration.sql +3 -0
- package/prisma/migrations/20260927050000_coding_run_services/migration.sql +74 -0
- package/prisma/migrations/20260928000000_coding_run_tool_image/migration.sql +5 -0
- package/prisma/schema.prisma +407 -21
|
@@ -0,0 +1,1137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RegistryService: the security core of the coding package registry. It
|
|
3
|
+
* authenticates the registry-only token, enforces the per-run allowlist and
|
|
4
|
+
* the dependency graph grown from it, filters versions by allowlisted
|
|
5
|
+
* range, minimum release age and OSV advisories, streams downloads with
|
|
6
|
+
* integrity verification and size/idle limits, and records every served or
|
|
7
|
+
* refused fetch. Adapters (npm, PyPI, …) supply ecosystem-specific parsing
|
|
8
|
+
* and protocol only; every safeguard lives here so a new adapter cannot
|
|
9
|
+
* weaken one by omission.
|
|
10
|
+
*/
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import { logger } from "../../../core/logger.js";
|
|
13
|
+
import { matchRoot, parseAllowlist, resolvePolicy } from "../../../coding/registry/allowlist.js";
|
|
14
|
+
import { PLAN_REFUSAL_FILENAME } from "../../../coding/registry/report.js";
|
|
15
|
+
import { RegistryError, } from "../../../coding/registry/types.js";
|
|
16
|
+
import { capabilityHash } from "../proxy.js";
|
|
17
|
+
import { boundedMetadataFetch, DEFAULT_MAX_METADATA_BYTES, DEFAULT_METADATA_TIMEOUT_MS } from "./bounded-fetch.js";
|
|
18
|
+
import { DEFAULT_PLAN_MAX_ENTRIES, DEFAULT_PLAN_TIMEOUT_MS, verifyLockfilePlan } from "./plan.js";
|
|
19
|
+
const registryLog = logger.child({ module: "coding-registry" });
|
|
20
|
+
/** Server-side record of a registry failure. The worker only ever sees the
|
|
21
|
+
* RegistryError's code and message; the operator needs the cause. Without
|
|
22
|
+
* this, a proxy database role missing its grants (permission denied) was
|
|
23
|
+
* indistinguishable from an upstream outage: an unlogged 502 to npm or pip.
|
|
24
|
+
* Expected refusals (4xx) stay unlogged here: they are recorded per run. */
|
|
25
|
+
function logRegistryFailure(route, ecosystem, error) {
|
|
26
|
+
if (error instanceof RegistryError) {
|
|
27
|
+
if (error.status >= 500) {
|
|
28
|
+
registryLog.warn({
|
|
29
|
+
event: "registry.unavailable",
|
|
30
|
+
route,
|
|
31
|
+
ecosystem,
|
|
32
|
+
status: error.status,
|
|
33
|
+
code: error.code,
|
|
34
|
+
reason: error.message,
|
|
35
|
+
}, "coding registry could not answer");
|
|
36
|
+
}
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
registryLog.error({ event: "registry.failed", route, ecosystem, err: error }, "coding registry request failed");
|
|
40
|
+
}
|
|
41
|
+
const DAY_MS = 86_400_000;
|
|
42
|
+
/** Bytes of a served file kept for `dependenciesFromFile`. A larger file is
|
|
43
|
+
* truncated here, the adapter cannot read its metadata from a truncated
|
|
44
|
+
* archive, and it yields no dependencies: this fails closed (nothing is
|
|
45
|
+
* allowed that the file did not prove). */
|
|
46
|
+
const DEPENDENCY_BUFFER_LIMIT = 64 * 1024 * 1024;
|
|
47
|
+
/** npm's own name-length limit; longer recorded names are truncated. */
|
|
48
|
+
const MAX_RECORDED_NAME = 214;
|
|
49
|
+
export const DEFAULT_MAX_GRAPH_PACKAGES = 3000;
|
|
50
|
+
export const DEFAULT_GRAPH_TIMEOUT_MS = 180_000;
|
|
51
|
+
/** Packages one graph walk expands at once (metadata + audit each). Each
|
|
52
|
+
* expansion may hold a whole parsed npm packument (tens of MB for the
|
|
53
|
+
* largest), so this bounds the walk's peak memory, not only its upstream
|
|
54
|
+
* load: 8 at once OOM-killed a 512Mi proxy in a live lockfile install. */
|
|
55
|
+
const GRAPH_WALK_CONCURRENCY = 2;
|
|
56
|
+
/** Graph-walk expansions in flight at once across every run, so one run's
|
|
57
|
+
* walk cannot starve the others' upstream fetches -- or the proxy's event
|
|
58
|
+
* loop: parsing packuments is CPU work on the same loop that carries model
|
|
59
|
+
* traffic (per-walk 2 and global 3 since a live install at 4/6 on 500m
|
|
60
|
+
* expired a ledger transaction). The walk is only the fallback now; lockfile
|
|
61
|
+
* verification answers most installs without it. */
|
|
62
|
+
export const DEFAULT_MAX_GRAPH_CONCURRENCY = 3;
|
|
63
|
+
/** A node whose metadata or audit fails transiently is retried once more
|
|
64
|
+
* in the same walk, then parked; a later miss retries it again, up to
|
|
65
|
+
* this many attempts per node per run. */
|
|
66
|
+
const GRAPH_NODE_ATTEMPTS_PER_WALK = 2;
|
|
67
|
+
const MAX_GRAPH_NODE_ATTEMPTS = 4;
|
|
68
|
+
/** RegistryErrors that are a definitive answer about a package (it has no
|
|
69
|
+
* dependencies the run can use), not a transient failure to retry. */
|
|
70
|
+
const DEFINITIVE_NODE_ERRORS = new Set(["wardby_package_not_found", "wardby_metadata_too_large"]);
|
|
71
|
+
/** Largest per-version document read from upstream for lockfile
|
|
72
|
+
* verification (they are a few KB). */
|
|
73
|
+
const MAX_VERSION_DOCUMENT_BYTES = 4 * 1024 * 1024;
|
|
74
|
+
/** Lockfile plans read and verified at once across every run: with the
|
|
75
|
+
* 20 MiB body cap, this bounds the lockfile text held at once. */
|
|
76
|
+
export const DEFAULT_PLAN_CONCURRENCY = 2;
|
|
77
|
+
/** Lockfile plans one run may make (REGISTRY_PLAN_MAX_PER_RUN). */
|
|
78
|
+
export const DEFAULT_PLAN_MAX_PER_RUN = 20;
|
|
79
|
+
/** The `filename` of a refused RegistryFetch row a lockfile plan recorded
|
|
80
|
+
* (the route it came from), which is how get_run tells plan refusals from
|
|
81
|
+
* download refusals. */
|
|
82
|
+
export const PLAN_FETCH_FILENAME = PLAN_REFUSAL_FILENAME;
|
|
83
|
+
/** Largest lockfile a plan reads (the HTTP layer enforces it: 413). */
|
|
84
|
+
export const MAX_PLAN_BODY_BYTES = 20 * 1024 * 1024;
|
|
85
|
+
/** A small counting semaphore. */
|
|
86
|
+
class Slots {
|
|
87
|
+
size;
|
|
88
|
+
active = 0;
|
|
89
|
+
waiting = [];
|
|
90
|
+
constructor(size) {
|
|
91
|
+
this.size = size;
|
|
92
|
+
}
|
|
93
|
+
async run(task) {
|
|
94
|
+
if (this.active >= this.size)
|
|
95
|
+
await new Promise((resolve) => this.waiting.push(resolve));
|
|
96
|
+
else
|
|
97
|
+
this.active += 1;
|
|
98
|
+
try {
|
|
99
|
+
return await task();
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
const next = this.waiting.shift();
|
|
103
|
+
if (next)
|
|
104
|
+
next();
|
|
105
|
+
else
|
|
106
|
+
this.active -= 1;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const edgeKey = (edge) => `${edge.name}\0${edge.range}`;
|
|
111
|
+
export const DEFAULT_MAX_REFUSAL_RECORDS = 500;
|
|
112
|
+
function contentTypeOf(route) {
|
|
113
|
+
return route.kind === "file-metadata" ? "text/plain" : "application/octet-stream";
|
|
114
|
+
}
|
|
115
|
+
export function errorResponse(error) {
|
|
116
|
+
return {
|
|
117
|
+
status: error.status,
|
|
118
|
+
contentType: "application/json",
|
|
119
|
+
body: JSON.stringify({ error: `${error.code}: ${error.message}` }),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
export const DEFAULT_METADATA_CACHE_ENTRIES = 500;
|
|
123
|
+
/** Byte budget of the metadata cache, by each entry's approximate retained
|
|
124
|
+
* size. Entries are trimmed (an npm package's is a few MB at most, for
|
|
125
|
+
* thousands of versions), but 500 of them could still add up. */
|
|
126
|
+
export const DEFAULT_METADATA_CACHE_BYTES = 64 * 1024 * 1024;
|
|
127
|
+
/** setTimeout's largest delay; a longer deadline is re-armed on firing. */
|
|
128
|
+
const MAX_TIMER_MS = 2 ** 31 - 1;
|
|
129
|
+
/** `approxBytes` when the adapter reports it, otherwise a generous
|
|
130
|
+
* estimate from the versions alone. */
|
|
131
|
+
function metadataBytes(meta) {
|
|
132
|
+
if (meta.approxBytes !== undefined)
|
|
133
|
+
return meta.approxBytes;
|
|
134
|
+
let bytes = 256 + meta.name.length;
|
|
135
|
+
for (const info of meta.versions.values()) {
|
|
136
|
+
bytes += 400 + 2 * info.version.length;
|
|
137
|
+
for (const spec of info.dependencySpecs ?? [])
|
|
138
|
+
bytes += 80 + spec.name.length + spec.range.length;
|
|
139
|
+
for (const name of info.dependencies)
|
|
140
|
+
bytes += 40 + name.length;
|
|
141
|
+
for (const file of info.files)
|
|
142
|
+
bytes += 400 + file.filename.length + file.upstreamUrl.length + (file.integrity?.hex.length ?? 0);
|
|
143
|
+
}
|
|
144
|
+
return bytes;
|
|
145
|
+
}
|
|
146
|
+
/** Normalized dependency names of a package's kept versions: what serving
|
|
147
|
+
* its metadata unlocks, and what the graph walk follows. Dependency keys
|
|
148
|
+
* come from the upstream document, so only real package names are kept:
|
|
149
|
+
* neither path ever records an allowance for, or builds an upstream
|
|
150
|
+
* request from, a key the adapter's own parser would reject. */
|
|
151
|
+
function keptDependencies(adapter, meta, keep) {
|
|
152
|
+
return [...new Set([...keep].flatMap((version) => versionDependencies(adapter, meta, version).map((d) => d.name)))];
|
|
153
|
+
}
|
|
154
|
+
/** One version's declared dependencies (name and range), normalized and
|
|
155
|
+
* limited to real package names: the single source both the metadata
|
|
156
|
+
* path (names only) and the graph walk (names and ranges) read. */
|
|
157
|
+
function versionDependencies(adapter, meta, version) {
|
|
158
|
+
const info = meta.versions.get(version);
|
|
159
|
+
if (!info)
|
|
160
|
+
return [];
|
|
161
|
+
const specs = info.dependencySpecs ?? info.dependencies.map((name) => ({ name, range: "*" }));
|
|
162
|
+
return specs
|
|
163
|
+
.map((spec) => ({ name: adapter.normalizeName(spec.name), range: spec.range }))
|
|
164
|
+
.filter((spec) => isPackageName(adapter, spec.name));
|
|
165
|
+
}
|
|
166
|
+
/** Whether `name` is a plain, valid package name in this ecosystem (no
|
|
167
|
+
* wildcard, no range), by the adapter's own allowlist parser. */
|
|
168
|
+
function isPackageName(adapter, name) {
|
|
169
|
+
try {
|
|
170
|
+
const entry = adapter.parseAllowlistEntry(name);
|
|
171
|
+
return !entry.wildcard && entry.range === undefined && entry.name === name;
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/** The strongest supported hash of an SRI integrity string (`sha512-…`,
|
|
178
|
+
* possibly several space-separated), or null when it has none. */
|
|
179
|
+
export function parseSri(value) {
|
|
180
|
+
const order = ["sha512", "sha384", "sha256", "sha1"];
|
|
181
|
+
let best = null;
|
|
182
|
+
for (const token of value.trim().split(/\s+/)) {
|
|
183
|
+
const match = token.match(/^(sha512|sha384|sha256|sha1)-([A-Za-z0-9+/]+={0,2})(?:\?.*)?$/);
|
|
184
|
+
if (!match)
|
|
185
|
+
continue;
|
|
186
|
+
const algorithm = match[1];
|
|
187
|
+
if (best && order.indexOf(best.algorithm) <= order.indexOf(algorithm))
|
|
188
|
+
continue;
|
|
189
|
+
best = { algorithm, hex: Buffer.from(match[2], "base64").toString("hex") };
|
|
190
|
+
}
|
|
191
|
+
return best;
|
|
192
|
+
}
|
|
193
|
+
export class RegistryService {
|
|
194
|
+
options;
|
|
195
|
+
/** Parsed, trimmed metadata (adapters keep only the fields the proxy
|
|
196
|
+
* reads, never the upstream document), least recently used first.
|
|
197
|
+
* Bounded to `metadataCacheEntries` and `metadataCacheBytes`; expired
|
|
198
|
+
* entries are dropped on access and on insert. */
|
|
199
|
+
metadataCache = new Map();
|
|
200
|
+
/** Sum of the cached entries' `bytes`. */
|
|
201
|
+
metadataCacheTotal = 0;
|
|
202
|
+
/** Single-flight: concurrent misses for one package (and render need)
|
|
203
|
+
* share one upstream fetch. */
|
|
204
|
+
metadataLoads = new Map();
|
|
205
|
+
now;
|
|
206
|
+
metadataTtlMs;
|
|
207
|
+
metadataCacheEntries;
|
|
208
|
+
metadataCacheBytes;
|
|
209
|
+
metadataUpstream;
|
|
210
|
+
/** Per-run in-flight download usage: files and bytes reserved by downloads
|
|
211
|
+
* that are streaming right now but not yet recorded to the store. Without
|
|
212
|
+
* this, `usage()` (which only counts already-served rows) is read once
|
|
213
|
+
* per request, so concurrent downloads for the same run (e.g. npm's
|
|
214
|
+
* parallel installs) count toward neither the file nor the byte limit
|
|
215
|
+
* until each finishes, letting them overshoot both. This reservation is
|
|
216
|
+
* in-process only — it holds per replica of the registry proxy, not
|
|
217
|
+
* across replicas. */
|
|
218
|
+
inFlight = new Map();
|
|
219
|
+
tallies = new Map();
|
|
220
|
+
walkSlots;
|
|
221
|
+
planSlots;
|
|
222
|
+
versionUpstream;
|
|
223
|
+
constructor(options) {
|
|
224
|
+
this.options = options;
|
|
225
|
+
this.now = options.now ?? (() => new Date());
|
|
226
|
+
this.walkSlots = new Slots(options.maxGraphConcurrency ?? DEFAULT_MAX_GRAPH_CONCURRENCY);
|
|
227
|
+
this.planSlots = new Slots(options.planConcurrency ?? DEFAULT_PLAN_CONCURRENCY);
|
|
228
|
+
this.metadataTtlMs = options.metadataTtlMs ?? 300_000;
|
|
229
|
+
this.metadataCacheEntries = options.metadataCacheEntries ?? DEFAULT_METADATA_CACHE_ENTRIES;
|
|
230
|
+
this.metadataCacheBytes = options.metadataCacheBytes ?? DEFAULT_METADATA_CACHE_BYTES;
|
|
231
|
+
this.metadataUpstream = boundedMetadataFetch(options.upstream, {
|
|
232
|
+
timeoutMs: options.metadataTimeoutMs ?? DEFAULT_METADATA_TIMEOUT_MS,
|
|
233
|
+
maxBytes: options.maxMetadataBytes ?? DEFAULT_MAX_METADATA_BYTES,
|
|
234
|
+
});
|
|
235
|
+
this.versionUpstream = boundedMetadataFetch(options.upstream, {
|
|
236
|
+
timeoutMs: options.metadataTimeoutMs ?? DEFAULT_METADATA_TIMEOUT_MS,
|
|
237
|
+
maxBytes: MAX_VERSION_DOCUMENT_BYTES,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
/** `POST /registry/<ecosystem>/-/plan`: verifies a lockfile against the
|
|
241
|
+
* registry and approves exactly its verified, reachable name@version
|
|
242
|
+
* pairs for the run (plan.ts). Answers `{ approved, refused }`. */
|
|
243
|
+
async plan(request) {
|
|
244
|
+
try {
|
|
245
|
+
return await this.planLockfile(request);
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
logRegistryFailure("plan", request.ecosystem, error);
|
|
249
|
+
if (error instanceof RegistryError)
|
|
250
|
+
return errorResponse(error);
|
|
251
|
+
return errorResponse(new RegistryError(502, "wardby_upstream_error", "the lockfile could not be verified"));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
async planLockfile(request) {
|
|
255
|
+
const adapter = this.options.adapters.get(request.ecosystem);
|
|
256
|
+
if (!adapter)
|
|
257
|
+
throw new RegistryError(404, "wardby_registry_unknown", `no registry named "${request.ecosystem}"`);
|
|
258
|
+
const context = await this.options.store.findRunByRegistryTokenHash(capabilityHash(request.token), this.now());
|
|
259
|
+
if (!context)
|
|
260
|
+
throw new RegistryError(401, "invalid_capability", "the registry token is not valid for a live run");
|
|
261
|
+
const support = adapter.lockfilePlan;
|
|
262
|
+
const auditVersions = this.options.audit.auditVersions?.bind(this.options.audit);
|
|
263
|
+
if (!support || !auditVersions)
|
|
264
|
+
throw new RegistryError(404, "wardby_plan_unsupported", `the ${adapter.id} registry does not verify lockfiles`);
|
|
265
|
+
const tally = this.tally(context);
|
|
266
|
+
if (tally.planning)
|
|
267
|
+
throw new RegistryError(429, "wardby_plan_in_progress", "a lockfile plan is already running for this run; wait for it to finish");
|
|
268
|
+
const maxPlans = this.options.planMaxPerRun ?? DEFAULT_PLAN_MAX_PER_RUN;
|
|
269
|
+
if ((tally.plans ?? 0) >= maxPlans)
|
|
270
|
+
throw new RegistryError(429, "wardby_plan_limit", `this run has made its ${maxPlans} lockfile plans (REGISTRY_PLAN_MAX_PER_RUN)`);
|
|
271
|
+
tally.planning = true;
|
|
272
|
+
tally.plans = (tally.plans ?? 0) + 1;
|
|
273
|
+
try {
|
|
274
|
+
// The body is read only while a slot is held: a queued plan holds no
|
|
275
|
+
// lockfile text, and at most `planConcurrency` bodies exist at once.
|
|
276
|
+
return await this.planSlots.run(async () => {
|
|
277
|
+
const lockfile = support.parse(await request.readBody(), {
|
|
278
|
+
maxEntries: this.options.planMaxEntries ?? DEFAULT_PLAN_MAX_ENTRIES,
|
|
279
|
+
proxyRegistryUrl: `${this.options.proxyBase}${adapter.id}/`,
|
|
280
|
+
});
|
|
281
|
+
return this.verifyPlan(request, adapter, support, lockfile, auditVersions, context);
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
finally {
|
|
285
|
+
tally.planning = false;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
async verifyPlan(request, adapter, support, lockfile, auditVersions, context) {
|
|
289
|
+
const entries = parseAllowlist(context.allowlist, this.options.adapters).get(adapter.id) ?? [];
|
|
290
|
+
const { minReleaseAgeDays } = resolvePolicy(context.policy);
|
|
291
|
+
const timeoutMs = this.options.planTimeoutMs ?? DEFAULT_PLAN_TIMEOUT_MS;
|
|
292
|
+
const signal = AbortSignal.any([AbortSignal.timeout(timeoutMs), request.signal]);
|
|
293
|
+
const store = this.options.store;
|
|
294
|
+
let result;
|
|
295
|
+
try {
|
|
296
|
+
result = await verifyLockfilePlan({
|
|
297
|
+
adapter,
|
|
298
|
+
support,
|
|
299
|
+
lockfile,
|
|
300
|
+
roots: entries,
|
|
301
|
+
cutoff: new Date(this.now().getTime() - minReleaseAgeDays * DAY_MS),
|
|
302
|
+
facts: {
|
|
303
|
+
getVersionFacts: (versions) => store.getVersionFacts(adapter.id, versions),
|
|
304
|
+
putVersionFacts: (facts) => store.putVersionFacts(adapter.id, facts),
|
|
305
|
+
},
|
|
306
|
+
upstream: this.versionUpstream,
|
|
307
|
+
streamUpstream: this.options.upstream,
|
|
308
|
+
audit: (versions, auditSignal) => auditVersions(adapter, versions, auditSignal),
|
|
309
|
+
hostAllowed: (url) => this.hostAllowed(adapter, url),
|
|
310
|
+
signal,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
catch (error) {
|
|
314
|
+
if (!signal.aborted)
|
|
315
|
+
throw error;
|
|
316
|
+
// Every fact read so far is stored, so a retry resumes from there.
|
|
317
|
+
throw new RegistryError(503, "wardby_plan_incomplete", request.signal.aborted
|
|
318
|
+
? "the client went away before the lockfile was verified"
|
|
319
|
+
: `the lockfile was not verified within ${Math.round(timeoutMs / 1000)} s (REGISTRY_PLAN_TIMEOUT_MS); what was verified is kept, so try again`);
|
|
320
|
+
}
|
|
321
|
+
await store.approveVersions(context.runId, adapter.id, result.approved);
|
|
322
|
+
// Only verdicts about the registry's name@version itself answer later
|
|
323
|
+
// downloads; a lockfile-dependent one (unreachable, an integrity or
|
|
324
|
+
// source the lockfile claims) must not block the same version reached
|
|
325
|
+
// another way, so those downloads take the usual path.
|
|
326
|
+
await store.refusePlanVersions(context.runId, adapter.id, result.refused
|
|
327
|
+
.filter((refusal) => refusal.intrinsic)
|
|
328
|
+
.map(({ name, version, code, reason, publishedAt }) => ({ name, version, code, reason, publishedAt })));
|
|
329
|
+
await this.recordPlanRefusals(context, adapter, result.refused);
|
|
330
|
+
return {
|
|
331
|
+
status: 200,
|
|
332
|
+
contentType: "application/json",
|
|
333
|
+
body: JSON.stringify({
|
|
334
|
+
approved: result.approved.length,
|
|
335
|
+
refused: result.refused.map(({ name, version, code, reason }) => ({ name, version, code, reason })),
|
|
336
|
+
}),
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
/** One refused row per plan refusal, within the run's refusal cap, and
|
|
340
|
+
* once per run however often the same lockfile is planned. */
|
|
341
|
+
async recordPlanRefusals(context, adapter, refused) {
|
|
342
|
+
const tally = this.tally(context);
|
|
343
|
+
tally.planRefusals ??= new Set();
|
|
344
|
+
for (const refusal of refused) {
|
|
345
|
+
const key = `${adapter.id}\0${refusal.name}@${refusal.version}\0${refusal.code}`;
|
|
346
|
+
if (tally.planRefusals.has(key))
|
|
347
|
+
continue;
|
|
348
|
+
tally.planRefusals.add(key);
|
|
349
|
+
await this.refuse(context, adapter, refusal.name.slice(0, MAX_RECORDED_NAME), refusal.code, {
|
|
350
|
+
version: refusal.version,
|
|
351
|
+
filename: PLAN_FETCH_FILENAME,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
async handle(request) {
|
|
356
|
+
try {
|
|
357
|
+
return await this.dispatch(request);
|
|
358
|
+
}
|
|
359
|
+
catch (error) {
|
|
360
|
+
logRegistryFailure("handle", request.ecosystem, error);
|
|
361
|
+
if (error instanceof RegistryError)
|
|
362
|
+
return errorResponse(error);
|
|
363
|
+
return errorResponse(new RegistryError(502, "wardby_upstream_error", "the package registry request failed"));
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
async dispatch(request) {
|
|
367
|
+
const adapter = this.options.adapters.get(request.ecosystem);
|
|
368
|
+
if (!adapter)
|
|
369
|
+
throw new RegistryError(404, "wardby_registry_unknown", `no registry named "${request.ecosystem}"`);
|
|
370
|
+
const context = await this.options.store.findRunByRegistryTokenHash(capabilityHash(request.token), this.now());
|
|
371
|
+
if (!context)
|
|
372
|
+
throw new RegistryError(401, "invalid_capability", "the registry token is not valid for a live run");
|
|
373
|
+
let route;
|
|
374
|
+
try {
|
|
375
|
+
route = adapter.route(request.method, request.subpath, new Headers());
|
|
376
|
+
}
|
|
377
|
+
catch (error) {
|
|
378
|
+
// A malformed path (bad percent-encoding, an invalid package name) is
|
|
379
|
+
// the client's error, recorded like any other refusal.
|
|
380
|
+
await this.refuse(context, adapter, request.subpath.slice(0, MAX_RECORDED_NAME), "wardby_bad_request");
|
|
381
|
+
throw error instanceof RegistryError
|
|
382
|
+
? error
|
|
383
|
+
: new RegistryError(400, "wardby_bad_request", "malformed registry path");
|
|
384
|
+
}
|
|
385
|
+
if (!route)
|
|
386
|
+
throw new RegistryError(404, "wardby_route_unknown", "unknown registry path");
|
|
387
|
+
const name = adapter.normalizeName(route.name);
|
|
388
|
+
if (route.kind === "download") {
|
|
389
|
+
// A version the run's lockfile plan approved is served as it stands:
|
|
390
|
+
// no allowlist lookup, no graph walk, integrity checked against the
|
|
391
|
+
// approved value.
|
|
392
|
+
const approved = await this.options.store.findApprovedVersion(context.runId, adapter.id, name, route.version);
|
|
393
|
+
const integrity = approved ? parseSri(approved.integrity) : null;
|
|
394
|
+
if (approved && integrity) {
|
|
395
|
+
const file = {
|
|
396
|
+
filename: route.filename,
|
|
397
|
+
version: route.version,
|
|
398
|
+
upstreamUrl: approved.downloadUrl,
|
|
399
|
+
integrity,
|
|
400
|
+
sizeBytes: null,
|
|
401
|
+
allowed: true,
|
|
402
|
+
publishedAt: null,
|
|
403
|
+
};
|
|
404
|
+
if (!this.hostAllowed(adapter, file.upstreamUrl)) {
|
|
405
|
+
await this.refuse(context, adapter, name, "wardby_upstream_host_not_allowed", file);
|
|
406
|
+
throw new RegistryError(502, "wardby_upstream_host_not_allowed", `"${file.filename}" is hosted outside the ${adapter.id} registry's upstreams`);
|
|
407
|
+
}
|
|
408
|
+
if (request.method === "HEAD")
|
|
409
|
+
return { status: 200, contentType: contentTypeOf(route), body: "" };
|
|
410
|
+
return this.download(adapter, context, name, route, file, request.signal);
|
|
411
|
+
}
|
|
412
|
+
// A version the plan definitively refused is answered from the plan,
|
|
413
|
+
// with its code and reason: no metadata, no graph walk.
|
|
414
|
+
const refused = approved
|
|
415
|
+
? null
|
|
416
|
+
: await this.options.store.findPlanRefusal(context.runId, adapter.id, name, route.version);
|
|
417
|
+
// A too-new refusal lapses once the version is old enough: the
|
|
418
|
+
// release-age cutoff moves with time.
|
|
419
|
+
const cutoff = this.now().getTime() - resolvePolicy(context.policy).minReleaseAgeDays * DAY_MS;
|
|
420
|
+
if (refused && (refused.publishedAt === null || refused.publishedAt.getTime() > cutoff)) {
|
|
421
|
+
const tally = this.tally(context);
|
|
422
|
+
tally.planAnswered ??= new Set();
|
|
423
|
+
const key = `${adapter.id}\0${name}@${route.version}\0${refused.code}`;
|
|
424
|
+
if (!tally.planAnswered.has(key)) {
|
|
425
|
+
tally.planAnswered.add(key);
|
|
426
|
+
await this.refuse(context, adapter, name, refused.code, { version: route.version, filename: route.filename });
|
|
427
|
+
}
|
|
428
|
+
throw new RegistryError(403, refused.code, `"${name}" ${route.version} was refused by this run's lockfile verification: ${refused.reason}`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
const root = await this.authorize(adapter, context, name);
|
|
432
|
+
let meta;
|
|
433
|
+
try {
|
|
434
|
+
// Only a metadata request renders a document; a download, the version
|
|
435
|
+
// filter and the graph walk need only the trimmed versions.
|
|
436
|
+
meta = await this.metadata(adapter, name, route.kind === "metadata");
|
|
437
|
+
}
|
|
438
|
+
catch (error) {
|
|
439
|
+
// An oversized document is a refusal of this package, not a transient
|
|
440
|
+
// upstream failure, so it gets a row like every other refusal.
|
|
441
|
+
if (error instanceof RegistryError && error.code === "wardby_metadata_too_large") {
|
|
442
|
+
await this.refuse(context, adapter, name, "wardby_metadata_too_large");
|
|
443
|
+
}
|
|
444
|
+
throw error;
|
|
445
|
+
}
|
|
446
|
+
let keep;
|
|
447
|
+
let keptFiles;
|
|
448
|
+
try {
|
|
449
|
+
({ keep, keptFiles } = await this.keptVersions(adapter, context, meta, root));
|
|
450
|
+
}
|
|
451
|
+
catch (error) {
|
|
452
|
+
// Every refusal gets a row, including one the run never chose: the
|
|
453
|
+
// audit being unreachable (fail-closed) still stops this request.
|
|
454
|
+
if (error instanceof RegistryError && error.code === "wardby_audit_unavailable") {
|
|
455
|
+
await this.refuse(context, adapter, name, "wardby_audit_unavailable");
|
|
456
|
+
}
|
|
457
|
+
throw error;
|
|
458
|
+
}
|
|
459
|
+
if (route.kind === "metadata") {
|
|
460
|
+
if (keep.size === 0) {
|
|
461
|
+
await this.refuse(context, adapter, name, "wardby_version_filtered");
|
|
462
|
+
throw new RegistryError(404, "wardby_version_filtered", `every matching version of "${name}" is outside the allowlisted range, newer than the release-age limit, or has a high-severity advisory`);
|
|
463
|
+
}
|
|
464
|
+
await this.options.store.addAllowances(context.runId, adapter.id, keptDependencies(adapter, meta, keep));
|
|
465
|
+
const document = adapter.renderMetadata(meta, keep, keptFiles, `${this.options.proxyBase}${adapter.id}/`);
|
|
466
|
+
return { status: 200, contentType: document.contentType, body: document.body };
|
|
467
|
+
}
|
|
468
|
+
const file = route.kind === "download"
|
|
469
|
+
? adapter.resolveDownload(route, meta)
|
|
470
|
+
: (adapter.resolveFileMetadata?.(route, meta) ?? null);
|
|
471
|
+
// The release age applies per file: a file newer than the cutoff is
|
|
472
|
+
// refused exactly like a filtered version, even in a kept release. A
|
|
473
|
+
// PEP 658 metadata file is judged by the wheel it describes
|
|
474
|
+
// (route.filename), since keptFiles holds wheel filenames.
|
|
475
|
+
const agedFile = route.kind === "file-metadata" ? route.filename : file?.filename;
|
|
476
|
+
if (!file || !keep.has(file.version) || !agedFile || !keptFiles.has(agedFile)) {
|
|
477
|
+
await this.refuse(context, adapter, name, "wardby_version_filtered", file ?? undefined);
|
|
478
|
+
const versionSuffix = route.kind === "download" ? ` ${route.version}` : "";
|
|
479
|
+
throw new RegistryError(404, "wardby_version_filtered", `"${name}"${versionSuffix} is not available to this run`);
|
|
480
|
+
}
|
|
481
|
+
if (!file.allowed) {
|
|
482
|
+
await this.refuse(context, adapter, name, "wardby_file_not_allowed", file);
|
|
483
|
+
throw new RegistryError(403, "wardby_file_not_allowed", `"${file.filename}" is a source distribution; only wheels are allowed`);
|
|
484
|
+
}
|
|
485
|
+
// Each adapter may reach only its own upstream hosts, not every host
|
|
486
|
+
// the pinned fetch allows for the registry as a whole.
|
|
487
|
+
if (!this.hostAllowed(adapter, file.upstreamUrl)) {
|
|
488
|
+
await this.refuse(context, adapter, name, "wardby_upstream_host_not_allowed", file);
|
|
489
|
+
throw new RegistryError(502, "wardby_upstream_host_not_allowed", `"${file.filename}" is hosted outside the ${adapter.id} registry's upstreams`);
|
|
490
|
+
}
|
|
491
|
+
// HEAD answers from metadata alone: no upstream download, no
|
|
492
|
+
// reservation, nothing recorded as served.
|
|
493
|
+
if (request.method === "HEAD")
|
|
494
|
+
return { status: 200, contentType: contentTypeOf(route), body: "" };
|
|
495
|
+
return this.download(adapter, context, name, route, file, request.signal);
|
|
496
|
+
}
|
|
497
|
+
hostAllowed(adapter, url) {
|
|
498
|
+
try {
|
|
499
|
+
const parsed = new URL(url);
|
|
500
|
+
return parsed.protocol === "https:" && adapter.upstreamHosts.includes(parsed.hostname.toLowerCase());
|
|
501
|
+
}
|
|
502
|
+
catch {
|
|
503
|
+
return false;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
/** Served usage for the run, seeded once from the store and then kept
|
|
507
|
+
* current in-process as downloads complete. */
|
|
508
|
+
async servedUsage(context) {
|
|
509
|
+
const tally = this.tally(context);
|
|
510
|
+
if (!tally.served) {
|
|
511
|
+
const recorded = await this.options.store.usage(context.runId);
|
|
512
|
+
tally.served ??= { files: recorded.files, bytes: recorded.bytes };
|
|
513
|
+
}
|
|
514
|
+
return tally.served;
|
|
515
|
+
}
|
|
516
|
+
async authorize(adapter, context, name) {
|
|
517
|
+
const entries = parseAllowlist(context.allowlist, this.options.adapters).get(adapter.id) ?? [];
|
|
518
|
+
const root = matchRoot(entries, name);
|
|
519
|
+
if (root || (await this.options.store.isAllowedDependency(context.runId, adapter.id, name)))
|
|
520
|
+
return root;
|
|
521
|
+
// Not a root and not yet an allowance: the name may still be in the
|
|
522
|
+
// approved graph, unrecorded only because the client never asked for
|
|
523
|
+
// its parent's metadata (a lockfile install requests tarballs
|
|
524
|
+
// directly). Resolve the graph before refusing.
|
|
525
|
+
const graph = await this.resolveInGraph(adapter, context, entries, name);
|
|
526
|
+
if (graph === "found")
|
|
527
|
+
return undefined;
|
|
528
|
+
if (typeof graph === "object" && "cutShort" in graph) {
|
|
529
|
+
if (graph.cutShort === "limit") {
|
|
530
|
+
// The bound is permanent for the rest of the run, so a retry can
|
|
531
|
+
// never succeed: answer with a definitive 4xx (npm does not retry
|
|
532
|
+
// it) rather than a 503 that a client or agent would retry forever.
|
|
533
|
+
// Still not "not allowed": the name was not proven absent.
|
|
534
|
+
await this.refuse(context, adapter, name, "wardby_graph_limit");
|
|
535
|
+
throw new RegistryError(403, "wardby_graph_limit", `"${name}" was not reached before this agent's ${adapter.id} dependency graph walk hit its package limit (REGISTRY_MAX_GRAPH_PACKAGES) for this run; retrying will not help: allowlist the package directly, or ask the operator to raise the limit`);
|
|
536
|
+
}
|
|
537
|
+
// Not proven absent, so never "not allowed": a timed-out walk resumes
|
|
538
|
+
// where it stopped on the next miss, so a retry (npm retries a 5xx on
|
|
539
|
+
// its own) can find the name.
|
|
540
|
+
await this.refuse(context, adapter, name, "wardby_graph_incomplete");
|
|
541
|
+
throw new RegistryError(503, "wardby_graph_incomplete", `"${name}" was not reached before this agent's ${adapter.id} dependency graph walk was cut short by its time limit (REGISTRY_GRAPH_TIMEOUT_MS); the walk continues where it stopped: try again`);
|
|
542
|
+
}
|
|
543
|
+
if (typeof graph === "object") {
|
|
544
|
+
// Some part of the graph could not be read, so whether the name is in
|
|
545
|
+
// it is unknown: say so honestly rather than "not allowed".
|
|
546
|
+
const [status, code, source] = graph.unavailable === "audit"
|
|
547
|
+
? [503, "wardby_audit_unavailable", "the OSV vulnerability audit"]
|
|
548
|
+
: [502, "wardby_upstream_error", `the ${adapter.id} registry`];
|
|
549
|
+
await this.refuse(context, adapter, name, code);
|
|
550
|
+
throw new RegistryError(status, code, `"${name}" could not be checked against this agent's approved ${adapter.id} dependency graph because ${source} was unavailable while resolving it; try again`);
|
|
551
|
+
}
|
|
552
|
+
await this.refuse(context, adapter, name, "wardby_package_not_allowed");
|
|
553
|
+
const hint = entries.length === 0
|
|
554
|
+
? `this agent has no ${adapter.id} package allowlist`
|
|
555
|
+
: `"${name}" is not on this agent's ${adapter.id} package allowlist or in its dependency graph`;
|
|
556
|
+
throw new RegistryError(403, "wardby_package_not_allowed", hint);
|
|
557
|
+
}
|
|
558
|
+
/** Whether `name` is in the run's approved graph for this ecosystem:
|
|
559
|
+
* the allowlist's exact roots plus the dependencies of every kept
|
|
560
|
+
* version (the same filter the metadata path applies: root range,
|
|
561
|
+
* release age, advisories), transitively. Each newly found name is
|
|
562
|
+
* recorded as an allowance as the walk goes, exactly as if its
|
|
563
|
+
* parent's metadata had been served. A scoped wildcard root cannot be
|
|
564
|
+
* enumerated, so it is never a starting point. */
|
|
565
|
+
async resolveInGraph(adapter, context, entries, name) {
|
|
566
|
+
if (!adapter.dependenciesInMetadata)
|
|
567
|
+
return "absent";
|
|
568
|
+
const tally = this.tally(context);
|
|
569
|
+
tally.graphs ??= new Map();
|
|
570
|
+
let walk = tally.graphs.get(adapter.id);
|
|
571
|
+
if (!walk) {
|
|
572
|
+
const roots = [...new Set(entries.filter((entry) => !entry.wildcard).map((entry) => entry.name))];
|
|
573
|
+
const edges = roots.map((root) => ({ name: root, range: "*" }));
|
|
574
|
+
walk = {
|
|
575
|
+
found: new Set(roots),
|
|
576
|
+
queue: edges,
|
|
577
|
+
seen: new Set(edges.map(edgeKey)),
|
|
578
|
+
expandedVersions: new Map(),
|
|
579
|
+
exhausted: false,
|
|
580
|
+
attempts: new Map(),
|
|
581
|
+
failed: new Map(),
|
|
582
|
+
};
|
|
583
|
+
tally.graphs.set(adapter.id, walk);
|
|
584
|
+
}
|
|
585
|
+
const current = walk;
|
|
586
|
+
let started = false;
|
|
587
|
+
for (;;) {
|
|
588
|
+
if (current.found.has(name))
|
|
589
|
+
return "found";
|
|
590
|
+
if (current.exhausted)
|
|
591
|
+
return { cutShort: "limit" };
|
|
592
|
+
// Single-flight: a miss while a walk is running waits for it rather
|
|
593
|
+
// than starting a second one, then re-checks (that walk may have
|
|
594
|
+
// stopped on finding a different name). Checked before the queue: a
|
|
595
|
+
// running walk has taken its current batch off the queue, so an
|
|
596
|
+
// empty queue alone does not mean the graph is complete. Each miss
|
|
597
|
+
// starts at most one walk of its own, so it spends at most one
|
|
598
|
+
// round of retries on failed nodes.
|
|
599
|
+
if (!current.running) {
|
|
600
|
+
const retriable = [...current.failed.keys()].some((key) => (current.attempts.get(key) ?? 0) < MAX_GRAPH_NODE_ATTEMPTS);
|
|
601
|
+
if (started || (current.queue.length === 0 && !retriable))
|
|
602
|
+
return this.graphMiss(current);
|
|
603
|
+
started = true;
|
|
604
|
+
current.running = this.walkGraph(adapter, context, entries, current, name).finally(() => {
|
|
605
|
+
current.running = undefined;
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
const end = await current.running;
|
|
609
|
+
if (end === "timeout" && !current.found.has(name))
|
|
610
|
+
return { cutShort: "timeout" };
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
/** The answer for a name the walk did not find: unavailable while any
|
|
614
|
+
* node could not be expanded (the audit, if any failure was the
|
|
615
|
+
* audit's), otherwise absent. */
|
|
616
|
+
graphMiss(walk) {
|
|
617
|
+
if (walk.failed.size === 0)
|
|
618
|
+
return "absent";
|
|
619
|
+
return { unavailable: [...walk.failed.values()].some((entry) => entry.cause === "audit") ? "audit" : "upstream" };
|
|
620
|
+
}
|
|
621
|
+
async walkGraph(adapter, context, entries, walk, target) {
|
|
622
|
+
const maxPackages = this.options.maxGraphPackages ?? DEFAULT_MAX_GRAPH_PACKAGES;
|
|
623
|
+
const deadline = this.now().getTime() + (this.options.graphTimeoutMs ?? DEFAULT_GRAPH_TIMEOUT_MS);
|
|
624
|
+
// Parked failures with attempts left go first: a later miss is this
|
|
625
|
+
// walk's retry of them.
|
|
626
|
+
const retry = [...walk.failed.entries()].filter(([key]) => (walk.attempts.get(key) ?? 0) < MAX_GRAPH_NODE_ATTEMPTS);
|
|
627
|
+
for (const [key] of retry)
|
|
628
|
+
walk.failed.delete(key);
|
|
629
|
+
walk.queue.unshift(...retry.map(([, entry]) => entry.edge));
|
|
630
|
+
const attemptsThisWalk = new Map();
|
|
631
|
+
// Per-walk memo: a walk lasts at most the graph timeout, well inside
|
|
632
|
+
// the metadata and audit cache lifetimes, so this changes no decision.
|
|
633
|
+
// A failed computation is not memoized, so its retry really retries.
|
|
634
|
+
const kept = new Map();
|
|
635
|
+
while (walk.queue.length > 0) {
|
|
636
|
+
if (walk.found.has(target))
|
|
637
|
+
return "found";
|
|
638
|
+
if (walk.released)
|
|
639
|
+
return "timeout";
|
|
640
|
+
const remaining = deadline - this.now().getTime();
|
|
641
|
+
if (remaining <= 0)
|
|
642
|
+
return "timeout";
|
|
643
|
+
const room = maxPackages - walk.expandedVersions.size;
|
|
644
|
+
if (room <= 0) {
|
|
645
|
+
walk.exhausted = true;
|
|
646
|
+
return "limit";
|
|
647
|
+
}
|
|
648
|
+
// Each edge adds at most one new package, so a batch no larger than
|
|
649
|
+
// the room left can never overshoot the bound.
|
|
650
|
+
const batch = walk.queue.splice(0, Math.min(GRAPH_WALK_CONCURRENCY, room));
|
|
651
|
+
const expansions = Promise.all(batch.map((edge) => this.graphNode(adapter, context, entries, edge, kept)));
|
|
652
|
+
let timer;
|
|
653
|
+
const timedOut = new Promise((resolve) => {
|
|
654
|
+
timer = setTimeout(() => resolve("timeout"), remaining);
|
|
655
|
+
});
|
|
656
|
+
const results = await Promise.race([expansions, timedOut]).finally(() => clearTimeout(timer));
|
|
657
|
+
if (results === "timeout") {
|
|
658
|
+
// Not expanded: put the batch back so a later miss retries it (its
|
|
659
|
+
// metadata is likely cached by then).
|
|
660
|
+
walk.queue.unshift(...batch);
|
|
661
|
+
return "timeout";
|
|
662
|
+
}
|
|
663
|
+
const expanded = [];
|
|
664
|
+
batch.forEach((edge, index) => {
|
|
665
|
+
const result = results[index];
|
|
666
|
+
if (result.ok) {
|
|
667
|
+
expanded.push({ edge, selected: result.selected });
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
// A transient failure is never counted as expanded: the edge is
|
|
671
|
+
// retried (once more in this walk, then by later misses) instead
|
|
672
|
+
// of silently closing its whole subtree for the run.
|
|
673
|
+
const key = edgeKey(edge);
|
|
674
|
+
walk.attempts.set(key, (walk.attempts.get(key) ?? 0) + 1);
|
|
675
|
+
attemptsThisWalk.set(key, (attemptsThisWalk.get(key) ?? 0) + 1);
|
|
676
|
+
if ((attemptsThisWalk.get(key) ?? 0) < GRAPH_NODE_ATTEMPTS_PER_WALK &&
|
|
677
|
+
(walk.attempts.get(key) ?? 0) < MAX_GRAPH_NODE_ATTEMPTS)
|
|
678
|
+
walk.queue.push(edge);
|
|
679
|
+
else
|
|
680
|
+
walk.failed.set(key, { edge, cause: result.cause });
|
|
681
|
+
});
|
|
682
|
+
// A package joins the graph (and gets its allowance) only when some
|
|
683
|
+
// kept version of it satisfies a range that reached it. Recorded
|
|
684
|
+
// before it counts as found, so `found` never holds a name the store
|
|
685
|
+
// would refuse.
|
|
686
|
+
const discovered = [
|
|
687
|
+
...new Set(expanded
|
|
688
|
+
.filter(({ edge, selected }) => selected.length > 0 && !walk.found.has(edge.name))
|
|
689
|
+
.map(({ edge }) => edge.name)),
|
|
690
|
+
];
|
|
691
|
+
try {
|
|
692
|
+
if (discovered.length > 0)
|
|
693
|
+
await this.options.store.addAllowances(context.runId, adapter.id, discovered);
|
|
694
|
+
}
|
|
695
|
+
catch (error) {
|
|
696
|
+
// The store write failed: put the expanded edges back so a later
|
|
697
|
+
// miss re-expands them, rather than leaving the graph silently short.
|
|
698
|
+
walk.queue.unshift(...expanded.map(({ edge }) => edge));
|
|
699
|
+
throw error;
|
|
700
|
+
}
|
|
701
|
+
for (const name of discovered)
|
|
702
|
+
walk.found.add(name);
|
|
703
|
+
for (const { edge, selected } of expanded) {
|
|
704
|
+
let versions = walk.expandedVersions.get(edge.name);
|
|
705
|
+
if (!versions)
|
|
706
|
+
walk.expandedVersions.set(edge.name, (versions = new Set()));
|
|
707
|
+
for (const { version, dependencies } of selected) {
|
|
708
|
+
if (versions.has(version))
|
|
709
|
+
continue;
|
|
710
|
+
versions.add(version);
|
|
711
|
+
for (const dependency of dependencies) {
|
|
712
|
+
const key = edgeKey(dependency);
|
|
713
|
+
if (walk.seen.has(key))
|
|
714
|
+
continue;
|
|
715
|
+
walk.seen.add(key);
|
|
716
|
+
walk.queue.push(dependency);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
return walk.found.has(target) ? "found" : "complete";
|
|
722
|
+
}
|
|
723
|
+
/** The kept versions of one package inside the edge's range, and each
|
|
724
|
+
* one's declared dependencies, through the same metadata cache and
|
|
725
|
+
* `keptVersions` filter the metadata path uses (a root's allowlist range
|
|
726
|
+
* applies there), within the process-wide walk concurrency cap. `"*"`
|
|
727
|
+
* selects every kept version; a range no kept version satisfies selects
|
|
728
|
+
* none. A package npm doesn't have, or whose metadata is too large,
|
|
729
|
+
* definitively selects nothing; any other failure (upstream error or
|
|
730
|
+
* timeout, audit unavailable) is transient and reported for retry. */
|
|
731
|
+
graphNode(adapter, context, entries, edge, memo) {
|
|
732
|
+
return this.walkSlots.run(async () => {
|
|
733
|
+
try {
|
|
734
|
+
let load = memo.get(edge.name);
|
|
735
|
+
if (!load) {
|
|
736
|
+
load = (async () => {
|
|
737
|
+
const meta = await this.metadata(adapter, edge.name, false);
|
|
738
|
+
const { keep } = await this.keptVersions(adapter, context, meta, matchRoot(entries, edge.name));
|
|
739
|
+
return new Map([...keep].map((version) => [version, versionDependencies(adapter, meta, version)]));
|
|
740
|
+
})();
|
|
741
|
+
memo.set(edge.name, load);
|
|
742
|
+
load.catch(() => memo.delete(edge.name));
|
|
743
|
+
}
|
|
744
|
+
const keep = await load;
|
|
745
|
+
const inRange = (version) => {
|
|
746
|
+
if (edge.range === "*")
|
|
747
|
+
return true;
|
|
748
|
+
try {
|
|
749
|
+
return adapter.satisfies(version, edge.range);
|
|
750
|
+
}
|
|
751
|
+
catch {
|
|
752
|
+
return false;
|
|
753
|
+
}
|
|
754
|
+
};
|
|
755
|
+
const selected = [];
|
|
756
|
+
for (const [version, dependencies] of keep)
|
|
757
|
+
if (inRange(version))
|
|
758
|
+
selected.push({ version, dependencies });
|
|
759
|
+
return { ok: true, selected };
|
|
760
|
+
}
|
|
761
|
+
catch (error) {
|
|
762
|
+
if (error instanceof RegistryError && DEFINITIVE_NODE_ERRORS.has(error.code))
|
|
763
|
+
return { ok: true, selected: [] };
|
|
764
|
+
const audit = error instanceof RegistryError && error.code === "wardby_audit_unavailable";
|
|
765
|
+
return { ok: false, cause: audit ? "audit" : "upstream" };
|
|
766
|
+
}
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
/** A package's metadata, cached. `render` asks for the form
|
|
770
|
+
* `renderMetadata` can serve; a cached entry fetched without it (by the
|
|
771
|
+
* graph walk or a download) is fetched again with it, and replaces it. */
|
|
772
|
+
async metadata(adapter, name, render) {
|
|
773
|
+
const key = `${adapter.id}:${name}`;
|
|
774
|
+
const now = this.now().getTime();
|
|
775
|
+
const cached = this.metadataCache.get(key);
|
|
776
|
+
if (cached) {
|
|
777
|
+
this.dropCached(key);
|
|
778
|
+
if (cached.expires > now) {
|
|
779
|
+
this.putCached(key, cached); // most recently used
|
|
780
|
+
if (!render || cached.meta.raw !== undefined)
|
|
781
|
+
return cached.meta;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
// Single-flight per need: a render load also serves a walk that asks
|
|
785
|
+
// meanwhile, but not the other way round.
|
|
786
|
+
const renderLoad = this.metadataLoads.get(`${key}\0render`);
|
|
787
|
+
if (renderLoad)
|
|
788
|
+
return renderLoad;
|
|
789
|
+
const loadKey = render ? `${key}\0render` : key;
|
|
790
|
+
const pending = this.metadataLoads.get(loadKey);
|
|
791
|
+
if (pending)
|
|
792
|
+
return pending;
|
|
793
|
+
const load = adapter
|
|
794
|
+
.fetchMetadata(name, this.metadataUpstream, { render })
|
|
795
|
+
.then((meta) => {
|
|
796
|
+
this.cacheMetadata(key, meta);
|
|
797
|
+
return meta;
|
|
798
|
+
})
|
|
799
|
+
.finally(() => this.metadataLoads.delete(loadKey));
|
|
800
|
+
this.metadataLoads.set(loadKey, load);
|
|
801
|
+
return load;
|
|
802
|
+
}
|
|
803
|
+
dropCached(key) {
|
|
804
|
+
const entry = this.metadataCache.get(key);
|
|
805
|
+
if (!entry)
|
|
806
|
+
return;
|
|
807
|
+
this.metadataCache.delete(key);
|
|
808
|
+
this.metadataCacheTotal -= entry.bytes;
|
|
809
|
+
}
|
|
810
|
+
putCached(key, entry) {
|
|
811
|
+
this.metadataCache.set(key, entry);
|
|
812
|
+
this.metadataCacheTotal += entry.bytes;
|
|
813
|
+
}
|
|
814
|
+
/** Caches `meta`, least recently used evicted first, within both the entry
|
|
815
|
+
* and the byte budget. An entry larger than the whole byte budget is not
|
|
816
|
+
* cached at all. A live entry that can render is not replaced by one that
|
|
817
|
+
* cannot. */
|
|
818
|
+
cacheMetadata(key, meta) {
|
|
819
|
+
const now = this.now().getTime();
|
|
820
|
+
for (const [cachedKey, entry] of this.metadataCache) {
|
|
821
|
+
if (entry.expires <= now)
|
|
822
|
+
this.dropCached(cachedKey);
|
|
823
|
+
}
|
|
824
|
+
const existing = this.metadataCache.get(key);
|
|
825
|
+
if (existing && meta.raw === undefined && existing.meta.raw !== undefined)
|
|
826
|
+
return;
|
|
827
|
+
this.dropCached(key);
|
|
828
|
+
const bytes = metadataBytes(meta);
|
|
829
|
+
if (bytes > this.metadataCacheBytes)
|
|
830
|
+
return;
|
|
831
|
+
while (this.metadataCache.size > 0 &&
|
|
832
|
+
(this.metadataCache.size >= this.metadataCacheEntries ||
|
|
833
|
+
this.metadataCacheTotal + bytes > this.metadataCacheBytes)) {
|
|
834
|
+
this.dropCached(this.metadataCache.keys().next().value);
|
|
835
|
+
}
|
|
836
|
+
this.putCached(key, { expires: now + this.metadataTtlMs, bytes, meta });
|
|
837
|
+
}
|
|
838
|
+
async keptVersions(adapter, context, meta, root) {
|
|
839
|
+
const { minReleaseAgeDays } = resolvePolicy(context.policy);
|
|
840
|
+
const cutoff = this.now().getTime() - minReleaseAgeDays * DAY_MS;
|
|
841
|
+
const advisories = await this.options.audit.audit(adapter, meta.name);
|
|
842
|
+
const keep = new Set();
|
|
843
|
+
const keptFiles = new Set();
|
|
844
|
+
for (const info of meta.versions.values()) {
|
|
845
|
+
if (root?.range && !adapter.satisfies(info.version, root.range))
|
|
846
|
+
continue;
|
|
847
|
+
// Per-file release age: a file with an unknown or too-recent publish
|
|
848
|
+
// time is never listed or served. A version survives if any of its
|
|
849
|
+
// files does.
|
|
850
|
+
const oldEnough = info.files.filter((file) => file.publishedAt && file.publishedAt.getTime() <= cutoff);
|
|
851
|
+
if (oldEnough.length === 0)
|
|
852
|
+
continue;
|
|
853
|
+
if (advisories.withheld(info.version).length > 0)
|
|
854
|
+
continue;
|
|
855
|
+
keep.add(info.version);
|
|
856
|
+
for (const file of oldEnough)
|
|
857
|
+
keptFiles.add(file.filename);
|
|
858
|
+
}
|
|
859
|
+
return { keep, keptFiles };
|
|
860
|
+
}
|
|
861
|
+
tally(context) {
|
|
862
|
+
const now = this.now().getTime();
|
|
863
|
+
for (const [runId, stale] of this.tallies)
|
|
864
|
+
if (stale.deadline <= now)
|
|
865
|
+
this.releaseRun(runId);
|
|
866
|
+
let tally = this.tallies.get(context.runId);
|
|
867
|
+
if (!tally) {
|
|
868
|
+
tally = { deadline: context.deadlineAt.getTime() };
|
|
869
|
+
this.tallies.set(context.runId, tally);
|
|
870
|
+
this.scheduleRelease(context.runId, tally);
|
|
871
|
+
}
|
|
872
|
+
return tally;
|
|
873
|
+
}
|
|
874
|
+
/** Releases a run's in-process state when its deadline passes, whether or
|
|
875
|
+
* not any other request ever arrives: the timer does not keep the process
|
|
876
|
+
* alive. */
|
|
877
|
+
scheduleRelease(runId, tally) {
|
|
878
|
+
const delay = Math.min(Math.max(0, tally.deadline - this.now().getTime()), MAX_TIMER_MS);
|
|
879
|
+
tally.timer = setTimeout(() => {
|
|
880
|
+
if (this.tallies.get(runId) !== tally)
|
|
881
|
+
return;
|
|
882
|
+
if (tally.deadline <= this.now().getTime())
|
|
883
|
+
this.releaseRun(runId);
|
|
884
|
+
else
|
|
885
|
+
this.scheduleRelease(runId, tally);
|
|
886
|
+
}, delay);
|
|
887
|
+
tally.timer.unref?.();
|
|
888
|
+
}
|
|
889
|
+
/** Drops a run's tally and graph walks, and stops any walk still running. */
|
|
890
|
+
releaseRun(runId) {
|
|
891
|
+
const tally = this.tallies.get(runId);
|
|
892
|
+
if (!tally)
|
|
893
|
+
return;
|
|
894
|
+
this.tallies.delete(runId);
|
|
895
|
+
clearTimeout(tally.timer);
|
|
896
|
+
for (const walk of tally.graphs?.values() ?? [])
|
|
897
|
+
walk.released = true;
|
|
898
|
+
tally.graphs?.clear();
|
|
899
|
+
}
|
|
900
|
+
/** Releases every run's in-process state (timers included), e.g. when the
|
|
901
|
+
* server shuts down. */
|
|
902
|
+
close() {
|
|
903
|
+
for (const runId of [...this.tallies.keys()])
|
|
904
|
+
this.releaseRun(runId);
|
|
905
|
+
}
|
|
906
|
+
/** Records a refused row, at most `maxRefusalRecords` per run so a worker
|
|
907
|
+
* hammering refused names cannot grow the table (or get_run and the PR
|
|
908
|
+
* body) without bound. Callers still return the refusal's error. */
|
|
909
|
+
async refuse(context, adapter, name, reason, file) {
|
|
910
|
+
const tally = this.tally(context);
|
|
911
|
+
if (tally.refused === undefined) {
|
|
912
|
+
const recorded = await this.options.store.refusalCount(context.runId);
|
|
913
|
+
tally.refused ??= recorded; // a concurrent first refusal may have seeded it already
|
|
914
|
+
}
|
|
915
|
+
if (tally.refused >= (this.options.limits.maxRefusalRecords ?? DEFAULT_MAX_REFUSAL_RECORDS))
|
|
916
|
+
return;
|
|
917
|
+
tally.refused += 1;
|
|
918
|
+
await this.options.store.recordFetch({
|
|
919
|
+
runId: context.runId,
|
|
920
|
+
ecosystem: adapter.id,
|
|
921
|
+
name,
|
|
922
|
+
version: file?.version,
|
|
923
|
+
filename: file?.filename,
|
|
924
|
+
outcome: "refused",
|
|
925
|
+
reason,
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
inFlightUsage(runId) {
|
|
929
|
+
return this.inFlight.get(runId) ?? { files: 0, bytes: 0 };
|
|
930
|
+
}
|
|
931
|
+
/** Reserve one file slot for a download that is about to start, and its
|
|
932
|
+
* declared size when known. An unknown size (`null`) reserves no bytes
|
|
933
|
+
* up front; those are added incrementally as they stream, via
|
|
934
|
+
* `trackStreamedBytes`. */
|
|
935
|
+
reserveDownload(runId, sizeBytes) {
|
|
936
|
+
const slot = this.inFlight.get(runId) ?? { files: 0, bytes: 0 };
|
|
937
|
+
slot.files += 1;
|
|
938
|
+
if (sizeBytes !== null)
|
|
939
|
+
slot.bytes += sizeBytes;
|
|
940
|
+
this.inFlight.set(runId, slot);
|
|
941
|
+
}
|
|
942
|
+
/** Only called for downloads whose declared size was unknown at
|
|
943
|
+
* reservation time; adds each chunk's bytes to the in-flight total as it
|
|
944
|
+
* arrives, so the shared budget check sees them immediately. */
|
|
945
|
+
trackStreamedBytes(runId, delta) {
|
|
946
|
+
const slot = this.inFlight.get(runId);
|
|
947
|
+
if (slot)
|
|
948
|
+
slot.bytes += delta;
|
|
949
|
+
}
|
|
950
|
+
/** Release a download's reservation on every exit path (served, failed,
|
|
951
|
+
* cancelled, or refused before it ever reserved streaming). Undoes
|
|
952
|
+
* exactly what was reserved: the declared size when known, or the bytes
|
|
953
|
+
* actually streamed when it was not. */
|
|
954
|
+
releaseDownload(runId, sizeBytes, streamedBytes) {
|
|
955
|
+
const slot = this.inFlight.get(runId);
|
|
956
|
+
if (!slot)
|
|
957
|
+
return;
|
|
958
|
+
slot.files = Math.max(0, slot.files - 1);
|
|
959
|
+
slot.bytes = Math.max(0, slot.bytes - (sizeBytes ?? streamedBytes));
|
|
960
|
+
if (slot.files === 0 && slot.bytes === 0)
|
|
961
|
+
this.inFlight.delete(runId);
|
|
962
|
+
else
|
|
963
|
+
this.inFlight.set(runId, slot);
|
|
964
|
+
}
|
|
965
|
+
async download(adapter, context, name, route, file, signal) {
|
|
966
|
+
const { limits, store } = this.options;
|
|
967
|
+
const runId = context.runId;
|
|
968
|
+
const served = await this.servedUsage(context);
|
|
969
|
+
const flight = this.inFlightUsage(runId);
|
|
970
|
+
if (served.files + flight.files >= limits.maxFiles) {
|
|
971
|
+
await this.refuse(context, adapter, name, "wardby_package_limit", file);
|
|
972
|
+
throw new RegistryError(429, "wardby_package_limit", "this run has reached its package file limit");
|
|
973
|
+
}
|
|
974
|
+
if (file.sizeBytes !== null && file.sizeBytes > limits.maxFileBytes) {
|
|
975
|
+
await this.refuse(context, adapter, name, "wardby_package_too_large", file);
|
|
976
|
+
throw new RegistryError(413, "wardby_package_too_large", `"${file.filename}" exceeds the per-file size limit`);
|
|
977
|
+
}
|
|
978
|
+
// Reserve this download's slot before any further await, so a
|
|
979
|
+
// concurrent download for the same run (e.g. npm installing several
|
|
980
|
+
// dependencies in parallel) sees it immediately rather than racing past
|
|
981
|
+
// the same `usage()` snapshot. Released on every exit path below.
|
|
982
|
+
this.reserveDownload(runId, file.sizeBytes);
|
|
983
|
+
let bytes = 0;
|
|
984
|
+
let released = false;
|
|
985
|
+
const release = () => {
|
|
986
|
+
if (released)
|
|
987
|
+
return;
|
|
988
|
+
released = true;
|
|
989
|
+
this.releaseDownload(runId, file.sizeBytes, bytes);
|
|
990
|
+
};
|
|
991
|
+
let upstream;
|
|
992
|
+
try {
|
|
993
|
+
upstream = await this.options.upstream(file.upstreamUrl, { signal });
|
|
994
|
+
}
|
|
995
|
+
catch (error) {
|
|
996
|
+
release();
|
|
997
|
+
throw error;
|
|
998
|
+
}
|
|
999
|
+
if (!upstream.ok || !upstream.body) {
|
|
1000
|
+
release();
|
|
1001
|
+
throw new RegistryError(502, "wardby_upstream_error", `the registry returned ${upstream.status}`);
|
|
1002
|
+
}
|
|
1003
|
+
const hash = file.integrity ? createHash(file.integrity.algorithm) : null;
|
|
1004
|
+
const buffered = [];
|
|
1005
|
+
let idle;
|
|
1006
|
+
// Guards the race the controller flagged: the idle timer's fail() and a
|
|
1007
|
+
// pending reader.read() can both try to act on the same controller. Once
|
|
1008
|
+
// either the stream has failed or it has recorded a served/refused row,
|
|
1009
|
+
// `settled` is true and every later exit path becomes a no-op, so at
|
|
1010
|
+
// most one refused/served row is ever recorded and enqueue/close/error
|
|
1011
|
+
// are never called on an already-settled controller.
|
|
1012
|
+
let settled = false;
|
|
1013
|
+
// Cast: undici's ReadableStream defaults its element type to `any`, and
|
|
1014
|
+
// `Response.body`'s type carries that through. The bytes are always
|
|
1015
|
+
// Uint8Array chunks in practice, so name the type explicitly rather than
|
|
1016
|
+
// let `any` leak into every read below.
|
|
1017
|
+
const reader = upstream.body.getReader();
|
|
1018
|
+
const clearIdle = () => {
|
|
1019
|
+
clearTimeout(idle);
|
|
1020
|
+
idle = undefined;
|
|
1021
|
+
};
|
|
1022
|
+
const fail = async (controller, reason) => {
|
|
1023
|
+
if (settled)
|
|
1024
|
+
return;
|
|
1025
|
+
settled = true;
|
|
1026
|
+
clearIdle();
|
|
1027
|
+
release();
|
|
1028
|
+
await reader.cancel().catch(() => undefined);
|
|
1029
|
+
try {
|
|
1030
|
+
await this.refuse(context, adapter, name, reason, file);
|
|
1031
|
+
}
|
|
1032
|
+
catch {
|
|
1033
|
+
// A failed record must never crash the process (this can run from a
|
|
1034
|
+
// fire-and-forget `void fail(...)` off the idle timer, where an
|
|
1035
|
+
// unhandled rejection would terminate the whole proxy) or block the
|
|
1036
|
+
// client from seeing the stream error below.
|
|
1037
|
+
}
|
|
1038
|
+
finally {
|
|
1039
|
+
controller.error(new RegistryError(502, reason, `download of "${file.filename}" stopped: ${reason}`));
|
|
1040
|
+
}
|
|
1041
|
+
};
|
|
1042
|
+
// The client went away (its request signal aborted the upstream read):
|
|
1043
|
+
// release the reservation and record nothing, since nothing was served
|
|
1044
|
+
// and nothing was refused.
|
|
1045
|
+
const abandon = (controller) => {
|
|
1046
|
+
if (settled)
|
|
1047
|
+
return;
|
|
1048
|
+
settled = true;
|
|
1049
|
+
clearIdle();
|
|
1050
|
+
release();
|
|
1051
|
+
controller.error(new RegistryError(499, "client_disconnected", "the client disconnected"));
|
|
1052
|
+
};
|
|
1053
|
+
const stream = new ReadableStream({
|
|
1054
|
+
pull: async (controller) => {
|
|
1055
|
+
if (settled)
|
|
1056
|
+
return;
|
|
1057
|
+
clearIdle();
|
|
1058
|
+
idle = setTimeout(() => void fail(controller, "wardby_download_idle"), limits.idleTimeoutMs);
|
|
1059
|
+
const outcome = await reader.read().then((result) => result.done ? { ok: true, done: true } : { ok: true, done: false, value: result.value }, () => ({ ok: false }));
|
|
1060
|
+
// The idle timer may have fired and already failed the stream while
|
|
1061
|
+
// this read was pending (or canceled the reader, causing the
|
|
1062
|
+
// rejection above); a late resolution must never enqueue, close, or
|
|
1063
|
+
// record on top of that already-settled outcome.
|
|
1064
|
+
if (settled)
|
|
1065
|
+
return;
|
|
1066
|
+
clearIdle();
|
|
1067
|
+
if (!outcome.ok)
|
|
1068
|
+
return signal.aborted ? abandon(controller) : fail(controller, "wardby_upstream_error");
|
|
1069
|
+
if (outcome.done) {
|
|
1070
|
+
if (hash && hash.digest("hex") !== file.integrity.hex)
|
|
1071
|
+
return fail(controller, "wardby_integrity_mismatch");
|
|
1072
|
+
settled = true;
|
|
1073
|
+
// Persist the served record (and the dependency allowances it
|
|
1074
|
+
// grows) BEFORE releasing the in-flight reservation. Releasing
|
|
1075
|
+
// first would open a window, between the release and the row
|
|
1076
|
+
// actually landing in the store, where neither `inFlight` nor
|
|
1077
|
+
// store.usage() counts this file — a concurrent request could be
|
|
1078
|
+
// admitted past maxFiles/maxTotalBytes in that window, reopening
|
|
1079
|
+
// the race the reservation exists to close. `release()` still
|
|
1080
|
+
// runs unconditionally (finally), so a failed record never leaks
|
|
1081
|
+
// the reservation; it errors the controller instead of closing it,
|
|
1082
|
+
// the same way every other failure path here does.
|
|
1083
|
+
try {
|
|
1084
|
+
await store.recordFetch({
|
|
1085
|
+
runId: context.runId,
|
|
1086
|
+
ecosystem: adapter.id,
|
|
1087
|
+
name,
|
|
1088
|
+
version: file.version,
|
|
1089
|
+
filename: file.filename,
|
|
1090
|
+
integrity: file.integrity ? `${file.integrity.algorithm}:${file.integrity.hex}` : undefined,
|
|
1091
|
+
sizeBytes: bytes,
|
|
1092
|
+
outcome: "served",
|
|
1093
|
+
});
|
|
1094
|
+
// Counted before release() drops the reservation, so no window
|
|
1095
|
+
// exists where neither the tally nor inFlight holds this file.
|
|
1096
|
+
served.files += 1;
|
|
1097
|
+
served.bytes += bytes;
|
|
1098
|
+
if (adapter.dependenciesFromFile && buffered.length > 0) {
|
|
1099
|
+
const body = Buffer.concat(buffered);
|
|
1100
|
+
const names = await adapter.dependenciesFromFile(route, body).catch(() => []);
|
|
1101
|
+
await store.addAllowances(context.runId, adapter.id, names.map((dependency) => adapter.normalizeName(dependency)));
|
|
1102
|
+
}
|
|
1103
|
+
controller.close();
|
|
1104
|
+
}
|
|
1105
|
+
catch (error) {
|
|
1106
|
+
controller.error(error instanceof RegistryError
|
|
1107
|
+
? error
|
|
1108
|
+
: new RegistryError(502, "wardby_upstream_error", `failed to record the completed download of "${file.filename}"`));
|
|
1109
|
+
}
|
|
1110
|
+
finally {
|
|
1111
|
+
release();
|
|
1112
|
+
}
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
const { value } = outcome;
|
|
1116
|
+
bytes += value.byteLength;
|
|
1117
|
+
if (file.sizeBytes === null)
|
|
1118
|
+
this.trackStreamedBytes(runId, value.byteLength);
|
|
1119
|
+
if (bytes > limits.maxFileBytes)
|
|
1120
|
+
return fail(controller, "wardby_package_too_large");
|
|
1121
|
+
if (served.bytes + this.inFlightUsage(runId).bytes > limits.maxTotalBytes)
|
|
1122
|
+
return fail(controller, "wardby_package_limit");
|
|
1123
|
+
hash?.update(value);
|
|
1124
|
+
if (adapter.dependenciesFromFile && bytes <= DEPENDENCY_BUFFER_LIMIT)
|
|
1125
|
+
buffered.push(value);
|
|
1126
|
+
controller.enqueue(value);
|
|
1127
|
+
},
|
|
1128
|
+
cancel: async () => {
|
|
1129
|
+
settled = true;
|
|
1130
|
+
clearIdle();
|
|
1131
|
+
release();
|
|
1132
|
+
await reader.cancel().catch(() => undefined);
|
|
1133
|
+
},
|
|
1134
|
+
});
|
|
1135
|
+
return { status: 200, contentType: contentTypeOf(route), stream };
|
|
1136
|
+
}
|
|
1137
|
+
}
|