diff --git a/.env.example b/.env.example index 01f1e4e085..02aa3405f6 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,16 @@ REDIS_URL= # NODE_ENV=production # CHANNEL_DISPATCH_SIGNING_PUBLIC_KEY= +# Host outbound network policy +# Remote modules and remote MCP calls may reach public HTTP(S) endpoints. The +# runtime rejects loopback, private, link-local, metadata, and other non-global +# destinations, including after DNS resolution and on every redirect hop. +# +# Emergency compatibility override. This disables that host SSRF ceiling and +# must remain unset in shared runtimes. Project environment overlays cannot set +# this host-owned option. +# VERYFRONT_HOST_ALLOW_INTERNAL_EGRESS=1 + # Binary compilation # Set to 1 to always rebuild binary, even if source unchanged VERYFRONT_BINARY_FRESH=1 diff --git a/cli/commands/eval/command.ts b/cli/commands/eval/command.ts index 8153f6692e..83a56ed2b0 100644 --- a/cli/commands/eval/command.ts +++ b/cli/commands/eval/command.ts @@ -1172,6 +1172,8 @@ export async function runEvalCommand( fsAdapter: adapter.fs, cacheKey: configCacheKey, verbose: options.debug, + // The CLI executes source from the operator-selected local project. + allowHostProjectCodeExecution: true, }); const evals = getDiscoveredEvals(projectRuntime); diff --git a/cli/commands/schedule/handler.test.ts b/cli/commands/schedule/handler.test.ts index a22da8202b..2ed0d31c10 100644 --- a/cli/commands/schedule/handler.test.ts +++ b/cli/commands/schedule/handler.test.ts @@ -31,6 +31,7 @@ const originalEnvironment = Object.fromEntries( const projectId = "22222222-2222-4222-8222-222222222222"; const scheduleId = "33333333-3333-4333-8333-333333333333"; const runId = "run_11111111-1111-4111-8111-111111111111"; +const TEST_PUBLIC_API_ORIGIN = "https://93.184.216.34"; class ExitSentinel extends Error { constructor(readonly code: number) { @@ -165,7 +166,7 @@ describe("schedule command", () => { await Deno.writeTextFile( `${projectDir}/veryfront.json`, JSON.stringify({ - apiUrl: "https://api.from-config.test", + apiUrl: TEST_PUBLIC_API_ORIGIN, apiToken: "config-token", projectSlug: "json-only-project", }) + "\n", @@ -216,9 +217,9 @@ describe("schedule command", () => { assertEquals(exitCode, 0); assertEquals(requests.map((request) => request.url), [ - "https://api.from-config.test/projects/json-only-project/schedules?status=active&source_trigger_id=process-job-submissions", - `https://api.from-config.test/projects/json-only-project/schedules/${scheduleId}/runs`, - `https://api.from-config.test/runs/${encodeURIComponent(runId)}`, + `${TEST_PUBLIC_API_ORIGIN}/projects/json-only-project/schedules?status=active&source_trigger_id=process-job-submissions`, + `${TEST_PUBLIC_API_ORIGIN}/projects/json-only-project/schedules/${scheduleId}/runs`, + `${TEST_PUBLIC_API_ORIGIN}/runs/${encodeURIComponent(runId)}`, ]); assertEquals( requests.map((request) => new Headers(request.init?.headers).get("Authorization")), diff --git a/cli/commands/schedule/handler.ts b/cli/commands/schedule/handler.ts index 1a6caf0c99..c440b3ed41 100644 --- a/cli/commands/schedule/handler.ts +++ b/cli/commands/schedule/handler.ts @@ -56,7 +56,12 @@ function formatSchedule(schedule: ScheduleDefinition): string { async function handleScheduleList(_args: ParsedArgs): Promise { const projectDir = Deno.cwd(); await withProjectSourceContext(projectDir, async ({ adapter, config }) => { - const result = await discoverSchedules({ projectDir, adapter, config }); + const result = await discoverSchedules({ + projectDir, + adapter, + config, + allowHostProjectCodeExecution: true, + }); await outputTriggerList({ command: "schedules", items: result.items, @@ -205,7 +210,12 @@ export async function handleScheduleCommand(args: ParsedArgs): Promise { await withProjectSourceContext(projectDir, async (context) => { const { adapter, config, configCacheKey, projectId } = context; const input = opts.input ? await readJsonFile(opts.input, "--input JSON file") : undefined; - const result = await discoverSchedules({ projectDir, adapter, config }); + const result = await discoverSchedules({ + projectDir, + adapter, + config, + allowHostProjectCodeExecution: true, + }); if (result.errors.length > 0) { throw DEPLOYMENT_ERROR.create({ detail: `Schedule discovery failed: ${result.errors[0]?.message}`, diff --git a/cli/commands/schedules/handler.ts b/cli/commands/schedules/handler.ts index acebb6960d..cd41618ea9 100644 --- a/cli/commands/schedules/handler.ts +++ b/cli/commands/schedules/handler.ts @@ -10,7 +10,12 @@ function formatSchedule(schedule: ScheduleDefinition): string { export async function handleSchedulesCommand(_args: ParsedArgs): Promise { const projectDir = Deno.cwd(); await withProjectSourceContext(projectDir, async ({ adapter, config }) => { - const result = await discoverSchedules({ projectDir, adapter, config }); + const result = await discoverSchedules({ + projectDir, + adapter, + config, + allowHostProjectCodeExecution: true, + }); await outputTriggerList({ command: "schedules", items: result.items, diff --git a/cli/commands/task/command.ts b/cli/commands/task/command.ts index afb3a48bc9..4f2556f4f7 100644 --- a/cli/commands/task/command.ts +++ b/cli/commands/task/command.ts @@ -64,6 +64,7 @@ export async function taskCommand(options: TaskOptions): Promise { fsAdapter: adapter.fs, cacheKey: configCacheKey, debug: options.debug, + allowHostProjectCodeExecution: true, }); logRuntimeDiscoveryWarnings(discovery.errors, options.debug); diff --git a/cli/commands/webhook/handler.ts b/cli/commands/webhook/handler.ts index c338915e0c..c5af38dff2 100644 --- a/cli/commands/webhook/handler.ts +++ b/cli/commands/webhook/handler.ts @@ -36,7 +36,12 @@ function formatWebhook(webhook: WebhookDefinition): string { async function handleWebhookList(_args: ParsedArgs): Promise { const projectDir = Deno.cwd(); await withProjectSourceContext(projectDir, async ({ adapter, config }) => { - const result = await discoverWebhooks({ projectDir, adapter, config }); + const result = await discoverWebhooks({ + projectDir, + adapter, + config, + allowHostProjectCodeExecution: true, + }); await outputTriggerList({ command: "webhooks", items: result.items, @@ -68,7 +73,12 @@ export async function handleWebhookCommand(args: ParsedArgs): Promise { await withProjectSourceContext(projectDir, async (context) => { const { adapter, config, configCacheKey, projectId } = context; - const result = await discoverWebhooks({ projectDir, adapter, config }); + const result = await discoverWebhooks({ + projectDir, + adapter, + config, + allowHostProjectCodeExecution: true, + }); if (result.errors.length > 0) { throw new Error(`Webhook discovery failed: ${result.errors[0]?.message}`); } diff --git a/cli/commands/webhooks/handler.ts b/cli/commands/webhooks/handler.ts index 10235947c6..6af04f2275 100644 --- a/cli/commands/webhooks/handler.ts +++ b/cli/commands/webhooks/handler.ts @@ -10,7 +10,12 @@ function formatWebhook(webhook: WebhookDefinition): string { export async function handleWebhooksCommand(_args: ParsedArgs): Promise { const projectDir = Deno.cwd(); await withProjectSourceContext(projectDir, async ({ adapter, config }) => { - const result = await discoverWebhooks({ projectDir, adapter, config }); + const result = await discoverWebhooks({ + projectDir, + adapter, + config, + allowHostProjectCodeExecution: true, + }); await outputTriggerList({ command: "webhooks", items: result.items, diff --git a/deno.json b/deno.json index bf2bb157f5..ed5fa04424 100644 --- a/deno.json +++ b/deno.json @@ -493,7 +493,7 @@ "docs": "deno run --allow-read --allow-write --allow-run --allow-env scripts/docs/generate-api-reference.ts", "docs:coverage": "deno run --allow-read scripts/docs/docs-coverage.ts", "docs:copy": "rm -rf ../../docs/docs/code/api-reference && cp -r docs/api-reference/ ../../docs/docs/code/api-reference/", - "docs:validate": "deno run --allow-read scripts/docs/validate-api-reference.ts && deno run --allow-read scripts/docs/validate-guides.ts && deno run --allow-read scripts/docs/validate-public-docs.ts && deno test --config=scripts/test.deno.json --no-check --allow-read scripts/docs/docs-coverage.test.ts && deno test --no-check --allow-read tests/docs/guide-contracts.test.ts tests/docs/guide-content.test.ts && deno test --no-check --allow-all tests/docs/guide-examples.test.ts tests/docs/guide-code-examples.test.ts && deno run -A scripts/lint/check-doc-links.ts", + "docs:validate": "deno run --allow-read scripts/docs/validate-api-reference.ts && deno run --allow-read scripts/docs/validate-guides.ts && deno run --allow-read scripts/docs/validate-public-docs.ts && deno test --config=scripts/test.deno.json --no-check --allow-read scripts/docs/docs-coverage.test.ts && deno test --no-check --allow-read tests/docs/guide-contracts.test.ts tests/docs/guide-content.test.ts && DENO_TESTING=1 deno test --no-check --allow-all tests/docs/guide-examples.test.ts tests/docs/guide-code-examples.test.ts && deno run -A scripts/lint/check-doc-links.ts", "docs:verify-npm": "node scripts/docs/verify-npm-exports.mjs && node scripts/docs/verify-npm-node.mjs", "docs:check-links": "deno run -A scripts/lint/check-doc-links.ts", "lint:ban-zod": "deno run --allow-read scripts/lint/ban-zod-imports.ts", @@ -528,7 +528,7 @@ "test:all-runtimes": "deno task test:unit && deno task test:node && deno task test:bun", "test:e2e": "deno task test:e2e:playwright", "test:e2e:playwright": "PW_DISABLE_TS_ESM=1 npx playwright test --config=tests/e2e/playwright.config.cjs", - "test:e2e:rsc-browser": "deno task generate && VF_DISABLE_LRU_INTERVAL=1 SSR_TRANSFORM_PER_PROJECT_LIMIT=0 REVALIDATION_PER_PROJECT_LIMIT=0 NODE_ENV=production LOG_FORMAT=text deno test --no-check --allow-all tests/e2e/regressions/rsc-proxy-hydration.test.ts tests/e2e/regressions/2026-07-27-legacy-router-hydration.test.ts tests/e2e/regressions/2026-07-27-release-asset-page-island-hydration.test.ts --unstable-worker-options --unstable-net", + "test:e2e:rsc-browser": "deno task generate && DENO_TESTING=1 VF_DISABLE_LRU_INTERVAL=1 SSR_TRANSFORM_PER_PROJECT_LIMIT=0 REVALIDATION_PER_PROJECT_LIMIT=0 NODE_ENV=production LOG_FORMAT=text deno test --no-check --allow-all tests/e2e/regressions/rsc-proxy-hydration.test.ts tests/e2e/regressions/2026-07-27-legacy-router-hydration.test.ts tests/e2e/regressions/2026-07-27-release-asset-page-island-hydration.test.ts --unstable-worker-options --unstable-net", "test:e2e:binary": "deno task generate && deno test --allow-all tests/integration/compiled-binary-e2e.test.ts", "test:e2e:binary:fresh": "deno task generate && VERYFRONT_BINARY_FRESH=1 deno test --allow-all tests/integration/compiled-binary-e2e.test.ts", "test:e2e:templates": "deno run --allow-all scripts/test/template-runtime-e2e.ts", diff --git a/scripts/lint/test-typecheck-baseline.json b/scripts/lint/test-typecheck-baseline.json index e63abc8dbe..7484599c47 100644 --- a/scripts/lint/test-typecheck-baseline.json +++ b/scripts/lint/test-typecheck-baseline.json @@ -50,11 +50,9 @@ "src/rendering/orchestrator/config.test.ts", "src/rendering/utils/react-helpers.test.ts", "src/resource/registry.test.ts", - "src/routing/api/module-loader/security-config.test.ts", "src/routing/api/openapi/mcp-resource.test.ts", "src/runs/schemas.test.ts", "src/server/build-service-worker.test.ts", - "src/server/handlers/response/cors.test.ts", "src/transforms/import-rewriter/strategies/import-map-strategy.test.ts", "src/transforms/md/compiler/md-compiler.test.ts", "src/transforms/mdx/compiler/index.test.ts", diff --git a/scripts/test/coverage-ci.ts b/scripts/test/coverage-ci.ts index 1a552e86c2..dd6a0b5adc 100644 --- a/scripts/test/coverage-ci.ts +++ b/scripts/test/coverage-ci.ts @@ -17,6 +17,7 @@ interface LcovLineRecord { const UNIT_COVERAGE_ROOTS = ["src", "cli"]; const UNIT_COVERAGE_ENV = { + DENO_TESTING: "1", VF_DISABLE_LRU_INTERVAL: "1", SSR_TRANSFORM_PER_PROJECT_LIMIT: "0", REVALIDATION_PER_PROJECT_LIMIT: "0", diff --git a/src/agent/ag-ui/detached-start.test.ts b/src/agent/ag-ui/detached-start.test.ts index 16c3fbde4e..ebeabe1c51 100644 --- a/src/agent/ag-ui/detached-start.test.ts +++ b/src/agent/ag-ui/detached-start.test.ts @@ -282,10 +282,20 @@ describe("agent/ag-ui-detached-start", () => { const response = await executeAgUiDetachedStart( { sessionManager, - context: { tenant: "acme" }, - startDetachedExecution: async ({ request, context }) => { + context: (request) => { + assertEquals(request.headers.get("authorization"), "Bearer public-user"); + assertEquals(request.headers.get("cookie"), "session=public"); + assertEquals(request.headers.get("x-token"), null); + assertEquals(request.headers.get("x-project-id"), null); + return { tenant: "acme" }; + }, + startDetachedExecution: async ({ request, context, rawRequest, requestOrCtx }) => { assertEquals(request.runId, "run_1"); assertEquals(context, { tenant: "acme" }); + assertEquals(rawRequest.headers.get("authorization"), "Bearer public-user"); + assertEquals(rawRequest.headers.get("x-forwarded-host"), null); + assertEquals(rawRequest.headers.get("x-veryfront-control-plane-jws"), null); + assertEquals(requestOrCtx, rawRequest); }, onAccepted: ({ runId }) => { acceptedRunId = runId; @@ -306,7 +316,15 @@ describe("agent/ag-ui-detached-start", () => { }), rawRequest: new Request("http://localhost/api/runs", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + Authorization: "Bearer public-user", + Cookie: "session=public", + "x-token": "host-secret", + "x-project-id": "infrastructure-project", + "x-forwarded-host": "trusted-proxy.example", + "x-veryfront-control-plane-jws": "signed-infrastructure-token", + }, }), }, ); diff --git a/src/agent/ag-ui/detached-start.ts b/src/agent/ag-ui/detached-start.ts index 8d77a6e6b5..14330fcbf1 100644 --- a/src/agent/ag-ui/detached-start.ts +++ b/src/agent/ag-ui/detached-start.ts @@ -17,6 +17,7 @@ import { } from "../runtime/index.ts"; import type { Agent } from "../types.ts"; import type { ChatUiMessage, MessageMetadata } from "#veryfront/chat/types.ts"; +import { createApplicationRequest } from "#veryfront/security/http/application-request.ts"; const AGENT_ID_PATTERN = /^[a-zA-Z0-9_-]+$/; const getAgUiDetachedRunIdSchema = defineSchema((v) => @@ -302,7 +303,11 @@ export async function executeAgUiDetachedStart( input: ExecuteAgUiDetachedStartInput, ): Promise { const rawRequest = assertDetachedStartRawRequest(options, input); - const context = await resolveDetachedStartContext(options, input); + const applicationRequest = rawRequest ? createApplicationRequest(rawRequest) : undefined; + const context = await resolveDetachedStartContext(options, { + ...input, + rawRequest: applicationRequest, + }); try { const abortSignal = options.sessionManager.startRun({ @@ -321,8 +326,8 @@ export async function executeAgUiDetachedStart( if (options.startDetachedExecution) { await options.startDetachedExecution({ request: input.request, - requestOrCtx: input.requestOrCtx, - rawRequest: rawRequest!, + requestOrCtx: applicationRequest, + rawRequest: applicationRequest!, context, abortSignal, }); @@ -410,9 +415,12 @@ export function createAgUiDetachedStartHandler( return async function POST(requestOrCtx: unknown): Promise { const request = extractRequest(requestOrCtx); + const applicationRequest = createApplicationRequest(request); try { - const parsed = getAgUiDetachedStartRequestSchema().parse(await parseAgUiJsonBody(request)); + const parsed = getAgUiDetachedStartRequestSchema().parse( + await parseAgUiJsonBody(applicationRequest), + ); return await executeAgUiDetachedStart(options, { request: parsed, rawRequest: request, diff --git a/src/agent/ag-ui/handler.test.ts b/src/agent/ag-ui/handler.test.ts index b896c1a359..7a6d54de8f 100644 --- a/src/agent/ag-ui/handler.test.ts +++ b/src/agent/ag-ui/handler.test.ts @@ -433,23 +433,42 @@ describe("agent/ag-ui-handler", () => { const testAgent = createTestAgent(); const handler = createAgUiHandler({ agent: testAgent.agent, - context: { tenant: "acme" }, - beforeStream: ({ lastUserText, context }) => ({ - prepend: [{ - role: "user", - parts: [{ - type: "text", - text: `Retrieved context for: ${lastUserText}`, + context: (request) => { + assertEquals(request.headers.get("authorization"), "Bearer public-user"); + assertEquals(request.headers.get("cookie"), "session=public"); + assertEquals(request.headers.get("x-token"), null); + assertEquals(request.headers.get("x-project-id"), null); + return { tenant: "acme" }; + }, + beforeStream: ({ request, lastUserText, context }) => { + assertEquals(request.headers.get("authorization"), "Bearer public-user"); + assertEquals(request.headers.get("x-forwarded-host"), null); + assertEquals(request.headers.get("x-veryfront-dispatch-jws"), null); + return { + prepend: [{ + role: "user", + parts: [{ + type: "text", + text: `Retrieved context for: ${lastUserText}`, + }], }], - }], - context: { ...context, retrieval: "complete" }, - }), + context: { ...context, retrieval: "complete" }, + }; + }, }); const response = await handler( new Request("http://localhost/api/ag-ui", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + Authorization: "Bearer public-user", + Cookie: "session=public", + "x-token": "host-secret", + "x-project-id": "infrastructure-project", + "x-forwarded-host": "trusted-proxy.example", + "x-veryfront-dispatch-jws": "signed-infrastructure-token", + }, body: JSON.stringify({ messages: [{ id: "msg-1", diff --git a/src/agent/ag-ui/handler.ts b/src/agent/ag-ui/handler.ts index ab19a48e56..bacba2260b 100644 --- a/src/agent/ag-ui/handler.ts +++ b/src/agent/ag-ui/handler.ts @@ -31,6 +31,7 @@ import { } from "./host-support.ts"; import { extractRequest } from "./request-shared.ts"; import { type AgUiResumeValue, buildMergedAgUiTools } from "./tool-shared.ts"; +import { createApplicationRequest } from "#veryfront/security/http/application-request.ts"; export { type AgUiContextItem, @@ -523,6 +524,7 @@ export function createAgUiHandler( ) { return async function POST(requestOrCtx: unknown): Promise { const request = extractRequest(requestOrCtx); + const applicationRequest = createApplicationRequest(request); let agent: Agent | undefined; @@ -548,7 +550,7 @@ export function createAgUiHandler( } try { - const parsed = await parseAgUiRequestOrError(request); + const parsed = await parseAgUiRequestOrError(applicationRequest); if (isResponseLike(parsed)) { return parsed; } @@ -565,13 +567,13 @@ export function createAgUiHandler( } const context = typeof options?.context === "function" - ? await options.context(request) + ? await options.context(applicationRequest) : options?.context ?? {}; return await createAgUiInjectedToolsStreamResponse( agent, parsed, - request, + applicationRequest, context, options.sessionManager, options?.beforeStream, @@ -580,13 +582,13 @@ export function createAgUiHandler( } const context = typeof options?.context === "function" - ? await options.context(request) + ? await options.context(applicationRequest) : options?.context ?? {}; return await createAgUiDirectStreamResponse( agent, parsed, - request, + applicationRequest, context, options?.beforeStream, options?.onComplete, diff --git a/src/agent/ag-ui/run-control.test.ts b/src/agent/ag-ui/run-control.test.ts index 38382e18bd..f0a14c069d 100644 --- a/src/agent/ag-ui/run-control.test.ts +++ b/src/agent/ag-ui/run-control.test.ts @@ -67,6 +67,39 @@ describe("agent/ag-ui-run-control", () => { assertEquals(await response.json(), { accepted: true }); }); + it("withholds infrastructure headers from run-id resolvers", async () => { + const sessionManager = new RunResumeSessionManager<{ ok: boolean }>(); + sessionManager.startRun({ runId: "run_1", threadId: crypto.randomUUID() }); + void sessionManager.waitForSignal("run_1", "tool_1").catch(() => undefined); + + const handler = createAgUiCancelHandler({ + sessionManager, + resolveRunId: ({ request, requestOrCtx }) => { + assertEquals(requestOrCtx, request); + assertEquals(request.headers.get("authorization"), "Bearer public-user"); + assertEquals(request.headers.get("cookie"), "session=public"); + assertEquals(request.headers.get("x-token"), null); + assertEquals(request.headers.get("x-project-id"), null); + assertEquals(request.headers.get("x-forwarded-host"), null); + return "run_1"; + }, + }); + const response = await handler( + new Request("https://example.com/api/runs/ignored", { + method: "DELETE", + headers: { + Authorization: "Bearer public-user", + Cookie: "session=public", + "x-token": "host-secret", + "x-project-id": "infrastructure-project", + "x-forwarded-host": "trusted-proxy.example", + }, + }), + ); + + assertEquals(response.status, 202); + }); + it("accepts a request wrapper and returns 410 for inactive runs", async () => { const handler = createAgUiResumeHandler({ sessionManager: new RunResumeSessionManager<{ result: unknown; isError: boolean }>(), diff --git a/src/agent/ag-ui/run-control.ts b/src/agent/ag-ui/run-control.ts index 2206b8433e..febe9b39fd 100644 --- a/src/agent/ag-ui/run-control.ts +++ b/src/agent/ag-ui/run-control.ts @@ -11,6 +11,7 @@ import { WaitConflictError, WaitNotPendingError, } from "../runtime/resume-session.ts"; +import { createApplicationRequest } from "#veryfront/security/http/application-request.ts"; const RESUME_PATH_REGEX = /^\/api\/runs\/([^/]+)\/resume$/; const CANCEL_PATH_REGEX = /^\/api\/runs\/([^/]+)$/; @@ -60,12 +61,15 @@ export interface AgUiCancelHandlerOptions extends AgUiRunControlHan } async function resolveRunId( - requestOrCtx: unknown, request: Request, options: AgUiRunControlHandlerOptions | undefined, regex: RegExp, ): Promise { - const explicit = await options?.resolveRunId?.({ request, requestOrCtx }); + const applicationRequest = options?.resolveRunId ? createApplicationRequest(request) : request; + const explicit = await options?.resolveRunId?.({ + request: applicationRequest, + requestOrCtx: applicationRequest, + }); if (explicit) return explicit; return getRunId(new URL(request.url).pathname, regex); } @@ -76,7 +80,7 @@ export function createAgUiResumeHandler( ): (requestOrCtx: unknown) => Promise { return async function POST(requestOrCtx: unknown): Promise { const request = extractRequest(requestOrCtx); - const runId = await resolveRunId(requestOrCtx, request, options, RESUME_PATH_REGEX); + const runId = await resolveRunId(request, options, RESUME_PATH_REGEX); if (!runId) { return Response.json({ error: "Run not found" }, { status: 404 }); @@ -146,7 +150,7 @@ export function createAgUiCancelHandler( ): (requestOrCtx: unknown) => Promise { return async function DELETE(requestOrCtx: unknown): Promise { const request = extractRequest(requestOrCtx); - const runId = await resolveRunId(requestOrCtx, request, options, CANCEL_PATH_REGEX); + const runId = await resolveRunId(request, options, CANCEL_PATH_REGEX); if (!runId) { return Response.json({ error: "Run not found" }, { status: 404 }); diff --git a/src/agent/ag-ui/runtime-handler.test.ts b/src/agent/ag-ui/runtime-handler.test.ts index 02387f027d..271767bce6 100644 --- a/src/agent/ag-ui/runtime-handler.test.ts +++ b/src/agent/ag-ui/runtime-handler.test.ts @@ -184,6 +184,50 @@ describe("agent/ag-ui-runtime-handler", () => { }); }); + it("withholds infrastructure headers from runtime context callbacks", async () => { + let admissionToken: string | null = null; + const handler = createAgUiRuntimeHandler({ + beforeParse: ({ request }) => { + admissionToken = request.headers.get("x-token"); + }, + context: (request) => { + assertEquals(request.headers.get("authorization"), "Bearer public-user"); + assertEquals(request.headers.get("cookie"), "session=public"); + assertEquals(request.headers.get("x-token"), null); + assertEquals(request.headers.get("x-project-slug"), null); + assertEquals(request.headers.get("x-forwarded-host"), null); + return { admitted: true }; + }, + execute: ({ request, context }) => { + assertEquals(request.headers.get("authorization"), "Bearer public-user"); + assertEquals(request.headers.get("x-token"), null); + return Response.json(context); + }, + }); + + const response = await handler( + new Request("http://localhost/api/ag-ui", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer public-user", + Cookie: "session=public", + "x-token": "host-secret", + "x-project-slug": "infrastructure-project", + "x-forwarded-host": "trusted-proxy.example", + }, + body: JSON.stringify({ + runId: "run_runtime_boundary", + threadId: crypto.randomUUID(), + messages: [{ id: "user-1", role: "user", content: "Hello" }], + }), + }), + ); + + assertEquals(admissionToken, "host-secret"); + assertEquals(await response.json(), { admitted: true }); + }); + it("lets hosts short-circuit before parsing the runtime request body", async () => { let beforeParseCalls = 0; let executeCalls = 0; diff --git a/src/agent/ag-ui/runtime-handler.ts b/src/agent/ag-ui/runtime-handler.ts index b6e65ced56..4a929cb2f3 100644 --- a/src/agent/ag-ui/runtime-handler.ts +++ b/src/agent/ag-ui/runtime-handler.ts @@ -12,6 +12,7 @@ import { parseAgUiRuntimeRequestOrError, } from "../runtime/ag-ui-contract.ts"; import { extractRequest } from "./request-shared.ts"; +import { createApplicationRequest } from "#veryfront/security/http/application-request.ts"; import { type AgUiResumeValue, buildMergedAgUiTools } from "./tool-shared.ts"; import { normalizeAgUiRuntimeMessages } from "./runtime-support.ts"; import { @@ -358,7 +359,10 @@ export type AgUiRuntimeHandlerExecute = ( ) => Promise | Response; export interface AgUiRuntimeRequestGateInput { + /** Original request for trusted framework admission and authentication only. */ request: Request; + /** Detached request safe to retain or pass into application callbacks. */ + applicationRequest: Request; } export type AgUiRuntimeRequestGate = ( @@ -410,24 +414,28 @@ export function createAgUiRuntimeHandler( return async function POST(requestOrCtx: unknown): Promise { const request = extractRequest(requestOrCtx); + const applicationRequest = createApplicationRequest(request); try { - const gateResult = await config.beforeParse?.({ request }); + const gateResult = await config.beforeParse?.({ request, applicationRequest }); if (isResponseLike(gateResult)) { return gateResult; } - const parsed = await parseAgUiRuntimeRequestOrError(request); + const parsed = await parseAgUiRuntimeRequestOrError(applicationRequest); if (isResponseLike(parsed)) { if (config.validationErrorResponse) { - return await config.validationErrorResponse({ request, response: parsed }); + return await config.validationErrorResponse({ + request: applicationRequest, + response: parsed, + }); } return parsed; } const context = typeof config.context === "function" - ? await config.context(request) + ? await config.context(applicationRequest) : config.context ?? {}; const createDefaultResponse = config.agent @@ -515,7 +523,7 @@ export function createAgUiRuntimeHandler( if (config.execute) { return await config.execute({ - request, + request: applicationRequest, agUiInput: parsed, context, createDefaultResponse: createDefaultResponseWithLifecycle, diff --git a/src/agent/hosted/cloud-agent-config.ts b/src/agent/hosted/cloud-agent-config.ts index a5d073ddfb..474fa29795 100644 --- a/src/agent/hosted/cloud-agent-config.ts +++ b/src/agent/hosted/cloud-agent-config.ts @@ -144,6 +144,7 @@ async function discoverProjectPrimitives( context.discoveryResult = await discoverProjectAgentRuntime({ projectDir: context.projectDir, adapter: nodeAdapter, + allowHostProjectCodeExecution: true, }); } diff --git a/src/agent/hosted/default-chat-runtime.test.ts b/src/agent/hosted/default-chat-runtime.test.ts index 70b0c20e61..d500c6fe13 100644 --- a/src/agent/hosted/default-chat-runtime.test.ts +++ b/src/agent/hosted/default-chat-runtime.test.ts @@ -126,6 +126,10 @@ Deno.test("createDefaultHostedChatRuntime builds a cloud-backed hosted runtime", Deno.test("hosted first provider call filters skill tools for every tool selector", async () => { try { + // Use the standards-reserved public documentation address so the outbound + // guard can validate the destination before handing the request to the + // deterministic test transport. + const testApiOrigin = "https://93.184.216.34"; const providerCappedToolNames = Array.from( { length: 129 }, (_, index) => `provider_cap_tool_${String(index).padStart(3, "0")}`, @@ -247,8 +251,8 @@ Deno.test("hosted first provider call filters skill tools for every tool selecto : { hostToolPolicy: { allow: testCase.hostToolAllow } }), options: { ...prepared.creationOptions, userId: "user-1" }, config: { - apiUrl: "https://api.example.com", - apiMcpUrl: "https://api.example.com/mcp", + apiUrl: testApiOrigin, + apiMcpUrl: `${testApiOrigin}/mcp`, }, buildLocalTools: () => ({ ...Object.fromEntries( @@ -296,6 +300,7 @@ Deno.test("hosted first provider call filters skill tools for every tool selecto }, ); + assertExists(capturedProviderBody); const providerBody = JSON.stringify(capturedProviderBody); assertEquals(providerBody.includes("Deploy the project"), true); for (const toolName of testCase.expectedPresent) { diff --git a/src/agent/project/agent-runtime.test.ts b/src/agent/project/agent-runtime.test.ts index e6b2b41789..5a1d4a0d4b 100644 --- a/src/agent/project/agent-runtime.test.ts +++ b/src/agent/project/agent-runtime.test.ts @@ -12,7 +12,7 @@ import { agent } from "../factory.ts"; import { createRuntimeAgentDefinitionFromAgent, describeProjectAgentRuntimeAgentIdCandidates, - discoverProjectAgentRuntime, + discoverProjectAgentRuntime as discoverProjectAgentRuntimeRaw, doesProjectAgentRuntimeAgentMatchSource, getProjectAgentRuntimeAgentIdCandidates, resolveSingleProjectAgentRuntimeAgentId, @@ -30,6 +30,12 @@ import { createLoadSkillTool } from "#veryfront/skill/tools.ts"; import { getEffectiveAgentSystem } from "../runtime/effective-agent-system.ts"; import { tool } from "#veryfront/tool"; +const discoverProjectAgentRuntime: typeof discoverProjectAgentRuntimeRaw = (input) => + discoverProjectAgentRuntimeRaw({ + ...input, + allowHostProjectCodeExecution: true, + }); + async function withTempDir(fn: (dir: string) => Promise | void): Promise { const dir = Deno.makeTempDirSync(); try { diff --git a/src/agent/project/agent-runtime.ts b/src/agent/project/agent-runtime.ts index 5c2fb888c0..b87d4d0f1f 100644 --- a/src/agent/project/agent-runtime.ts +++ b/src/agent/project/agent-runtime.ts @@ -48,6 +48,8 @@ export type DiscoverProjectAgentRuntimeInput = { verbose?: boolean; /** Immutable outer restriction to preserve while loading and discovering this source. */ sourceIntegrationPolicy?: SourceIntegrationPolicyManifest; + /** Explicit host-owned capability for a trusted local or dedicated runtime. */ + allowHostProjectCodeExecution?: boolean; }; /** Project discovery plus the normalized policy owned by that exact source. */ @@ -137,6 +139,7 @@ export async function discoverProjectAgentRuntime( config, fsAdapter: input.fsAdapter, verbose: input.verbose, + allowHostProjectCodeExecution: input.allowHostProjectCodeExecution, }); const currentSourcePolicy = normalizeSourceIntegrationPolicy(config.integrations); diff --git a/src/agent/runtime/mcp-server-tool-sources.test.ts b/src/agent/runtime/mcp-server-tool-sources.test.ts index 2660ff0bdc..8b24fbc52f 100644 --- a/src/agent/runtime/mcp-server-tool-sources.test.ts +++ b/src/agent/runtime/mcp-server-tool-sources.test.ts @@ -15,6 +15,7 @@ import { } from "./mcp-server-tool-sources.ts"; import { VeryfrontError } from "#veryfront/errors"; import { runWithExactRuntimeRemoteToolSources } from "./remote-tool-source-context.ts"; +import { withMockFetch } from "#veryfront/testing/mock-fetch.ts"; Deno.test("getRequestedUnresolvedBooleanToolNames keeps legacy delegation local", () => { assertEquals( @@ -72,15 +73,14 @@ Deno.test("getRuntimeRemoteToolSources builds MCP sources with bearer auth and a tools: { search_docs: true }, mcpServers: [{ id: "docs", - transport: { type: "http", url: "https://docs.example.com/mcp" }, + transport: { type: "http", url: "https://93.184.216.34/mcp" }, auth: { type: "bearer", token: () => "docs-token" }, toolPolicy: { allow: ["search_docs"] }, - fetch: createMcpFetch(calls), }], }); assertEquals(sources?.length, 1); - assertEquals(await sources?.[0]?.listTools(), [{ + assertEquals(await withMockFetch(createMcpFetch(calls), () => sources![0]!.listTools()), [{ name: "search_docs", description: "Search docs", parameters: { type: "object", properties: {} }, @@ -97,7 +97,6 @@ Deno.test("getRuntimeRemoteToolSources blocks denied MCP tool execution", async id: "docs", transport: { type: "http", url: "https://docs.example.com/mcp" }, toolPolicy: { deny: ["delete_docs"] }, - fetch: createMcpFetch(calls), }], }); diff --git a/src/agent/runtime/mcp-server-tool-sources.ts b/src/agent/runtime/mcp-server-tool-sources.ts index 128587a57c..3e80de331c 100644 --- a/src/agent/runtime/mcp-server-tool-sources.ts +++ b/src/agent/runtime/mcp-server-tool-sources.ts @@ -104,7 +104,6 @@ function createMcpServerToolSource(server: AgentHttpMcpServerConfig): RemoteTool id: server.id, endpoint: (context) => resolveValue(server.transport.url, context), headers: (context) => resolveHeaders(server.auth, context), - ...(server.fetch ? { fetch: server.fetch } : {}), }); return wrapRemoteToolSourceWithMcpPolicy(source, server.toolPolicy, { diff --git a/src/agent/runtime/tool-helpers.test.ts b/src/agent/runtime/tool-helpers.test.ts index bba3581220..fa4c79c950 100644 --- a/src/agent/runtime/tool-helpers.test.ts +++ b/src/agent/runtime/tool-helpers.test.ts @@ -494,7 +494,7 @@ describe("tool-helpers", () => { it("executes remote MCP tools from configured remote tool sources", async () => { const remoteSource = createRemoteMCPToolSource({ id: "docs", - endpoint: "https://mcp.test", + endpoint: "https://93.184.216.34", }); const requestMethods: string[] = []; @@ -1011,7 +1011,7 @@ describe("tool-helpers", () => { const remoteSource = createRemoteMCPToolSource({ id: "docs", - endpoint: (context) => `https://mcp.test/${context?.projectId ?? "default"}`, + endpoint: (context) => `https://93.184.216.34/${context?.projectId ?? "default"}`, }); try { diff --git a/src/agent/service/mcp-server-config.test.ts b/src/agent/service/mcp-server-config.test.ts index ba2e4f94c1..f1b2e74d60 100644 --- a/src/agent/service/mcp-server-config.test.ts +++ b/src/agent/service/mcp-server-config.test.ts @@ -46,7 +46,6 @@ Deno.test("createAgentServiceRemoteMcpConfig builds Veryfront API MCP config", a }); Deno.test("createAgentServiceRemoteMcpConfig builds generic MCP config without dropping options", () => { - const customFetch = () => Promise.resolve(new Response("{}")); const headers = { Authorization: "Bearer external-token" }; assertEquals( createAgentServiceRemoteMcpConfig({ @@ -54,7 +53,6 @@ Deno.test("createAgentServiceRemoteMcpConfig builds generic MCP config without d id: "linear", endpoint: "https://linear.example/mcp", headers, - fetch: customFetch, listMethod: "tools/list", callMethod: "tools/call", }, @@ -65,7 +63,6 @@ Deno.test("createAgentServiceRemoteMcpConfig builds generic MCP config without d id: "linear", endpoint: "https://linear.example/mcp", headers, - fetch: customFetch, listMethod: "tools/list", callMethod: "tools/call", }, diff --git a/src/agent/service/mcp-server-config.ts b/src/agent/service/mcp-server-config.ts index e832615541..6ba44f8dc6 100644 --- a/src/agent/service/mcp-server-config.ts +++ b/src/agent/service/mcp-server-config.ts @@ -20,7 +20,6 @@ export type AgentServiceGenericMcpServerConfig = { id?: string; endpoint: RemoteMCPToolSourceConfig["endpoint"]; headers?: RemoteMCPToolSourceConfig["headers"]; - fetch?: RemoteMCPToolSourceConfig["fetch"]; listMethod?: RemoteMCPToolSourceConfig["listMethod"]; callMethod?: RemoteMCPToolSourceConfig["callMethod"]; toolPolicy?: AgentMcpToolPolicy; @@ -55,7 +54,6 @@ function createGenericRemoteMcpConfig( if (server.id !== undefined) config.id = server.id; if (server.headers !== undefined) config.headers = server.headers; - if (server.fetch !== undefined) config.fetch = server.fetch; if (server.listMethod !== undefined) config.listMethod = server.listMethod; if (server.callMethod !== undefined) config.callMethod = server.callMethod; diff --git a/src/agent/service/routes.ts b/src/agent/service/routes.ts index 0e414b7861..4a4a3a129a 100644 --- a/src/agent/service/routes.ts +++ b/src/agent/service/routes.ts @@ -19,6 +19,7 @@ import { import { executeHostedDurableChatRun } from "../hosted/durable-chat-run-start.ts"; import { type HostedServiceAuthenticatedRequest, HostedServiceAuthError } from "./auth.ts"; import { createRequestAuthCache } from "./request-auth-cache.ts"; +import { createApplicationRequest } from "#veryfront/security/http/application-request.ts"; import { isResponseLike } from "./response-like.ts"; import type { AgUiRuntimeRequest } from "../runtime/ag-ui-contract.ts"; import { @@ -268,8 +269,8 @@ export function createHostedAgentServiceRouteSet( } const hostedAgUiRuntimeHandler = createAgUiRuntimeHandler({ - beforeParse: async ({ request }) => { - const result = await authenticateAgUiRequest(request); + beforeParse: async ({ applicationRequest }) => { + const result = await authenticateAgUiRequest(applicationRequest); return isResponseLike(result) ? result : undefined; }, validationErrorResponse: ({ response }) => createHostedAgUiValidationErrorResponse(response), @@ -309,6 +310,7 @@ export function createHostedAgentServiceRouteSet( requestOrCtx?: unknown; }): Promise { return trace("handler.durableChatRunExecute", async () => { + const applicationRequest = createApplicationRequest(input.request); const req = await parseHostedChatRequestFromRequest(input.request, { authenticate: options.authenticateRequest, verifyProjectAccess: ({ projectId, authToken }) => @@ -321,7 +323,7 @@ export function createHostedAgentServiceRouteSet( return executeParsedDurableChatRun({ req, - request: input.request, + request: applicationRequest, requestOrCtx: input.requestOrCtx, }); }); @@ -333,6 +335,7 @@ export function createHostedAgentServiceRouteSet( runId?: string; }): Promise { return trace("handler.runtimeAgentRunInvocationExecute", async () => { + const applicationRequest = createApplicationRequest(input.request); const req = await parseRuntimeAgentRunInvocationHostedChatRequestFromRequest(input.request, { authenticate: options.authenticateRequest, verifyProjectAccess: ({ projectId, authToken }) => @@ -350,7 +353,7 @@ export function createHostedAgentServiceRouteSet( return executeParsedDurableChatRun({ req, - request: input.request, + request: applicationRequest, requestOrCtx: input.requestOrCtx, }); }); diff --git a/src/agent/types.ts b/src/agent/types.ts index 9c080bf474..836486010d 100644 --- a/src/agent/types.ts +++ b/src/agent/types.ts @@ -133,7 +133,6 @@ export interface AgentHttpMcpServerConfig { transport: AgentMcpHttpTransport; auth?: AgentMcpServerAuth; toolPolicy?: AgentMcpToolPolicy; - fetch?: typeof fetch; } /** MCP server available to an agent. */ diff --git a/src/cache/backend.test.ts b/src/cache/backend.test.ts index 5685c8648b..1b9ecf0fc8 100644 --- a/src/cache/backend.test.ts +++ b/src/cache/backend.test.ts @@ -477,7 +477,8 @@ Deno.test("ApiCacheBackend enforces exact bounded decoded values", async () => { try { const cache = new ApiCacheBackend({ - apiBaseUrl: "https://api.example.test", + apiBaseUrl: "https://93.184.216.34", + apiToken: "test-explicit-token", circuitBreakerName: "api-cache-bounded-value-test", }); assertEquals(await cache.getWithinLimit("key", 2), "é"); @@ -510,7 +511,8 @@ Deno.test("ApiCacheBackend reserves JSON escape bytes outside its response polic try { const cache = new ApiCacheBackend({ - apiBaseUrl: "https://api.example.test", + apiBaseUrl: "https://93.184.216.34", + apiToken: "test-explicit-token", // The compact string envelope is 12 bytes; the selected value receives // its own deterministic six-bytes-per-logical-byte wire allowance. maxResponseBytes: 12, @@ -545,7 +547,8 @@ Deno.test("ApiCacheBackend keeps unused string headroom outside its response pol try { const cache = new ApiCacheBackend({ - apiBaseUrl: "https://api.example.test", + apiBaseUrl: "https://93.184.216.34", + apiToken: "test-explicit-token", // The empty value uses none of its 12 bytes of wire headroom. The extra // metadata must still fail the independent 12-byte response policy. maxResponseBytes: 12, @@ -579,7 +582,8 @@ Deno.test("ApiCacheBackend rejects unsafe combined response limits before fetchi try { const cache = new ApiCacheBackend({ - apiBaseUrl: "https://api.example.test", + apiBaseUrl: "https://93.184.216.34", + apiToken: "test-explicit-token", maxResponseBytes: 1, circuitBreakerName: "api-cache-unsafe-combined-limit-test", }); @@ -620,7 +624,8 @@ Deno.test("ApiCacheBackend rejects oversized escaped values before JSON.parse", try { const cache = new ApiCacheBackend({ - apiBaseUrl: "https://api.example.test", + apiBaseUrl: "https://93.184.216.34", + apiToken: "test-explicit-token", circuitBreakerName: "api-cache-bounded-envelope-test", }); await assertRejects( @@ -656,7 +661,8 @@ Deno.test("ApiCacheBackend bounded overflows do not open the dependency circuit" try { const cache = new ApiCacheBackend({ - apiBaseUrl: "https://api.example.test", + apiBaseUrl: "https://93.184.216.34", + apiToken: "test-explicit-token", circuitBreakerName: "api-cache-neutral-bounded-overflow-test", }); for (let attempt = 0; attempt < 12; attempt++) { @@ -712,7 +718,8 @@ Deno.test("ApiCacheBackend propagates attempted delete failures", async () => { try { const cache = new ApiCacheBackend({ - apiBaseUrl: "https://api.example.test", + apiBaseUrl: "https://93.184.216.34", + apiToken: "test-explicit-token", circuitBreakerName: "api-cache-delete-failure-test", }); @@ -812,7 +819,8 @@ Deno.test("ApiCacheBackend safely maps query-aware keys without logging key-deri try { const cache = new ApiCacheBackend({ - apiBaseUrl: "https://api.example.test", + apiBaseUrl: "https://93.184.216.34", + apiToken: "test-explicit-token", keyPrefix: "prefix", circuitBreakerName: "api-cache-malformed-key-test", }); @@ -903,7 +911,8 @@ Deno.test("ApiCacheBackend bounds long keys and refuses malformed delete pattern try { const cache = new ApiCacheBackend({ - apiBaseUrl: "https://api.example.test", + apiBaseUrl: "https://93.184.216.34", + apiToken: "test-explicit-token", keyPrefix: "prefix", circuitBreakerName: "api-cache-long-key-test", }); @@ -964,7 +973,8 @@ Deno.test("ApiCacheBackend URL-encodes project refs and omits cache keys from sp try { const cache = new ApiCacheBackend({ - apiBaseUrl: "https://api.example.test", + apiBaseUrl: "https://93.184.216.34", + apiToken: "test-explicit-token", keyPrefix: "prefix", circuitBreakerName: "api-cache-url-encoding-test", }); @@ -982,14 +992,14 @@ Deno.test("ApiCacheBackend URL-encodes project refs and omits cache keys from sp const encodedProjectRef = encodeURIComponent(projectRef); assertEquals( capturedUrl, - `https://api.example.test/projects/${encodedProjectRef}/cache/get?key=prefix%3Asecret-cache-key`, + `https://93.184.216.34/projects/${encodedProjectRef}/cache/get?key=prefix%3Asecret-cache-key`, ); const span = records.find((record) => record.name === "http.client.fetch"); assertExists(span); assertEquals( span.attributes["http.url"], - `https://api.example.test/projects/${encodedProjectRef}/cache/get`, + `https://93.184.216.34/projects/${encodedProjectRef}/cache/get`, ); assertEquals(span.attributes["cache.operation"], "/get"); assertEquals(String(span.attributes["http.url"]).includes("secret-cache-key"), false); @@ -1005,7 +1015,7 @@ Deno.test("ApiCacheBackend URL-encodes project refs and omits cache keys from sp } }); -Deno.test("ApiCacheBackend only prefers verified control-plane request tokens", async () => { +Deno.test("ApiCacheBackend uses the credential paired with an explicit endpoint", async () => { const { ApiCacheBackend } = await importBackend(); const globals = globalThis as Record; const originalAdapter = globals.__vf_multi_project_adapter; @@ -1036,7 +1046,8 @@ Deno.test("ApiCacheBackend only prefers verified control-plane request tokens", try { const cache = new ApiCacheBackend({ - apiBaseUrl: "https://api.example.test", + apiBaseUrl: "https://93.184.216.34", + apiToken: "test-explicit-token", circuitBreakerName: "api-cache-host-token-test", }); const verifiedClaims = await createVerifiedCacheClaims({ @@ -1052,7 +1063,7 @@ Deno.test("ApiCacheBackend only prefers verified control-plane request tokens", assertEquals(requestScopedDeleted, 3); assertEquals( capturedUrls[0], - "https://api.example.test/projects/project-123/cache/del-pattern", + "https://93.184.216.34/projects/project-123/cache/del-pattern", ); const forgedTrustDeleted = await cache.delByPattern("agent:*"); @@ -1087,11 +1098,11 @@ Deno.test("ApiCacheBackend only prefers verified control-plane request tokens", assertEquals(hostFallbackDeleted, 3); assertEquals(capturedAuthorizations, [ - "Bearer run-scoped-request-token", - "Bearer host-framework-token", - "Bearer host-framework-token", - "Bearer unverified-proxy-token", - "Bearer host-framework-token", + "Bearer test-explicit-token", + "Bearer test-explicit-token", + "Bearer test-explicit-token", + "Bearer test-explicit-token", + "Bearer test-explicit-token", ]); } finally { if (originalAdapter === undefined) { @@ -1597,7 +1608,7 @@ Deno.test({ const originalProxyMode = Deno.env.get("PROXY_MODE"); const originalNodeEnv = Deno.env.get("NODE_ENV"); - Deno.env.set("VERYFRONT_API_BASE_URL", "https://api.example.test"); + Deno.env.set("VERYFRONT_API_BASE_URL", "https://93.184.216.34"); Deno.env.delete("PROXY_MODE"); Deno.env.delete("NODE_ENV"); globals.__vfProjectEnvGetter = () => undefined; @@ -1638,3 +1649,59 @@ Deno.test({ } }, }); + +Deno.test({ + name: "ApiCacheBackend does not pair a tenant env endpoint with a host credential", + fn: async () => { + const { ApiCacheBackend } = await importBackend(); + const globals = globalThis as Record; + const originalAdapter = globals.__vf_multi_project_adapter; + const originalProjectEnvGetter = globals.__vfProjectEnvGetter; + const originalProjectEnvActiveChecker = globals.__vfProjectEnvActiveChecker; + const originalFetch = globalThis.fetch; + const originalApiBaseUrl = Deno.env.get("VERYFRONT_API_BASE_URL"); + const originalApiToken = Deno.env.get("VERYFRONT_API_TOKEN"); + const capturedUrls: string[] = []; + + Deno.env.set("VERYFRONT_API_BASE_URL", "https://93.184.216.34"); + Deno.env.set("VERYFRONT_API_TOKEN", "host-token"); + globals.__vfProjectEnvGetter = (key: string) => + key === "VERYFRONT_API_BASE_URL" ? "https://93.184.216.35" : undefined; + globals.__vfProjectEnvActiveChecker = () => true; + globals.__vf_multi_project_adapter = { + getCurrentRequestContext: () => ({ projectId: "project-123" }), + }; + globalThis.fetch = ((input: RequestInfo | URL) => { + capturedUrls.push(String(input)); + return Promise.resolve( + Response.json({ deleted: 1 }), + ); + }) as typeof fetch; + + try { + const cache = new ApiCacheBackend({ + circuitBreakerName: "api-cache-tenant-endpoint-isolation-test", + }); + assertEquals(await cache.delByPattern("agent:*"), 1); + assertEquals( + capturedUrls, + ["https://93.184.216.34/projects/project-123/cache/del-pattern"], + ); + } finally { + if (originalAdapter === undefined) delete globals.__vf_multi_project_adapter; + else globals.__vf_multi_project_adapter = originalAdapter; + if (originalProjectEnvGetter === undefined) delete globals.__vfProjectEnvGetter; + else globals.__vfProjectEnvGetter = originalProjectEnvGetter; + if (originalProjectEnvActiveChecker === undefined) { + delete globals.__vfProjectEnvActiveChecker; + } else { + globals.__vfProjectEnvActiveChecker = originalProjectEnvActiveChecker; + } + globalThis.fetch = originalFetch; + if (originalApiBaseUrl === undefined) Deno.env.delete("VERYFRONT_API_BASE_URL"); + else Deno.env.set("VERYFRONT_API_BASE_URL", originalApiBaseUrl); + if (originalApiToken === undefined) Deno.env.delete("VERYFRONT_API_TOKEN"); + else Deno.env.set("VERYFRONT_API_TOKEN", originalApiToken); + } + }, +}); diff --git a/src/cache/backends/api.ts b/src/cache/backends/api.ts index 1586477e8e..5d74c307d9 100644 --- a/src/cache/backends/api.ts +++ b/src/cache/backends/api.ts @@ -9,6 +9,10 @@ import { getEnvValue } from "./helpers.ts"; import { buildBatchResults } from "../batch-results.ts"; import { REQUEST_ERROR } from "#veryfront/errors"; import { getHostEnv } from "#veryfront/platform/compat/process.ts"; +import { + guardedOutboundFetch, + OutboundRequestBlockedError, +} from "#veryfront/security/http/outbound-fetch.ts"; import { getVerifiedCacheApiCredential } from "../verified-api-credential-context.ts"; import { assertCacheReadMaximumBytes, @@ -78,6 +82,9 @@ function getCurrentRequestContext(): CacheRequestContext | null { export class ApiCacheBackend implements CacheBackend { readonly type = "api" as const; private apiBaseUrl: string; + private readonly apiOrigin: string; + private readonly hasExplicitApiBaseUrl: boolean; + private readonly explicitApiToken?: string; private keyPrefix: string; private timeoutMs: number; private readonly maxResponseBytes: number; @@ -86,16 +93,20 @@ export class ApiCacheBackend implements CacheBackend { constructor( options: { apiBaseUrl?: string; + /** Credential paired with a caller-selected apiBaseUrl. */ + apiToken?: string; keyPrefix?: string; timeoutMs?: number; maxResponseBytes?: number; circuitBreakerName?: string; } = {}, ) { + this.hasExplicitApiBaseUrl = options.apiBaseUrl !== undefined; this.apiBaseUrl = options.apiBaseUrl ?? getHostEnv("VERYFRONT_API_BASE_URL") ?? - getEnvValue("VERYFRONT_API_BASE_URL") ?? "https://api.veryfront.com"; + this.apiOrigin = new URL(this.apiBaseUrl).origin; + this.explicitApiToken = options.apiToken; this.keyPrefix = options.keyPrefix ?? ""; this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; const maxResponseBytes = options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES; @@ -162,10 +173,19 @@ export class ApiCacheBackend implements CacheBackend { const envToken = getEnvValue("VERYFRONT_API_TOKEN"); const verifiedCredential = getVerifiedCacheApiCredential(); const verifiedRequestToken = verifiedCredential?.token; + if (this.hasExplicitApiBaseUrl && !this.explicitApiToken) { + logger.warn("Caller-selected cache API endpoint omitted its credential", { + apiOrigin: this.apiOrigin, + }); + return null; + } // The private verified-request context cannot be changed through the // globally exposed filesystem request context. - const token = verifiedRequestToken || hostToken || reqCtx?.token || envToken || null; - const tokenSource = verifiedRequestToken + const token = this.explicitApiToken ?? verifiedRequestToken ?? hostToken ?? reqCtx?.token ?? + envToken ?? null; + const tokenSource = this.explicitApiToken + ? "explicit-endpoint" + : verifiedRequestToken ? "verified-control-plane" : hostToken ? "host-env" @@ -199,15 +219,28 @@ export class ApiCacheBackend implements CacheBackend { const response = await withSpan( SpanNames.HTTP_CLIENT_FETCH, () => - fetch(url, { - method, - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, + guardedOutboundFetch( + url, + { + method, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: body ? JSON.stringify(body) : undefined, + signal: controller.signal, + redirect: "error", + }, + { + authorizeUrl: (target) => { + if (target.origin !== this.apiOrigin) { + throw new OutboundRequestBlockedError( + "Cache API request blocked: destination origin is not authorized", + ); + } + }, }, - body: body ? JSON.stringify(body) : undefined, - signal: controller.signal, - }), + ), { "http.method": method, "http.url": spanUrl, diff --git a/src/cache/backends/factory.ts b/src/cache/backends/factory.ts index 4ae618e445..78dc616f9d 100644 --- a/src/cache/backends/factory.ts +++ b/src/cache/backends/factory.ts @@ -12,7 +12,6 @@ import { MemoryCacheBackend } from "./memory.ts"; import { isRedisConfigured, RedisCacheBackend } from "./redis.ts"; import { ApiCacheBackend } from "./api.ts"; import { DiskCacheBackend } from "./disk.ts"; -import { getEnvValue } from "./helpers.ts"; const logger = baseLogger.component("cache-backend"); @@ -32,7 +31,7 @@ export interface CacheBackendConfig { export function isApiCacheAvailable(): boolean { const proxyMode = getEnv("PROXY_MODE"); const nodeEnv = getEnv("NODE_ENV"); - const apiUrl = getHostEnv("VERYFRONT_API_BASE_URL") ?? getEnvValue("VERYFRONT_API_BASE_URL"); + const apiUrl = getHostEnv("VERYFRONT_API_BASE_URL"); const isProduction = proxyMode === "1" || nodeEnv === "production" || diff --git a/src/data/data-fetcher.ts b/src/data/data-fetcher.ts index 850f84d22c..77c263ac13 100644 --- a/src/data/data-fetcher.ts +++ b/src/data/data-fetcher.ts @@ -15,6 +15,14 @@ export interface FetchDataOptions { modulePath?: string; /** Project directory for worker scoping */ projectDir?: string; + /** Host-owned locality decision for development-only behavior. */ + isLocalProject?: boolean; + /** Narrow host-owned capability for project-code execution. */ + allowHostProjectCodeExecution?: boolean; + /** Stable host-owned tenant/project scope for reusable workers. */ + workerScope?: string; + /** Immutable release or source-snapshot identity for reusable workers. */ + sourceGeneration?: string; } export class DataFetcher { @@ -47,7 +55,14 @@ export class DataFetcher { : "none"; const isolationOptions: ServerDataFetchOptions | undefined = options - ? { modulePath: options.modulePath, projectDir: options.projectDir } + ? { + modulePath: options.modulePath, + projectDir: options.projectDir, + isLocalProject: options.isLocalProject, + allowHostProjectCodeExecution: options.allowHostProjectCodeExecution, + workerScope: options.workerScope, + sourceGeneration: options.sourceGeneration, + } : undefined; return withSpan( diff --git a/src/data/helpers.ts b/src/data/helpers.ts index 058a2c6e7d..f0f2c7c709 100644 --- a/src/data/helpers.ts +++ b/src/data/helpers.ts @@ -81,3 +81,59 @@ export function toDataControlResult(result: DataResult): DataResult { if (result.redirect) return { redirect: result.redirect }; return { notFound: true }; } + +/** Validate and snapshot a project hook result before recording success. */ +export function validateDataResult( + value: unknown, + hookName: "getServerData" | "getStaticData", +): DataResult { + const fail = (): never => { + throw new TypeError(`${hookName} must return a valid data result object`); + }; + if (value === null || typeof value !== "object" || Array.isArray(value)) return fail(); + + const result = value as Record; + const props = result.props; + const redirect = result.redirect; + const notFound = result.notFound; + const revalidate = result.revalidate; + let redirectDestination: string | undefined; + let redirectPermanent: boolean | undefined; + + if ( + redirect !== undefined && + (redirect === null || typeof redirect !== "object" || Array.isArray(redirect)) + ) { + return fail(); + } + if (redirect !== undefined) { + const redirectRecord = redirect as Record; + if ( + typeof redirectRecord.destination !== "string" || + (redirectRecord.permanent !== undefined && typeof redirectRecord.permanent !== "boolean") + ) return fail(); + redirectDestination = redirectRecord.destination; + redirectPermanent = redirectRecord.permanent as boolean | undefined; + } + if (notFound !== undefined && typeof notFound !== "boolean") return fail(); + if ( + revalidate !== undefined && revalidate !== false && + (typeof revalidate !== "number" || !Number.isFinite(revalidate) || revalidate < 0) + ) return fail(); + + const activeOutcomes = Number(props !== undefined) + Number(redirect !== undefined) + + Number(notFound === true); + if (activeOutcomes > 1) return fail(); + + const normalized: DataResult = {}; + if (props !== undefined) normalized.props = props; + if (redirectDestination !== undefined) { + normalized.redirect = { + destination: redirectDestination, + ...(redirectPermanent !== undefined ? { permanent: redirectPermanent } : {}), + }; + } + if (notFound !== undefined) normalized.notFound = notFound; + if (revalidate !== undefined) normalized.revalidate = revalidate as number | false; + return normalized; +} diff --git a/src/data/server-data-fetcher.test.ts b/src/data/server-data-fetcher.test.ts index d280ea9ea2..6c1ef3a7ae 100644 --- a/src/data/server-data-fetcher.test.ts +++ b/src/data/server-data-fetcher.test.ts @@ -1,12 +1,13 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals, assertExists, assertRejects } from "#veryfront/testing/assert.ts"; import { afterEach, describe, it } from "#veryfront/testing/bdd.ts"; -import { ServerDataFetcher } from "./server-data-fetcher.ts"; +import { __resolveDataWorkerIdentityForTests, ServerDataFetcher } from "./server-data-fetcher.ts"; import type { DataContext, DataResult, PageWithData } from "./types.ts"; import { notFound, redirect } from "./helpers.ts"; import { __resetPoolForTests } from "#veryfront/security/sandbox/worker-pool.ts"; import { runWithExactSourceIntegrationPolicy } from "#veryfront/integrations/source-policy-context.ts"; import { join } from "node:path"; +import { runWithProjectEnv } from "#veryfront/server/project-env/storage.ts"; describe("ServerDataFetcher", () => { function createContext(overrides: Partial = {}): DataContext { @@ -30,6 +31,100 @@ describe("ServerDataFetcher", () => { }); describe("fetch", () => { + it("rejects remote raw server-data execution before project code runs", async () => { + const fetcher = new ServerDataFetcher(); + let executed = false; + const pageModule: PageWithData = { + default: () => null, + getServerData: () => { + executed = true; + return { props: {} }; + }, + }; + + await assertRejects( + () => + fetcher.fetch(pageModule, createContext(), { + isLocalProject: false, + modulePath: "/tenant/page.ts", + projectDir: "/tenant", + }), + Error, + "Remote server-data execution requires", + ); + assertEquals(executed, false); + }); + + it("allows server-data execution in an explicitly capable dedicated runtime", async () => { + const fetcher = new ServerDataFetcher(); + let executed = false; + const pageModule: PageWithData = { + default: () => null, + getServerData: () => { + executed = true; + return { props: { source: "dedicated" } }; + }, + }; + + const result = await fetcher.fetch(pageModule, createContext(), { + isLocalProject: false, + allowHostProjectCodeExecution: true, + }); + + assertEquals(result.props, { source: "dedicated" }); + assertEquals(executed, true); + }); + + it("binds reusable data workers to tenant, source, policy, and project env", async () => { + const identity = ( + workerScope: string, + sourceGeneration: string, + policy: Parameters[0], + projectEnv: Record, + ) => + runWithProjectEnv( + projectEnv, + () => + runWithExactSourceIntegrationPolicy(policy, () => + __resolveDataWorkerIdentityForTests({ + workerScope, + sourceGeneration, + })), + ); + + const unrestricted = { schemaVersion: 1, mode: "unrestricted" } as const; + const denyAll = { + schemaVersion: 1, + mode: "allowlist", + integrations: {}, + } as const; + const baseline = await identity("tenant-a", "release-a", unrestricted, { + TENANT_SECRET: "one", + }); + const same = await identity("tenant-a", "release-a", unrestricted, { + TENANT_SECRET: "one", + }); + const changedTenant = await identity("tenant-b", "release-a", unrestricted, { + TENANT_SECRET: "one", + }); + const changedSource = await identity("tenant-a", "release-b", unrestricted, { + TENANT_SECRET: "one", + }); + const changedPolicy = await identity("tenant-a", "release-a", denyAll, { + TENANT_SECRET: "one", + }); + const changedEnv = await identity("tenant-a", "release-a", unrestricted, { + TENANT_SECRET: "two", + }); + + assertEquals(baseline.reusable, true); + assertEquals(same.workerId, baseline.workerId); + assertEquals(changedTenant.workerId === baseline.workerId, false); + assertEquals(changedSource.workerId === baseline.workerId, false); + assertEquals(changedPolicy.workerId === baseline.workerId, false); + assertEquals(changedEnv.workerId === baseline.workerId, false); + }); + it("should return empty props when getServerData is not defined", async () => { const fetcher = new ServerDataFetcher(); const pageModule: PageWithData = { default: () => null }; @@ -268,7 +363,7 @@ describe("ServerDataFetcher", () => { { modulePath: "/tmp/test/page.ts", projectDir: "/tmp/test" }, ), Error, - "too large", + "exceeds size limit", ); }); @@ -298,10 +393,47 @@ describe("ServerDataFetcher", () => { { modulePath: "/tmp/test/page.ts", projectDir: "/tmp/test" }, ), Error, - "too large", + "exceeds size limit", ); }); + it("should bound chunked bodies while streaming and cancel the source", async () => { + Deno.env.set("WORKER_ISOLATION_ENABLED", "1"); + Deno.env.set("WORKER_ISOLATION_DATA", "1"); + __resetPoolForTests(); + + const fetcher = new ServerDataFetcher(); + const pageModule: PageWithData = { + default: () => null, + getServerData: () => ({ props: {} }), + }; + const chunk = new Uint8Array(6 * 1024 * 1024); + let cancelled = false; + const request = new Request("http://localhost/test", { + method: "POST", + body: new ReadableStream({ + pull(controller) { + controller.enqueue(chunk); + }, + cancel() { + cancelled = true; + }, + }), + }); + + await assertRejects( + () => + fetcher.fetch( + pageModule, + createContext({ request }), + { modulePath: "/tmp/test/page.ts", projectDir: "/tmp/test" }, + ), + Error, + "exceeds size limit", + ); + assertEquals(cancelled, true); + }); + it("should skip body size guard when request has no body", async () => { Deno.env.set("WORKER_ISOLATION_ENABLED", "1"); Deno.env.set("WORKER_ISOLATION_DATA", "1"); @@ -532,6 +664,7 @@ describe("ServerDataFetcher", () => { function isolatedFetch( modulePath: string, dir: string, + context: DataContext = createContext(), ): Promise { const fetcher = new ServerDataFetcher(); const pageModule: PageWithData = { @@ -542,9 +675,10 @@ describe("ServerDataFetcher", () => { return runWithExactSourceIntegrationPolicy( { schemaVersion: 1, mode: "unrestricted" }, () => - fetcher.fetch(pageModule, createContext(), { + fetcher.fetch(pageModule, context, { modulePath, projectDir: dir, + isLocalProject: true, }), ); } @@ -596,6 +730,43 @@ describe("ServerDataFetcher", () => { "intentional test error from isolated getServerData", ); }); + + it("does not expose infrastructure headers to isolated server-data hooks", async () => { + const { modulePath, projectDir: dir } = await writeIsolatedPage( + `export function getServerData(context) { + return { + props: { + authorization: context.request.headers.get("authorization"), + projectId: context.request.headers.get("x-project-id"), + token: context.request.headers.get("x-token"), + veryfront: context.request.headers.get("x-veryfront-release-id"), + }, + }; + } + export default function Page() { return null; }`, + ); + const request = new Request("http://localhost/test", { + headers: { + authorization: "Bearer application-user", + "x-project-id": "tenant-42", + "x-token": "platform-secret", + "x-veryfront-release-id": "release-secret", + }, + }); + + const result = await isolatedFetch( + modulePath, + dir, + createContext({ request }), + ); + + assertEquals(result.props, { + authorization: "Bearer application-user", + projectId: null, + token: null, + veryfront: null, + }); + }); }); it("still opens the circuit breaker on repeated genuine errors", async () => { diff --git a/src/data/server-data-fetcher.ts b/src/data/server-data-fetcher.ts index b3b7642b70..91bb8425a8 100644 --- a/src/data/server-data-fetcher.ts +++ b/src/data/server-data-fetcher.ts @@ -11,6 +11,17 @@ import { type WorkerResponse, } from "#veryfront/security/sandbox/worker-types.ts"; import { requireActiveSourceIntegrationPolicy } from "#veryfront/integrations/source-policy-context.ts"; +import type { SourceIntegrationPolicyManifest } from "#veryfront/integrations/source-policy.ts"; +import { + digestWorkerGenerationMaterial, + resolveWorkerGeneration, + snapshotWorkerGenerationIdentity, +} from "#veryfront/security/sandbox/worker-generation.ts"; +import { getTrustedProjectEnvSnapshot } from "#veryfront/platform/compat/process/env.ts"; +import type { ProjectEnvSnapshot } from "#veryfront/platform/compat/process/project-env-contract.ts"; +import { INITIALIZATION_ERROR } from "#veryfront/errors"; +import { readBodyBytesWithLimit } from "#veryfront/security/input-validation/limits.ts"; +import { createApplicationRequestHeaders } from "#veryfront/security/http/application-request.ts"; /** * Options for isolated data fetching through Worker pool. @@ -20,6 +31,84 @@ export interface ServerDataFetchOptions { modulePath?: string; /** Project directory for worker scoping */ projectDir?: string; + /** Host-owned locality decision for development-only behavior. */ + isLocalProject?: boolean; + /** Narrow host-owned capability for project-code execution. */ + allowHostProjectCodeExecution?: boolean; + /** Stable host-owned tenant/project scope for reusable workers. */ + workerScope?: string; + /** Immutable release or source-snapshot identity for reusable workers. */ + sourceGeneration?: string; +} + +interface DataWorkerAdmission { + readonly projectEnv?: ProjectEnvSnapshot; + readonly sourceIntegrationPolicy: SourceIntegrationPolicyManifest; + readonly workerId: string; + readonly reusable: boolean; +} + +function appendIdentityPart(parts: string[], value: string): void { + parts.push(`${value.length}:${value}`); +} + +async function resolveDataWorkerAdmission( + options: ServerDataFetchOptions, +): Promise { + const sourceIntegrationPolicy = requireActiveSourceIntegrationPolicy(); + const projectEnv = getTrustedProjectEnvSnapshot(); + const hasScope = options.workerScope !== undefined; + const hasGeneration = options.sourceGeneration !== undefined; + if (hasScope !== hasGeneration) { + throw new TypeError( + "Data worker scope and source generation must be supplied together", + ); + } + + if (!hasScope || !hasGeneration) { + const generation = await resolveWorkerGeneration("data"); + return { + projectEnv, + sourceIntegrationPolicy, + workerId: generation.workerId, + reusable: false, + }; + } + + const semanticParts: string[] = []; + appendIdentityPart(semanticParts, options.sourceGeneration!); + appendIdentityPart(semanticParts, sourceIntegrationPolicy.mode); + appendIdentityPart(semanticParts, JSON.stringify(sourceIntegrationPolicy)); + if (projectEnv) { + for (const key of Object.keys(projectEnv).sort()) { + appendIdentityPart(semanticParts, key); + appendIdentityPart(semanticParts, projectEnv[key]!); + } + } + + const identity = snapshotWorkerGenerationIdentity( + options.workerScope!, + await digestWorkerGenerationMaterial(semanticParts.join("|")), + ); + if (!identity) throw new TypeError("Data worker generation identity is required"); + const generation = await resolveWorkerGeneration("data", identity); + return { + projectEnv, + sourceIntegrationPolicy, + workerId: generation.workerId, + reusable: generation.reusable, + }; +} + +/** @internal Exact worker identity probe for boundary regression tests. */ +export async function __resolveDataWorkerIdentityForTests( + options: ServerDataFetchOptions, +): Promise>> { + const admission = await resolveDataWorkerAdmission(options); + return Object.freeze({ + workerId: admission.workerId, + reusable: admission.reusable, + }); } export class ServerDataFetcher { @@ -28,6 +117,16 @@ export class ServerDataFetcher { context: DataContext, options?: ServerDataFetchOptions, ): Promise { + if ( + options?.isLocalProject === false && + options.allowHostProjectCodeExecution !== true + ) { + return Promise.reject( + INITIALIZATION_ERROR.create({ + detail: "Remote server-data execution requires a generation-owned prepared module graph", + }), + ); + } if (typeof pageModule.getServerData !== "function") { return Promise.resolve({ props: {} }); } @@ -58,7 +157,7 @@ export class ServerDataFetcher { try { return await withTimeoutThrow( useIsolation - ? this.fetchIsolated(options!.modulePath!, options!.projectDir!, context) + ? this.fetchIsolated(options!, context) : Promise.resolve(pageModule.getServerData!(context)), DATA_FETCH_TIMEOUT_MS, `getServerData for ${pathname}`, @@ -122,72 +221,65 @@ export class ServerDataFetcher { * Execute getServerData in a per-project Worker. */ private async fetchIsolated( - modulePath: string, - projectDir: string, + options: ServerDataFetchOptions, context: DataContext, ): Promise { + const modulePath = options.modulePath!; + const projectDir = options.projectDir!; const pool = getWorkerPool(); let body: Uint8Array | null = null; if (context.request?.body) { - // Fast path: reject before buffering if Content-Length is known - const contentLength = context.request.headers?.get("content-length"); - if (contentLength) { - const bytes = parseInt(contentLength, 10); - if (bytes > MAX_WORKER_BODY_BYTES) { - throw new Error( - `Request body too large for isolated data fetch (${ - (bytes / 1024 / 1024).toFixed(1) - } MB, limit ${MAX_WORKER_BODY_BYTES / 1024 / 1024} MB)`, - ); - } - } + body = await readBodyBytesWithLimit( + context.request, + MAX_WORKER_BODY_BYTES, + ); + } - body = new Uint8Array(await context.request.arrayBuffer()); + const applicationHeaders = context.request + ? createApplicationRequestHeaders(context.request.headers) + : undefined; - // Fallback: check actual size for chunked/streaming bodies - if (body.byteLength > MAX_WORKER_BODY_BYTES) { - throw new Error( - `Request body too large for isolated data fetch (${ - (body.byteLength / 1024 / 1024).toFixed(1) - } MB, limit ${MAX_WORKER_BODY_BYTES / 1024 / 1024} MB)`, - ); - } - } + const admission = await resolveDataWorkerAdmission(options); - const workerResponse: WorkerResponse = await pool.execute( - projectDir, - [projectDir], - { - type: "fetch-data", - id: crypto.randomUUID(), - modulePath, - context: { - params: context.params, - query: context.query?.toString() ?? "", - request: { - url: context.request?.url ?? context.url?.toString() ?? "http://localhost", - method: context.request?.method ?? "GET", - headers: context.request ? [...context.request.headers.entries()] : [], - body, + try { + const workerResponse: WorkerResponse = await pool.execute( + admission.workerId, + [projectDir], + { + type: "fetch-data", + id: crypto.randomUUID(), + modulePath, + context: { + params: context.params, + query: context.query?.toString() ?? "", + request: { + url: context.request?.url ?? context.url?.toString() ?? "http://localhost", + method: context.request?.method ?? "GET", + headers: applicationHeaders ? [...applicationHeaders.entries()] : [], + body, + }, + url: context.url?.toString() ?? "http://localhost", }, - url: context.url?.toString() ?? "http://localhost", + sourceIntegrationPolicy: admission.sourceIntegrationPolicy, + projectEnv: admission.projectEnv, }, - sourceIntegrationPolicy: requireActiveSourceIntegrationPolicy(), - }, - ); + ); - if (workerResponse.type === "error") { - const err = new Error(workerResponse.error.message); - err.name = workerResponse.error.name; - throw err; - } + if (workerResponse.type === "error") { + const err = new Error(workerResponse.error.message); + err.name = workerResponse.error.name; + throw err; + } - if (workerResponse.type === "data-result") { - return workerResponse.result as DataResult; - } + if (workerResponse.type === "data-result") { + return workerResponse.result as DataResult; + } - // Unexpected response type — shouldn't happen but be defensive - throw new Error(`Unexpected worker response type: ${workerResponse.type}`); + // Unexpected response type — shouldn't happen but be defensive + throw new Error(`Unexpected worker response type: ${workerResponse.type}`); + } finally { + if (!admission.reusable) pool.evictWorker(admission.workerId); + } } /** diff --git a/src/discovery/agent-scoped-capabilities.test.ts b/src/discovery/agent-scoped-capabilities.test.ts index 4c34f4272d..804bdad077 100644 --- a/src/discovery/agent-scoped-capabilities.test.ts +++ b/src/discovery/agent-scoped-capabilities.test.ts @@ -33,7 +33,10 @@ function emptyResult(): DiscoveryResult { }; } -const context: FileDiscoveryContext = { platform: "node" }; +const context: FileDiscoveryContext = { + platform: "node", + allowHostProjectCodeExecution: true, +}; async function writeFixtureProject(root: string): Promise { const agentsDir = `${root}/agents`; @@ -252,7 +255,11 @@ Deno.test("agent ids that sanitize to the same namespace report a collision erro // ── Full-pipeline regression (review finding: discoverAll wiped colocated skills) ── -import { discoverAll } from "./index.ts"; +import { discoverAll as discoverAllRaw } from "./index.ts"; + +function discoverAll(config: import("./types.ts").DiscoveryConfig) { + return discoverAllRaw({ ...config, allowHostProjectCodeExecution: true }); +} Deno.test("discoverAll preserves directory-agent colocated skills through the skill-registry clear", async () => { const root = await Deno.makeTempDir(); diff --git a/src/discovery/auto-discovery.integration.test.ts b/src/discovery/auto-discovery.integration.test.ts index 15db3db409..d3b8d35889 100644 --- a/src/discovery/auto-discovery.integration.test.ts +++ b/src/discovery/auto-discovery.integration.test.ts @@ -11,13 +11,23 @@ import { promptRegistry } from "#veryfront/prompt"; import { resourceRegistry } from "#veryfront/resource"; import { agentRegistry } from "#veryfront/agent/composition/index.ts"; import { createMockAdapter } from "#veryfront/platform"; -import { discoverSchedules } from "#veryfront/schedule"; -import { discoverWebhooks } from "#veryfront/webhook"; +import { discoverSchedules as discoverSchedulesRaw } from "#veryfront/schedule"; +import { discoverWebhooks as discoverWebhooksRaw } from "#veryfront/webhook"; import { join, resolve } from "#veryfront/compat/path"; import { cwd } from "#veryfront/compat/process.ts"; import { clearTranspileCache } from "#veryfront/discovery/transpiler.ts"; import { stop as stopEsbuild } from "veryfront/extensions/bundler"; -import { discoverAll } from "./index.ts"; +import { discoverAll as discoverAllRaw } from "./index.ts"; +import type { DiscoveryConfig } from "./types.ts"; + +function discoverAll(config: DiscoveryConfig) { + return discoverAllRaw({ ...config, allowHostProjectCodeExecution: true }); +} + +const discoverSchedules: typeof discoverSchedulesRaw = (options) => + discoverSchedulesRaw({ ...options, allowHostProjectCodeExecution: true }); +const discoverWebhooks: typeof discoverWebhooksRaw = (options) => + discoverWebhooksRaw({ ...options, allowHostProjectCodeExecution: true }); function getFixturePath(): string { return resolve(join(cwd(), "src", "discovery", "__fixtures__", "autodiscovery")); @@ -284,10 +294,15 @@ describe( const result = await discoverWebhooks({ projectDir: "/project", adapter }); - assertEquals(result.items.map((item) => item.id), ["ticket-created"]); - assertEquals(result.errors.length, 1); - assertEquals(result.errors[0]?.code, "duplicate_source_id"); - assertEquals(result.errors[0]?.sourceId, "ticket-created"); + assertEquals(result.items, []); + assertEquals(result.errors.length, 2); + assertEquals( + result.errors.map((error) => [error.code, error.sourceId]), + [ + ["duplicate_source_id", "ticket-created"], + ["duplicate_source_id", "ticket-created"], + ], + ); }); it("should discover all valid named exports from a single tool file", async () => { diff --git a/src/discovery/discovery-engine.ts b/src/discovery/discovery-engine.ts index 9c53d9a5e5..55d8991d7a 100644 --- a/src/discovery/discovery-engine.ts +++ b/src/discovery/discovery-engine.ts @@ -31,6 +31,7 @@ import { } from "./handlers/index.ts"; import { discoverRuntimeAgentMarkdownDefinitions } from "./handlers/runtime-agent-markdown-handler.ts"; import { filenameToId } from "./discovery-utils.ts"; +import { isExplicitHostProjectCodeExecutionAllowed } from "#veryfront/security/project-locality.ts"; const logger = agentLogger.component("discovery"); @@ -185,6 +186,12 @@ async function discoverConfiguredItems( * Discover all items in configured directories */ export async function discoverAll(config: DiscoveryConfig): Promise { + if (!isExplicitHostProjectCodeExecutionAllowed(config)) { + throw new TypeError( + "Executable project discovery requires explicit trusted-local execution", + ); + } + const baseDir = config.baseDir; const context: FileDiscoveryContext = { @@ -192,6 +199,7 @@ export async function discoverAll(config: DiscoveryConfig): Promise; diff --git a/src/discovery/project-discovery-config.ts b/src/discovery/project-discovery-config.ts index 2c367a67d0..7b5f9e8f09 100644 --- a/src/discovery/project-discovery-config.ts +++ b/src/discovery/project-discovery-config.ts @@ -26,6 +26,8 @@ type ProjectDiscoveryConfigInput = { config?: VeryfrontConfig | null; fsAdapter?: FileSystemAdapter; verbose?: boolean; + /** Explicit host-owned capability for trusted local/dedicated runtimes only. */ + allowHostProjectCodeExecution?: boolean; }; export type ProjectDiscoveryConfig = DiscoveryConfig & { @@ -113,5 +115,6 @@ export function createProjectDiscoveryConfig( ), fsAdapter: input.fsAdapter, verbose: input.verbose ?? false, + allowHostProjectCodeExecution: input.allowHostProjectCodeExecution === true, }; } diff --git a/src/discovery/registry-replacement.test.ts b/src/discovery/registry-replacement.test.ts index 4ee94d73b5..e4d4a228f5 100644 --- a/src/discovery/registry-replacement.test.ts +++ b/src/discovery/registry-replacement.test.ts @@ -48,6 +48,7 @@ describe("replaceDiscoveredProjectPrimitives", () => { scheduleDirs: [], webhookDirs: [], evalDirs: [], + allowHostProjectCodeExecution: true, }; const failure = await assertRejects( () => replaceDiscoveredProjectPrimitives(config), @@ -109,6 +110,7 @@ describe("replaceDiscoveredProjectPrimitives", () => { scheduleDirs: [], webhookDirs: [], evalDirs: [], + allowHostProjectCodeExecution: true, }, { errorPolicy: "publish-valid" }); assertEquals(result.errors.length, 1); diff --git a/src/discovery/skill-discovery.test.ts b/src/discovery/skill-discovery.test.ts index 65a1f6fae6..8b6c95fedb 100644 --- a/src/discovery/skill-discovery.test.ts +++ b/src/discovery/skill-discovery.test.ts @@ -9,7 +9,12 @@ import { type SkillDocumentParserProvider, SkillDocumentParserProviderName, } from "#veryfront/extensions/parser/skill-document-parser.ts"; -import { discoverAll } from "./index.ts"; +import { discoverAll as discoverAllRaw } from "./index.ts"; +import type { DiscoveryConfig } from "./types.ts"; + +function discoverAll(config: DiscoveryConfig) { + return discoverAllRaw({ ...config, allowHostProjectCodeExecution: true }); +} describe("src/discovery/skill-discovery", () => { beforeEach(() => { diff --git a/src/discovery/transpiler.test.ts b/src/discovery/transpiler.test.ts index 59dbf5ee46..9f36c64425 100644 --- a/src/discovery/transpiler.test.ts +++ b/src/discovery/transpiler.test.ts @@ -2,13 +2,20 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; import { afterAll, afterEach, describe, it } from "#veryfront/testing/bdd.ts"; import type { FileSystemAdapter } from "#veryfront/platform/adapters/base.ts"; -import { clearTranspileCache, importModule } from "./transpiler.ts"; +import { clearTranspileCache, importModule as importModuleRaw } from "./transpiler.ts"; import type { FileDiscoveryContext } from "./types.ts"; import { stop as stopEsbuild } from "veryfront/extensions/bundler"; import { reset, tryResolve } from "#veryfront/extensions/contracts.ts"; import * as embeddingMod from "#veryfront/embedding/index.ts"; import * as knowledgeMod from "#veryfront/knowledge"; +function importModule(file: string, context: FileDiscoveryContext) { + return importModuleRaw(file, { + ...context, + allowHostProjectCodeExecution: true, + }); +} + /** * Creates a mock FileSystemAdapter backed by an in-memory file map. * @@ -120,6 +127,34 @@ describe("discovery/transpiler", { sanitizeOps: false, sanitizeResources: false }); describe("importModule with fsAdapter", () => { + it("rejects untrusted discovery before reading or evaluating project code", async () => { + const marker = "__vf_untrusted_discovery_marker__"; + delete (globalThis as Record)[marker]; + let reads = 0; + const adapter = createMockAdapter({ + "/project/tools/untrusted.ts": + `globalThis.${marker} = Deno.env.get("VERYFRONT_API_TOKEN"); export default {};`, + }); + const readFile = adapter.readFile.bind(adapter); + adapter.readFile = (path) => { + reads++; + return readFile(path); + }; + + await assertRejects( + () => + importModuleRaw("file:///project/tools/untrusted.ts", { + platform: "node", + fsAdapter: adapter, + baseDir: "/project", + }), + TypeError, + "explicit trusted-local execution", + ); + assertEquals(reads, 0); + assertEquals((globalThis as Record)[marker], undefined); + }); + it("should transpile a simple module via fsAdapter", async () => { const files: Record = { "/project/agents/assistant.ts": `export default { name: "test-agent" };`, diff --git a/src/discovery/transpiler.ts b/src/discovery/transpiler.ts index 4b67ec15ee..1b42367206 100644 --- a/src/discovery/transpiler.ts +++ b/src/discovery/transpiler.ts @@ -18,6 +18,7 @@ import { rewriteDiscoveryImports, rewriteForDeno } from "./import-rewriter.ts"; import { COMPILATION_ERROR, FILE_NOT_FOUND } from "#veryfront/errors"; import { wrapWithCurrentContext } from "#veryfront/platform/adapters/fs/veryfront/request-context.ts"; import { getDiscoveryRuntimeModules } from "./runtime-modules.ts"; +import { isExplicitHostProjectCodeExecutionAllowed } from "#veryfront/security/project-locality.ts"; type TranspileCacheEntry = { /** Content hashes of every file esbuild bundled into the module besides the entry. */ @@ -178,6 +179,12 @@ export async function importModule( file: string, context: FileDiscoveryContext, ): Promise { + if (!isExplicitHostProjectCodeExecutionAllowed(context)) { + throw new TypeError( + "Discovery module host loading requires explicit trusted-local execution", + ); + } + // Ensure veryfront modules are available as globals for compiled binaries await ensureVeryfrontGlobals(); diff --git a/src/discovery/types.ts b/src/discovery/types.ts index ba649324b5..0b056978c5 100644 --- a/src/discovery/types.ts +++ b/src/discovery/types.ts @@ -30,6 +30,8 @@ export interface FileDiscoveryContext { path: typeof import("node:path"); }; baseDir?: string; + /** Explicit host-owned capability for trusted local/dedicated runtimes only. */ + allowHostProjectCodeExecution?: boolean; } /** @@ -51,6 +53,8 @@ export interface DiscoveryConfig { evalDirs?: string[]; verbose?: boolean; fsAdapter?: FileSystemAdapter; + /** Explicit host-owned capability required before executable modules are imported. */ + allowHostProjectCodeExecution?: boolean; } /** diff --git a/src/embedding/embedding.test.ts b/src/embedding/embedding.test.ts index b17ba534a6..3b830b3ebc 100644 --- a/src/embedding/embedding.test.ts +++ b/src/embedding/embedding.test.ts @@ -1,11 +1,18 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; import { afterEach, describe, it } from "#veryfront/testing/bdd.ts"; +import { deleteEnv, setEnv } from "#veryfront/compat/process.ts"; +import { ensureBuiltinLLMProviders } from "#veryfront/extensions/builtin-extensions.ts"; import { embedding } from "./embedding.ts"; import { clearEmbeddingProviders, registerEmbeddingProvider } from "./resolve.ts"; describe("embedding", () => { + const originalFetch = globalThis.fetch; + afterEach(() => { + globalThis.fetch = originalFetch; + deleteEnv("GOOGLE_API_KEY"); + deleteEnv("GOOGLE_GENERATIVE_AI_API_KEY"); clearEmbeddingProviders(); }); @@ -64,4 +71,32 @@ describe("embedding", () => { assertEquals(result, [1, 2, 3]); assertEquals(values, ["search_query: cats"]); }); + + it("keeps auto-initialized Google embeddings on the captured guarded transport", async () => { + ensureBuiltinLLMProviders(); + setEnv("GOOGLE_API_KEY", "google-test-key"); + let guardedCalls = 0; + let replacedGlobalCalls = 0; + let requestedApiKey: string | null = null; + globalThis.fetch = (async (input: URL | Request | string, init?: RequestInit) => { + guardedCalls++; + const request = new Request(input, init); + requestedApiKey = request.headers.get("x-goog-api-key"); + return Response.json({ + embedding: { values: [0.25, 0.75] }, + usageMetadata: { promptTokenCount: 2 }, + }); + }) as typeof fetch; + + const embedder = embedding({ model: "google/gemini-embedding-001" }); + globalThis.fetch = (() => { + replacedGlobalCalls++; + return Promise.resolve(new Response("unexpected", { status: 500 })); + }) as typeof fetch; + + assertEquals(await embedder.embed("hello"), [0.25, 0.75]); + assertEquals(guardedCalls, 1); + assertEquals(replacedGlobalCalls, 0); + assertEquals(requestedApiKey, "google-test-key"); + }); }); diff --git a/src/embedding/resolve.ts b/src/embedding/resolve.ts index fa79f5226b..522e0d4ed5 100644 --- a/src/embedding/resolve.ts +++ b/src/embedding/resolve.ts @@ -1,5 +1,7 @@ import { createError, toError } from "#veryfront/errors"; import { getGoogleGenAIEnvConfig, getOpenAIEnvConfig } from "#veryfront/config/env.ts"; +import { createOriginBoundOutboundFetch } from "#veryfront/security/http/outbound-fetch.ts"; +import { DEFAULT_GOOGLE_BASE_URL } from "#veryfront/provider/runtime-loader/provider-endpoints.ts"; import { createLocalEmbeddingModel } from "#veryfront/provider/local/embedding-runtime-adapter.ts"; import type { EmbeddingRuntime } from "#veryfront/provider/types.ts"; import { tryResolve } from "#veryfront/extensions/contracts.ts"; @@ -49,6 +51,9 @@ function autoInitializeFromEnv(): void { return provider.createEmbedding(id, { credential: config.apiKey, baseURL: config.baseURL, + fetch: createOriginBoundOutboundFetch( + config.baseURL ?? "https://api.openai.com/v1", + ), }); } throw toError( @@ -78,6 +83,7 @@ function autoInitializeFromEnv(): void { if (provider?.createEmbedding) { return provider.createEmbedding(id, { credential: config.apiKey, + fetch: createOriginBoundOutboundFetch(DEFAULT_GOOGLE_BASE_URL), }); } throw toError( diff --git a/src/embedding/upload-handler.test.ts b/src/embedding/upload-handler.test.ts index f031d0a122..42716625b2 100644 --- a/src/embedding/upload-handler.test.ts +++ b/src/embedding/upload-handler.test.ts @@ -17,6 +17,11 @@ const EXPLICIT_UNAUTHENTICATED = { auth: { type: "none", allowUnauthenticated: true }, } as const; +// Literal public addresses exercise the egress guard without relying on +// environment-specific DNS behavior for reserved `.test` hostnames. +const TEST_PUBLIC_API_ORIGIN = "https://93.184.216.34"; +const TEST_PUBLIC_STORAGE_ORIGIN = "https://1.1.1.1"; + function clearCloudEnv(): void { for (const key of CLOUD_ENV_KEYS) { try { @@ -273,7 +278,7 @@ describe("createUploadHandler", () => { it("stores uploaded source binaries in Veryfront Cloud when bootstrap is present", async () => { setEnv("VERYFRONT_API_TOKEN", "vf_test_uploads"); setEnv("VERYFRONT_PROJECT_SLUG", "demo-project"); - setEnv("VERYFRONT_API_BASE_URL", "https://api.test"); + setEnv("VERYFRONT_API_BASE_URL", TEST_PUBLIC_API_ORIGIN); const calls: Array<{ method: string; url: string; body?: unknown }> = []; const store = createStubStore(); @@ -284,19 +289,22 @@ describe("createUploadHandler", () => { const url = request.url; const method = request.method; - if (method === "POST" && url === "https://api.test/projects/demo-project/uploads") { + if ( + method === "POST" && + url === `${TEST_PUBLIC_API_ORIGIN}/projects/demo-project/uploads` + ) { const body = await request.json(); calls.push({ method, url, body }); return Response.json({ - file_upload_url: "https://storage.test/upload/doc-123", + file_upload_url: `${TEST_PUBLIC_STORAGE_ORIGIN}/upload/doc-123`, file_path: ".veryfront/rag/uploads/doc-123.blob", upload_id: "upload-123", required_headers: {}, }); } - if (method === "PUT" && url === "https://storage.test/upload/doc-123") { + if (method === "PUT" && url === `${TEST_PUBLIC_STORAGE_ORIGIN}/upload/doc-123`) { calls.push({ method, url }); return new Response(null, { status: 200 }); } @@ -319,29 +327,35 @@ describe("createUploadHandler", () => { assertEquals(response.status, 200); assertEquals(calls.length, 4); assertEquals(calls[0]?.method, "POST"); - assertEquals(calls[0]?.url, "https://api.test/projects/demo-project/uploads"); + assertEquals( + calls[0]?.url, + `${TEST_PUBLIC_API_ORIGIN}/projects/demo-project/uploads`, + ); assertEquals(calls[0]?.body, { file_path: ".veryfront/rag/uploads/doc-123.blob", content_type: "text/plain", size: 11, }); assertEquals(calls[1]?.method, "PUT"); - assertEquals(calls[1]?.url, "https://storage.test/upload/doc-123"); + assertEquals(calls[1]?.url, `${TEST_PUBLIC_STORAGE_ORIGIN}/upload/doc-123`); assertEquals(calls[2]?.method, "POST"); - assertEquals(calls[2]?.url, "https://api.test/projects/demo-project/uploads"); + assertEquals( + calls[2]?.url, + `${TEST_PUBLIC_API_ORIGIN}/projects/demo-project/uploads`, + ); const metadataCreateBody = calls[2]?.body as Record; assertEquals(metadataCreateBody.file_path, ".veryfront/rag/uploads/doc-123.meta.json"); assertEquals(metadataCreateBody.content_type, "application/json"); assertEquals(typeof metadataCreateBody.size, "number"); assertEquals(calls[3]?.method, "PUT"); - assertEquals(calls[3]?.url, "https://storage.test/upload/doc-123"); + assertEquals(calls[3]?.url, `${TEST_PUBLIC_STORAGE_ORIGIN}/upload/doc-123`); }); }); it("rolls back the RAG document when cloud source persistence fails", async () => { setEnv("VERYFRONT_API_TOKEN", "vf_test_uploads"); setEnv("VERYFRONT_PROJECT_SLUG", "demo-project"); - setEnv("VERYFRONT_API_BASE_URL", "https://api.test"); + setEnv("VERYFRONT_API_BASE_URL", TEST_PUBLIC_API_ORIGIN); const removed: string[] = []; const store = createStubStore({ @@ -356,17 +370,20 @@ describe("createUploadHandler", () => { if ( request.method === "POST" && - request.url === "https://api.test/projects/demo-project/uploads" + request.url === `${TEST_PUBLIC_API_ORIGIN}/projects/demo-project/uploads` ) { return Response.json({ - file_upload_url: "https://storage.test/upload/doc-123", + file_upload_url: `${TEST_PUBLIC_STORAGE_ORIGIN}/upload/doc-123`, file_path: ".veryfront/rag/uploads/doc-123.blob", upload_id: "upload-123", required_headers: {}, }); } - if (request.method === "PUT" && request.url === "https://storage.test/upload/doc-123") { + if ( + request.method === "PUT" && + request.url === `${TEST_PUBLIC_STORAGE_ORIGIN}/upload/doc-123` + ) { return new Response("boom", { status: 500 }); } @@ -393,7 +410,7 @@ describe("createUploadHandler", () => { it("cleans up cloud source binaries on delete when bootstrap is present", async () => { setEnv("VERYFRONT_API_TOKEN", "vf_test_uploads"); setEnv("VERYFRONT_PROJECT_SLUG", "demo-project"); - setEnv("VERYFRONT_API_BASE_URL", "https://api.test"); + setEnv("VERYFRONT_API_BASE_URL", TEST_PUBLIC_API_ORIGIN); const removed: string[] = []; const deleteCalls: string[] = []; @@ -416,17 +433,20 @@ describe("createUploadHandler", () => { assertEquals(response.status, 200); assertEquals(removed, ["doc-123"]); - assertEquals(deleteCalls, [ - "DELETE https://api.test/projects/demo-project/uploads/.veryfront%2Frag%2Fuploads%2Fdoc-123.meta.json", - "DELETE https://api.test/projects/demo-project/uploads/.veryfront%2Frag%2Fuploads%2Fdoc-123.blob", - ]); + assertEquals( + deleteCalls.toSorted(), + [ + `DELETE ${TEST_PUBLIC_API_ORIGIN}/projects/demo-project/uploads/.veryfront%2Frag%2Fuploads%2Fdoc-123.meta.json`, + `DELETE ${TEST_PUBLIC_API_ORIGIN}/projects/demo-project/uploads/.veryfront%2Frag%2Fuploads%2Fdoc-123.blob`, + ].toSorted(), + ); }); }); it("lists cloud-backed uploads with signed source URLs", async () => { setEnv("VERYFRONT_API_TOKEN", "vf_test_uploads"); setEnv("VERYFRONT_PROJECT_SLUG", "demo-project"); - setEnv("VERYFRONT_API_BASE_URL", "https://api.test"); + setEnv("VERYFRONT_API_BASE_URL", TEST_PUBLIC_API_ORIGIN); const store = createStubStore({ async listDocuments() { @@ -449,18 +469,19 @@ describe("createUploadHandler", () => { if ( method === "GET" && url === - "https://api.test/projects/demo-project/uploads/.veryfront%2Frag%2Fuploads%2Fdoc-123.meta.json/url" + `${TEST_PUBLIC_API_ORIGIN}/projects/demo-project/uploads/.veryfront%2Frag%2Fuploads%2Fdoc-123.meta.json/url` ) { return Response.json({ signed_url: - "https://download.test/demo-project/.veryfront%2Frag%2Fuploads%2Fdoc-123.meta.json", + `${TEST_PUBLIC_STORAGE_ORIGIN}/demo-project/.veryfront%2Frag%2Fuploads%2Fdoc-123.meta.json`, expires_at: "2026-03-09T12:30:00.000Z", }); } if ( method === "GET" && - url === "https://download.test/demo-project/.veryfront%2Frag%2Fuploads%2Fdoc-123.meta.json" + url === + `${TEST_PUBLIC_STORAGE_ORIGIN}/demo-project/.veryfront%2Frag%2Fuploads%2Fdoc-123.meta.json` ) { return Response.json({ version: 1, @@ -475,11 +496,11 @@ describe("createUploadHandler", () => { if ( method === "GET" && url === - "https://api.test/projects/demo-project/uploads/.veryfront%2Frag%2Fuploads%2Fdoc-123.blob/url" + `${TEST_PUBLIC_API_ORIGIN}/projects/demo-project/uploads/.veryfront%2Frag%2Fuploads%2Fdoc-123.blob/url` ) { return Response.json({ signed_url: - "https://download.test/demo-project/.veryfront%2Frag%2Fuploads%2Fdoc-123.blob", + `${TEST_PUBLIC_STORAGE_ORIGIN}/demo-project/.veryfront%2Frag%2Fuploads%2Fdoc-123.blob`, expires_at: "2026-03-09T12:30:00.000Z", }); } @@ -497,7 +518,7 @@ describe("createUploadHandler", () => { assertEquals(upload.mediaType, "text/plain"); assertEquals( upload.url, - "https://download.test/demo-project/.veryfront%2Frag%2Fuploads%2Fdoc-123.blob", + `${TEST_PUBLIC_STORAGE_ORIGIN}/demo-project/.veryfront%2Frag%2Fuploads%2Fdoc-123.blob`, ); }); }); @@ -505,7 +526,7 @@ describe("createUploadHandler", () => { it("removes the document even when blob cleanup fails", async () => { setEnv("VERYFRONT_API_TOKEN", "vf_test_uploads"); setEnv("VERYFRONT_PROJECT_SLUG", "demo-project"); - setEnv("VERYFRONT_API_BASE_URL", "https://api.test"); + setEnv("VERYFRONT_API_BASE_URL", TEST_PUBLIC_API_ORIGIN); const removed: string[] = []; const store = createStubStore({ diff --git a/src/embedding/veryfront-cloud/provider.ts b/src/embedding/veryfront-cloud/provider.ts index 474452e60d..7845323585 100644 --- a/src/embedding/veryfront-cloud/provider.ts +++ b/src/embedding/veryfront-cloud/provider.ts @@ -15,7 +15,7 @@ export function createVeryfrontCloudEmbeddingModel(modelId: string): EmbeddingRu const { provider, modelId: upstreamModelId } = parseVeryfrontCloudModelId(modelId, "embedding"); const { apiBaseUrl, apiToken } = requireVeryfrontCloudBootstrap(); const baseURL = getVeryfrontCloudGatewayBaseUrl(apiBaseUrl, provider); - const fetch = createVeryfrontCloudFetch(apiToken); + const fetch = createVeryfrontCloudFetch(apiToken, baseURL); switch (provider) { case "openai": diff --git a/src/embedding/veryfront-cloud/rag-store.ts b/src/embedding/veryfront-cloud/rag-store.ts index 5a018d1e94..fad941b2e1 100644 --- a/src/embedding/veryfront-cloud/rag-store.ts +++ b/src/embedding/veryfront-cloud/rag-store.ts @@ -268,7 +268,7 @@ function getCloudStoreContext(config: RagStoreConfig): CloudStoreContext { return { apiBaseUrl: bootstrap.apiBaseUrl, - fetch: createVeryfrontCloudFetch(bootstrap.apiToken), + fetch: createVeryfrontCloudFetch(bootstrap.apiToken, bootstrap.apiBaseUrl), projectSlug: bootstrap.projectSlug, branch: config.branch ?? requestContext?.branch ?? "main", environmentName: requestContext?.environmentName ?? null, diff --git a/src/errors/error-registry.test.ts b/src/errors/error-registry.test.ts index 6038770118..77121ac802 100644 --- a/src/errors/error-registry.test.ts +++ b/src/errors/error-registry.test.ts @@ -16,6 +16,7 @@ import { INPUT_VALIDATION_FAILED, RESOURCE_NOT_FOUND, SECURITY_VIOLATION, + SSR_OUTPUT_LIMIT_EXCEEDED, TOKEN_STORAGE_ERROR, } from "./error-registry.ts"; import type { ErrorCategory } from "./types.ts"; @@ -28,9 +29,9 @@ describe("error-registry", () => { assertEquals(slugs.length, uniqueSlugs.size, "Duplicate slugs detected"); }); - it("should have 98 registered errors", () => { + it("should have 100 registered errors", () => { const slugs = getAllSlugs(); - assertEquals(slugs.length, 98); + assertEquals(slugs.length, 100); }); }); @@ -318,8 +319,8 @@ describe("error-registry", () => { RUNTIME: 10, ROUTE: 6, MODULE: 6, - SERVER: 15, - BOUNDARY: 6, + SERVER: 16, + BOUNDARY: 7, DEV: 5, DEPLOY: 12, AGENT: 7, @@ -385,6 +386,18 @@ describe("error-registry", () => { }); }); + describe("SSR_OUTPUT_LIMIT_EXCEEDED", () => { + it("exposes a stable boundary error for bounded SSR rendering", () => { + const error = SSR_OUTPUT_LIMIT_EXCEEDED.create({ + detail: "Rendered HTML exceeded the configured output ceiling", + }); + + assertEquals(error.slug, "ssr-output-limit-exceeded"); + assertEquals(error.category, "BOUNDARY"); + assertEquals(error.status, 500); + }); + }); + describe("INPUT_VALIDATION_FAILED", () => { it("should default to status 400", () => { const error = INPUT_VALIDATION_FAILED.create({ diff --git a/src/errors/error-registry/boundary.ts b/src/errors/error-registry/boundary.ts index 4ccc1c6ddf..f1cc0feff2 100644 --- a/src/errors/error-registry/boundary.ts +++ b/src/errors/error-registry/boundary.ts @@ -48,6 +48,14 @@ export const RSC_PAYLOAD_ERROR = defineError({ suggestion: "Ensure props are serializable (no functions, symbols, etc.)", }); +export const SSR_OUTPUT_LIMIT_EXCEEDED = defineError({ + slug: "ssr-output-limit-exceeded", + category: "BOUNDARY", + status: 500, + title: "SSR output limit exceeded", + suggestion: "Reduce the rendered HTML size or split the response into smaller pages", +}); + /** Registry fragment for BOUNDARY errors (slug → definition). */ export const BOUNDARY_REGISTRY = { "client-boundary-violation": CLIENT_BOUNDARY_VIOLATION, @@ -56,4 +64,5 @@ export const BOUNDARY_REGISTRY = { "invalid-use-client": INVALID_USE_CLIENT, "invalid-use-server": INVALID_USE_SERVER, "rsc-payload-error": RSC_PAYLOAD_ERROR, + "ssr-output-limit-exceeded": SSR_OUTPUT_LIMIT_EXCEEDED, } as const; diff --git a/src/errors/error-registry/server.ts b/src/errors/error-registry/server.ts index 290ff38df8..527157ca68 100644 --- a/src/errors/error-registry/server.ts +++ b/src/errors/error-registry/server.ts @@ -48,6 +48,14 @@ export const SERVICE_OVERLOADED = defineError({ suggestion: "Reduce load or scale up resources", }); +export const PROJECT_EXECUTION_UNAVAILABLE = defineError({ + slug: "project-execution-unavailable", + category: "SERVER", + status: 503, + title: "Project execution unavailable", + suggestion: "Route the project to a dedicated isolated runtime", +}); + export const SEMAPHORE_TIMEOUT = defineError({ slug: "semaphore-timeout", category: "SERVER", @@ -133,6 +141,7 @@ export const SERVER_REGISTRY = { "file-watch-error": FILE_WATCH_ERROR, "request-error": REQUEST_ERROR, "service-overloaded": SERVICE_OVERLOADED, + "project-execution-unavailable": PROJECT_EXECUTION_UNAVAILABLE, "semaphore-timeout": SEMAPHORE_TIMEOUT, "circuit-breaker-open": CIRCUIT_BREAKER_OPEN, "cache-path-mismatch": CACHE_PATH_MISMATCH, diff --git a/src/errors/index.ts b/src/errors/index.ts index 11b1ec3e25..ac34b1c4be 100644 --- a/src/errors/index.ts +++ b/src/errors/index.ts @@ -109,6 +109,7 @@ export { PORT_IN_USE, PREVIEW_HOSTNAME_TOO_LONG, PRODUCTION_BUILD_REQUIRED, + PROJECT_EXECUTION_UNAVAILABLE, PROJECT_SOURCE_EMPTY, PUSH_RECEIPT_MISSING, RELEASE_BUILD_TIMEOUT, @@ -132,6 +133,7 @@ export { SOURCE_MAP_ERROR, SOURCEMAP_ERROR, SSG_GENERATION_ERROR, + SSR_OUTPUT_LIMIT_EXCEEDED, TIMEOUT_ERROR, TOKEN_STORAGE_ERROR, TOOL_ID_CONFLICT, diff --git a/src/eval/discovery.test.ts b/src/eval/discovery.test.ts index ae04039273..c9db5d9002 100644 --- a/src/eval/discovery.test.ts +++ b/src/eval/discovery.test.ts @@ -149,23 +149,29 @@ describe("eval/discovery", () => { const adapter = createRuntimeAdapter({ "/project/evals/deep-research.eval.ts": "", }); + let receivedHostExecutionCapability: boolean | undefined; const result = await discoverEvals({ projectDir: "/project", adapter, config: { fs: { type: "veryfront-api" } } as never, - moduleLoader: async () => ({ - default: evalAgent({ - id: "eval:deep-research", - name: "Deep research eval", - target: "agent:researcher", - dataset: datasets.inline([{ id: "q1", input: "capital", reference: "Paris" }]), - metrics: [metrics.answer.contains({ text: "Paris" }).gate()], - }), - }), + allowHostProjectCodeExecution: true, + moduleLoader: async (_filePath, options) => { + receivedHostExecutionCapability = options.allowHostProjectCodeExecution; + return { + default: evalAgent({ + id: "eval:deep-research", + name: "Deep research eval", + target: "agent:researcher", + dataset: datasets.inline([{ id: "q1", input: "capital", reference: "Paris" }]), + metrics: [metrics.answer.contains({ text: "Paris" }).gate()], + }), + }; + }, }); assertEquals(result.errors, []); + assertEquals(receivedHostExecutionCapability, true); assertEquals( result.evals.map((item) => ({ id: item.id, diff --git a/src/eval/discovery.ts b/src/eval/discovery.ts index 780adfff72..4fc086b53a 100644 --- a/src/eval/discovery.ts +++ b/src/eval/discovery.ts @@ -31,7 +31,11 @@ export interface DiscoveredEval { /** Loader used to import an eval source module during discovery. */ export type EvalModuleLoader = ( filePath: string, - options: { adapter: RuntimeAdapter; projectDir: string }, + options: { + adapter: RuntimeAdapter; + projectDir: string; + allowHostProjectCodeExecution?: boolean; + }, ) => Promise>; /** Options for project-local eval discovery. */ @@ -40,6 +44,8 @@ export interface EvalDiscoveryOptions { adapter: RuntimeAdapter; config?: VeryfrontConfig; evalsDir?: string; + /** Explicit host-owned capability for a trusted local or dedicated runtime. */ + allowHostProjectCodeExecution?: boolean; /** @internal Override source loading for tests and custom runtimes. */ moduleLoader?: EvalModuleLoader; } @@ -121,10 +127,12 @@ async function loadEvalFromFile( adapter: RuntimeAdapter, projectDir: string, moduleLoader: EvalModuleLoader, + allowHostProjectCodeExecution?: boolean, ): Promise { const module = await moduleLoader(filePath, { adapter, projectDir, + allowHostProjectCodeExecution, }); const evalExport = extractEvalExport(module); if (!evalExport) return null; @@ -159,6 +167,7 @@ export async function discoverEvals( config, evalsDir = "evals", moduleLoader = importDiscoveryModule, + allowHostProjectCodeExecution, } = options; const evals: DiscoveredEval[] = []; @@ -179,6 +188,7 @@ export async function discoverEvals( adapter, projectDir, moduleLoader, + allowHostProjectCodeExecution, ); if (evalItem) evals.push(evalItem); } catch (error) { diff --git a/src/modules/server/module-server.test.ts b/src/modules/server/module-server.test.ts index c776cf0d09..7eaa8bedb2 100644 --- a/src/modules/server/module-server.test.ts +++ b/src/modules/server/module-server.test.ts @@ -11,6 +11,7 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals, assertStringIncludes } from "#veryfront/testing/assert.ts"; import { afterEach, describe, it } from "#veryfront/testing/bdd.ts"; +import { withMockFetch } from "#veryfront/testing/mock-fetch.ts"; import { buildServerTimingHeader, finalizeRequestProfiling, @@ -1199,7 +1200,6 @@ describe({ name: "serveModule", sanitizeResources: false, sanitizeOps: false }, it("path-binds same-origin absolute module literals before strict child lookup", async () => { const projectDir = await Deno.makeTempDir({ prefix: "vf-absolute-module-pins-" }); const packageJsonPath = `${projectDir}/package.json`; - const originalFetch = globalThis.fetch; try { setEnv(DEPENDENCY_PINNING_ENV_FLAG, "1"); @@ -1216,9 +1216,9 @@ describe({ name: "serveModule", sanitizeResources: false, sanitizeOps: false }, await Deno.writeTextFile( `${projectDir}/components/Parent.ts`, [ - `export { value as absolute } from "http://localhost:3000/_vf_modules/shared/Absolute.js";`, - `export { value as protocol } from "//localhost:3000/_vf_modules/shared/Protocol.js";`, - `export { value as foreign } from "https://cdn.example/_vf_modules/shared/Foreign.js";`, + `export { value as absolute } from "http://93.184.216.34:3000/_vf_modules/shared/Absolute.js";`, + `export { value as protocol } from "//93.184.216.34:3000/_vf_modules/shared/Protocol.js";`, + `export { value as foreign } from "https://1.1.1.1/_vf_modules/shared/Foreign.js";`, ].join("\n"), ); await Deno.writeTextFile(`${projectDir}/shared/Absolute.ts`, `export const value = "abs";`); @@ -1227,7 +1227,7 @@ describe({ name: "serveModule", sanitizeResources: false, sanitizeOps: false }, const snapshot = await getDependencyPinningSnapshot(projectDir); const encodedKey = encodeURIComponent(snapshot.cacheKey); const parentUrl = new URL( - `http://localhost:3000/_vf_modules/_pins/${encodedKey}/components/Parent.js`, + `http://93.184.216.34:3000/_vf_modules/_pins/${encodedKey}/components/Parent.js`, ); const absolutePath = `/_vf_modules/_pins/${encodedKey}/shared/Absolute.js`; const protocolPath = `/_vf_modules/_pins/${encodedKey}/shared/Protocol.js`; @@ -1239,7 +1239,7 @@ describe({ name: "serveModule", sanitizeResources: false, sanitizeOps: false }, assertStringIncludes(parentCode, protocolPath); assertStringIncludes( parentCode, - "https://cdn.example/_vf_modules/shared/Foreign.js", + "https://1.1.1.1/_vf_modules/shared/Foreign.js", ); for (const childPath of [absolutePath, protocolPath]) { @@ -1251,42 +1251,44 @@ describe({ name: "serveModule", sanitizeResources: false, sanitizeOps: false }, } const nestedFetches: string[] = []; - globalThis.fetch = async ( - input: RequestInfo | URL, - init?: RequestInit, - ): Promise => { - const request = input instanceof Request ? input : new Request(input, init); - const requestUrl = new URL(request.url); - if ( - requestUrl.origin === parentUrl.origin && - requestUrl.pathname.startsWith("/_vf_modules/") - ) { - nestedFetches.push(requestUrl.href); - return await serve(request, projectDir); - } - if (requestUrl.origin === "https://cdn.example") { - return new Response(`export const value = "foreign";`, { - headers: { "content-type": "application/javascript" }, - }); - } - return await originalFetch(input, init); - }; - - const ssrParentUrl = new URL(parentUrl); - ssrParentUrl.searchParams.set("ssr", "true"); - const ssrParentResponse = await serve(new Request(ssrParentUrl), projectDir); - assertEquals(ssrParentResponse.status, 200); - for (const childName of ["Absolute.js", "Protocol.js"]) { - const childFetch = nestedFetches.find((href) => - new URL(href).pathname.endsWith(`/shared/${childName}`) - ); - assertEquals(childFetch !== undefined, true); - const childUrl = new URL(childFetch!); - assertEquals(childUrl.searchParams.get("ssr"), "true"); - assertEquals(childUrl.searchParams.get("pins"), snapshot.cacheKey); - } + await withMockFetch( + async ( + input: RequestInfo | URL, + init?: RequestInit, + ): Promise => { + const request = input instanceof Request ? input : new Request(input, init); + const requestUrl = new URL(request.url); + if ( + requestUrl.origin === parentUrl.origin && + requestUrl.pathname.startsWith("/_vf_modules/") + ) { + nestedFetches.push(requestUrl.href); + return await serve(request, projectDir); + } + if (requestUrl.origin === "https://1.1.1.1") { + return new Response(`export const value = "foreign";`, { + headers: { "content-type": "application/javascript" }, + }); + } + throw new Error(`Unexpected module fetch: ${requestUrl.href}`); + }, + async () => { + const ssrParentUrl = new URL(parentUrl); + ssrParentUrl.searchParams.set("ssr", "true"); + const ssrParentResponse = await serve(new Request(ssrParentUrl), projectDir); + assertEquals(ssrParentResponse.status, 200); + for (const childName of ["Absolute.js", "Protocol.js"]) { + const childFetch = nestedFetches.find((href) => + new URL(href).pathname.endsWith(`/shared/${childName}`) + ); + assertEquals(childFetch !== undefined, true); + const childUrl = new URL(childFetch!); + assertEquals(childUrl.searchParams.get("ssr"), "true"); + assertEquals(childUrl.searchParams.get("pins"), snapshot.cacheKey); + } + }, + ); } finally { - globalThis.fetch = originalFetch; clearReactVersionCache(); await Deno.remove(projectDir, { recursive: true }); } diff --git a/src/oauth/handlers/callback-dispatcher.test.ts b/src/oauth/handlers/callback-dispatcher.test.ts index ec5b674d05..1b5ad53be6 100644 --- a/src/oauth/handlers/callback-dispatcher.test.ts +++ b/src/oauth/handlers/callback-dispatcher.test.ts @@ -29,10 +29,10 @@ const ALPHA_CONFIG: OAuthServiceConfig = { displayName: "Alpha", clientIdEnvVar: "ALPHA_CLIENT_ID", clientSecretEnvVar: "ALPHA_CLIENT_SECRET", - authorizationUrl: "https://alpha.provider.test/auth", - tokenUrl: "https://alpha.provider.test/token", + authorizationUrl: "https://93.184.216.34/alpha/auth", + tokenUrl: "https://93.184.216.34/alpha/token", defaultScopes: ["alpha:read"], - apiBaseUrl: "https://alpha.provider.test/api", + apiBaseUrl: "https://93.184.216.34/alpha/api", }; const BETA_CONFIG: OAuthServiceConfig = { @@ -41,10 +41,10 @@ const BETA_CONFIG: OAuthServiceConfig = { displayName: "Beta", clientIdEnvVar: "BETA_CLIENT_ID", clientSecretEnvVar: "BETA_CLIENT_SECRET", - authorizationUrl: "https://beta.provider.test/auth", - tokenUrl: "https://beta.provider.test/token", + authorizationUrl: "https://93.184.216.34/beta/auth", + tokenUrl: "https://93.184.216.34/beta/token", defaultScopes: ["beta:read"], - apiBaseUrl: "https://beta.provider.test/api", + apiBaseUrl: "https://93.184.216.34/beta/api", pkceMode: "unsupported", }; diff --git a/src/oauth/handlers/callback-handler.test.ts b/src/oauth/handlers/callback-handler.test.ts index 3cf4711df7..7f7ba4ee34 100644 --- a/src/oauth/handlers/callback-handler.test.ts +++ b/src/oauth/handlers/callback-handler.test.ts @@ -2,6 +2,7 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals, assertNotEquals, assertThrows } from "#std/assert"; import { FakeTime } from "#std/testing/time"; import { createTestEnvironmentConfig } from "#veryfront/config/environment-config.ts"; +import { withMockFetch } from "#veryfront/testing/mock-fetch.ts"; import { createOAuthCallbackHandler as createRuntimeOAuthCallbackHandler, type OAuthCallbackHandlerOptions, @@ -9,16 +10,17 @@ import { import { MemoryTokenStore } from "../token-store/memory.ts"; import type { OAuthServiceConfig, OAuthTokens, StoredOAuthState, TokenStore } from "../types.ts"; +const TEST_PUBLIC_PROVIDER_ORIGIN = "https://93.184.216.34"; const TEST_CONFIG: OAuthServiceConfig = { providerId: "test-provider", serviceId: "test-provider", displayName: "Test Provider", clientIdEnvVar: "TEST_CLIENT_ID", clientSecretEnvVar: "TEST_CLIENT_SECRET", - authorizationUrl: "https://provider.test/auth", - tokenUrl: "https://provider.test/token", + authorizationUrl: `${TEST_PUBLIC_PROVIDER_ORIGIN}/provider/auth`, + tokenUrl: `${TEST_PUBLIC_PROVIDER_ORIGIN}/provider/token`, defaultScopes: ["read"], - apiBaseUrl: "https://api.provider.test", + apiBaseUrl: `${TEST_PUBLIC_PROVIDER_ORIGIN}/provider/api`, }; const ENV: Record = { @@ -42,6 +44,22 @@ function createOAuthCallbackHandler( }); } +async function withTokenExchange( + response: () => Response, + operation: () => Promise, +): Promise { + return await withMockFetch(async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init); + assertEquals(request.url, TEST_CONFIG.tokenUrl); + assertEquals(request.method, "POST"); + return response(); + }, operation); +} + +function tokenExchangeError(): Response { + return Response.json({ error: "invalid_grant" }, { status: 400 }); +} + function makeRequest(params: Record): Request { const url = new URL(`${APP_URL}/api/auth/test-provider/callback`); for (const [k, v] of Object.entries(params)) { @@ -264,8 +282,12 @@ Deno.test("callback-handler accepts verifier-free state for a provider without P envReader: (key) => ENV[key], }); - const response = await handler( - makeRequest({ code: "auth-code-123", state: "verifier-free-state" }), + const response = await withTokenExchange( + tokenExchangeError, + () => + handler( + makeRequest({ code: "auth-code-123", state: "verifier-free-state" }), + ), ); assertNotEquals( @@ -292,13 +314,15 @@ Deno.test("callback-handler: consumes state once (double-use rejected)", async ( envReader: (key) => ENV[key], }); - // First call consumes state - await handler(makeRequest({ code: "auth-code-123", state: "valid-state" })); + const response = await withTokenExchange(tokenExchangeError, async () => { + // First call consumes state. + await handler(makeRequest({ code: "auth-code-123", state: "valid-state" })); - // Second call with same state should fail with invalid_state - const response = await handler( - makeRequest({ code: "auth-code-456", state: "valid-state" }), - ); + // A second call with the same state fails before another token exchange. + return await handler( + makeRequest({ code: "auth-code-456", state: "valid-state" }), + ); + }); assertEquals(response.status, 302); const location = new URL(response.headers.get("location")!); @@ -359,8 +383,12 @@ Deno.test("callback-handler: proceeds with valid state matching serviceId", asyn envReader: (key) => ENV[key], }); - const response = await handler( - makeRequest({ code: "auth-code-123", state: "valid-state-abc" }), + const response = await withTokenExchange( + tokenExchangeError, + () => + handler( + makeRequest({ code: "auth-code-123", state: "valid-state-abc" }), + ), ); assertEquals(response.status, 302); @@ -389,47 +417,36 @@ Deno.test("callback-handler: stores tokens keyed by (serviceId, userId) — bob' createdAt: Date.now(), }); - // Stub token exchange to succeed without a network call by intercepting fetch. - const origFetch = globalThis.fetch; - globalThis.fetch = async (url: string | URL | Request, _init?: RequestInit) => { - const href = typeof url === "string" ? url : (url as URL).toString(); - if (href === TEST_CONFIG.tokenUrl) { - return new Response( - JSON.stringify({ - access_token: "alice-access-token", - refresh_token: "alice-refresh-token", - expires_in: 3600, - token_type: "Bearer", - scope: "read", - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); - } - return new Response("not found", { status: 404 }); - }; - - try { - const handler = createOAuthCallbackHandler(TEST_CONFIG, { - tokenStore, - baseUrl: "http://localhost:3000", - envReader: (key) => ENV[key], - }); + await withTokenExchange( + () => + Response.json({ + access_token: "alice-access-token", + refresh_token: "alice-refresh-token", + expires_in: 3600, + token_type: "Bearer", + scope: "read", + }), + async () => { + const handler = createOAuthCallbackHandler(TEST_CONFIG, { + tokenStore, + baseUrl: "http://localhost:3000", + envReader: (key) => ENV[key], + }); - const response = await handler( - makeRequest({ code: "auth-code-abc", state: "alice-state" }), - ); - assertEquals(response.status, 302); + const response = await handler( + makeRequest({ code: "auth-code-abc", state: "alice-state" }), + ); + assertEquals(response.status, 302); - // Alice's tokens stored under her userId - const aliceTokens = await tokenStore.getTokens(TEST_CONFIG.serviceId, "alice"); - assertEquals(aliceTokens?.accessToken, "alice-access-token"); + // Alice's tokens stored under her userId + const aliceTokens = await tokenStore.getTokens(TEST_CONFIG.serviceId, "alice"); + assertEquals(aliceTokens?.accessToken, "alice-access-token"); - // Bob's slot untouched - const bobTokens = await tokenStore.getTokens(TEST_CONFIG.serviceId, "bob"); - assertEquals(bobTokens?.accessToken, "bob-existing-token"); - } finally { - globalThis.fetch = origFetch; - } + // Bob's slot untouched + const bobTokens = await tokenStore.getTokens(TEST_CONFIG.serviceId, "bob"); + assertEquals(bobTokens?.accessToken, "bob-existing-token"); + }, + ); }); Deno.test("callback-handler: validates and consumes state before handling provider errors", async () => { @@ -668,30 +685,27 @@ Deno.test("callback-handler: detaches persisted tokens from post-commit hooks", setState: () => Promise.resolve(), consumeState: () => Promise.resolve(storedState), }; - const original = globalThis.fetch; - globalThis.fetch = - (() => Promise.resolve(Response.json({ access_token: "provider-token" }))) as typeof fetch; - - try { - const handler = createOAuthCallbackHandler(TEST_CONFIG, { - tokenStore, - baseUrl: "http://localhost:3000", - envReader: (key) => ENV[key], - onSuccess: (_serviceId, tokens) => { - tokens.accessToken = "hook-mutated-token"; - throw new Error("notification failed"); - }, - }); - const response = await handler(makeRequest({ code: "code", state: "state" })); - - assertEquals( - new URL(response.headers.get("location")!).searchParams.get("connected"), - TEST_CONFIG.serviceId, - ); - assertEquals((persistedTokens as OAuthTokens | null)?.accessToken, "provider-token"); - } finally { - globalThis.fetch = original; - } + await withTokenExchange( + () => Response.json({ access_token: "provider-token" }), + async () => { + const handler = createOAuthCallbackHandler(TEST_CONFIG, { + tokenStore, + baseUrl: "http://localhost:3000", + envReader: (key) => ENV[key], + onSuccess: (_serviceId, tokens) => { + tokens.accessToken = "hook-mutated-token"; + throw new Error("notification failed"); + }, + }); + const response = await handler(makeRequest({ code: "code", state: "state" })); + + assertEquals( + new URL(response.headers.get("location")!).searchParams.get("connected"), + TEST_CONFIG.serviceId, + ); + assertEquals((persistedTokens as OAuthTokens | null)?.accessToken, "provider-token"); + }, + ); }); Deno.test("callback-handler: error hook failures do not replace the OAuth response", async () => { diff --git a/src/oauth/providers/base.test.ts b/src/oauth/providers/base.test.ts index 835e133dd9..2854125453 100644 --- a/src/oauth/providers/base.test.ts +++ b/src/oauth/providers/base.test.ts @@ -18,10 +18,10 @@ const TEST_CONFIG: OAuthServiceConfig = { displayName: "Test Provider", clientIdEnvVar: "TEST_CLIENT_ID", clientSecretEnvVar: "TEST_CLIENT_SECRET", - authorizationUrl: "https://provider.test/auth", - tokenUrl: "https://provider.test/token", + authorizationUrl: "https://93.184.216.34/auth", + tokenUrl: "https://93.184.216.34/token", defaultScopes: ["read"], - apiBaseUrl: "https://api.provider.test", + apiBaseUrl: "https://93.184.216.34", }; const ENV: Record = { @@ -97,12 +97,12 @@ Deno.test("OAuthService.fetch: relative endpoint resolves against apiBaseUrl", a assertEquals(result, { ok: true }); }); - assertEquals(captured, ["https://api.provider.test/v1/me"]); + assertEquals(captured, ["https://93.184.216.34/v1/me"]); }); Deno.test("OAuthService.fetch: joins relative endpoints without requiring a leading slash", async () => { const service = new OAuthService( - { ...TEST_CONFIG, apiBaseUrl: "https://api.provider.test/v1" }, + { ...TEST_CONFIG, apiBaseUrl: "https://93.184.216.34/v1" }, makeAuthedTokenStore(), (k) => ENV[k], ); @@ -112,7 +112,7 @@ Deno.test("OAuthService.fetch: joins relative endpoints without requiring a lead await service.fetch("user-1", "me"); }); - assertEquals(captured, ["https://api.provider.test/v1/me"]); + assertEquals(captured, ["https://93.184.216.34/v1/me"]); }); Deno.test("OAuthService.fetch: preserves Headers instances and caller content types", async () => { @@ -416,7 +416,7 @@ Deno.test("OAuthService.fetch timeout cancels a stalled API body", async () => { Deno.test("OAuthProvider preserves existing authorization endpoint query parameters", async () => { const provider = new OAuthProvider( - { ...TEST_CONFIG, authorizationUrl: "https://provider.test/auth?audience=existing" }, + { ...TEST_CONFIG, authorizationUrl: "https://93.184.216.34/auth?audience=existing" }, (key) => ENV[key], ); @@ -652,7 +652,7 @@ Deno.test("OAuthService rejects accessor-backed authorization options without in Deno.test("OAuthService.fetch: absolute endpoint matching apiBaseUrl origin is allowed", async () => { const service = new OAuthService(TEST_CONFIG, makeAuthedTokenStore(), (k) => ENV[k]); const captured: string[] = []; - const sameOrigin = "https://api.provider.test/v1/me"; + const sameOrigin = "https://93.184.216.34/v1/me"; await withStubbedFetch(captured, async () => { const result = await service.fetch<{ ok: boolean }>("user-1", sameOrigin); @@ -704,8 +704,8 @@ Deno.test("OAuthService.fetch: rejects endpoint credentials and fragments before await withStubbedFetch(captured, async () => { for ( const endpoint of [ - "https://user:password@api.provider.test/v1/me", - "https://api.provider.test/v1/me#secret", + "https://user:password@93.184.216.34/v1/me", + "https://93.184.216.34/v1/me#secret", ] ) { await assertRejects( @@ -1235,13 +1235,38 @@ Deno.test("OAuthProvider requires HTTPS provider endpoints", () => { } }); +Deno.test("OAuthProvider blocks an internal token endpoint before credentials leave the process", async () => { + const provider = new OAuthProvider( + { ...TEST_CONFIG, tokenUrl: "https://169.254.169.254/token" }, + (key) => ENV[key], + ); + const original = globalThis.fetch; + let fetchCalls = 0; + globalThis.fetch = (() => { + fetchCalls++; + return Promise.resolve(Response.json({ access_token: "unexpected" })); + }) as typeof fetch; + + try { + const result = await provider.exchangeCode({ + code: "code", + redirectUri: "https://app.test/callback", + }); + assertEquals(result.success, false); + assertEquals(result.error, "network_error"); + assertEquals(fetchCalls, 0); + } finally { + globalThis.fetch = original; + } +}); + Deno.test("OAuthProvider rejects reserved token endpoint query parameters", () => { assertThrows( () => new OAuthProvider( { ...TEST_CONFIG, - tokenUrl: "https://provider.test/token?grant_type=password&client_secret=attacker", + tokenUrl: "https://93.184.216.34/token?grant_type=password&client_secret=attacker", }, (key) => ENV[key], ), @@ -1252,7 +1277,7 @@ Deno.test("OAuthProvider rejects reserved token endpoint query parameters", () = Deno.test("OAuthProvider rejects cross-origin HTTP redirects for secret-bearing requests", async () => { const provider = new OAuthProvider( - { ...TEST_CONFIG, revocationUrl: "https://provider.test/revoke" }, + { ...TEST_CONFIG, revocationUrl: "https://93.184.216.34/revoke" }, (key) => ENV[key], ); const original = globalThis.fetch; @@ -1269,12 +1294,12 @@ Deno.test("OAuthProvider rejects cross-origin HTTP redirects for secret-bearing globalThis.fetch = original; } - assertEquals(redirects, ["error", "error"]); + assertEquals(redirects, ["manual", "manual"]); }); Deno.test("OAuthProvider bounds revocation tokens before fetch and releases response bodies", async () => { const provider = new OAuthProvider( - { ...TEST_CONFIG, revocationUrl: "https://provider.test/revoke" }, + { ...TEST_CONFIG, revocationUrl: "https://93.184.216.34/revoke" }, (key) => ENV[key], ); const original = globalThis.fetch; @@ -1311,7 +1336,7 @@ Deno.test("OAuthProvider bounds revocation tokens before fetch and releases resp Deno.test("OAuthProvider authenticates revocation with client credentials in the body", async () => { const provider = new OAuthProvider( - { ...TEST_CONFIG, revocationUrl: "https://provider.test/revoke" }, + { ...TEST_CONFIG, revocationUrl: "https://93.184.216.34/revoke" }, (key) => ENV[key], ); const original = globalThis.fetch; @@ -1331,14 +1356,14 @@ Deno.test("OAuthProvider authenticates revocation with client credentials in the assertEquals(body.get("token"), "token"); assertEquals(body.get("client_id"), "test-id"); assertEquals(body.get("client_secret"), "test-secret"); - const headers = init?.headers as Record; - assertEquals(headers["Content-Type"], "application/x-www-form-urlencoded"); - assertEquals(headers.Authorization, undefined); + const headers = new Headers(init?.headers); + assertEquals(headers.get("Content-Type"), "application/x-www-form-urlencoded"); + assertEquals(headers.get("Authorization"), null); }); Deno.test("OAuthProvider authenticates revocation with Basic auth when configured", async () => { const provider = new OAuthProvider( - { ...TEST_CONFIG, revocationUrl: "https://provider.test/revoke", useBasicAuth: true }, + { ...TEST_CONFIG, revocationUrl: "https://93.184.216.34/revoke", useBasicAuth: true }, (key) => ENV[key], ); const original = globalThis.fetch; @@ -1354,8 +1379,8 @@ Deno.test("OAuthProvider authenticates revocation with Basic auth when configure globalThis.fetch = original; } - const headers = init?.headers as Record; - assertEquals(headers.Authorization, `Basic ${btoa("test-id:test-secret")}`); + const headers = new Headers(init?.headers); + assertEquals(headers.get("Authorization"), `Basic ${btoa("test-id:test-secret")}`); const body = new URLSearchParams(String(init?.body)); assertEquals(body.get("token"), "token"); // Basic-auth providers must not also receive the secret in the body. @@ -1365,7 +1390,7 @@ Deno.test("OAuthProvider authenticates revocation with Basic auth when configure Deno.test("OAuthProvider skips revocation when client credentials are missing", async () => { const provider = new OAuthProvider( - { ...TEST_CONFIG, revocationUrl: "https://provider.test/revoke" }, + { ...TEST_CONFIG, revocationUrl: "https://93.184.216.34/revoke" }, () => undefined, ); const original = globalThis.fetch; @@ -1386,7 +1411,7 @@ Deno.test("OAuthProvider skips revocation when client credentials are missing", Deno.test("OAuthProvider revocation logging does not coerce hostile thrown values", async () => { const provider = new OAuthProvider( - { ...TEST_CONFIG, revocationUrl: "https://provider.test/revoke" }, + { ...TEST_CONFIG, revocationUrl: "https://93.184.216.34/revoke" }, (key) => ENV[key], ); const original = globalThis.fetch; @@ -1422,7 +1447,7 @@ Deno.test("OAuthService.fetch cannot be configured to follow redirects", async ( globalThis.fetch = original; } - assertEquals(redirect, "error"); + assertEquals(redirect, "manual"); }); Deno.test("OAuthService rejects oversized authorization codes before fetch", async () => { diff --git a/src/oauth/providers/base.ts b/src/oauth/providers/base.ts index e77068b568..3dba952a71 100644 --- a/src/oauth/providers/base.ts +++ b/src/oauth/providers/base.ts @@ -10,6 +10,7 @@ import type { } from "../types.ts"; import { getEnv } from "#veryfront/platform/compat/process.ts"; import { INVALID_ARGUMENT, NETWORK_ERROR, TOKEN_STORAGE_ERROR } from "#veryfront/errors"; +import { guardedOutboundFetch } from "#veryfront/security/http/outbound-fetch.ts"; import { base64urlEncodeBytes, logger as baseLogger } from "#veryfront/utils"; import { HTTP_FETCH_TIMEOUT_MS } from "#veryfront/utils/constants/index.ts"; import { readResponseTextPrefix } from "#veryfront/utils/response-body.ts"; @@ -387,7 +388,7 @@ async function fetchWithStrictAbort( signal: AbortSignal, ): Promise { throwIfAborted(signal); - const pending = Promise.resolve().then(() => fetch(input, { ...init, signal })); + const pending = Promise.resolve().then(() => guardedOutboundFetch(input, { ...init, signal })); try { return await awaitAbortable(pending, signal); } catch (error) { diff --git a/src/platform/adapters/veryfront-api-client/retry-handler.ts b/src/platform/adapters/veryfront-api-client/retry-handler.ts index bcd428730b..e9cd28f26a 100644 --- a/src/platform/adapters/veryfront-api-client/retry-handler.ts +++ b/src/platform/adapters/veryfront-api-client/retry-handler.ts @@ -13,8 +13,11 @@ export function requestWithRetry( apiToken: string, retryConfig: RetryConfig, options: RequestOptions = {}, + outboundPolicy?: { + authorizeUrl?: (url: URL) => void | Promise; + }, ): Promise { const { origin } = new URL(url); - return createCanonicalVeryfrontApiTransport(origin, () => apiToken, retryConfig) + return createCanonicalVeryfrontApiTransport(origin, () => apiToken, retryConfig, outboundPolicy) .request(url, options); } diff --git a/src/platform/adapters/veryfront-api-transport.ts b/src/platform/adapters/veryfront-api-transport.ts index 801a3acfb8..0c4eaef095 100644 --- a/src/platform/adapters/veryfront-api-transport.ts +++ b/src/platform/adapters/veryfront-api-transport.ts @@ -13,6 +13,7 @@ import { } from "#veryfront/utils/config-resource-limits.ts"; import { serverLogger } from "#veryfront/utils/logger/logger.ts"; import { sanitizeUrlCredentials, sanitizeUrlForSpan } from "#veryfront/utils/logger/redact.ts"; +import { guardedOutboundFetch } from "#veryfront/security/http/outbound-fetch.ts"; import { JsonStringValueTooLargeError, maximumJsonStringDocumentBytes, @@ -77,6 +78,10 @@ export interface VeryfrontApiTransportConfig { }) => void; wrapFinalError?: (lastError: Error, lastAttempt: number) => Error; wrapFetch?: (fn: () => Promise, url: string, method: string, attempt: number) => Promise; + /** Optional host egress policy applied to every redirect hop. */ + outboundPolicy?: { + authorizeUrl?: (url: URL) => void | Promise; + }; } export interface VeryfrontApiTransport { @@ -160,12 +165,17 @@ function createValidatedVeryfrontApiTransport( headers.set("Authorization", `Bearer ${token}`); injectContext(headers); const start = performance.now(); - const res = await fetch(url, { + const requestInit: RequestInit = { method, headers, body, signal, - }); + }; + const res = config.outboundPolicy + ? await guardedOutboundFetch(url, { ...requestInit, redirect: "error" }, { + authorizeUrl: config.outboundPolicy.authorizeUrl, + }) + : await fetch(url, requestInit); afterFetch?.(res.status, performance.now() - start); return await onResponse(res, responseInit, url, signal); }; @@ -213,6 +223,7 @@ export function createCanonicalVeryfrontApiTransport( baseUrl: string, getToken: () => string, retry: TransportRetryConfig, + outboundPolicy?: VeryfrontApiTransportConfig["outboundPolicy"], ): VeryfrontApiTransport { const normalizedRetry = requireVeryfrontApiRetryConfig(retry); return createValidatedVeryfrontApiTransport( @@ -220,6 +231,7 @@ export function createCanonicalVeryfrontApiTransport( baseUrl, getToken, retry: normalizedRetry, + outboundPolicy, defaultHeaders: { "Content-Type": "application/json" }, afterFetch(status) { recordApiRequest(status); diff --git a/src/platform/cloud/resolver.test.ts b/src/platform/cloud/resolver.test.ts index c9792d3bb0..ffa32efbf1 100644 --- a/src/platform/cloud/resolver.test.ts +++ b/src/platform/cloud/resolver.test.ts @@ -127,6 +127,17 @@ describe("platform/cloud/resolver", () => { assertEquals(getVeryfrontCloudProjectSlug(), "env-project"); }); + it("does not pair a scoped endpoint with a host-owned token", () => { + setEnv("VERYFRONT_API_TOKEN", "vf_host_token"); + + runWithVeryfrontCloudContext({ apiBaseUrl: "https://untrusted.example.com" }, () => { + assertEquals(getVeryfrontCloudBootstrap().apiBaseUrl, "https://untrusted.example.com"); + assertEquals(getVeryfrontCloudBootstrap().apiToken, undefined); + assertEquals(getVeryfrontCloudAuthToken(), undefined); + assertEquals(isVeryfrontCloudEnabled(), false); + }); + }); + it("keeps direct host bootstrap identity isolated from scoped request context", () => { setEnv("VERYFRONT_API_URL", "https://api.veryfront.org"); setEnv("VERYFRONT_API_TOKEN", "vf_host_token"); diff --git a/src/platform/cloud/resolver.ts b/src/platform/cloud/resolver.ts index 23fde55911..5ab9e781c7 100644 --- a/src/platform/cloud/resolver.ts +++ b/src/platform/cloud/resolver.ts @@ -111,7 +111,7 @@ function getResolvedVeryfrontCloudContext(): Omit { + it("preserves Fetch null-body semantics for 204, 205, and 304", async () => { + for (const status of [204, 205, 304]) { + const response = createPinnedFetchResponse( + status, + "", + new Headers(), + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("protocol-invalid-body")); + controller.close(); + }, + }), + ); + assertEquals(response.status, status); + assertEquals(response.body, null); + assertEquals(await response.text(), ""); + } + }); + + it("preserves HEAD null-body semantics for every response status", async () => { + for (const status of [200, 404, 500]) { + const response = createPinnedFetchResponse( + status, + "", + new Headers({ "content-length": "21" }), + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("protocol-invalid-body")); + controller.close(); + }, + }), + "HEAD", + ); + assertEquals(response.status, status); + assertEquals(response.headers.get("content-length"), "21"); + assertEquals(response.body, null); + assertEquals(await response.text(), ""); + } + }); + + it("returns a null body for HEAD through the native Node transport", async () => { + if (!isNode) return; + + const { createServer } = await import("node:http"); + let seenMethod: string | undefined; + const server = createServer((request, response) => { + seenMethod = request.method; + response.writeHead(200, { + "content-length": "21", + "content-type": "text/plain", + }); + response.end("protocol-invalid-body"); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + + try { + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Node test server did not expose a TCP address"); + } + const response = await fetchWithPinnedAddresses( + new URL(`http://pinned-head.test:${address.port}/resource`), + ["127.0.0.1"], + { method: "HEAD" }, + ); + assertEquals(seenMethod, "HEAD"); + assertEquals(response.status, 200); + assertEquals(response.headers.get("content-length"), "21"); + assertEquals(response.body, null); + assertEquals(await response.text(), ""); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => error ? reject(error) : resolve()); + }); + } + }); +}); diff --git a/src/platform/compat/http/pinned-fetch.ts b/src/platform/compat/http/pinned-fetch.ts new file mode 100644 index 0000000000..2edb1ccd39 --- /dev/null +++ b/src/platform/compat/http/pinned-fetch.ts @@ -0,0 +1,249 @@ +/** + * Dependency-free Node/Bun HTTP transport that connects only to DNS addresses + * already validated by the host egress policy while preserving the original + * Host header and TLS SNI name. + */ + +import type { ClientRequest, IncomingMessage, RequestOptions } from "node:http"; +import type { Readable } from "node:stream"; + +const NULL_BODY_STATUSES = new Set([204, 205, 304]); + +/** @internal Construct a Fetch response without violating null-body statuses. */ +export function createPinnedFetchResponse( + status: number, + statusText: string, + headers: Headers, + body: BodyInit | null, + requestMethod = "GET", +): Response { + const responseBody = requestMethod.toUpperCase() === "HEAD" || NULL_BODY_STATUSES.has(status) + ? null + : body; + return new Response(responseBody, { + status, + statusText, + headers, + }); +} + +function addressFamily(address: string): 4 | 6 { + return address.includes(":") ? 6 : 4; +} + +function createPinnedLookup(addresses: readonly string[]): RequestOptions["lookup"] { + let nextIndex = 0; + return ((_hostname: string, options: unknown, callback: (...args: unknown[]) => void) => { + const requestedFamily = typeof options === "number" + ? options + : typeof options === "object" && options !== null && "family" in options + ? Number((options as { family?: unknown }).family ?? 0) + : 0; + const candidates = addresses.filter((address) => + requestedFamily === 0 || addressFamily(address) === requestedFamily + ); + if (candidates.length === 0) { + callback(new Error("No validated address matches the requested address family")); + return; + } + const wantsAll = typeof options === "object" && options !== null && + (options as { all?: unknown }).all === true; + if (wantsAll) { + callback( + null, + candidates.map((address) => ({ address, family: addressFamily(address) })), + ); + return; + } + const address = candidates[nextIndex++ % candidates.length]!; + callback(null, address, addressFamily(address)); + }) as RequestOptions["lookup"]; +} + +function copyResponseHeaders(message: IncomingMessage): Headers { + const headers = new Headers(); + for (let i = 0; i < message.rawHeaders.length; i += 2) { + const name = message.rawHeaders[i]; + const value = message.rawHeaders[i + 1]; + if (name !== undefined && value !== undefined) headers.append(name, value); + } + return headers; +} + +async function normalizeRequestBody( + url: URL, + init: RequestInit, + headers: Headers, +): Promise { + const body = init.body ?? null; + if (body instanceof URLSearchParams && !headers.has("content-type")) { + headers.set("content-type", "application/x-www-form-urlencoded;charset=UTF-8"); + } else if (body instanceof Blob && body.type && !headers.has("content-type")) { + headers.set("content-type", body.type); + } else if (typeof FormData !== "undefined" && body instanceof FormData) { + const normalized = new Request(url, { + method: init.method ?? "POST", + headers, + body, + }); + const normalizedHeaders = new Headers(normalized.headers); + for (const [name, value] of normalizedHeaders) headers.set(name, value); + return new Uint8Array(await normalized.arrayBuffer()); + } + return body; +} + +async function writeRequestBody(request: ClientRequest, body: BodyInit | null): Promise { + if (body === null) { + request.end(); + return; + } + if (typeof body === "string" || body instanceof URLSearchParams) { + request.end(String(body)); + return; + } + if (body instanceof ArrayBuffer) { + request.end(new Uint8Array(body)); + return; + } + if (ArrayBuffer.isView(body)) { + request.end(new Uint8Array(body.buffer, body.byteOffset, body.byteLength)); + return; + } + + const { Readable } = await import("node:stream"); + const webStream = body instanceof Blob ? body.stream() : body; + const source = Readable.fromWeb( + webStream as unknown as import("node:stream/web").ReadableStream, + ); + await new Promise((resolve, reject) => { + source.once("error", reject); + request.once("error", reject); + request.once("finish", resolve); + source.pipe(request); + }); +} + +async function decodeResponseBody( + response: IncomingMessage, + headers: Headers, +): Promise { + const encoding = headers.get("content-encoding")?.trim().toLowerCase(); + if (!encoding || encoding === "identity") return response; + + const zlib = await import("node:zlib"); + let decoder: + | ReturnType + | ReturnType + | ReturnType; + if (encoding === "gzip" || encoding === "x-gzip") { + decoder = zlib.createGunzip(); + } else if (encoding === "deflate") { + decoder = zlib.createInflate(); + } else if (encoding === "br") { + decoder = zlib.createBrotliDecompress(); + } else { + return response; + } + headers.delete("content-encoding"); + headers.delete("content-length"); + return response.pipe(decoder); +} + +/** @internal Used by the central egress guard after DNS policy validation. */ +export async function fetchWithPinnedAddresses( + url: URL, + addresses: readonly string[], + init: RequestInit, +): Promise { + if (addresses.length === 0) { + throw new Error(`No validated addresses are available for ${url.host}`); + } + const headers = new Headers(init.headers); + const body = await normalizeRequestBody(url, init, headers); + const method = (init.method ?? "GET").toUpperCase(); + const requestHeaders: Record = {}; + for (const [name, value] of headers) requestHeaders[name] = value; + + const transport = url.protocol === "https:" + ? await import("node:https") + : await import("node:http"); + const requestOptions: RequestOptions & { autoSelectFamily?: boolean } = { + protocol: url.protocol, + hostname: url.hostname, + port: url.port || undefined, + path: `${url.pathname}${url.search}`, + method, + headers: requestHeaders, + lookup: createPinnedLookup(addresses), + // Let Node/Bun race the complete validated address set instead of binding + // availability to whichever A/AAAA record happened to be returned first. + autoSelectFamily: true, + ...(url.protocol === "https:" ? { servername: url.hostname } : {}), + }; + + return await new Promise((resolve, reject) => { + let settled = false; + let responseMessage: IncomingMessage | undefined; + const cleanupAbortListener = () => init.signal?.removeEventListener("abort", abort); + const rejectBeforeResponse = (error: unknown) => { + cleanupAbortListener(); + reject(error); + }; + const request = transport.request(requestOptions, async (message) => { + responseMessage = message; + try { + const responseHeaders = copyResponseHeaders(message); + const status = message.statusCode ?? 500; + if (method === "HEAD" || NULL_BODY_STATUSES.has(status)) { + message.once("end", cleanupAbortListener); + message.once("close", cleanupAbortListener); + message.once("error", cleanupAbortListener); + // Drain any protocol-invalid payload without exposing it through the + // Fetch response. Response rejects stream bodies for these statuses. + message.resume(); + settled = true; + resolve(createPinnedFetchResponse( + status, + message.statusMessage ?? "", + responseHeaders, + null, + method, + )); + return; + } + const decoded = await decodeResponseBody(message, responseHeaders); + decoded.once("end", cleanupAbortListener); + decoded.once("close", cleanupAbortListener); + decoded.once("error", cleanupAbortListener); + const { Readable } = await import("node:stream"); + const webBody = Readable.toWeb(decoded) as globalThis.ReadableStream; + settled = true; + resolve(createPinnedFetchResponse( + status, + message.statusMessage ?? "", + responseHeaders, + webBody, + method, + )); + } catch (error) { + rejectBeforeResponse(error); + } + }); + + const abort = () => { + const reason = init.signal?.reason ?? + new DOMException("The operation was aborted", "AbortError"); + responseMessage?.destroy(reason instanceof Error ? reason : undefined); + request.destroy(reason instanceof Error ? reason : undefined); + if (!settled) rejectBeforeResponse(reason); + }; + init.signal?.addEventListener("abort", abort, { once: true }); + if (init.signal?.aborted) { + abort(); + return; + } + request.once("error", rejectBeforeResponse); + void writeRequestBody(request, body).catch((error) => request.destroy(error)); + }); +} diff --git a/src/provider/model-registry.test.ts b/src/provider/model-registry.test.ts index 24a2874467..ade9e18407 100644 --- a/src/provider/model-registry.test.ts +++ b/src/provider/model-registry.test.ts @@ -1,5 +1,5 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals } from "#veryfront/testing/assert.ts"; +import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; import { afterEach, describe, it } from "#veryfront/testing/bdd.ts"; import { deleteEnv, setEnv } from "#veryfront/compat/process.ts"; import { clearModelProviders, resolveModel } from "./model-registry.ts"; @@ -7,6 +7,8 @@ import { clearModelProviders, resolveModel } from "./model-registry.ts"; const MODEL_REGISTRY_ENV_KEYS = [ "OPENAI_API_KEY", "OPENAI_BASE_URL", + "GOOGLE_API_KEY", + "GOOGLE_GENERATIVE_AI_API_KEY", "VERYFRONT_API_TOKEN", "VERYFRONT_PROJECT_SLUG", ] as const; @@ -175,4 +177,73 @@ describe("provider/model-registry", () => { assertEquals(requestedBody?.custom_compat, true); assertEquals(requestedBody?.service_tier, "default"); }); + + it("keeps env-backed Google credentials on the captured guarded transport", async () => { + setEnv("GOOGLE_API_KEY", "google-test-key"); + let guardedCalls = 0; + let replacedGlobalCalls = 0; + let requestedUrl = ""; + let requestedApiKey: string | null = null; + globalThis.fetch = (async (input: URL | Request | string, init?: RequestInit) => { + guardedCalls++; + const request = new Request(input, init); + requestedUrl = request.url; + requestedApiKey = request.headers.get("x-goog-api-key"); + return Response.json({ + candidates: [{ + content: { parts: [{ text: "Guarded response" }] }, + finishReason: "STOP", + }], + usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 2 }, + }); + }) as typeof fetch; + + const runtime = resolveModel("google/gemini-2.5-flash"); + globalThis.fetch = (() => { + replacedGlobalCalls++; + return Promise.resolve(new Response("unexpected", { status: 500 })); + }) as typeof fetch; + + const result = await runtime.doGenerate({ + prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }], + }); + + assertEquals(guardedCalls, 1); + assertEquals(replacedGlobalCalls, 0); + assertEquals( + requestedUrl, + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent", + ); + assertEquals(requestedApiKey, "google-test-key"); + assertEquals(result.content, [{ type: "text", text: "Guarded response" }]); + }); + + it("rejects Google redirects before its API key can cross origins", async () => { + setEnv("GOOGLE_API_KEY", "google-test-key"); + let calls = 0; + let requestedApiKey: string | null = null; + globalThis.fetch = (async (input: URL | Request | string, init?: RequestInit) => { + calls++; + const request = new Request(input, init); + requestedApiKey = request.headers.get("x-goog-api-key"); + return new Response(null, { + status: 307, + headers: { location: "https://93.184.216.35/collect" }, + }); + }) as typeof fetch; + const runtime = resolveModel("google/gemini-2.5-flash"); + + await assertRejects( + () => + Promise.resolve( + runtime.doGenerate({ + prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }], + }), + ), + Error, + "unexpected redirect", + ); + assertEquals(calls, 1); + assertEquals(requestedApiKey, "google-test-key"); + }); }); diff --git a/src/provider/model-registry.ts b/src/provider/model-registry.ts index 65608e1b53..d42b0708cc 100644 --- a/src/provider/model-registry.ts +++ b/src/provider/model-registry.ts @@ -22,6 +22,8 @@ import { } from "#veryfront/config/env.ts"; import { ensureBuiltinLLMProviders } from "#veryfront/extensions/builtin-extensions.ts"; import { ProjectScopedRegistryManager } from "#veryfront/registry/project-scoped-registry-manager.ts"; +import { createOriginBoundOutboundFetch } from "#veryfront/security/http/outbound-fetch.ts"; +import { DEFAULT_GOOGLE_BASE_URL } from "#veryfront/provider/runtime-loader/provider-endpoints.ts"; import { createLocalModel } from "./local/model-runtime-adapter.ts"; import { verifyLocalRuntime } from "./local/local-engine.ts"; import { createVeryfrontCloudModel } from "./veryfront-cloud/provider.ts"; @@ -101,6 +103,9 @@ function autoInitializeFromEnv(): void { return provider.createModel(id, { credential: config.apiKey, baseURL: config.baseURL, + fetch: createOriginBoundOutboundFetch( + config.baseURL ?? "https://api.openai.com/v1", + ), providerName: getOpenAIEnvProviderName(config.baseURL), }); } @@ -130,6 +135,9 @@ function autoInitializeFromEnv(): void { return provider.createModel(id, { credential: config.apiKey, baseURL: config.baseURL, + fetch: createOriginBoundOutboundFetch( + config.baseURL ?? "https://api.anthropic.com/v1", + ), }); } throw toError(createError({ @@ -157,6 +165,7 @@ function autoInitializeFromEnv(): void { if (provider) { return provider.createModel(id, { credential: config.apiKey, + fetch: createOriginBoundOutboundFetch(DEFAULT_GOOGLE_BASE_URL), }); } throw toError( @@ -187,6 +196,9 @@ function autoInitializeFromEnv(): void { return provider.createModel(id, { credential: config.apiKey, baseURL: config.baseURL, + fetch: createOriginBoundOutboundFetch( + config.baseURL ?? "https://api.mistral.ai/v1", + ), }); } throw toError(createError({ diff --git a/src/provider/runtime-loader/provider-endpoints.ts b/src/provider/runtime-loader/provider-endpoints.ts index d2f89be4bf..5455c1e74f 100644 --- a/src/provider/runtime-loader/provider-endpoints.ts +++ b/src/provider/runtime-loader/provider-endpoints.ts @@ -1,6 +1,6 @@ const DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com/v1"; const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1"; -const DEFAULT_GOOGLE_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"; +export const DEFAULT_GOOGLE_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"; function joinUrl(base: string, path: string): string { let url: URL; diff --git a/src/provider/veryfront-cloud/provider.ts b/src/provider/veryfront-cloud/provider.ts index d741cccff5..29133a4ffc 100644 --- a/src/provider/veryfront-cloud/provider.ts +++ b/src/provider/veryfront-cloud/provider.ts @@ -25,7 +25,7 @@ export function createVeryfrontCloudModel(modelId: string): ModelRuntime { const { provider, modelId: upstreamModelId } = parseVeryfrontCloudModelId(modelId, "language"); const { apiBaseUrl, apiToken, projectSlug } = requireVeryfrontCloudBootstrap(); const baseURL = getVeryfrontCloudGatewayBaseUrl(apiBaseUrl, provider); - const fetch = createVeryfrontCloudFetch(apiToken, projectSlug); + const fetch = createVeryfrontCloudFetch(apiToken, baseURL, projectSlug); const registry = ensureBuiltinLLMProviders(); switch (provider) { diff --git a/src/provider/veryfront-cloud/shared.test.ts b/src/provider/veryfront-cloud/shared.test.ts index 9347291c2e..3e074b91aa 100644 --- a/src/provider/veryfront-cloud/shared.test.ts +++ b/src/provider/veryfront-cloud/shared.test.ts @@ -1,5 +1,5 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; +import { assertEquals, assertRejects, assertThrows } from "#veryfront/testing/assert.ts"; import { afterEach, describe, it } from "#veryfront/testing/bdd.ts"; import { runWithVeryfrontCloudContext } from "#veryfront/provider"; import { @@ -68,9 +68,12 @@ describe("provider/veryfront-cloud/shared", () => { return Promise.resolve(new Response(null, { status: 204 })); }) as typeof fetch; - const wrappedFetch = createVeryfrontCloudFetch("vf_test_provider"); + const wrappedFetch = createVeryfrontCloudFetch( + "vf_test_provider", + "https://93.184.216.34/ai/gateway/openai/v1", + ); - await wrappedFetch("https://api.veryfront.com/ai/gateway/openai/v1/chat/completions", { + await wrappedFetch("https://93.184.216.34/ai/gateway/openai/v1/chat/completions", { headers: { Authorization: "Bearer upstream-token", "x-api-key": "anthropic-key", @@ -93,11 +96,14 @@ describe("provider/veryfront-cloud/shared", () => { return Promise.resolve(new Response(null, { status: 204 })); }) as typeof fetch; - const wrappedFetch = createVeryfrontCloudFetch("vf_test_provider"); + const wrappedFetch = createVeryfrontCloudFetch( + "vf_test_provider", + "https://93.184.216.34/ai/gateway/openai/v1", + ); await runWithVeryfrontCloudContext( { billingGroupId: "evalrun_20260628_kimi" }, - () => wrappedFetch("https://api.veryfront.com/ai/gateway/openai/v1/chat/completions"), + () => wrappedFetch("https://93.184.216.34/ai/gateway/openai/v1/chat/completions"), ); assertEquals( @@ -105,4 +111,29 @@ describe("provider/veryfront-cloud/shared", () => { "evalrun_20260628_kimi", ); }); + + it("rejects redirects before the gateway credential reaches another origin", async () => { + const seen: Request[] = []; + globalThis.fetch = ((input: URL | Request | string, init?: RequestInit) => { + seen.push(new Request(input, init)); + return Promise.resolve( + new Response(null, { + status: 302, + headers: { location: "https://93.184.216.35/steal" }, + }), + ); + }) as typeof fetch; + const wrappedFetch = createVeryfrontCloudFetch( + "vf_test_provider", + "https://93.184.216.34/ai/gateway/openai/v1", + ); + + await assertRejects( + () => wrappedFetch("https://93.184.216.34/ai/gateway/openai/v1/chat/completions"), + Error, + "redirect", + ); + assertEquals(seen.length, 1); + assertEquals(seen[0]?.headers.get("authorization"), "Bearer vf_test_provider"); + }); }); diff --git a/src/provider/veryfront-cloud/shared.ts b/src/provider/veryfront-cloud/shared.ts index fef55ffa84..78ccbc4843 100644 --- a/src/provider/veryfront-cloud/shared.ts +++ b/src/provider/veryfront-cloud/shared.ts @@ -1,5 +1,9 @@ import { createError, toError } from "#veryfront/errors"; import { getVeryfrontCloudBootstrap } from "#veryfront/platform/cloud/resolver.ts"; +import { + guardedOutboundFetch, + OutboundRequestBlockedError, +} from "#veryfront/security/http/outbound-fetch.ts"; import { getCurrentVeryfrontCloudContext, markCurrentVeryfrontCloudBillingGroupUsed, @@ -135,8 +139,11 @@ export function getVeryfrontCloudGatewayBaseUrl( */ export function createVeryfrontCloudFetch( apiToken: string, + apiBaseUrl: string, projectSlug?: string, ): typeof fetch { + const authorizedOrigin = new URL(apiBaseUrl).origin; + return (input, init) => { const request = new Request(input, init); const headers = new Headers(request.headers); @@ -155,6 +162,18 @@ export function createVeryfrontCloudFetch( markCurrentVeryfrontCloudBillingGroupUsed(); } - return fetch(new Request(request, { headers })); + return guardedOutboundFetch( + new Request(request, { headers }), + { redirect: "error" }, + { + authorizeUrl(url) { + if (url.origin !== authorizedOrigin) { + throw new OutboundRequestBlockedError( + "Veryfront Cloud request blocked: destination origin is not authorized", + ); + } + }, + }, + ); }; } diff --git a/src/rendering/cache/stores/api-store.test.ts b/src/rendering/cache/stores/api-store.test.ts index 9e16fbea24..4419556e23 100644 --- a/src/rendering/cache/stores/api-store.test.ts +++ b/src/rendering/cache/stores/api-store.test.ts @@ -1,10 +1,15 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; +import { withMockFetch } from "#veryfront/testing/mock-fetch.ts"; import { withTimeoutThrow } from "../../utils/stream-utils.ts"; import { APICacheStore } from "./api-store.ts"; import type { CachePayload } from "../types.ts"; +// IANA's documentation address is public, so the egress guard can validate it +// before the deterministic test transport handles the request. +const TEST_PUBLIC_API_ORIGIN = "https://93.184.216.34"; + async function withStoreTtlEnabled(fn: () => Promise): Promise { const previousGlobal = (globalThis as Record).__vfDisableLruInterval; const previousEnv = Deno.env.get("VF_DISABLE_LRU_INTERVAL"); @@ -183,25 +188,7 @@ describe("rendering/cache/stores/api-store", () => { const releaseSet = Promise.withResolvers(); let setCompleted = false; let setPromise: Promise | undefined; - const server = Deno.serve( - { hostname: "127.0.0.1", port: 0, onListen: () => {} }, - async (request) => { - const url = new URL(request.url); - if ( - request.method !== "POST" || - url.pathname !== "/projects/api-store-test-project/cache/set" - ) { - return Response.json({ error: "not found" }, { status: 404 }); - } - - setStarted.resolve(); - await releaseSet.promise; - setCompleted = true; - return Response.json({ success: true }); - }, - ); - const addr = server.addr as Deno.NetAddr; - Deno.env.set("VERYFRONT_API_BASE_URL", `http://${addr.hostname}:${addr.port}`); + Deno.env.set("VERYFRONT_API_BASE_URL", TEST_PUBLIC_API_ORIGIN); Deno.env.set("VERYFRONT_API_TOKEN", "test-token"); globals.__vf_multi_project_adapter = { getCurrentRequestContext: () => ({ @@ -217,27 +204,50 @@ describe("rendering/cache/stores/api-store", () => { } as any; try { - let setResolved = false; - setPromise = store.set("distributed-key", payload).then(() => { - setResolved = true; - }); - - await withTimeoutThrow(setStarted.promise, 10_000, "distributed cache write to start"); - assertEquals(setResolved, false); - assertEquals(setCompleted, false); - - releaseSet.resolve(); - await setPromise; - - assertEquals(setCompleted, true); - assertEquals(setResolved, true); + await withMockFetch( + async (input: string | URL | Request, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init); + const url = new URL(request.url); + if ( + request.method !== "POST" || + url.origin !== TEST_PUBLIC_API_ORIGIN || + url.pathname !== "/projects/api-store-test-project/cache/set" + ) { + return Response.json({ error: "not found" }, { status: 404 }); + } + + setStarted.resolve(); + await releaseSet.promise; + setCompleted = true; + return Response.json({ success: true }); + }, + async () => { + let setResolved = false; + setPromise = store.set("distributed-key", payload).then(() => { + setResolved = true; + }); + + await withTimeoutThrow( + setStarted.promise, + 10_000, + "distributed cache write to start", + ); + assertEquals(setResolved, false); + assertEquals(setCompleted, false); + + releaseSet.resolve(); + await setPromise; + + assertEquals(setCompleted, true); + assertEquals(setResolved, true); + }, + ); } finally { releaseSet.resolve(); try { await withTimeoutThrow( Promise.all([ store.destroy(), - server.shutdown(), setPromise ?? Promise.resolve(), ]), 10_000, @@ -263,29 +273,13 @@ describe("rendering/cache/stores/api-store", () => { } }); - it("preserves Dates through an actual API backend round-trip", async () => { + it("preserves Dates through an API transport round-trip", async () => { const previousApiBaseUrl = Deno.env.get("VERYFRONT_API_BASE_URL"); const previousApiToken = Deno.env.get("VERYFRONT_API_TOKEN"); const globals = globalThis as Record; const originalAdapter = globals.__vf_multi_project_adapter; const values = new Map(); - const server = Deno.serve( - { hostname: "127.0.0.1", port: 0, onListen: () => {} }, - async (request) => { - const url = new URL(request.url); - if (url.pathname === "/projects/api-store-date-project/cache/set") { - const body = await request.json() as { key: string; value: string }; - values.set(body.key, body.value); - return Response.json({ success: true }); - } - if (url.pathname === "/projects/api-store-date-project/cache/get") { - return Response.json({ value: values.get(url.searchParams.get("key") ?? "") ?? null }); - } - return Response.json({ error: "not found" }, { status: 404 }); - }, - ); - const addr = server.addr as Deno.NetAddr; - Deno.env.set("VERYFRONT_API_BASE_URL", `http://${addr.hostname}:${addr.port}`); + Deno.env.set("VERYFRONT_API_BASE_URL", TEST_PUBLIC_API_ORIGIN); Deno.env.set("VERYFRONT_API_TOKEN", "test-token"); globals.__vf_multi_project_adapter = { getCurrentRequestContext: () => ({ @@ -306,13 +300,39 @@ describe("rendering/cache/stores/api-store", () => { }; try { - await store.set("dated-key", payload); - const result = await store.get("dated-key"); - - assertEquals(result?.result.frontmatter as unknown, { publishedAt }); + await withMockFetch( + async (input: string | URL | Request, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init); + const url = new URL(request.url); + if ( + request.method === "POST" && + url.origin === TEST_PUBLIC_API_ORIGIN && + url.pathname === "/projects/api-store-date-project/cache/set" + ) { + const body = await request.json() as { key: string; value: string }; + values.set(body.key, body.value); + return Response.json({ success: true }); + } + if ( + request.method === "GET" && + url.origin === TEST_PUBLIC_API_ORIGIN && + url.pathname === "/projects/api-store-date-project/cache/get" + ) { + return Response.json({ + value: values.get(url.searchParams.get("key") ?? "") ?? null, + }); + } + return Response.json({ error: "not found" }, { status: 404 }); + }, + async () => { + await store.set("dated-key", payload); + const result = await store.get("dated-key"); + + assertEquals(result?.result.frontmatter as unknown, { publishedAt }); + }, + ); } finally { await store.destroy(); - await server.shutdown(); if (previousApiBaseUrl === undefined) { Deno.env.delete("VERYFRONT_API_BASE_URL"); } else { @@ -339,25 +359,7 @@ describe("rendering/cache/stores/api-store", () => { let receivedTtl: number | undefined; let receivedValue = ""; - const server = Deno.serve( - { hostname: "127.0.0.1", port: 0, onListen: () => {} }, - async (request) => { - const url = new URL(request.url); - if ( - request.method !== "POST" || - url.pathname !== "/projects/api-store-test-project/cache/set" - ) { - return Response.json({ error: "not found" }, { status: 404 }); - } - - const body = await request.json() as { ttl?: number; value?: string }; - receivedTtl = body.ttl; - receivedValue = body.value ?? ""; - return Response.json({ success: true }); - }, - ); - const addr = server.addr as Deno.NetAddr; - Deno.env.set("VERYFRONT_API_BASE_URL", `http://${addr.hostname}:${addr.port}`); + Deno.env.set("VERYFRONT_API_BASE_URL", TEST_PUBLIC_API_ORIGIN); Deno.env.set("VERYFRONT_API_TOKEN", "test-token"); globals.__vf_multi_project_adapter = { getCurrentRequestContext: () => ({ @@ -377,13 +379,32 @@ describe("rendering/cache/stores/api-store", () => { } as any; try { - await store.set("distributed-stale-key", payload); - - assertEquals(receivedTtl !== undefined && receivedTtl > 5, true); - assertEquals(receivedValue.includes('"staleUntil"'), true); + await withMockFetch( + async (input: string | URL | Request, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init); + const url = new URL(request.url); + if ( + request.method !== "POST" || + url.origin !== TEST_PUBLIC_API_ORIGIN || + url.pathname !== "/projects/api-store-test-project/cache/set" + ) { + return Response.json({ error: "not found" }, { status: 404 }); + } + + const body = await request.json() as { ttl?: number; value?: string }; + receivedTtl = body.ttl; + receivedValue = body.value ?? ""; + return Response.json({ success: true }); + }, + async () => { + await store.set("distributed-stale-key", payload); + + assertEquals(receivedTtl !== undefined && receivedTtl > 5, true); + assertEquals(receivedValue.includes('"staleUntil"'), true); + }, + ); } finally { await store.destroy(); - await server.shutdown(); if (previousApiBaseUrl === undefined) { Deno.env.delete("VERYFRONT_API_BASE_URL"); } else { diff --git a/src/rendering/context/render-context.test.ts b/src/rendering/context/render-context.test.ts index af3f65dfad..fbccebc816 100644 --- a/src/rendering/context/render-context.test.ts +++ b/src/rendering/context/render-context.test.ts @@ -7,6 +7,7 @@ import { isSameTenant, type RenderContext, } from "./render-context.ts"; +import type { EnrichedContext } from "#veryfront/server/context/enriched-context.ts"; function makeMockRenderContext( overrides: Partial = {}, @@ -25,16 +26,18 @@ function makeMockRenderContext( }; } -function makeEnrichedContext(overrides: Record = {}): Record< - string, - unknown -> { +function makeEnrichedContext(overrides: Partial = {}): EnrichedContext { return { projectId: "p1", projectSlug: "slug", projectDir: "/dir", - config: {}, - adapter: {}, + token: "token", + branch: null, + isLocalProject: false, + parsedDomain: {} as EnrichedContext["parsedDomain"], + createdAt: 0, + config: {} as EnrichedContext["config"], + adapter: {} as EnrichedContext["adapter"], cachePrefix: "prefix", environment: "production", contentSourceId: "release-x", @@ -93,7 +96,7 @@ describe("rendering/context/render-context", () => { it("should throw when enriched context is missing config", () => { const enriched = makeEnrichedContext({ config: undefined }); assertThrows( - () => createRenderContextFromEnriched(enriched as any), + () => createRenderContextFromEnriched(enriched), Error, "missing required config", ); @@ -102,7 +105,7 @@ describe("rendering/context/render-context", () => { it("should throw when enriched context is missing adapter", () => { const enriched = makeEnrichedContext({ adapter: undefined }); assertThrows( - () => createRenderContextFromEnriched(enriched as any), + () => createRenderContextFromEnriched(enriched), Error, "missing required adapter", ); @@ -111,7 +114,7 @@ describe("rendering/context/render-context", () => { it("should throw when enriched context is missing contentSourceId", () => { const enriched = makeEnrichedContext({ contentSourceId: undefined }); assertThrows( - () => createRenderContextFromEnriched(enriched as any), + () => createRenderContextFromEnriched(enriched), Error, "missing required contentSourceId", ); @@ -120,15 +123,16 @@ describe("rendering/context/render-context", () => { it("should create render context from valid enriched context", () => { const enriched = makeEnrichedContext({ config: { dev: { port: 3000 } }, - adapter: { fs: {} }, + adapter: { fs: {} } as EnrichedContext["adapter"], branch: "main", releaseId: "r1", token: "tok-123", moduleServerUrl: "http://modules.local", nonce: "abc", + allowHostProjectCodeExecution: true, }); - const ctx = createRenderContextFromEnriched(enriched as any); + const ctx = createRenderContextFromEnriched(enriched); assertEquals(ctx.projectId, "p1"); assertEquals(ctx.projectSlug, "slug"); assertEquals(ctx.projectDir, "/dir"); @@ -138,12 +142,21 @@ describe("rendering/context/render-context", () => { assertEquals(ctx.releaseId, "r1"); assertEquals(ctx.proxyToken, "tok-123"); assertEquals(ctx.nonce, "abc"); + assertEquals(ctx.allowHostProjectCodeExecution, true); + }); + + it("should not infer host execution from a non-local enriched context", () => { + const ctx = createRenderContextFromEnriched( + makeEnrichedContext({ isLocalProject: false }), + ); + + assertEquals(ctx.allowHostProjectCodeExecution, false); }); it("should apply options overrides", () => { const enriched = makeEnrichedContext(); - const ctx = createRenderContextFromEnriched(enriched as any, { + const ctx = createRenderContextFromEnriched(enriched, { port: 8080, moduleServerUrl: "http://custom:9090", nonce: "custom-nonce", diff --git a/src/rendering/context/render-context.ts b/src/rendering/context/render-context.ts index 119f16560d..9916f5ad4b 100644 --- a/src/rendering/context/render-context.ts +++ b/src/rendering/context/render-context.ts @@ -21,6 +21,8 @@ export interface RenderContext { mode: "development" | "production"; /** Whether browser-facing local filesystem module URLs are trusted. */ isLocalProject?: boolean; + /** Narrow host-owned capability for project-code execution. */ + allowHostProjectCodeExecution?: boolean; adapter: RuntimeAdapter; cachePrefix: string; environment: RenderEnvironment; @@ -83,6 +85,8 @@ export function createRenderContext( config: ctx.config, mode: isLocal ? "development" : "production", isLocalProject: isLocal, + allowHostProjectCodeExecution: isLocal || + ctx.allowHostProjectCodeExecution === true, adapter: ctx.adapter, cachePrefix, environment, @@ -137,6 +141,8 @@ export function createRenderContextFromEnriched( config: enriched.config, mode: enriched.mode, isLocalProject: enriched.isLocalProject, + allowHostProjectCodeExecution: enriched.isLocalProject || + enriched.allowHostProjectCodeExecution === true, adapter: enriched.adapter, cachePrefix: enriched.cachePrefix, environment: enriched.environment, diff --git a/src/rendering/orchestrator/pipeline.behavior.test.ts b/src/rendering/orchestrator/pipeline.behavior.test.ts index 42e5b1fce4..817d4a9668 100644 --- a/src/rendering/orchestrator/pipeline.behavior.test.ts +++ b/src/rendering/orchestrator/pipeline.behavior.test.ts @@ -98,6 +98,7 @@ function createPipeline( } as any, mode: "production", projectDir: "/project", + isLocalProject: true, ...overrides, }; @@ -1043,6 +1044,9 @@ describe("RenderPipeline behavior", () => { [nestedClientLayoutPath, "'use client';\nexport default function GuidesLayout() {}"], ]); const pipeline = createPipeline(pagePath, { + // Server-owned page islands use the hosted module transport rather than + // the local filesystem transport exercised by the default fixture. + isLocalProject: false, pageResolver: { resolvePage: async () => ({ entity: { diff --git a/src/rendering/orchestrator/pipeline.test.ts b/src/rendering/orchestrator/pipeline.test.ts index e74a4b13dc..4f69129aa9 100644 --- a/src/rendering/orchestrator/pipeline.test.ts +++ b/src/rendering/orchestrator/pipeline.test.ts @@ -284,8 +284,9 @@ describe("RenderPipeline helpers", () => { "adapter", "mode", "projectDir", + "isLocalProject", ]; - assertEquals(requiredFields.length, 8); + assertEquals(requiredFields.length, 9); }); it("should accept development mode", () => { diff --git a/src/rendering/orchestrator/pipeline.ts b/src/rendering/orchestrator/pipeline.ts index 146c238d63..370cc2bad1 100644 --- a/src/rendering/orchestrator/pipeline.ts +++ b/src/rendering/orchestrator/pipeline.ts @@ -140,7 +140,9 @@ export interface RenderPipelineConfig { mode: "development" | "production"; projectDir: string; /** Whether browser module URLs may use the local filesystem endpoint. */ - isLocalProject?: boolean; + isLocalProject: boolean; + /** Narrow host-owned capability for project-code execution. */ + allowHostProjectCodeExecution?: boolean; /** Stable project identity used to isolate transformed module caches. */ projectId?: string; /** Release or preview source used to isolate transformed module caches. */ @@ -179,6 +181,13 @@ interface FetchedDataResult { error: Error | null; } +interface DataWorkerIdentity { + readonly isLocalProject: boolean; + readonly allowHostProjectCodeExecution: boolean; + readonly workerScope?: string; + readonly sourceGeneration?: string; +} + const PRE_RESOLVED_DATA = Symbol("veryfront.preResolvedData"); type InternalRenderOptions = RenderOptions & { @@ -520,6 +529,8 @@ export class RenderPipeline { return { params, pageProps, layoutProps }; } + const dataWorkerIdentity = await this.resolveDataWorkerIdentity(options); + const dataResults = await profilePhase( "render.fetch_data", () => @@ -534,6 +545,7 @@ export class RenderPipeline { const fetchOptions: FetchDataOptions = { modulePath: jobPath, projectDir: this.config.projectDir, + ...dataWorkerIdentity, }; const result = await this.dataFetcher .fetchData( @@ -560,6 +572,47 @@ export class RenderPipeline { return { params, pageProps, layoutProps }; } + /** + * Build a host-owned worker generation for raw local data modules. + * + * A mutable source may only reuse a Worker when its filesystem adapter + * supplies an exact snapshot generation. Otherwise the data fetcher selects + * a single-use Worker so an imported module graph cannot survive a source + * change. Production releases are immutable and may use the release id. + */ + private async resolveDataWorkerIdentity( + options: RenderOptions | undefined, + ): Promise { + const isLocalProject = this.config.isLocalProject === true; + const allowHostProjectCodeExecution = isLocalProject || + this.config.allowHostProjectCodeExecution === true; + if (!isLocalProject) { + return { isLocalProject, allowHostProjectCodeExecution }; + } + + const sourceSnapshotVersion = await this.config.adapter.fs + .getSourceSnapshotVersion?.(); + const releaseId = options?.releaseId; + if (!releaseId && sourceSnapshotVersion === undefined) { + return { isLocalProject, allowHostProjectCodeExecution }; + } + + const workerScope = this.config.projectId ?? this.config.projectDir; + const sourceGeneration = JSON.stringify({ + releaseId: releaseId ?? null, + sourceSnapshotVersion: sourceSnapshotVersion ?? null, + contentSourceId: options?.contentSourceId ?? this.config.contentSourceId ?? null, + environment: options?.environment ?? null, + dependencyPinningCacheKey: options?.dependencyPinningCacheKey ?? null, + }); + return { + isLocalProject, + allowHostProjectCodeExecution, + workerScope, + sourceGeneration, + }; + } + private applyFetchedDataResults( slug: string, dataResults: FetchedDataResult[], diff --git a/src/rendering/renderer.ts b/src/rendering/renderer.ts index d66e0f8bf2..92a2f53e71 100644 --- a/src/rendering/renderer.ts +++ b/src/rendering/renderer.ts @@ -1226,6 +1226,7 @@ export class Renderer { mode: ctx.mode, projectDir: ctx.projectDir, isLocalProject: ctx.isLocalProject === true, + allowHostProjectCodeExecution: ctx.allowHostProjectCodeExecution, projectId: ctx.projectId, contentSourceId: ctx.contentSourceId, config: ctx.config, diff --git a/src/routing/api/context-builder.ts b/src/routing/api/context-builder.ts index b36a19fead..e0b9a67e45 100644 --- a/src/routing/api/context-builder.ts +++ b/src/routing/api/context-builder.ts @@ -31,8 +31,14 @@ export interface APIContext { body: () => Promise; text: (data: string, init?: ResponseInit) => Response; fs: FileSystemAdapter; + /** Immutable environment snapshot for the current project request. */ + env: Readonly>; } +const EMPTY_PROJECT_ENV = Object.freeze( + Object.create(null) as Record, +); + /** * Statuses that the Fetch spec forbids from carrying a body. Constructing * `new Response(body, { status })` with a non-null `body` (an empty string @@ -113,6 +119,7 @@ export function createContext( request: Request, match: RouteMatch, fs: FileSystemAdapter, + env: Readonly> = EMPTY_PROJECT_ENV, ): APIContext { const url = new URL(request.url); const json = createJsonHelper(request); @@ -131,6 +138,7 @@ export function createContext( body, text, fs, + env, }; } diff --git a/src/routing/api/handler.test.ts b/src/routing/api/handler.test.ts index 4565bcd601..c5b1f40f2e 100644 --- a/src/routing/api/handler.test.ts +++ b/src/routing/api/handler.test.ts @@ -1,5 +1,5 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals, assertExists } from "#veryfront/testing/assert.ts"; +import { assert, assertEquals, assertExists } from "#veryfront/testing/assert.ts"; import { afterEach, describe, it } from "#veryfront/testing/bdd.ts"; import { createMockAdapter } from "#veryfront/platform/adapters/mock.ts"; import { HTTP_OK } from "#veryfront/utils"; @@ -10,6 +10,9 @@ import { APIRouteHandler, sanitizeLoadErrorForResponse, } from "./handler.ts"; +import { __resetPoolForTests } from "#veryfront/security/sandbox/worker-pool.ts"; +import { runWithExactSourceIntegrationPolicy } from "#veryfront/integrations/source-policy-context.ts"; +import { normalizeSourceIntegrationPolicy } from "#veryfront/integrations/source-policy.ts"; const handlers: APIRouteHandler[] = []; @@ -31,9 +34,12 @@ async function createInitializedHandler( return handler; } -afterEach((): void => { +afterEach(async (): Promise => { while (handlers.length) handlers.pop()?.destroy(); __injectDepsForTests(null); + await __resetPoolForTests(); + Deno.env.delete("WORKER_ISOLATION_ENABLED"); + Deno.env.delete("WORKER_ISOLATION_API"); }); describe("APIRouteHandler", () => { @@ -115,6 +121,276 @@ describe("APIRouteHandler", () => { }); }); + describe("remote execution isolation", () => { + it("rejects shared-runtime API execution before preparing or starting a Worker", async () => { + const adapter = createMockAdapter(); + adapter.fs.files.set( + "/test/project/app/api/isolation/route.ts", + "export function GET() { return new Response('must-not-run'); }", + ); + let hostLoads = 0; + let preparations = 0; + __injectDepsForTests({ + loadHandlerModule: () => { + hostLoads++; + throw new Error("shared tenant reached host import"); + }, + prepareHandlerModule: () => { + preparations++; + throw new Error("shared tenant reached same-process worker preparation"); + }, + }); + + const handler = await createInitializedHandler("/test/project", adapter); + const response = await handler.handle( + new Request("http://localhost/api/isolation"), + { + projectDir: "/test/project", + adapter, + securityConfig: null, + cspUserHeader: null, + isLocalProject: false, + prepareHostedConfigContext: () => + Promise.reject(new Error("hosted config must not be evaluated")), + }, + ); + + assertEquals(response?.status, 503); + assertEquals(response?.headers.get("cache-control"), "no-store"); + assertEquals(hostLoads, 0); + assertEquals(preparations, 0); + }); + + it("prepares without host import and executes top-level code in an env-denied worker", async () => { + const adapter = createMockAdapter(); + adapter.fs.files.set( + "/test/project/app/api/isolation/route.ts", + "export function GET() { return new Response('discovery-only'); }", + ); + + const marker = "__vf_remote_route_isolation_test__"; + delete (globalThis as Record)[marker]; + const source = [ + `import "data:text/javascript,globalThis.${marker}%3D%27worker-imported%27";`, + "let envAccess = 'allowed';", + "try { Deno.env.get('VF_TEST_HOST_ONLY_SECRET'); } catch { envAccess = 'blocked'; }", + "export function GET(request) {", + ` return Response.json({`, + ` envAccess,`, + ` marker: globalThis.${marker},`, + ` applicationAuthorization: request.headers.get("authorization"),`, + ` applicationCookie: request.headers.get("cookie"),`, + ` infrastructureToken: request.headers.get("x-token"),`, + ` projectSlug: request.headers.get("x-project-slug"),`, + ` });`, + "}", + ].join("\n"); + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(source)); + let hostLoads = 0; + let preparations = 0; + + __injectDepsForTests({ + loadHandlerModule: () => { + hostLoads++; + throw new Error("remote project module reached host import"); + }, + prepareHandlerModule: () => { + preparations++; + return Promise.resolve({ + source, + sha256: new Uint8Array(digest).toHex(), + }); + }, + }); + + Deno.env.delete("WORKER_ISOLATION_ENABLED"); + Deno.env.delete("WORKER_ISOLATION_API"); + await __resetPoolForTests(); + const handler = await createInitializedHandler("/test/project", adapter); + const remoteCtx = { + projectDir: "/test/project", + adapter, + securityConfig: null, + cspUserHeader: null, + isLocalProject: false, + } satisfies HandlerContext; + + const response = await runWithExactSourceIntegrationPolicy( + normalizeSourceIntegrationPolicy({ allow: {} }), + () => + handler.handle( + new Request("http://localhost/api/isolation", { + headers: { + authorization: "Bearer application-user-token", + cookie: "session=application-cookie", + "x-project-slug": "tenant-project", + "x-token": "platform-service-token", + }, + }), + remoteCtx, + ), + ); + + assertExists(response); + assertEquals(response.status, 200); + assertEquals(await response.json(), { + applicationAuthorization: "Bearer application-user-token", + applicationCookie: "session=application-cookie", + envAccess: "blocked", + infrastructureToken: null, + marker: "worker-imported", + projectSlug: null, + }); + assertEquals(hostLoads, 0); + assertEquals(preparations, 1); + assertEquals((globalThis as Record)[marker], undefined); + assert( + Deno.env.get("VF_TEST_HOST_ONLY_SECRET") === undefined, + "the test must not depend on a real host secret", + ); + }); + + it("keeps local development on the host-compatible route path", async () => { + const adapter = createMockAdapter(); + adapter.fs.files.set( + "/test/project/pages/api/local.ts", + "export function GET() { return new Response('local'); }", + ); + let hostLoads = 0; + let preparations = 0; + __injectDepsForTests({ + loadHandlerModule: () => { + hostLoads++; + return Promise.resolve({ + GET: (request: Request) => + Response.json({ + authorization: request.headers.get("authorization"), + infrastructureToken: request.headers.get("x-token"), + }), + }); + }, + prepareHandlerModule: () => { + preparations++; + throw new Error("local development should not prepare a worker module"); + }, + }); + + const handler = await createInitializedHandler("/test/project", adapter); + const response = await handler.handle( + new Request("http://localhost/api/local", { + headers: { + authorization: "Bearer local-application-token", + "x-token": "local-infrastructure-token", + }, + }), + { + projectDir: "/test/project", + adapter, + securityConfig: null, + cspUserHeader: null, + isLocalProject: true, + }, + ); + + assertEquals(response?.status, 200); + assertEquals(await response?.json(), { + authorization: "Bearer local-application-token", + infrastructureToken: null, + }); + assertEquals(hostLoads, 1); + assertEquals(preparations, 0); + }); + + it("allows an explicitly capable dedicated runtime to use the host route path", async () => { + const adapter = createMockAdapter(); + adapter.fs.files.set( + "/test/project/pages/api/dedicated.ts", + "export function GET() { return new Response('dedicated'); }", + ); + let hostLoads = 0; + let preparations = 0; + __injectDepsForTests({ + loadHandlerModule: () => { + hostLoads++; + return Promise.resolve({ + GET: () => new Response("dedicated"), + }); + }, + prepareHandlerModule: () => { + preparations++; + throw new Error("dedicated runtime should not prepare a worker module"); + }, + }); + + const handler = await createInitializedHandler("/test/project", adapter); + const response = await handler.handle( + new Request("http://localhost/api/dedicated"), + { + projectDir: "/test/project", + adapter, + securityConfig: null, + cspUserHeader: null, + isLocalProject: false, + allowHostProjectCodeExecution: true, + }, + ); + + assertEquals(response?.status, 200); + assertEquals(await response?.text(), "dedicated"); + assertEquals(hostLoads, 1); + assertEquals(preparations, 0); + }); + + it("prepares local routes before execution when API isolation is enabled", async () => { + const adapter = createMockAdapter(); + adapter.fs.files.set( + "/test/project/pages/api/local-isolated.ts", + "export function GET() { return new Response('discovery-only'); }", + ); + const source = `export function GET() { return new Response("local-isolated"); }`; + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(source)); + let hostLoads = 0; + let preparations = 0; + __injectDepsForTests({ + loadHandlerModule: () => { + hostLoads++; + throw new Error("isolated local route reached host import"); + }, + prepareHandlerModule: () => { + preparations++; + return Promise.resolve({ + source, + sha256: new Uint8Array(digest).toHex(), + }); + }, + }); + Deno.env.set("WORKER_ISOLATION_ENABLED", "1"); + Deno.env.set("WORKER_ISOLATION_API", "1"); + await __resetPoolForTests(); + + const handler = await createInitializedHandler("/test/project", adapter); + const response = await runWithExactSourceIntegrationPolicy( + normalizeSourceIntegrationPolicy({ allow: {} }), + () => + handler.handle( + new Request("http://localhost/api/local-isolated"), + { + projectDir: "/test/project", + adapter, + securityConfig: null, + cspUserHeader: null, + isLocalProject: true, + }, + ), + ); + + assertEquals(response?.status, 200); + assertEquals(await response?.text(), "local-isolated"); + assertEquals(hostLoads, 0); + assertEquals(preparations, 1); + }); + }); + describe("OPTIONS/CORS handling", () => { it("should handle OPTIONS preflight requests with secure-by-default CORS", async () => { const adapter = createMockAdapter(); @@ -275,7 +551,17 @@ describe("APIRouteHandler", () => { }); const handler = await createInitializedHandler("/test/project", adapter); - const responsePromise = handler.handle(new Request("http://localhost/api/status")); + const localCtx = { + projectDir: "/test/project", + adapter, + securityConfig: null, + cspUserHeader: null, + isLocalProject: true, + } satisfies HandlerContext; + const responsePromise = handler.handle( + new Request("http://localhost/api/status"), + localCtx, + ); handler.destroy(); const response = await responsePromise; @@ -284,6 +570,7 @@ describe("APIRouteHandler", () => { const responseAfterDestroy = await handler.handle( new Request("http://localhost/api/status"), + localCtx, ); assertEquals(responseAfterDestroy?.status, 404); }); @@ -498,7 +785,21 @@ describe("APIRouteHandler", () => { "export const notAMethod = 1;", ); - __injectDepsForTests({ loadHandlerModule: ({ modulePath }) => onLoad(modulePath) }); + __injectDepsForTests({ + loadHandlerModule: ({ modulePath }) => onLoad(modulePath), + prepareHandlerModule: async ({ modulePath }) => { + const route = await onLoad(modulePath); + if (!route || Object.keys(route).length === 0) { + throw new Error("Handler not found"); + } + const source = "export function GET() { return new Response('prepared'); }"; + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(source), + ); + return { source, sha256: new Uint8Array(digest).toHex() }; + }, + }); return { handler: await createInitializedHandler("/test/project", adapter), diff --git a/src/routing/api/handler.ts b/src/routing/api/handler.ts index d5e3214593..4db9c430e3 100644 --- a/src/routing/api/handler.ts +++ b/src/routing/api/handler.ts @@ -3,18 +3,39 @@ import { join } from "#veryfront/compat/path/index.ts"; import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; import { getConfig } from "#veryfront/config"; import { LRUCache } from "#veryfront/utils/lru-wrapper.ts"; -import { createError, toError } from "#veryfront/errors"; +import { + createError, + createErrorResponseFromDefinition, + PROJECT_EXECUTION_UNAVAILABLE, + toError, +} from "#veryfront/errors"; import { badGateway, internalServerError, notFound } from "#veryfront/http/responses"; import type { CORSConfig } from "#veryfront/security"; import { applyCORSHeaders, handleCORSPreflight } from "#veryfront/security"; import { type APIContext } from "./context-builder.ts"; import { ApiRouteMatcher, type RouteMatch } from "./api-route-matcher.ts"; import type { APIRoute } from "./module-loader/types.ts"; -import { loadHandlerModule } from "./module-loader/loader.ts"; +import { loadHandlerModule, prepareHandlerModule } from "./module-loader/loader.ts"; import { discoverAppRoutes, discoverPagesRoutes } from "./route-discovery.ts"; -import { executeAppRoute, executePagesRoute, type ExecuteRouteOptions } from "./route-executor.ts"; +import { + executeAppRoute, + executePagesRoute, + executePreparedAppRoute, + executePreparedPagesRoute, + type ExecuteRouteOptions, +} from "./route-executor.ts"; import { withSpan } from "#veryfront/observability/tracing/otlp-setup.ts"; import type { HandlerContext } from "#veryfront/types"; +import type { PreparedWorkerModule } from "#veryfront/security/sandbox/worker-types.ts"; +import { + evictWorkerScopeIfPresent, + isWorkerIsolationEnabled, +} from "#veryfront/security/sandbox/worker-pool.ts"; +import { createApplicationRequest } from "#veryfront/security/http/application-request.ts"; +import { + isHostProjectCodeExecutionAllowed, + isSharedProjectRuntime, +} from "#veryfront/security/project-locality.ts"; /** Max entries in the loaded-handler LRU cache */ const HANDLER_CACHE_MAX_ENTRIES = 256; @@ -60,6 +81,7 @@ export function sanitizeLoadErrorForResponse(message: string, projectDir?: strin */ interface APIRouteHandlerDeps { loadHandlerModule?: typeof loadHandlerModule; + prepareHandlerModule?: typeof prepareHandlerModule; discoverPagesRoutes?: typeof discoverPagesRoutes; discoverAppRoutes?: typeof discoverAppRoutes; getConfig?: typeof getConfig; @@ -77,6 +99,7 @@ export function __injectDepsForTests(deps: APIRouteHandlerDeps | null): void { function getDeps(): Required { return { loadHandlerModule: injectedDeps?.loadHandlerModule ?? loadHandlerModule, + prepareHandlerModule: injectedDeps?.prepareHandlerModule ?? prepareHandlerModule, discoverPagesRoutes: injectedDeps?.discoverPagesRoutes ?? discoverPagesRoutes, discoverAppRoutes: injectedDeps?.discoverAppRoutes ?? discoverAppRoutes, getConfig: injectedDeps?.getConfig ?? getConfig, @@ -98,13 +121,18 @@ export type APIHandler = (ctx: APIContext) => Promise | Response; * produced it, so a later route can never report an earlier route's error. */ interface LoadAttempt { - handler: APIRoute | null; + route: LoadedRoute | null; errorMessage: string | null; } +type LoadedRoute = + | { readonly kind: "host"; readonly handler: APIRoute } + | { readonly kind: "isolated"; readonly module: PreparedWorkerModule }; + export class APIRouteHandler { private router = new ApiRouteMatcher(); - private routeCache = new LRUCache({ maxEntries: HANDLER_CACHE_MAX_ENTRIES }); + private routeCache = new LRUCache({ maxEntries: HANDLER_CACHE_MAX_ENTRIES }); + private executionScopeId = crypto.randomUUID(); private activeRequests = 0; private destroyRequested = false; private destroyed = false; @@ -218,8 +246,27 @@ export class APIRouteHandler { params: match.params, }); - const { handler, errorMessage } = await this.loadHandler(match); - if (!handler) { + const isLocalProject = ctx?.isLocalProject === true; + const allowHostProjectCodeExecution = isHostProjectCodeExecutionAllowed(ctx); + if (!allowHostProjectCodeExecution && isSharedProjectRuntime(ctx)) { + const unavailable = createErrorResponseFromDefinition( + PROJECT_EXECUTION_UNAVAILABLE, + { + detail: + "Shared runtimes require a dedicated isolated project runtime for API execution", + instance: pathname, + }, + ); + unavailable.headers.set("cache-control", "no-store"); + return await applyCORSHeaders({ + request, + response: unavailable, + config: this.corsConfig ?? undefined, + }) ?? unavailable; + } + const useHostRealm = allowHostProjectCodeExecution && !isWorkerIsolationEnabled(); + const { route, errorMessage } = await this.loadRoute(match, useHostRealm); + if (!route) { const msg = errorMessage ?? "Handler not found"; try { @@ -252,14 +299,39 @@ export class APIRouteHandler { const isolationOptions: ExecuteRouteOptions = { modulePath: match.route.page, projectDir: this.projectDir, - isLocalProject: ctx?.isLocalProject, + isLocalProject, + allowHostProjectCodeExecution: useHostRealm, }; - const response = isAppRoute - ? await executeAppRoute(handler, request, match, pathname, adapter, isolationOptions) + const applicationRequest = createApplicationRequest(request); + const response = route.kind === "isolated" + ? isAppRoute + ? await executePreparedAppRoute(applicationRequest, match, pathname, { + executionScopeId: this.executionScopeId, + module: route.module, + modulePath: match.route.page, + projectDir: this.projectDir, + isLocalProject, + }) + : await executePreparedPagesRoute(applicationRequest, match, pathname, { + executionScopeId: this.executionScopeId, + module: route.module, + modulePath: match.route.page, + projectDir: this.projectDir, + isLocalProject, + }) + : isAppRoute + ? await executeAppRoute( + route.handler, + applicationRequest, + match, + pathname, + adapter, + isolationOptions, + ) : await executePagesRoute( - handler, - request, + route.handler, + applicationRequest, match, pathname, adapter, @@ -279,38 +351,54 @@ export class APIRouteHandler { ).finally(() => this.completeRequest()); } - private loadHandler(match: RouteMatch): Promise { + private loadRoute(match: RouteMatch, useHostRealm: boolean): Promise { const modulePath = match.route.page; + const cacheKey = `${useHostRealm ? "host" : "isolated"}:${modulePath}`; return withSpan( - "api.loadHandler", + "api.loadRoute", async () => { const adapter = await this.ensureAdapter(); await this.ensureConfig(adapter); - const cached = this.routeCache.get(modulePath); - if (cached) return { handler: cached, errorMessage: null }; + const cached = this.routeCache.get(cacheKey); + if (cached) return { route: cached, errorMessage: null }; try { const deps = getDeps(); + if (!useHostRealm) { + const module = await deps.prepareHandlerModule({ + projectDir: this.projectDir, + modulePath, + adapter, + config: this.config ?? undefined, + }); + const prepared: LoadedRoute = Object.freeze({ kind: "isolated", module }); + this.routeCache.set(cacheKey, prepared); + return { route: prepared, errorMessage: null }; + } + const handler = await deps.loadHandlerModule({ projectDir: this.projectDir, modulePath, adapter, config: this.config ?? undefined, + allowHostProjectCodeExecution: true, }); // Only cache handlers that export at least one HTTP method. // Empty objects ({}) from failed imports are truthy but useless — // caching them would prevent retry after the user fixes the import. - const usable = handler && Object.keys(handler).length > 0 ? handler : null; - if (usable) this.routeCache.set(modulePath, usable); + const usable = handler && Object.keys(handler).length > 0 + ? Object.freeze({ kind: "host", handler }) satisfies LoadedRoute + : null; + if (usable) this.routeCache.set(cacheKey, usable); - return { handler: usable, errorMessage: null }; + return { route: usable, errorMessage: null }; } catch (error) { const msg = error instanceof Error ? error.message : String(error); logger.error(`[API] Failed to load handler for ${modulePath}: ${msg}`); - return { handler: null, errorMessage: msg }; + return { route: null, errorMessage: msg }; } }, { "api.modulePath": modulePath }, @@ -318,6 +406,9 @@ export class APIRouteHandler { } clearCache(): void { + const previousScopeId = this.executionScopeId; + this.executionScopeId = crypto.randomUUID(); + evictWorkerScopeIfPresent(previousScopeId); this.routeCache.clear(); this.router.clearCache(); } @@ -342,6 +433,7 @@ export class APIRouteHandler { if (this.destroyed) return; this.destroyed = true; + evictWorkerScopeIfPresent(this.executionScopeId); this.routeCache.destroy(); this.router.destroy(); } diff --git a/src/routing/api/module-loader/esbuild-plugin.test.ts b/src/routing/api/module-loader/esbuild-plugin.test.ts index d3576b34ec..0f20689559 100644 --- a/src/routing/api/module-loader/esbuild-plugin.test.ts +++ b/src/routing/api/module-loader/esbuild-plugin.test.ts @@ -2,6 +2,7 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals, assertExists, assertRejects } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { computeIntegrity, type LockfileManager } from "#veryfront/utils"; +import { MAX_BUNDLE_CHUNK_SIZE_BYTES } from "#veryfront/utils/constants/buffers.ts"; import { createHTTPPlugin } from "./esbuild-plugin.ts"; import * as esbuild from "veryfront/extensions/bundler"; import type { @@ -305,6 +306,113 @@ describe("routing/api/module-loader/esbuild-plugin", () => { } }); + it("blocks every remote module when the allowed host list is empty", async () => { + const originalFetch = globalThis.fetch; + let fetchCalls = 0; + let loadHandler: ((args: OnLoadArgs) => unknown) | undefined; + const plugin = createHTTPPlugin([]); + plugin.setup(createMockBuild( + () => {}, + (_opts, fn) => { + loadHandler = fn; + }, + )); + assertExists(loadHandler); + + try { + globalThis.fetch = (() => { + fetchCalls += 1; + return Promise.resolve(new Response("unexpected")); + }) as typeof fetch; + const result = await loadHandler({ + path: "https://esm.sh/yaml@2", + namespace: "http-url", + pluginData: undefined, + suffix: "", + }); + + const errors = (result as { errors?: Array<{ text: string }> }).errors; + assertExists(errors?.[0]); + assertEquals(errors[0].text.includes("Remote import blocked by allow-list"), true); + assertEquals(fetchCalls, 0); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("blocks internal module targets before invoking fetch", async () => { + const originalFetch = globalThis.fetch; + let fetchCalls = 0; + let loadHandler: ((args: OnLoadArgs) => unknown) | undefined; + const plugin = createHTTPPlugin(["http://169.254.169.254"]); + const mockBuild = createMockBuild( + () => {}, + (_opts, fn) => { + loadHandler = fn; + }, + ); + plugin.setup(mockBuild); + assertExists(loadHandler); + + try { + globalThis.fetch = (() => { + fetchCalls += 1; + return Promise.resolve(new Response("unexpected")); + }) as typeof fetch; + const result = await loadHandler({ + path: "http://169.254.169.254/module.js", + namespace: "http-url", + pluginData: undefined, + suffix: "", + }); + const errors = (result as { errors?: Array<{ text: string }> }).errors; + assertExists(errors?.[0]); + assertEquals(errors[0].text.includes("internal host"), true); + assertEquals(fetchCalls, 0); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("reapplies the remote-host allow-list to redirects", async () => { + const originalFetch = globalThis.fetch; + let fetchCalls = 0; + let loadHandler: ((args: OnLoadArgs) => unknown) | undefined; + const plugin = createHTTPPlugin({ allowedHosts: ["https://93.184.216.34"] }); + const mockBuild = createMockBuild( + () => {}, + (_opts, fn) => { + loadHandler = fn; + }, + ); + plugin.setup(mockBuild); + assertExists(loadHandler); + + try { + globalThis.fetch = (() => { + fetchCalls += 1; + return Promise.resolve( + new Response(null, { + status: 302, + headers: { location: "https://93.184.216.35/module.js" }, + }), + ); + }) as typeof fetch; + const result = await loadHandler({ + path: "https://93.184.216.34/module.js", + namespace: "http-url", + pluginData: undefined, + suffix: "", + }); + const errors = (result as { errors?: Array<{ text: string }> }).errors; + assertExists(errors?.[0]); + assertEquals(errors[0].text.includes("Remote import blocked by allow-list"), true); + assertEquals(fetchCalls, 1); + } finally { + globalThis.fetch = originalFetch; + } + }); + it("serves a previously fetched remote module when the CDN later returns an error", async () => { const originalFetch = globalThis.fetch; const projectDir = await Deno.makeTempDir(); @@ -394,6 +502,97 @@ describe("routing/api/module-loader/esbuild-plugin", () => { } }); + it("rejects oversized remote module bodies without retrying", async () => { + const originalFetch = globalThis.fetch; + let attempts = 0; + let loadHandler: ((args: OnLoadArgs) => unknown) | undefined; + const plugin = createHTTPPlugin({ allowedHosts: ["https://esm.sh"] }); + plugin.setup(createMockBuild( + () => {}, + (_opts, fn) => { + loadHandler = fn; + }, + )); + assertExists(loadHandler); + + try { + globalThis.fetch = (async () => { + attempts += 1; + return new Response("export {};", { + headers: { + "content-length": String(MAX_BUNDLE_CHUNK_SIZE_BYTES + 1), + }, + }); + }) as typeof fetch; + + await assertRejects( + async () => { + await loadHandler!({ + path: "https://esm.sh/yaml@2", + namespace: "http-url", + pluginData: undefined, + suffix: "", + }); + }, + Error, + `exceeds ${MAX_BUNDLE_CHUNK_SIZE_BYTES} bytes`, + ); + assertEquals(attempts, 1); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("keeps the fetch deadline active while reading a streaming module body", async () => { + const originalFetch = globalThis.fetch; + let attempts = 0; + let loadHandler: ((args: OnLoadArgs) => unknown) | undefined; + const plugin = createHTTPPlugin({ + allowedHosts: ["https://93.184.216.34"], + fetchTimeoutMs: 20, + }); + plugin.setup(createMockBuild( + () => {}, + (_opts, fn) => { + loadHandler = fn; + }, + )); + assertExists(loadHandler); + + try { + globalThis.fetch = ((_input, init) => { + attempts += 1; + const signal = init?.signal; + return Promise.resolve( + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("export const pending = ")); + signal?.addEventListener("abort", () => controller.error(signal.reason), { + once: true, + }); + }, + }), + ), + ); + }) as typeof fetch; + + const result = await loadHandler!({ + path: "https://93.184.216.34/module.js", + namespace: "http-url", + pluginData: undefined, + suffix: "", + }); + + const errors = (result as { errors?: Array<{ text: string }> }).errors; + assertExists(errors?.[0]); + assertEquals(errors[0].text.includes("Failed to fetch"), true); + assertEquals(attempts, 3); + } finally { + globalThis.fetch = originalFetch; + } + }); + it("serves remote modules without repeated warnings when lockfile flush hits a read-only filesystem", async () => { const originalFetch = globalThis.fetch; const originalWarn = console.warn; diff --git a/src/routing/api/module-loader/esbuild-plugin.ts b/src/routing/api/module-loader/esbuild-plugin.ts index 55b45734f9..6e2052d615 100644 --- a/src/routing/api/module-loader/esbuild-plugin.ts +++ b/src/routing/api/module-loader/esbuild-plugin.ts @@ -12,6 +12,15 @@ import { createFileSystem, type FileSystem } from "#veryfront/platform/compat/fs import * as pathHelper from "#veryfront/compat/path"; import type { Message, Plugin } from "veryfront/extensions/bundler"; import { isAllowedRemoteHost } from "./http-validator.ts"; +import { + guardedOutboundFetch, + OutboundRequestBlockedError, +} from "#veryfront/security/http/outbound-fetch.ts"; +import { + HttpModuleBodyError, + readHttpModuleText, +} from "../../../transforms/shared/http-module-response.ts"; +import { MAX_BUNDLE_CHUNK_SIZE_BYTES } from "#veryfront/utils/constants/buffers.ts"; const logger = serverLogger.component("api"); const HTTP_MODULE_CACHE_DIR = ".veryfront/cache/api-http-imports"; @@ -20,6 +29,8 @@ const HTTP_MODULE_FETCH_RETRY_DELAY_MS = 100; interface HTTPPluginOptions { allowedHosts: string[]; + /** One end-to-end deadline for response headers and the bounded source body. */ + fetchTimeoutMs?: number; lockfile?: LockfileManager; projectDir?: string; strict?: boolean; @@ -42,6 +53,19 @@ interface HTTPModuleCache { ): Promise; } +type RemoteModuleFetchResult = + | { + ok: true; + status: number; + text: string; + url: string; + } + | { + ok: false; + status: number; + url: string; + }; + function createHTTPModuleCache(projectDir: string | undefined): HTTPModuleCache | null { if (!projectDir) return null; @@ -136,6 +160,10 @@ function createHTTPModuleCache(projectDir: string | undefined): HTTPModuleCache export function createHTTPPlugin(options: HTTPPluginOptions | string[]): Plugin { const opts: HTTPPluginOptions = Array.isArray(options) ? { allowedHosts: options } : options; const { allowedHosts, strict = false } = opts; + const fetchTimeoutMs = opts.fetchTimeoutMs ?? HTTP_MODULE_FETCH_TIMEOUT_MS; + if (!Number.isSafeInteger(fetchTimeoutMs) || fetchTimeoutMs <= 0) { + throw new TypeError("HTTP module fetch timeout must be a positive safe integer"); + } const lockfile = opts.lockfile ?? (opts.projectDir ? createLockfileManager(opts.projectDir) : null); const moduleCache = createHTTPModuleCache(opts.projectDir); @@ -147,16 +175,59 @@ export function createHTTPPlugin(options: HTTPPluginOptions | string[]): Plugin const resolvedUrls: string[] = []; const nodeMapped: Array<{ from: string; to: string }> = []; - async function fetchWithTimeout(url: string): Promise { + function authorizeRemoteUrl(url: URL): void { + if (isAllowedRemoteHost(url, allowedHosts)) return; + throw new OutboundRequestBlockedError( + `Remote import blocked by allow-list: ${url.origin}`, + ); + } + + async function fetchRemoteModuleAttempt(url: string): Promise { const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), HTTP_MODULE_FETCH_TIMEOUT_MS); + const timeout = setTimeout(() => controller.abort(), fetchTimeoutMs); + let response: Response | undefined; try { - return await fetch(url, { + response = await guardedOutboundFetch(url, { headers: { "user-agent": "Mozilla/5.0 Veryfront/1.0" }, signal: controller.signal, redirect: "follow", + }, { + authorizeUrl: authorizeRemoteUrl, }); + + if (!response.ok) { + await response.body?.cancel().catch(() => undefined); + return { + ok: false, + status: response.status, + url: response.url || url, + }; + } + + return { + ok: true, + status: response.status, + text: await readHttpModuleText( + response, + MAX_BUNDLE_CHUNK_SIZE_BYTES, + controller.signal, + ), + url: response.url || url, + }; + } catch (error) { + if (response) await response.body?.cancel().catch(() => undefined); + if ( + error instanceof OutboundRequestBlockedError || + error instanceof HttpModuleBodyError + ) { + throw error; + } + return { + ok: false, + status: HTTP_NETWORK_CONNECT_TIMEOUT, + url, + }; } finally { clearTimeout(timeout); } @@ -211,13 +282,9 @@ export function createHTTPPlugin(options: HTTPPluginOptions | string[]): Plugin } } - async function fetchRemoteModule(url: string): Promise { + async function fetchRemoteModule(url: string): Promise { for (let attempt = 1; attempt <= HTTP_MODULE_FETCH_MAX_ATTEMPTS; attempt += 1) { - const response = await fetchWithTimeout(url).catch((error) => - new Response(String(error?.message ?? error), { - status: HTTP_NETWORK_CONNECT_TIMEOUT, - }) - ); + const response = await fetchRemoteModuleAttempt(url); if (!shouldRetryFetch(response.status) || attempt === HTTP_MODULE_FETCH_MAX_ATTEMPTS) { return response; } @@ -228,9 +295,7 @@ export function createHTTPPlugin(options: HTTPPluginOptions | string[]): Plugin await sleep(HTTP_MODULE_FETCH_RETRY_DELAY_MS * attempt); } - return new Response("Remote module fetch failed", { - status: HTTP_NETWORK_CONNECT_TIMEOUT, - }); + return { ok: false, status: HTTP_NETWORK_CONNECT_TIMEOUT, url }; } build.onResolve({ filter: /^(http|https):\/\// }, (args) => ({ @@ -278,18 +343,16 @@ export function createHTTPPlugin(options: HTTPPluginOptions | string[]): Plugin try { const u = new URL(args.path); - if (allowedHosts?.length) { - if (!isAllowedRemoteHost(u, allowedHosts)) { - const remediation = - `Add "${u.origin}" to security.remoteHosts in veryfront.config.(ts|js) or replace with an approved CDN (e.g., https://esm.sh).`; - return { - errors: [ - { - text: `Remote import blocked by allow-list: ${u.origin}. ${remediation}`, - } as Message, - ], - }; - } + if (!isAllowedRemoteHost(u, allowedHosts)) { + const remediation = + `Add "${u.origin}" to security.remoteHosts in veryfront.config.(ts|js) or replace with an approved CDN (e.g., https://esm.sh).`; + return { + errors: [ + { + text: `Remote import blocked by allow-list: ${u.origin}. ${remediation}`, + } as Message, + ], + }; } if (u.hostname === "esm.sh") { @@ -321,7 +384,7 @@ export function createHTTPPlugin(options: HTTPPluginOptions | string[]): Plugin try { const res = await fetchRemoteModule(lockfileEntry.resolved); if (res.ok) { - const text = await res.text(); + const text = res.text; const integrity = await computeIntegrity(text); if (integrity === lockfileEntry.integrity) { @@ -353,7 +416,8 @@ export function createHTTPPlugin(options: HTTPPluginOptions | string[]): Plugin `[http] cached URL returned ${res.status}, trying module cache: ${args.path}`, ); } - } catch (_error) { + } catch (error) { + if (error instanceof OutboundRequestBlockedError) throw error; logger.warn(`[http] cached URL failed, trying module cache: ${args.path}`); } @@ -368,7 +432,15 @@ export function createHTTPPlugin(options: HTTPPluginOptions | string[]): Plugin } } - const res = await fetchRemoteModule(requestUrl); + let res: RemoteModuleFetchResult; + try { + res = await fetchRemoteModule(requestUrl); + } catch (error) { + if (error instanceof OutboundRequestBlockedError) { + return { errors: [{ text: error.message } as Message] }; + } + throw error; + } if (!res.ok) { const cachedText = @@ -390,8 +462,8 @@ export function createHTTPPlugin(options: HTTPPluginOptions | string[]): Plugin }; } - const text = await res.text(); - const resolvedUrl = res.url || requestUrl; + const text = res.text; + const resolvedUrl = res.url; const integrity = await computeIntegrity(text); await persistLockfileEntry(args.path, { diff --git a/src/routing/api/module-loader/external-import-rewriter.ts b/src/routing/api/module-loader/external-import-rewriter.ts index 45d4738d7c..b426316084 100644 --- a/src/routing/api/module-loader/external-import-rewriter.ts +++ b/src/routing/api/module-loader/external-import-rewriter.ts @@ -18,8 +18,10 @@ import type { EsmDependencyLocation as RouteEsmDependencyLocation, } from "#veryfront/transforms/import-rewriter/route-adapter.ts"; import { resolveExportEntry } from "./loader-helpers.ts"; +import { rethrowProjectBoundaryViolation } from "./project-source-snapshot.ts"; const logger = serverLogger.component("api"); +type SourceReader = Pick; /** Node.js built-in module names — shared across the CJS shim, esbuild externals, and Deno rewrites. */ export const NODE_BUILTINS = ROUTE_NODE_BUILTINS; @@ -129,7 +131,7 @@ export function getNodeExternalPackagesToResolve(userDeps: Map): export async function resolveNodePackageToFileUrl( projectDir: string, packageName: string, - fs: FileSystem, + fs: SourceReader, pathToFileURL: typeof import("node:url").pathToFileURL, ): Promise { const packagePath = pathHelper.join(projectDir, "node_modules", packageName); @@ -147,7 +149,8 @@ export async function resolveNodePackageToFileUrl( if (!entryPoint) return null; return pathToFileURL(pathHelper.join(packagePath, entryPoint)).href; - } catch (_) { + } catch (error) { + rethrowProjectBoundaryViolation(error); /* expected: package.json may not exist or be invalid */ return null; } @@ -163,7 +166,7 @@ export type EsmDependencyLocation = RouteEsmDependencyLocation; */ export async function resolveEsmUserDependencies( projectDir: string, - fs: FileSystem, + fs: SourceReader, userDeps: Map, ): Promise> { return await resolveEsmUserDependenciesForRoute(projectDir, fs, userDeps); @@ -171,7 +174,7 @@ export async function resolveEsmUserDependencies( export async function loadVeryfrontExportsMap( projectDir: string, - fs: FileSystem, + fs: SourceReader, ): Promise> { const vfPackagePath = pathHelper.join(projectDir, "node_modules", "veryfront"); const vfPackageJsonPath = pathHelper.join(vfPackagePath, "package.json"); @@ -179,7 +182,8 @@ export async function loadVeryfrontExportsMap( try { const pkgJson = JSON.parse(await fs.readTextFile(vfPackageJsonPath)); return pkgJson.exports || {}; - } catch (_error) { + } catch (error) { + rethrowProjectBoundaryViolation(error); logger.debug("Could not read veryfront package.json"); return {}; } @@ -188,7 +192,7 @@ export async function loadVeryfrontExportsMap( export async function rewriteNodeExternalImports( code: string, projectDir: string, - fs: FileSystem, + fs: SourceReader, userDeps: Map, ): Promise { const { pathToFileURL } = await import("node:url"); @@ -270,7 +274,7 @@ export function rewriteCompiledBinaryUserDependencyImports( export async function rewriteDenoNpmDependencyImports( code: string, projectDir: string, - fs: FileSystem, + fs: SourceReader, userDeps: Map, ): Promise { return await rewriteDenoNpmDependencyImportsForRoute(code, projectDir, fs, userDeps); @@ -283,7 +287,7 @@ export function rewriteDenoNodeBuiltinImports(code: string): string { export async function rewriteExternalImports( code: string, projectDir: string, - fs: FileSystem, + fs: SourceReader, userDeps: Map = new Map(), ): Promise { let transformed = code; @@ -292,6 +296,7 @@ export async function rewriteExternalImports( try { transformed = await rewriteNodeExternalImports(transformed, projectDir, fs, userDeps); } catch (e) { + rethrowProjectBoundaryViolation(e); logger.warn(`Failed to import node:module: ${e}`); } } diff --git a/src/routing/api/module-loader/http-validator.test.ts b/src/routing/api/module-loader/http-validator.test.ts index 5403e71660..a8f36932a9 100644 --- a/src/routing/api/module-loader/http-validator.test.ts +++ b/src/routing/api/module-loader/http-validator.test.ts @@ -5,8 +5,12 @@ import { validateHTTPImports } from "./http-validator.ts"; describe("routing/api/module-loader/http-validator", () => { describe("validateHTTPImports", () => { - it("should do nothing when allowedHosts is empty", () => { - validateHTTPImports('import foo from "https://evil.com/lib.js";', []); + it("should block all remote imports when allowedHosts is empty", () => { + assertThrows( + () => validateHTTPImports('import foo from "https://evil.com/lib.js";', []), + Error, + "Remote import blocked", + ); }); it("should allow imports from allowed hosts", () => { diff --git a/src/routing/api/module-loader/http-validator.ts b/src/routing/api/module-loader/http-validator.ts index 1d7b1680f6..6f80583f59 100644 --- a/src/routing/api/module-loader/http-validator.ts +++ b/src/routing/api/module-loader/http-validator.ts @@ -11,8 +11,6 @@ export function isAllowedRemoteHost(url: URL, allowedHosts: string[]): boolean { } export function validateHTTPImports(source: string, allowedHosts: string[]): void { - if (!allowedHosts?.length) return; - const importRegex = /import\s+(?:[\w\s{},*]+\s+from\s+)?['"]https?:\/\/[^'"]+['"]/g; const dynamicImportRegex = /import\s*\(['"]https?:\/\/[^'"]+['"]\)/g; diff --git a/src/routing/api/module-loader/loader.test.ts b/src/routing/api/module-loader/loader.test.ts index bbad79c986..f7de53133c 100644 --- a/src/routing/api/module-loader/loader.test.ts +++ b/src/routing/api/module-loader/loader.test.ts @@ -7,7 +7,8 @@ import { getNodeExternalPackagesToResolve, getUserDependencies, isSpecifierResolutionError, - loadHandlerModule, + loadHandlerModule as loadHandlerModuleRaw, + prepareHandlerModule, resolveEsmUserDependencies, rewriteCompiledBinaryUserDependencyImports, rewriteCompiledBinaryVeryfrontImports, @@ -21,9 +22,21 @@ import { createFileSystem } from "#veryfront/platform/compat/fs.ts"; import { env, getEnv, setEnv } from "#veryfront/compat/process.ts"; import { makeTempDir } from "#veryfront/testing/deno-compat.ts"; import type { VeryfrontConfig } from "#veryfront/config"; +import type { LoadModuleOptions } from "./types.ts"; +import { executeAppRoute } from "../route-executor.ts"; +import { __resetPoolForTests } from "#veryfront/security/sandbox/worker-pool.ts"; +import { runWithExactSourceIntegrationPolicy } from "#veryfront/integrations/source-policy-context.ts"; +import { normalizeSourceIntegrationPolicy } from "#veryfront/integrations/source-policy.ts"; const fs = createFileSystem(); +function loadHandlerModule(options: LoadModuleOptions) { + return loadHandlerModuleRaw({ + ...options, + allowHostProjectCodeExecution: true, + }); +} + const adapter: RuntimeAdapter = { id: "node", name: "node-stub", @@ -86,6 +99,7 @@ const adapter: RuntimeAdapter = { describe("loadHandlerModule", { sanitizeResources: false, sanitizeOps: false }, () => { afterAll(async () => { + await __resetPoolForTests(); const { stop } = await import("veryfront/extensions/bundler"); await stop(); }); @@ -106,6 +120,134 @@ describe("loadHandlerModule", { sanitizeResources: false, sanitizeOps: false }, assertEquals(typeof route?.GET, "function"); }); + it("rejects host loading without an explicit capability before evaluation", async () => { + const tmpDir = await makeTempDir(); + const modulePath = join(tmpDir, "untrusted-handler.ts"); + const marker = "__vf_untrusted_host_loader_marker__"; + delete (globalThis as Record)[marker]; + await fs.writeTextFile( + modulePath, + `globalThis.${marker} = true; export const GET = () => new Response("ok");`, + ); + + await assertRejects( + () => + loadHandlerModuleRaw({ + projectDir: tmpDir, + modulePath, + adapter, + config: undefined, + } as never), + TypeError, + "explicit trusted-local execution", + ); + assertEquals((globalThis as Record)[marker], undefined); + }); + + it("prepares route source without evaluating top-level project code", async () => { + const tmpDir = await makeTempDir(); + const modulePath = join(tmpDir, "prepared-handler.ts"); + const marker = "__vf_prepare_route_host_marker__"; + delete (globalThis as Record)[marker]; + await fs.writeTextFile( + modulePath, + [ + `globalThis.${marker} = "evaluated";`, + `export const GET = () => new Response("ok");`, + ].join("\n"), + ); + + const prepared = await prepareHandlerModule({ + projectDir: tmpDir, + modulePath, + adapter, + config: undefined, + }); + + assertEquals((globalThis as Record)[marker], undefined); + assertEquals(prepared.sha256.length, 64); + assertMatch(prepared.source, /__vf_prepare_route_host_marker__/); + }); + + it("keeps an authenticated hosted empty remote-host policy fail-closed", async () => { + const projectDir = await makeTempDir(); + const modulePath = join(projectDir, "hosted-handler.ts"); + await fs.writeTextFile( + modulePath, + `import { parse } from "https://esm.sh/yaml@2";\n` + + `export const GET = () => new Response(typeof parse);`, + ); + + const originalFetch = globalThis.fetch; + let fetchCalls = 0; + try { + globalThis.fetch = (() => { + fetchCalls += 1; + return Promise.resolve(new Response("export const parse = () => {};")); + }) as typeof fetch; + + await assertRejects( + () => + prepareHandlerModule({ + projectDir, + modulePath, + adapter, + // Hosted callers supply the already authenticated and validated + // project config. An explicit empty list means deny every host. + config: { security: { remoteHosts: [] } }, + }), + Error, + "Remote import blocked by allow-list", + ); + assertEquals(fetchCalls, 0); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("executes prepared bundled source only inside the project worker", async () => { + const tmpDir = await makeTempDir(); + const modulePath = join(tmpDir, "isolated-handler.ts"); + const marker = "__vf_prepared_route_worker_marker__"; + delete (globalThis as Record)[marker]; + await fs.writeTextFile( + modulePath, + [ + `globalThis.${marker} = "worker-only";`, + `export function GET() { return new Response(String(globalThis.${marker})); }`, + ].join("\n"), + ); + + const prepared = await prepareHandlerModule({ + projectDir: tmpDir, + modulePath, + adapter, + config: undefined, + }); + const response = await runWithExactSourceIntegrationPolicy( + normalizeSourceIntegrationPolicy({ allow: {} }), + () => + executeAppRoute( + {}, + new Request("http://localhost/api/isolated"), + { route: { pattern: "/api/isolated", page: modulePath }, params: {} }, + "/api/isolated", + adapter, + { + modulePath, + projectDir: tmpDir, + isLocalProject: false, + preparedModule: prepared, + executionScopeId: `loader-test-${crypto.randomUUID()}`, + }, + ), + ); + + assertEquals(response.status, 200); + assertEquals(await response.text(), "worker-only"); + assertEquals((globalThis as Record)[marker], undefined); + }); + it("resolves relative imports through adapter when file is not local", async () => { const realDir = await makeTempDir(); await fs.mkdir(join(realDir, "lib"), { recursive: true }); @@ -1058,6 +1200,277 @@ describe("loadHandlerModule", { sanitizeResources: false, sanitizeOps: false }, ); }); + it("rejects prepared absolute imports from an unrelated node_modules directory", async () => { + const projectDir = await makeTempDir(); + const unrelatedDir = await makeTempDir(); + const unrelatedPackageDir = join(unrelatedDir, "node_modules", "host-only"); + await fs.mkdir(unrelatedPackageDir, { recursive: true }); + + const unrelatedModule = join(unrelatedPackageDir, "index.js"); + await fs.writeTextFile( + unrelatedModule, + `export const value = "outside-project";`, + ); + const modulePath = join(projectDir, "route.ts"); + await fs.writeTextFile( + modulePath, + `import { value } from ${JSON.stringify(unrelatedModule)};\n` + + `export const GET = () => new Response(value);`, + ); + + await assertRejects( + () => + prepareHandlerModule({ + projectDir, + modulePath, + adapter, + config: undefined, + }), + Error, + "Import escapes the project directory", + ); + }); + + it("allows prepared imports from the canonical project dependency root", async () => { + const projectDir = await makeTempDir(); + const packageDir = join(projectDir, "node_modules", "project-owned"); + await fs.mkdir(packageDir, { recursive: true }); + await fs.writeTextFile( + join(packageDir, "package.json"), + JSON.stringify({ name: "project-owned", type: "module", main: "index.js" }), + ); + await fs.writeTextFile( + join(packageDir, "index.js"), + `export const value = "project-dependency";`, + ); + + const modulePath = join(projectDir, "route.ts"); + await fs.writeTextFile( + modulePath, + `import { value } from "project-owned";\n` + + `export const GET = () => new Response(value);`, + ); + + const prepared = await prepareHandlerModule({ + projectDir, + modulePath, + adapter, + config: undefined, + }); + assertMatch(prepared.source, /project-dependency/); + }); + + it("rejects project symlink escapes before the adapter reads the target", async () => { + const projectDir = await makeTempDir(); + const outsideDir = await makeTempDir(); + const projectLibDir = join(projectDir, "lib"); + await fs.mkdir(projectLibDir, { recursive: true }); + + const outsideModule = join(outsideDir, "secret.ts"); + const linkedModule = join(projectLibDir, "linked.ts"); + await fs.writeTextFile(outsideModule, `export const secret = "outside-project";`); + try { + await Deno.symlink(outsideModule, linkedModule); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (/permission|not supported/i.test(message)) return; + throw error; + } + + const modulePath = join(projectDir, "route.ts"); + await fs.writeTextFile( + modulePath, + `import { secret } from "./lib/linked.ts";\n` + + `export const GET = () => new Response(secret);`, + ); + + let linkedModuleRead = false; + const observingAdapter: RuntimeAdapter = { + ...adapter, + fs: { + ...adapter.fs, + readFile(path: string): Promise { + if (path === linkedModule) linkedModuleRead = true; + return adapter.fs.readFile(path); + }, + }, + }; + + await assertRejects( + () => + prepareHandlerModule({ + projectDir, + modulePath, + adapter: observingAdapter, + config: undefined, + }), + Error, + "Import escapes the project directory", + ); + assertEquals(linkedModuleRead, false); + }); + + it("rejects a project package manifest symlink before reading outside the project", async () => { + const projectDir = await makeTempDir(); + const outsideDir = await makeTempDir(); + const outsideManifest = join(outsideDir, "package.json"); + const projectManifest = join(projectDir, "package.json"); + await fs.writeTextFile( + outsideManifest, + JSON.stringify({ dependencies: { "outside-only": "1.0.0" } }), + ); + try { + await Deno.symlink(outsideManifest, projectManifest); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (/permission|not supported/i.test(message)) return; + throw error; + } + + const modulePath = join(projectDir, "route.ts"); + await fs.writeTextFile(modulePath, `export const GET = () => new Response("ok");`); + + const canonicalOutsideManifest = await Deno.realPath(outsideManifest); + let outsideManifestRead = false; + const observingAdapter: RuntimeAdapter = { + ...adapter, + fs: { + ...adapter.fs, + readFile(path: string): Promise { + if (path === canonicalOutsideManifest) outsideManifestRead = true; + return adapter.fs.readFile(path); + }, + }, + }; + + await assertRejects( + () => + prepareHandlerModule({ + projectDir, + modulePath, + adapter: observingAdapter, + config: undefined, + }), + Error, + "Import escapes the project directory", + ); + assertEquals(outsideManifestRead, false); + }); + + it("rejects a symlinked dependency manifest outside the project", async () => { + const projectDir = await makeTempDir(); + const outsideDir = await makeTempDir(); + const packageName = "outside-dependency"; + const projectModules = join(projectDir, "node_modules"); + const outsidePackage = join(outsideDir, packageName); + await fs.mkdir(projectModules, { recursive: true }); + await fs.mkdir(outsidePackage, { recursive: true }); + await fs.writeTextFile( + join(projectDir, "package.json"), + JSON.stringify({ dependencies: { [packageName]: "1.0.0" } }), + ); + const outsideManifest = join(outsidePackage, "package.json"); + await fs.writeTextFile( + outsideManifest, + JSON.stringify({ name: packageName, version: "1.0.0", type: "module" }), + ); + await fs.writeTextFile(join(outsidePackage, "index.js"), `export const value = "outside";`); + try { + await Deno.symlink(outsidePackage, join(projectModules, packageName)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (/permission|not supported/i.test(message)) return; + throw error; + } + + const modulePath = join(projectDir, "route.ts"); + await fs.writeTextFile( + modulePath, + `import { value } from "${packageName}";\n` + + `export const GET = () => new Response(value);`, + ); + + const canonicalOutsideManifest = await Deno.realPath(outsideManifest); + let outsideManifestRead = false; + const observingAdapter: RuntimeAdapter = { + ...adapter, + fs: { + ...adapter.fs, + readFile(path: string): Promise { + if (path === canonicalOutsideManifest) outsideManifestRead = true; + return adapter.fs.readFile(path); + }, + }, + }; + + await assertRejects( + () => + prepareHandlerModule({ + projectDir, + modulePath, + adapter: observingAdapter, + config: undefined, + }), + Error, + "Import escapes the project directory", + ); + assertEquals(outsideManifestRead, false); + }); + + it("reads the authorized canonical path when a project symlink is swapped", async () => { + const projectDir = await makeTempDir(); + const outsideDir = await makeTempDir(); + const projectLibDir = join(projectDir, "lib"); + await fs.mkdir(projectLibDir, { recursive: true }); + + const insideModule = join(projectLibDir, "inside.ts"); + const outsideModule = join(outsideDir, "outside.ts"); + const linkedModule = join(projectLibDir, "linked.ts"); + await fs.writeTextFile(insideModule, `export const value = "inside-project-only";`); + await fs.writeTextFile(outsideModule, `export const value = "outside-project";`); + try { + await Deno.symlink(insideModule, linkedModule); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (/permission|not supported/i.test(message)) return; + throw error; + } + + const modulePath = join(projectDir, "route.ts"); + await fs.writeTextFile( + modulePath, + `import { value } from "./lib/linked.ts";\n` + + `export const GET = () => new Response(value);`, + ); + + const canonicalInsideModule = await Deno.realPath(insideModule); + let swapped = false; + const swappingAdapter: RuntimeAdapter = { + ...adapter, + fs: { + ...adapter.fs, + async readFile(path: string): Promise { + if (!swapped && path === canonicalInsideModule) { + swapped = true; + await Deno.remove(linkedModule); + await Deno.symlink(outsideModule, linkedModule); + } + return await adapter.fs.readFile(path); + }, + }, + }; + + const prepared = await prepareHandlerModule({ + projectDir, + modulePath, + adapter: swappingAdapter, + config: undefined, + }); + assertEquals(swapped, true); + assertMatch(prepared.source, /inside-project-only/); + assertEquals(prepared.source.includes("outside-project"), false); + }); + it("rejects API handlers with remote imports when the project lockfile cannot be written for non-read-only reasons", async () => { const originalFetch = globalThis.fetch; const realDir = await makeTempDir(); diff --git a/src/routing/api/module-loader/loader.ts b/src/routing/api/module-loader/loader.ts index fc59a97564..0ca4aaf550 100644 --- a/src/routing/api/module-loader/loader.ts +++ b/src/routing/api/module-loader/loader.ts @@ -5,10 +5,10 @@ import type { VeryfrontConfig } from "#veryfront/config"; import { createHTTPPlugin } from "./esbuild-plugin.ts"; import { validateHTTPImports } from "./http-validator.ts"; import { loadSecurityConfig } from "./security-config.ts"; -import type { APIRoute, LoadModuleOptions } from "./types.ts"; +import type { APIRoute, LoadHostModuleOptions, LoadModuleOptions } from "./types.ts"; import { createError, toError } from "#veryfront/errors"; import { getEsbuildLoader } from "#veryfront/utils/path-utils.ts"; -import { createFileSystem, realPath } from "#veryfront/platform/compat/fs.ts"; +import { createFileSystem } from "#veryfront/platform/compat/fs.ts"; import type { FileSystem } from "#veryfront/platform/compat/fs.ts"; import * as pathHelper from "#veryfront/compat/path"; import { FILE_EXTENSIONS, getLoaderForFile, validateModulePath } from "./loader-helpers.ts"; @@ -23,6 +23,16 @@ import { readProjectDependencies, rewriteExternalImports, } from "./external-import-rewriter.ts"; +import { + MAX_WORKER_MODULE_SOURCE_BYTES, + type PreparedWorkerModule, +} from "#veryfront/security/sandbox/worker-types.ts"; +import { isExplicitHostProjectCodeExecutionAllowed } from "#veryfront/security/project-locality.ts"; +import { + createProjectSourceSnapshot, + ProjectBoundaryViolationError, + type ProjectSourceSnapshot, +} from "./project-source-snapshot.ts"; export { generateCompiledBinaryRequireShim, getNodeExternalPackagesToResolve, @@ -40,7 +50,12 @@ const logger = serverLogger.component("api"); export { toCjsDestructureBindings } from "./loader-helpers.ts"; -export function loadHandlerModule(options: LoadModuleOptions): Promise { +export function loadHandlerModule(options: LoadHostModuleOptions): Promise { + if (!isExplicitHostProjectCodeExecutionAllowed(options)) { + return Promise.reject( + new TypeError("Host API module loading requires explicit trusted-local execution"), + ); + } return withSpan( "api.loadHandlerModule", async () => { @@ -67,6 +82,60 @@ export function loadHandlerModule(options: LoadModuleOptions): Promise { + return withSpan( + "api.prepareHandlerModule", + async () => { + const { projectDir, modulePath, adapter, config } = options; + validateModulePath(modulePath, projectDir); + + if (isCompiledBinary()) { + throw toError( + createError({ + type: "api", + message: "Isolated API route preparation is unavailable in this compiled runtime", + }), + ); + } + + try { + const source = await buildTranspiledModuleSource( + modulePath, + projectDir, + adapter, + config, + ); + const bytes = new TextEncoder().encode(source); + if (bytes.byteLength > MAX_WORKER_MODULE_SOURCE_BYTES) { + throw new TypeError( + `Prepared API route exceeds the ${MAX_WORKER_MODULE_SOURCE_BYTES}-byte worker limit`, + ); + } + const digest = await crypto.subtle.digest("SHA-256", bytes); + return Object.freeze({ + source, + sha256: new Uint8Array(digest).toHex(), + }); + } catch (error: unknown) { + const errorMsg = error instanceof Error ? error.message : String(error); + logger.error(`Failed to prepare isolated API handler ${modulePath}:`, error); + throw toError( + createError({ + type: "api", + message: `Failed to prepare isolated API handler: ${errorMsg}`, + }), + ); + } + }, + { "api.modulePath": options.modulePath, "api.projectDir": options.projectDir }, + ); +} + async function loadModule(args: { modulePath: string; projectDir: string; @@ -149,7 +218,7 @@ function loadJSModule(modulePath: string): Promise { function createImportMapPlugin( projectDir: string, - adapter: RuntimeAdapter, + sourceSnapshot: ProjectSourceSnapshot, config?: VeryfrontConfig, ): Plugin { const importMap = config?.resolve?.importMap?.imports ?? {}; @@ -221,7 +290,7 @@ function createImportMapPlugin( build.onLoad( { filter: /.*/, namespace: "import-map" }, createNamespaceOnLoadHandler({ - adapter, + sourceSnapshot, projectDir, errorLabel: "file via import map", }), @@ -231,16 +300,16 @@ function createImportMapPlugin( } function createNamespaceOnLoadHandler(options: { - adapter: RuntimeAdapter; + sourceSnapshot: ProjectSourceSnapshot; projectDir: string; errorLabel: string; }) { - const { adapter, projectDir, errorLabel } = options; + const { sourceSnapshot, projectDir, errorLabel } = options; return wrapWithCurrentContext(async (args: { path: string }) => { try { const { filePath, contents } = await readFileWithExtensions( - adapter, + sourceSnapshot, args.path, FILE_EXTENSIONS, projectDir, @@ -261,7 +330,7 @@ function createNamespaceOnLoadHandler(options: { /** Resolves the framework's built-in @/ project alias through the runtime adapter. */ function createProjectAliasPlugin( - adapter: RuntimeAdapter, + sourceSnapshot: ProjectSourceSnapshot, projectDir: string, ): Plugin { const projectRoot = pathHelper.resolve(projectDir); @@ -284,7 +353,7 @@ function createProjectAliasPlugin( build.onLoad( { filter: /.*/, namespace: "vf-project-alias" }, createNamespaceOnLoadHandler({ - adapter, + sourceSnapshot, projectDir, errorLabel: "via project alias", }), @@ -295,7 +364,7 @@ function createProjectAliasPlugin( /** Resolves relative imports through the adapter's virtual FS for remote projects. */ function createAdapterResolvePlugin( - adapter: RuntimeAdapter, + sourceSnapshot: ProjectSourceSnapshot, projectDir: string, ): Plugin { return { @@ -334,7 +403,7 @@ function createAdapterResolvePlugin( build.onLoad( { filter: /.*/, namespace: "vf-adapter" }, createNamespaceOnLoadHandler({ - adapter, + sourceSnapshot, projectDir, errorLabel: "via adapter", }), @@ -352,65 +421,76 @@ function createAdapterResolvePlugin( * is resolved by esbuild straight to a path above the project root and loaded * in the default namespace, where none of those guards ever see it. * - * This runs at load time instead, which is the one point every resolution - * strategy converges on. Returning `undefined` defers to normal loading, so the - * plugin only ever subtracts. Package code is exempt: `node_modules` is - * resolved by esbuild's own node resolution rather than by a project alias, and - * is legitimately hoisted above the project root in a monorepo. - * - * `roots` carries both the project path as configured and its symlink-resolved - * form, because esbuild reports the real path of a file it loaded. A project - * reached through a symlink (`/var` -> `/private/var` on macOS, and any deploy - * layout that symlinks a release directory) would otherwise fail every import. - */ -/** - * The project path as configured, plus its symlink-resolved form when they - * differ. `realPath` throws if the directory is missing, in which case the - * configured path is all there is to compare against. + * The source snapshot canonicalizes both roots and resolved files. Dependencies + * are admitted only through the project's own canonical `node_modules` root; + * an unrelated path is never trusted merely because one segment has that name. */ -async function resolveProjectRoots(projectDir: string): Promise { - const configured = pathHelper.resolve(projectDir); - - try { - const real = await realPath(configured); - return real === configured ? [configured] : [configured, real]; - } catch { - return [configured]; - } +function projectBoundaryError(path: string): { errors: Array<{ text: string }> } { + logger.error(`[API] Resolved import escapes project: ${path}`); + return { + errors: [{ + text: `Import escapes the project directory: ${path}. ` + + `API routes may only import project files and project-owned dependencies.`, + }], + }; } -function createProjectBoundaryPlugin(roots: string[]): Plugin { +function createProjectBoundaryPlugin( + sourceSnapshot: ProjectSourceSnapshot, +): Plugin { return { name: "vf-project-boundary", setup(build) { - build.onLoad({ filter: /.*/ }, (args) => { - if (roots.some((root) => isWithinDirectory(root, args.path))) return undefined; - if (args.path.split(/[\\/]/).includes("node_modules")) return undefined; - - logger.error(`[API] Resolved import escapes project: ${args.path}`); - return { - errors: [{ - text: `Import escapes the project directory: ${args.path}. ` + - `API routes may only import files inside the project.`, - }], - }; + build.onLoad({ filter: /.*/ }, async (args) => { + try { + const source = await sourceSnapshot.read(args.path); + return { + contents: source.contents, + loader: getLoaderForFile(source.logicalPath), + resolveDir: pathHelper.dirname(source.logicalPath), + }; + } catch (error) { + if (error instanceof ProjectBoundaryViolationError) { + return projectBoundaryError(args.path); + } + const message = error instanceof Error ? error.message : String(error); + return { + errors: [{ text: `Failed to read authorized import: ${message}` }], + }; + } }); }, }; } -function loadAndTranspileModule( +async function loadAndTranspileModule( modulePath: string, projectDir: string, adapter: RuntimeAdapter, fs: FileSystem, config?: VeryfrontConfig, ): Promise { + const source = await buildTranspiledModuleSource( + modulePath, + projectDir, + adapter, + config, + ); + return await loadModuleFromCode(source, fs); +} + +function buildTranspiledModuleSource( + modulePath: string, + projectDir: string, + adapter: RuntimeAdapter, + config?: VeryfrontConfig, +): Promise { return withSpan( - "api.loadAndTranspileModule", + "api.buildTranspiledModuleSource", async () => { + const sourceSnapshot = await createProjectSourceSnapshot(projectDir, adapter); const { filePath: resolvedPath, contents: source } = await readFileWithExtensions( - adapter, + sourceSnapshot, modulePath, FILE_EXTENSIONS, projectDir, @@ -427,13 +507,10 @@ function loadAndTranspileModule( const loader = getEsbuildLoader(resolvedPath); - const allowedHosts = await loadSecurityConfig(projectDir, adapter); + const allowedHosts = await loadSecurityConfig(projectDir, adapter, config); validateHTTPImports(source, allowedHosts); - const projectSourceReader = { - readTextFile: (filePath: string) => adapter.fs.readFile(filePath), - }; - const allDeps = await readProjectDependencies(projectDir, projectSourceReader); + const allDeps = await readProjectDependencies(projectDir, sourceSnapshot); // Filter out framework-managed packages from user deps. These are already // handled by the framework's own external/rewrite logic and should not be @@ -503,11 +580,11 @@ function loadAndTranspileModule( sourcefile: resolvedPath, }, plugins: [ - createImportMapPlugin(projectDir, adapter, config), - createProjectAliasPlugin(adapter, projectDir), - createAdapterResolvePlugin(adapter, projectDir), + createImportMapPlugin(projectDir, sourceSnapshot, config), + createProjectAliasPlugin(sourceSnapshot, projectDir), + createAdapterResolvePlugin(sourceSnapshot, projectDir), createHTTPPlugin({ allowedHosts, projectDir }), - createProjectBoundaryPlugin(await resolveProjectRoots(projectDir)), + createProjectBoundaryPlugin(sourceSnapshot), ], }); @@ -525,14 +602,14 @@ function loadAndTranspileModule( const js = result.outputFiles?.[0]?.text ?? "export {}"; logger.debug(`transpiled size ${js.length} bytes`); - return loadModuleFromCode(js, projectDir, fs, userDeps); + return await rewriteExternalImports(js, projectDir, sourceSnapshot, userDeps); }, { "api.modulePath": modulePath, "api.projectDir": projectDir }, ); } async function readFileWithExtensions( - adapter: RuntimeAdapter, + sourceSnapshot: ProjectSourceSnapshot, basePath: string, extensions: string[], projectDir?: string, @@ -555,9 +632,10 @@ async function readFileWithExtensions( } try { - const contents = await adapter.fs.readFile(filePath); + const contents = await sourceSnapshot.readTextFile(filePath); return { filePath, contents }; - } catch (_) { + } catch (error) { + if (error instanceof ProjectBoundaryViolationError) throw error; /* expected: trying next file extension candidate */ } } @@ -587,14 +665,12 @@ export function getUserDependencies( async function loadModuleFromCode( code: string, - projectDir: string, fs: FileSystem, - userDeps: Map = new Map(), ): Promise { const tempDir = await fs.makeTempDir({ prefix: "vf-api-" }); const tempFile = pathHelper.join(tempDir, "handler.mjs"); - const transformedCode = await rewriteExternalImports(code, projectDir, fs, userDeps); + const transformedCode = code; // In compiled Deno binaries, external modules loaded from temp files cannot // resolve "veryfront" since the source is embedded in the binary's virtual FS. diff --git a/src/routing/api/module-loader/project-source-snapshot.ts b/src/routing/api/module-loader/project-source-snapshot.ts new file mode 100644 index 0000000000..9e08fb11a2 --- /dev/null +++ b/src/routing/api/module-loader/project-source-snapshot.ts @@ -0,0 +1,142 @@ +import * as pathHelper from "#veryfront/compat/path"; +import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; +import { realPath } from "#veryfront/platform/compat/fs.ts"; +import { isWithinDirectory } from "#veryfront/security/path-validation.ts"; + +interface ProjectBoundaryRoots { + readonly configuredProject: string; + /** Canonical source roots. Empty for adapter-only virtual projects. */ + readonly project: readonly string[]; + /** Canonical dependency roots explicitly owned by the project. */ + readonly dependencies: readonly string[]; +} + +export interface ProjectSourceFile { + /** Logical path used for relative module resolution. */ + readonly logicalPath: string; + /** Authorized path supplied to the adapter for I/O. */ + readonly readPath: string; + readonly contents: string; +} + +export interface ProjectSourceSnapshot { + read(path: string): Promise; + readTextFile(path: string): Promise; +} + +export class ProjectBoundaryViolationError extends TypeError { + override name = "ProjectBoundaryViolationError"; +} + +export function rethrowProjectBoundaryViolation(error: unknown): void { + if (error instanceof ProjectBoundaryViolationError) throw error; +} + +async function canonicalPathIfPresent(path: string): Promise { + try { + return pathHelper.resolve(await realPath(path)); + } catch { + return null; + } +} + +async function resolveProjectRoots(projectDir: string): Promise { + const configuredProject = pathHelper.resolve(projectDir); + const canonicalProject = await canonicalPathIfPresent(configuredProject); + const project = canonicalProject ? [canonicalProject] : []; + + // A dependency root is trusted only when its canonical target remains inside + // this project. A project node_modules symlink to another host directory must + // not turn that directory into an authorized source root. + const dependencyCandidates = new Set([ + pathHelper.join(configuredProject, "node_modules"), + ...(canonicalProject ? [pathHelper.join(canonicalProject, "node_modules")] : []), + ]); + const dependencies: string[] = []; + for (const candidate of dependencyCandidates) { + const canonical = await canonicalPathIfPresent(candidate); + if ( + canonical && + project.some((root) => isWithinDirectory(root, canonical)) && + !dependencies.includes(canonical) + ) { + dependencies.push(canonical); + } + } + + return Object.freeze({ + configuredProject, + project: Object.freeze(project), + dependencies: Object.freeze(dependencies), + }); +} + +function isWithinProjectBoundary(path: string, roots: ProjectBoundaryRoots): boolean { + return roots.project.some((root) => isWithinDirectory(root, path)) || + roots.dependencies.some((root) => isWithinDirectory(root, path)); +} + +function boundaryViolation(path: string): ProjectBoundaryViolationError { + return new ProjectBoundaryViolationError( + `Import escapes the project directory: ${path}. ` + + `API routes may only import project files and project-owned dependencies.`, + ); +} + +async function resolveAuthorizedReadPath( + path: string, + roots: ProjectBoundaryRoots, +): Promise { + const logicalPath = pathHelper.resolve(path); + if ( + !isWithinDirectory(roots.configuredProject, logicalPath) && + !isWithinProjectBoundary(logicalPath, roots) + ) { + throw boundaryViolation(path); + } + + const canonical = await canonicalPathIfPresent(logicalPath); + // Adapter-only projects intentionally have no corresponding host path. Their + // adapter remains authoritative, after lexical project containment above. + if (canonical === null) return logicalPath; + if (isWithinProjectBoundary(canonical, roots)) return canonical; + throw boundaryViolation(path); +} + +/** + * Capture project source through one canonical, memoized read boundary. + * + * All host-backed reads use the authorized canonical path. Repeated consumers + * (dependency discovery, bundling, and post-build rewriting) receive the same + * immutable source bytes rather than reopening a path that may have changed. + */ +export async function createProjectSourceSnapshot( + projectDir: string, + adapter: RuntimeAdapter, +): Promise { + const roots = await resolveProjectRoots(projectDir); + const snapshots = new Map>(); + + async function read(path: string): Promise { + const logicalPath = pathHelper.resolve(path); + const readPath = await resolveAuthorizedReadPath(logicalPath, roots); + let contents = snapshots.get(readPath); + if (!contents) { + contents = adapter.fs.readFile(readPath); + snapshots.set(readPath, contents); + } + + return Object.freeze({ + logicalPath, + readPath, + contents: await contents, + }); + } + + return Object.freeze({ + read, + async readTextFile(path: string): Promise { + return (await read(path)).contents; + }, + }); +} diff --git a/src/routing/api/module-loader/security-config.test.ts b/src/routing/api/module-loader/security-config.test.ts index 323d78ba60..43e6ffe4a4 100644 --- a/src/routing/api/module-loader/security-config.test.ts +++ b/src/routing/api/module-loader/security-config.test.ts @@ -7,7 +7,24 @@ import { loadSecurityConfig } from "./security-config.ts"; function makeAdapter(): RuntimeAdapter { return { - env: { get: () => undefined }, + id: "memory", + name: "security-config-test", + capabilities: { + typescript: false, + jsx: false, + http2: false, + websocket: false, + workers: false, + fileWatching: false, + shell: false, + kvStore: false, + writableFs: false, + }, + env: { + get: () => undefined, + set: () => {}, + toObject: () => ({}), + }, fs: { readFile: () => Promise.resolve(""), writeFile: () => Promise.resolve(), @@ -29,6 +46,12 @@ function makeAdapter(): RuntimeAdapter { [Symbol.asyncIterator]: async function* () {}, }), }, + server: { + upgradeWebSocket: () => { + throw new Error("not supported"); + }, + }, + serve: () => Promise.reject(new Error("not supported")), }; } @@ -49,5 +72,23 @@ describe("routing/api/module-loader/security-config", () => { const result = await loadSecurityConfig("/tmp/nonexistent-project", makeAdapter()); assertEquals(result.length > 0, true); }); + + it("uses a supplied config snapshot without broadening an explicit empty allow-list", async () => { + const result = await loadSecurityConfig( + "/tmp/nonexistent-project", + makeAdapter(), + { security: { remoteHosts: [] } }, + ); + assertEquals(result, []); + }); + + it("uses defaults only when the supplied config snapshot omits remoteHosts", async () => { + const result = await loadSecurityConfig( + "/tmp/nonexistent-project", + makeAdapter(), + { security: {} }, + ); + assertEquals(result, DEFAULT_ALLOWED_CDN_HOSTS); + }); }); }); diff --git a/src/routing/api/module-loader/security-config.ts b/src/routing/api/module-loader/security-config.ts index fadf1d775c..e55cf35788 100644 --- a/src/routing/api/module-loader/security-config.ts +++ b/src/routing/api/module-loader/security-config.ts @@ -5,24 +5,37 @@ import type { VeryfrontConfig } from "#veryfront/config"; export async function loadSecurityConfig( projectDir: string, adapter: RuntimeAdapter, + config?: VeryfrontConfig, ): Promise { + // A supplied config has already crossed the caller's trust boundary (for + // example, authenticated hosted config). Keep that snapshot authoritative: + // reloading here can either fail and broaden policy to the defaults or race a + // different config version during the same request. + if (config !== undefined) { + return remoteHostsFromConfig(config); + } + try { const { getConfig } = await import("#veryfront/config"); const cfg: VeryfrontConfig = await getConfig(projectDir, adapter); - const remote = cfg.security?.remoteHosts; - - if (Array.isArray(remote)) { - if (remote.length === 0) { - logger.warn( - "security.remoteHosts is set to an empty array — all remote requests will be blocked. " + - "If this is intentional, you can ignore this warning.", - ); - } - return remote; - } + return remoteHostsFromConfig(cfg); } catch (e) { logger.warn("Failed to load security.remoteHosts", e); } - return DEFAULT_ALLOWED_CDN_HOSTS; + return [...DEFAULT_ALLOWED_CDN_HOSTS]; +} + +function remoteHostsFromConfig(config: VeryfrontConfig): string[] { + const remoteHosts = config.security?.remoteHosts; + if (!Array.isArray(remoteHosts)) return [...DEFAULT_ALLOWED_CDN_HOSTS]; + + if (remoteHosts.length === 0) { + logger.warn( + "security.remoteHosts is set to an empty array — all remote requests will be blocked. " + + "If this is intentional, you can ignore this warning.", + ); + } + + return [...remoteHosts]; } diff --git a/src/routing/api/module-loader/types.ts b/src/routing/api/module-loader/types.ts index f5121dc997..2bf809c3bc 100644 --- a/src/routing/api/module-loader/types.ts +++ b/src/routing/api/module-loader/types.ts @@ -4,6 +4,8 @@ import type { APIContext } from "../context-builder.ts"; export interface AppRouteContext { params: Record; + /** Immutable environment snapshot for the current project request. */ + env: Readonly>; } export type HTTPMethod = @@ -35,3 +37,8 @@ export interface LoadModuleOptions { adapter: RuntimeAdapter; config?: VeryfrontConfig; } + +export interface LoadHostModuleOptions extends LoadModuleOptions { + /** Explicit host-owned capability for trusted local development only. */ + allowHostProjectCodeExecution: true; +} diff --git a/src/routing/api/openapi/create-route.test.ts b/src/routing/api/openapi/create-route.test.ts index 6f06278394..58cd19ae74 100644 --- a/src/routing/api/openapi/create-route.test.ts +++ b/src/routing/api/openapi/create-route.test.ts @@ -134,6 +134,7 @@ describe("createRoute", () => { const mockContext = { params: {}, searchParams: new URLSearchParams(), + env: {}, }; const response = await handler(new Request("http://test.com"), mockContext); assertEquals(await response.text(), "success"); diff --git a/src/routing/api/openapi/mcp-tools.test.ts b/src/routing/api/openapi/mcp-tools.test.ts index 07548fd659..89dfe58952 100644 --- a/src/routing/api/openapi/mcp-tools.test.ts +++ b/src/routing/api/openapi/mcp-tools.test.ts @@ -253,7 +253,7 @@ describe("routing/api/openapi/mcp-tools", () => { }, }, }), - { baseUrl: "http://localhost:3000" }, + { baseUrl: "http://93.184.216.34:3000" }, ); const first = tools[0]; @@ -266,5 +266,36 @@ describe("routing/api/openapi/mcp-tools", () => { globalThis.fetch = originalFetch; } }); + + it("blocks an internal configured API base URL before invoking fetch", async () => { + const originalFetch = globalThis.fetch; + let fetchCalls = 0; + globalThis.fetch = (() => { + fetchCalls++; + return Promise.resolve(Response.json({ unexpected: true })); + }) as typeof fetch; + + try { + const tools = generateTools( + makeSpec({ + "/api/users": { + get: { + operationId: "getUsers", + responses: { "200": { description: "OK" } }, + }, + }, + }), + { baseUrl: "http://169.254.169.254" }, + ); + const first = tools[0]; + assertExists(first); + + const result = await first.execute({}); + assertEquals(fetchCalls, 0); + assertEquals((result as { error?: boolean }).error, true); + } finally { + globalThis.fetch = originalFetch; + } + }); }); }); diff --git a/src/routing/api/openapi/mcp-tools.ts b/src/routing/api/openapi/mcp-tools.ts index 0b9e112779..4389a880c0 100644 --- a/src/routing/api/openapi/mcp-tools.ts +++ b/src/routing/api/openapi/mcp-tools.ts @@ -10,7 +10,9 @@ import { dynamicTool } from "#veryfront/tool"; import type { Tool, ToolExecutionContext } from "#veryfront/tool"; import { logger as baseLogger } from "#veryfront/utils"; +import { guardedOutboundFetch } from "#veryfront/security/http/outbound-fetch.ts"; import { defineSchema, lazySchema } from "#veryfront/schemas/index.ts"; +import { readResponseTextPrefix } from "#veryfront/utils/response-body.ts"; import type { Schema, SchemaValidator } from "#veryfront/extensions/schema/index.ts"; import type { OpenAPIOperation, OpenAPIParameter, OpenAPISpec } from "./types.ts"; @@ -19,6 +21,9 @@ const logger = baseLogger.component("open-api-mcp"); const HTTP_METHODS = ["get", "post", "put", "patch", "delete", "head", "options"] as const; type HttpMethod = (typeof HTTP_METHODS)[number]; +const OPENAPI_MCP_REQUEST_TIMEOUT_MS = 30_000; +const MAX_OPENAPI_MCP_RESPONSE_BYTES = 4 * 1024 * 1024; + function isHttpMethod(method: string): method is HttpMethod { return HTTP_METHODS.includes(method as HttpMethod); } @@ -198,14 +203,28 @@ async function executeAPICall( logger.debug("Executing API call", { method, url }); try { - const response = await fetch(url, requestInit); + const signal = AbortSignal.timeout(OPENAPI_MCP_REQUEST_TIMEOUT_MS); + const response = await guardedOutboundFetch(url, { + ...requestInit, + redirect: "error", + signal, + }); const contentType = response.headers.get("content-type") ?? ""; + const { text, truncated } = await readResponseTextPrefix( + response, + MAX_OPENAPI_MCP_RESPONSE_BYTES + 1, + signal, + { fatalUtf8: true }, + ); + if (truncated || new TextEncoder().encode(text).byteLength > MAX_OPENAPI_MCP_RESPONSE_BYTES) { + throw new RangeError("OpenAPI MCP response exceeds the maximum allowed size"); + } let data: unknown; if (contentType.includes("application/json")) { - data = await response.json(); + data = JSON.parse(text); } else { - data = await response.text(); + data = text; } return { diff --git a/src/routing/api/openapi/spec-generator.test.ts b/src/routing/api/openapi/spec-generator.test.ts index 73fc6f1e22..b2d55f6e37 100644 --- a/src/routing/api/openapi/spec-generator.test.ts +++ b/src/routing/api/openapi/spec-generator.test.ts @@ -1,8 +1,9 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals } from "#veryfront/testing/assert.ts"; +import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; -import { specToYaml } from "./spec-generator.ts"; +import { generateOpenAPISpec, specToYaml } from "./spec-generator.ts"; import type { OpenAPISpec } from "./types.ts"; +import { ApiRouteMatcher } from "../api-route-matcher.ts"; function assertIncludes(haystack: string, needle: string): void { assertEquals(haystack.includes(needle), true); @@ -13,6 +14,14 @@ function assertNotIncludes(haystack: string, needle: string): void { } describe("routing/api/openapi/spec-generator", () => { + it("rejects route imports without an explicit trusted-local capability", async () => { + await assertRejects( + () => generateOpenAPISpec(new ApiRouteMatcher(), "/project", {} as never), + TypeError, + "explicit trusted-local route execution", + ); + }); + describe("specToYaml()", () => { it("should convert a minimal spec to YAML", () => { const spec: OpenAPISpec = { diff --git a/src/routing/api/openapi/spec-generator.ts b/src/routing/api/openapi/spec-generator.ts index 3f9db6348b..ed4e378c65 100644 --- a/src/routing/api/openapi/spec-generator.ts +++ b/src/routing/api/openapi/spec-generator.ts @@ -38,6 +38,8 @@ interface GenerateSpecOptions { description?: string; /** Server URLs to include */ servers?: Array<{ url: string; description?: string }>; + /** Explicit trusted-local capability required before route modules are imported. */ + allowHostProjectCodeExecution?: boolean; } export async function generateOpenAPISpec( @@ -47,6 +49,12 @@ export async function generateOpenAPISpec( config?: VeryfrontConfig, options?: GenerateSpecOptions, ): Promise { + if (options?.allowHostProjectCodeExecution !== true) { + throw new TypeError( + "OpenAPI generation requires explicit trusted-local route execution", + ); + } + const spec: OpenAPISpec = { openapi: "3.1.0", info: { @@ -95,6 +103,7 @@ async function processRoute( modulePath: entry.route.page, adapter, config, + allowHostProjectCodeExecution: true, }); if (!module) return null; diff --git a/src/routing/api/response-normalization.test.ts b/src/routing/api/response-normalization.test.ts new file mode 100644 index 0000000000..e4df04648e --- /dev/null +++ b/src/routing/api/response-normalization.test.ts @@ -0,0 +1,109 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals, assertRejects, assertThrows } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { + deserializeRouteResponse, + MAX_WORKER_RESPONSE_BODY_BYTES, + MAX_WORKER_RESPONSE_HEADERS, + serializeRouteResponse, +} from "./response-normalization.ts"; + +describe("routing/api/response-normalization", () => { + it("serializes a bounded response and omits the body for HEAD", async () => { + const serialized = await serializeRouteResponse( + new Response("hello", { + status: 201, + headers: { "x-test": "yes" }, + }), + "GET", + ); + assertEquals(serialized.status, 201); + assertEquals(new TextDecoder().decode(serialized.body ?? undefined), "hello"); + + const head = await serializeRouteResponse(new Response("ignored"), "HEAD"); + assertEquals(head.body, null); + }); + + it("rejects an oversized declared response without pulling its body", async () => { + let pulls = 0; + const response = new Response( + new ReadableStream({ + pull(controller) { + pulls++; + controller.enqueue(new Uint8Array([1])); + controller.close(); + }, + }, { highWaterMark: 0 }), + { + headers: { + "content-length": String(MAX_WORKER_RESPONSE_BODY_BYTES + 1), + }, + }, + ); + + await assertRejects( + () => serializeRouteResponse(response), + Error, + "response body exceeds", + ); + assertEquals(pulls, 0); + }); + + it("cancels a chunked response as soon as it crosses the limit", async () => { + const chunks = [ + new Uint8Array(MAX_WORKER_RESPONSE_BODY_BYTES), + new Uint8Array(1), + ]; + let pulls = 0; + let cancellations = 0; + const response = new Response( + new ReadableStream({ + pull(controller) { + const chunk = chunks[pulls++]; + if (chunk) controller.enqueue(chunk); + else controller.close(); + }, + cancel() { + cancellations++; + }, + }, { highWaterMark: 0 }), + ); + + await assertRejects( + () => serializeRouteResponse(response), + Error, + "response body exceeds", + ); + assertEquals(pulls, 2); + assertEquals(cancellations, 1); + }); + + it("rejects oversized transferred bodies and header catalogs", () => { + assertThrows( + () => + deserializeRouteResponse({ + status: 200, + statusText: "OK", + headers: [], + body: new Uint8Array(MAX_WORKER_RESPONSE_BODY_BYTES + 1), + }), + Error, + "API handler must return a Response", + ); + + assertThrows( + () => + deserializeRouteResponse({ + status: 200, + statusText: "OK", + headers: Array.from( + { length: MAX_WORKER_RESPONSE_HEADERS + 1 }, + (_, index) => [`x-${index}`, "value"], + ), + body: null, + }), + Error, + "API handler must return a Response", + ); + }); +}); diff --git a/src/routing/api/response-normalization.ts b/src/routing/api/response-normalization.ts new file mode 100644 index 0000000000..64e5a8eb55 --- /dev/null +++ b/src/routing/api/response-normalization.ts @@ -0,0 +1,608 @@ +import { createError, toError } from "#veryfront/errors"; +import { types as nodeUtilTypes } from "node:util"; + +interface ResponseSlotSnapshot { + readonly type: string; + readonly status: number; + readonly statusText: string; + readonly headers: ReadonlyArray; + readonly body: ReadableStream | null; + readonly bodyUsed: boolean; +} + +export interface SerializedRouteResponse { + readonly status: number; + readonly statusText: string; + readonly headers: Array<[string, string]>; + readonly body: Uint8Array | null; +} + +/** Maximum response body buffered for transfer out of an isolated route worker. */ +export const MAX_WORKER_RESPONSE_BODY_BYTES = 10 * 1024 * 1024; +/** Maximum response headers transferred out of an isolated route worker. */ +export const MAX_WORKER_RESPONSE_HEADERS = 256; +const MAX_WORKER_RESPONSE_HEADER_CODE_UNITS = 64 * 1024; +const MAX_WORKER_RESPONSE_STATUS_TEXT_CODE_UNITS = 1_024; + +/* + * Capture the Web API primordials before any project handler can mutate its + * worker/global realm. Every operation below uses these bindings directly: + * later replacements of Response, Headers, their prototypes, or instance + * properties cannot redirect validation or serialization into project code. + */ +const NativeResponse = Response; +const NativePromise = Promise; +const NativeUint8Array = Uint8Array; +const NativeArrayBuffer = ArrayBuffer; +const NativeNumber = Number; +const NativeObjectPrototype = Object.prototype; +const apply = Reflect.apply; +const isArray = Array.isArray; +const defineProperty = Object.defineProperty; +const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const getPrototypeOf = Object.getPrototypeOf; +const isInteger = Number.isInteger; +const isSafeInteger = Number.isSafeInteger; +const isPromise = nodeUtilTypes.isPromise; +const isProxy = nodeUtilTypes.isProxy; +const stringToUpperCase = String.prototype.toUpperCase; +const stringCharCodeAt = String.prototype.charCodeAt; +const RESPONSE_STATUS_GETTER = getOwnPropertyDescriptor( + NativeResponse.prototype, + "status", +)?.get; +const RESPONSE_STATUS_TEXT_GETTER = getOwnPropertyDescriptor( + NativeResponse.prototype, + "statusText", +)?.get; +const RESPONSE_HEADERS_GETTER = getOwnPropertyDescriptor( + NativeResponse.prototype, + "headers", +)?.get; +const RESPONSE_BODY_GETTER = getOwnPropertyDescriptor( + NativeResponse.prototype, + "body", +)?.get; +const RESPONSE_BODY_USED_GETTER = getOwnPropertyDescriptor( + NativeResponse.prototype, + "bodyUsed", +)?.get; +const RESPONSE_TYPE_GETTER = getOwnPropertyDescriptor( + NativeResponse.prototype, + "type", +)?.get; +const STREAM_GET_READER = getOwnPropertyDescriptor( + ReadableStream.prototype, + "getReader", +)?.value; +const READER_READ = getOwnPropertyDescriptor( + ReadableStreamDefaultReader.prototype, + "read", +)?.value; +const READER_CANCEL = getOwnPropertyDescriptor( + ReadableStreamDefaultReader.prototype, + "cancel", +)?.value; +const READER_RELEASE_LOCK = getOwnPropertyDescriptor( + ReadableStreamDefaultReader.prototype, + "releaseLock", +)?.value; +const HEADERS_FOR_EACH = getOwnPropertyDescriptor( + Headers.prototype, + "forEach", +)?.value; +const HEADERS_APPEND = getOwnPropertyDescriptor( + Headers.prototype, + "append", +)?.value; +const TYPED_ARRAY_PROTOTYPE = getPrototypeOf(NativeUint8Array.prototype); +const TYPED_ARRAY_BUFFER_GETTER = getOwnPropertyDescriptor( + TYPED_ARRAY_PROTOTYPE, + "buffer", +)?.get; +const TYPED_ARRAY_BYTE_LENGTH_GETTER = getOwnPropertyDescriptor( + TYPED_ARRAY_PROTOTYPE, + "byteLength", +)?.get; +const TYPED_ARRAY_BYTE_OFFSET_GETTER = getOwnPropertyDescriptor( + TYPED_ARRAY_PROTOTYPE, + "byteOffset", +)?.get; +const ARRAY_BUFFER_BYTE_LENGTH_GETTER = getOwnPropertyDescriptor( + NativeArrayBuffer.prototype, + "byteLength", +)?.get; +const TYPED_ARRAY_SET = getOwnPropertyDescriptor( + TYPED_ARRAY_PROTOTYPE, + "set", +)?.value; +const PROMISE_CATCH = NativePromise.prototype.catch; +const ARRAY_PUSH = Array.prototype.push; +const isNativeUint8Array = nodeUtilTypes.isUint8Array; + +function preventThenableAssimilation(value: T): T { + defineProperty(value, "then", { + configurable: false, + enumerable: false, + value: undefined, + writable: false, + }); + return value; +} + +/** + * Accept only the realm's intrinsic Promise objects from route handlers. + * Arbitrary thenables and Promise proxies are response candidates, not code + * execution hooks, and will be rejected by the Response boundary. + */ +export function isTrustedRouteResponsePromise( + value: unknown, +): value is Promise { + if ( + value === null || + typeof value !== "object" || + isProxy(value) || + !isPromise(value) + ) { + return false; + } + + try { + return getPrototypeOf(value) === NativePromise.prototype && + getOwnPropertyDescriptor(value, "then") === undefined; + } catch { + return false; + } +} + +function invalidResponseError(): Error { + return toError( + createError({ + type: "api", + message: "API handler must return a Response", + }), + ); +} + +function responseBodyTooLargeError(actual?: number): Error { + const measured = actual === undefined ? "declared Content-Length" : `${actual} bytes`; + return toError( + createError({ + type: "api", + message: + `API response body exceeds the isolated worker transfer limit (${measured}; limit ${MAX_WORKER_RESPONSE_BODY_BYTES} bytes)`, + }), + ); +} + +/** + * Read Web API Response internal slots through captured platform getters. + * + * Calling the native getters with an arbitrary receiver performs the runtime's + * Response brand check without consulting project-owned properties. Native + * Responses, including subclasses and objects with an extra prototype layer, + * retain that brand; plain lookalikes, proxies, and foreign implementations do + * not. + */ +function snapshotResponseSlots(value: unknown): ResponseSlotSnapshot | null { + if ( + value === null || + typeof value !== "object" || + isProxy(value) || + !RESPONSE_TYPE_GETTER || + !RESPONSE_STATUS_GETTER || + !RESPONSE_STATUS_TEXT_GETTER || + !RESPONSE_HEADERS_GETTER || + !RESPONSE_BODY_GETTER || + !RESPONSE_BODY_USED_GETTER || + typeof HEADERS_FOR_EACH !== "function" + ) { + return null; + } + + try { + const type = apply(RESPONSE_TYPE_GETTER, value, []); + const status = apply(RESPONSE_STATUS_GETTER, value, []); + const statusText = apply(RESPONSE_STATUS_TEXT_GETTER, value, []); + const nativeHeaders = apply(RESPONSE_HEADERS_GETTER, value, []); + const body = apply(RESPONSE_BODY_GETTER, value, []); + const bodyUsed = apply(RESPONSE_BODY_USED_GETTER, value, []); + + if ( + typeof type !== "string" || + typeof status !== "number" || + !isInteger(status) || + typeof statusText !== "string" || + nativeHeaders === null || + typeof nativeHeaders !== "object" || + isProxy(nativeHeaders) || + (body !== null && typeof body !== "object") || + (body !== null && isProxy(body)) || + typeof bodyUsed !== "boolean" + ) { + return null; + } + + const headers: Array = []; + let invalidHeader = false; + let headerCodeUnits = 0; + apply(HEADERS_FOR_EACH, nativeHeaders, [ + (headerValue: unknown, headerName: unknown) => { + if ( + typeof headerName !== "string" || + typeof headerValue !== "string" || + headers.length >= MAX_WORKER_RESPONSE_HEADERS + ) { + invalidHeader = true; + return; + } + headerCodeUnits += headerName.length + headerValue.length; + if (headerCodeUnits > MAX_WORKER_RESPONSE_HEADER_CODE_UNITS) { + invalidHeader = true; + return; + } + headers[headers.length] = [headerName, headerValue]; + }, + ]); + if ( + invalidHeader || + statusText.length > MAX_WORKER_RESPONSE_STATUS_TEXT_CODE_UNITS + ) { + return null; + } + + return { + type, + status, + statusText, + headers, + body: body as ReadableStream | null, + bodyUsed, + }; + } catch { + return null; + } +} + +function createNativeResponseFromParts( + status: number, + statusText: string, + headers: ReadonlyArray, + body: BodyInit | null, +): Response { + if (!RESPONSE_HEADERS_GETTER || typeof HEADERS_APPEND !== "function") { + throw invalidResponseError(); + } + + let response: Response; + try { + response = new NativeResponse(body, { + status, + statusText, + // Keep the optional member own so a poisoned Object.prototype cannot + // supply a project-owned HeadersInit to the native constructor. + headers: undefined, + }); + } catch { + throw invalidResponseError(); + } + + try { + const targetHeaders = apply(RESPONSE_HEADERS_GETTER, response, []); + for (let index = 0; index < headers.length; index += 1) { + const header = headers[index]; + if (!header) throw invalidResponseError(); + apply(HEADERS_APPEND, targetHeaders, [header[0], header[1]]); + } + } catch { + throw invalidResponseError(); + } + + return preventThenableAssimilation(response); +} + +function createNativeResponse( + snapshot: ResponseSlotSnapshot, + includeBody: boolean, +): Response { + if (snapshot.status === 0) { + // Response.error() is a Fetch-internal network-error sentinel, not an HTTP + // response: status 0 cannot be serialized by the server wrapper. + throw invalidResponseError(); + } + + return createNativeResponseFromParts( + snapshot.status, + snapshot.statusText, + snapshot.headers, + includeBody ? snapshot.body : null, + ); +} + +/** + * Normalize a genuine Response into a framework-owned native Response. + * Response-shaped objects and constructor lookalikes are rejected. + */ +export function normalizeRouteResponse(value: unknown): Response { + const snapshot = snapshotResponseSlots(value); + if (!snapshot) throw invalidResponseError(); + return createNativeResponse(snapshot, true); +} + +/** Normalize response metadata for HEAD without consuming or retaining its body. */ +export function normalizeRouteHeadResponse(value: unknown): Response { + const snapshot = snapshotResponseSlots(value); + if (!snapshot) throw invalidResponseError(); + return createNativeResponse(snapshot, false); +} + +/** + * Snapshot a genuine route Response for worker transfer using captured native + * methods. HEAD responses never consume the handler's body. + */ +export async function serializeRouteResponse( + value: unknown, + requestMethod?: string, +): Promise { + const snapshot = snapshotResponseSlots(value); + if (!snapshot) throw invalidResponseError(); + if (snapshot.status === 0) throw invalidResponseError(); + + let body: Uint8Array | null = null; + if (requestMethod !== undefined && typeof requestMethod !== "string") { + throw invalidResponseError(); + } + const normalizedMethod = requestMethod === undefined + ? undefined + : apply(stringToUpperCase, requestMethod, []); + if (normalizedMethod !== "HEAD" && snapshot.body !== null) { + assertDeclaredResponseBodySize(snapshot.headers); + body = await readBoundedResponseBody(snapshot.body); + } + + const headers: Array<[string, string]> = []; + for (let index = 0; index < snapshot.headers.length; index += 1) { + const header = snapshot.headers[index]; + if (!header) throw invalidResponseError(); + headers[index] = [header[0], header[1]]; + } + + return preventThenableAssimilation({ + status: snapshot.status, + statusText: snapshot.statusText, + headers, + body, + }); +} + +function assertDeclaredResponseBodySize( + headers: ReadonlyArray, +): void { + for (let index = 0; index < headers.length; index++) { + const header = headers[index]; + if (!header || header[0] !== "content-length") continue; + if (header[1].length === 0) throw invalidResponseError(); + for (let offset = 0; offset < header[1].length; offset++) { + const code = apply(stringCharCodeAt, header[1], [offset]) as number; + if (code < 48 || code > 57) throw invalidResponseError(); + } + + const declaredLength = NativeNumber(header[1]); + if ( + !isSafeInteger(declaredLength) || + declaredLength > MAX_WORKER_RESPONSE_BODY_BYTES + ) { + throw responseBodyTooLargeError(); + } + return; + } +} + +function cancelResponseReader( + reader: ReadableStreamDefaultReader, +): void { + if (typeof READER_CANCEL !== "function") return; + try { + const cancellation = apply(READER_CANCEL, reader, []); + if (isPromise(cancellation)) { + apply(PROMISE_CATCH, cancellation, [() => undefined]); + } + } catch { + // Cancellation is best effort after the primary response failure is known. + } +} + +async function readBoundedResponseBody( + stream: ReadableStream, +): Promise { + if ( + typeof STREAM_GET_READER !== "function" || + typeof READER_READ !== "function" || + typeof READER_RELEASE_LOCK !== "function" || + typeof TYPED_ARRAY_SET !== "function" || + typeof TYPED_ARRAY_BYTE_LENGTH_GETTER !== "function" + ) { + throw invalidResponseError(); + } + + let reader: ReadableStreamDefaultReader; + try { + reader = apply(STREAM_GET_READER, stream, []) as ReadableStreamDefaultReader; + } catch { + throw invalidResponseError(); + } + + const chunks: Uint8Array[] = []; + let totalBytes = 0; + try { + while (true) { + let result: ReadableStreamReadResult; + try { + result = await apply(READER_READ, reader, []) as ReadableStreamReadResult; + } catch { + cancelResponseReader(reader); + throw invalidResponseError(); + } + if (result === null || typeof result !== "object" || isProxy(result)) { + cancelResponseReader(reader); + throw invalidResponseError(); + } + + const done = getOwnPropertyDescriptor(result, "done"); + const value = getOwnPropertyDescriptor(result, "value"); + if (!done || !("value" in done) || typeof done.value !== "boolean") { + cancelResponseReader(reader); + throw invalidResponseError(); + } + if (done.value) break; + + const chunk = value && "value" in value ? value.value : undefined; + if (!isNativeUint8Array(chunk) || isProxy(chunk)) { + cancelResponseReader(reader); + throw invalidResponseError(); + } + const chunkByteLength = apply(TYPED_ARRAY_BYTE_LENGTH_GETTER!, chunk, []) as number; + if (chunkByteLength > MAX_WORKER_RESPONSE_BODY_BYTES - totalBytes) { + cancelResponseReader(reader); + throw responseBodyTooLargeError(totalBytes + chunkByteLength); + } + + apply(ARRAY_PUSH, chunks, [chunk]); + totalBytes += chunkByteLength; + } + } finally { + try { + apply(READER_RELEASE_LOCK, reader, []); + } catch { + // The response outcome is already determined. + } + } + + const body = new NativeUint8Array(totalBytes); + let offset = 0; + for (let index = 0; index < chunks.length; index++) { + const chunk = chunks[index]!; + apply(TYPED_ARRAY_SET, body, [chunk, offset]); + offset += apply(TYPED_ARRAY_BYTE_LENGTH_GETTER!, chunk, []) as number; + } + return body; +} + +const INVALID_SERIALIZED_FIELD = Symbol("invalid-serialized-field"); + +function readOwnDataField( + value: object, + key: PropertyKey, +): unknown | typeof INVALID_SERIALIZED_FIELD { + try { + const descriptor = getOwnPropertyDescriptor(value, key); + return descriptor && "value" in descriptor ? descriptor.value : INVALID_SERIALIZED_FIELD; + } catch { + return INVALID_SERIALIZED_FIELD; + } +} + +function snapshotSerializedHeaders( + value: unknown, +): Array | null { + if (!isArray(value) || isProxy(value)) return null; + if (value.length > MAX_WORKER_RESPONSE_HEADERS) return null; + + const headers: Array = []; + let headerCodeUnits = 0; + for (let index = 0; index < value.length; index += 1) { + const pair = readOwnDataField(value, index); + if (!isArray(pair) || isProxy(pair) || pair.length !== 2) return null; + + const name = readOwnDataField(pair, 0); + const headerValue = readOwnDataField(pair, 1); + if (typeof name !== "string" || typeof headerValue !== "string") { + return null; + } + headerCodeUnits += name.length + headerValue.length; + if (headerCodeUnits > MAX_WORKER_RESPONSE_HEADER_CODE_UNITS) return null; + headers[index] = [name, headerValue]; + } + return headers; +} + +function snapshotSerializedBody( + value: unknown, +): Uint8Array | null | typeof INVALID_SERIALIZED_FIELD { + if (value === null) return null; + if ( + typeof value !== "object" || + isProxy(value) || + getPrototypeOf(value) !== NativeUint8Array.prototype || + !TYPED_ARRAY_BUFFER_GETTER || + !TYPED_ARRAY_BYTE_LENGTH_GETTER || + !TYPED_ARRAY_BYTE_OFFSET_GETTER || + !ARRAY_BUFFER_BYTE_LENGTH_GETTER + ) { + return INVALID_SERIALIZED_FIELD; + } + + try { + const buffer = apply(TYPED_ARRAY_BUFFER_GETTER, value, []); + const byteLength = apply(TYPED_ARRAY_BYTE_LENGTH_GETTER, value, []); + const byteOffset = apply(TYPED_ARRAY_BYTE_OFFSET_GETTER, value, []); + apply(ARRAY_BUFFER_BYTE_LENGTH_GETTER, buffer, []); + if ( + buffer === null || + typeof buffer !== "object" || + isProxy(buffer) || + typeof byteLength !== "number" || + typeof byteOffset !== "number" || + byteLength > MAX_WORKER_RESPONSE_BODY_BYTES + ) { + return INVALID_SERIALIZED_FIELD; + } + return new NativeUint8Array(buffer as ArrayBuffer, byteOffset, byteLength); + } catch { + return INVALID_SERIALIZED_FIELD; + } +} + +/** + * Reconstruct a worker-transferred response after validating its data-only + * shape. Status zero is rejected because it is not serializable over HTTP. + */ +export function deserializeRouteResponse(value: unknown): Response { + if ( + value === null || + typeof value !== "object" || + isProxy(value) + ) { + throw invalidResponseError(); + } + + try { + const prototype = getPrototypeOf(value); + if (prototype !== NativeObjectPrototype && prototype !== null) { + throw invalidResponseError(); + } + } catch { + throw invalidResponseError(); + } + + const status = readOwnDataField(value, "status"); + const statusText = readOwnDataField(value, "statusText"); + const headers = snapshotSerializedHeaders(readOwnDataField(value, "headers")); + const body = snapshotSerializedBody(readOwnDataField(value, "body")); + if ( + typeof status !== "number" || + !isInteger(status) || + typeof statusText !== "string" || + statusText.length > MAX_WORKER_RESPONSE_STATUS_TEXT_CODE_UNITS || + headers === null || + body === INVALID_SERIALIZED_FIELD + ) { + throw invalidResponseError(); + } + + if (status === 0) { + throw invalidResponseError(); + } + + return createNativeResponseFromParts(status, statusText, headers, body); +} diff --git a/src/routing/api/route-executor.test.ts b/src/routing/api/route-executor.test.ts index 570feee955..5c85dc92c8 100644 --- a/src/routing/api/route-executor.test.ts +++ b/src/routing/api/route-executor.test.ts @@ -1,15 +1,15 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assert, assertEquals } from "#veryfront/testing/assert.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; import { afterEach, describe, it } from "#veryfront/testing/bdd.ts"; import { - __resetInProcessIsolationWarningForTests, - executeAppRoute, - executePagesRoute, + __serializeRequestForTests, + executeAppRoute as executeAppRouteRaw, + executePagesRoute as executePagesRouteRaw, + type ExecuteRouteOptions, } from "./route-executor.ts"; import type { RouteMatch } from "./api-route-matcher.ts"; import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; import { __resetPoolForTests } from "#veryfront/security/sandbox/worker-pool.ts"; -import { __resetLoggerConfigForTests } from "../../utils/logger/index.ts"; import { runWithExactSourceIntegrationPolicy } from "#veryfront/integrations/source-policy-context.ts"; import { normalizeSourceIntegrationPolicy } from "#veryfront/integrations/source-policy.ts"; @@ -75,37 +75,108 @@ function makeMatch( return { route: { pattern, page }, params }; } -function captureConsoleWarn(): { getOutput: () => string; restore: () => void } { - const originalWarn = console.warn; - const output: string[] = []; +const LOCAL_EXECUTION: ExecuteRouteOptions = Object.freeze({ + isLocalProject: true, + allowHostProjectCodeExecution: true, +}); - console.warn = (...args: unknown[]) => { - output.push(args.map(String).join(" ")); - }; +function executeAppRoute( + handler: Parameters[0], + request: Request, + match: RouteMatch, + pathname: string, + adapter: RuntimeAdapter, + options?: ExecuteRouteOptions, +): Promise { + return executeAppRouteRaw( + handler, + request, + match, + pathname, + adapter, + options ?? LOCAL_EXECUTION, + ); +} - return { - getOutput: () => output.join("\n"), - restore: () => { - console.warn = originalWarn; - }, - }; +function executePagesRoute( + handler: Parameters[0], + request: Request, + match: RouteMatch, + pathname: string, + adapter: RuntimeAdapter, + projectDir?: string, + options?: ExecuteRouteOptions, +): Promise { + return executePagesRouteRaw( + handler, + request, + match, + pathname, + adapter, + projectDir, + options ?? LOCAL_EXECUTION, + ); } -function restoreEnv(snapshot: Map): void { - for (const [key, value] of snapshot) { - if (value === undefined) { - Deno.env.delete(key); - } else { - Deno.env.set(key, value); - } - } +async function prepareModuleSource(source: string) { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(source)); + return { source, sha256: new Uint8Array(digest).toHex() }; } -function snapshotEnv(keys: string[]): Map { - return new Map(keys.map((key) => [key, Deno.env.get(key)])); +async function isolatedRouteOptions( + source: string, + executionScopeId: string, +): Promise { + return { + modulePath: "/tmp/test/handler.ts", + projectDir: "/tmp/test", + isLocalProject: false, + preparedModule: await prepareModuleSource(source), + executionScopeId, + }; } describe("routing/api/route-executor", () => { + describe("application request boundary", () => { + it("withholds infrastructure credentials from remote project code", async () => { + const serialized = await __serializeRequestForTests( + new Request("https://tenant.example/api/test", { + headers: { + "authorization": "Bearer application-user-token", + "cookie": "session=application-cookie", + "proxy-authorization": "Basic infrastructure-proxy-token", + "x-project-slug": "tenant", + "x-token": "platform-service-token", + "x-veryfront-control-plane-jws": "signed-control-plane-request", + "x-veryfront-dispatch-jws": "signed-dispatch-request", + "x-veryfront-future-infrastructure-secret": "future-secret", + }, + }), + ); + + assertEquals(serialized.headers, [ + ["authorization", "Bearer application-user-token"], + ["cookie", "session=application-cookie"], + ]); + }); + + it("withholds reserved infrastructure headers from local project code too", async () => { + const serialized = await __serializeRequestForTests( + new Request("http://localhost/api/test", { + headers: { + authorization: "Bearer local-application-token", + "x-token": "local-infrastructure-token", + }, + }), + ); + + assertEquals(serialized.headers, [[ + "authorization", + "Bearer local-application-token", + ]]); + }); + }); + describe("executeAppRoute()", () => { it("should call the matching HTTP method handler", async () => { const handler = { @@ -232,7 +303,7 @@ describe("routing/api/route-executor", () => { assertEquals(await response.json(), { ok: true }); }); - it("should accept cross-context Response-like objects (duck typing)", async () => { + it("should reject forged Response-like objects", async () => { const bodyStream = new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode('{"cross":"context"}')); @@ -270,13 +341,10 @@ describe("routing/api/route-executor", () => { makeAdapter(), ); - assert(response instanceof Response, "should be normalized to a real Response instance"); - assertEquals(response.status, 200); - assertEquals(response.headers.get("content-type"), "application/json"); - assertEquals(await response.text(), '{"cross":"context"}'); + assertEquals(response.status, 500); }); - it("should normalize cross-context Response for HEAD requests", async () => { + it("should reject forged Response-like objects for HEAD requests", async () => { const fakeResponse = { status: 201, statusText: "Created", @@ -308,10 +376,7 @@ describe("routing/api/route-executor", () => { makeAdapter(), ); - assert(response instanceof Response, "should be a real Response instance"); - assertEquals(response.status, 201); - assertEquals(response.headers.get("x-custom"), "value"); - assertEquals(await response.text(), ""); + assertEquals(response.status, 500); }); it("should return error response when handler returns null", async () => { @@ -507,184 +572,24 @@ describe("routing/api/route-executor", () => { assertEquals(response.status, 500); }); - - it("continues pages API route execution when isolation warning logging fails", async () => { - const envSnapshot = snapshotEnv([ - "WORKER_ISOLATION_ENABLED", - "WORKER_ISOLATION_API", - "LOG_FORMAT", - "LOG_LEVEL", - "NO_COLOR", - ]); - Deno.env.delete("WORKER_ISOLATION_ENABLED"); - Deno.env.delete("WORKER_ISOLATION_API"); - Deno.env.set("LOG_FORMAT", "text"); - Deno.env.set("LOG_LEVEL", "WARN"); - Deno.env.set("NO_COLOR", "1"); - __resetPoolForTests(); - __resetInProcessIsolationWarningForTests(); - __resetLoggerConfigForTests(); - const originalWarn = console.warn; - - try { - console.warn = () => { - throw new Error("warning sink unavailable"); - }; - - const handler = { - GET: () => Response.json({ msg: "pages api" }), - }; - - const request = new Request("http://localhost/api/hello", { method: "GET" }); - const response = await executePagesRoute( - handler, - request, - makeMatch("/api/hello", "/tmp/test/pages/api/hello.ts"), - "/api/hello", - makeAdapter("production"), - "/tmp/test", - { - modulePath: "/tmp/test/pages/api/hello.ts", - projectDir: "/tmp/test", - isLocalProject: false, - }, - ); - - assertEquals(response.status, 200); - assertEquals(await response.json(), { msg: "pages api" }); - } finally { - console.warn = originalWarn; - restoreEnv(envSnapshot); - __resetLoggerConfigForTests(); - } - }); - }); - - describe("untrusted in-process execution warning", () => { - const envKeys = [ - "WORKER_ISOLATION_ENABLED", - "WORKER_ISOLATION_API", - "LOG_FORMAT", - "LOG_LEVEL", - "NO_COLOR", - ]; - - afterEach(() => { - Deno.env.delete("WORKER_ISOLATION_ENABLED"); - Deno.env.delete("WORKER_ISOLATION_API"); - __resetPoolForTests(); - __resetInProcessIsolationWarningForTests(); - __resetLoggerConfigForTests(); - }); - - it("warns once when a remote app route falls back to in-process execution", async () => { - const envSnapshot = snapshotEnv(envKeys); - Deno.env.delete("WORKER_ISOLATION_ENABLED"); - Deno.env.delete("WORKER_ISOLATION_API"); - Deno.env.set("LOG_FORMAT", "text"); - Deno.env.set("LOG_LEVEL", "WARN"); - Deno.env.set("NO_COLOR", "1"); - __resetPoolForTests(); - __resetInProcessIsolationWarningForTests(); - __resetLoggerConfigForTests(); - - const captured = captureConsoleWarn(); - try { - const handler = { - GET: () => new Response("ok"), - }; - const request = new Request("http://localhost/api/test", { method: "GET" }); - const options = { - modulePath: "/tmp/test/handler.ts", - projectDir: "/tmp/test", - isLocalProject: false, - }; - - const first = await executeAppRoute( - handler, - request, - makeMatch(), - "/api/test", - makeAdapter(), - options, - ); - const second = await executeAppRoute( - handler, - new Request("http://localhost/api/test", { method: "GET" }), - makeMatch(), - "/api/test", - makeAdapter(), - options, - ); - - assertEquals(first.status, 200); - assertEquals(second.status, 200); - assertEquals(await first.text(), "ok"); - assertEquals(await second.text(), "ok"); - - const output = captured.getOutput(); - assertEquals((output.match(/worker isolation disabled/g) ?? []).length, 1); - assert(output.includes("WORKER_ISOLATION_ENABLED")); - assert(output.includes("WORKER_ISOLATION_API")); - } finally { - captured.restore(); - restoreEnv(envSnapshot); - __resetLoggerConfigForTests(); - } - }); - - it("does not warn for local app route in-process execution", async () => { - const envSnapshot = snapshotEnv(envKeys); - Deno.env.delete("WORKER_ISOLATION_ENABLED"); - Deno.env.delete("WORKER_ISOLATION_API"); - Deno.env.set("LOG_FORMAT", "text"); - Deno.env.set("LOG_LEVEL", "WARN"); - Deno.env.set("NO_COLOR", "1"); - __resetPoolForTests(); - __resetInProcessIsolationWarningForTests(); - __resetLoggerConfigForTests(); - - const captured = captureConsoleWarn(); - try { - const response = await executeAppRoute( - { GET: () => new Response("ok") }, - new Request("http://localhost/api/test", { method: "GET" }), - makeMatch(), - "/api/test", - makeAdapter(), - { - modulePath: "/tmp/test/handler.ts", - projectDir: "/tmp/test", - isLocalProject: true, - }, - ); - - assertEquals(response.status, 200); - assertEquals(captured.getOutput(), ""); - } finally { - captured.restore(); - restoreEnv(envSnapshot); - __resetLoggerConfigForTests(); - } - }); }); describe("body size guard (isolated execution)", () => { - afterEach(() => { + afterEach(async () => { try { Deno.env.delete("WORKER_ISOLATION_ENABLED"); } catch { /* ok */ } try { Deno.env.delete("WORKER_ISOLATION_API"); } catch { /* ok */ } - __resetPoolForTests(); + await __resetPoolForTests(); }); it("should reject oversized request bodies in isolated app route execution", async () => { // Enable worker isolation Deno.env.set("WORKER_ISOLATION_ENABLED", "1"); Deno.env.set("WORKER_ISOLATION_API", "1"); - __resetPoolForTests(); + await __resetPoolForTests(); const handler = { POST: (_req: Request) => new Response("ok"), @@ -697,13 +602,20 @@ describe("routing/api/route-executor", () => { body: largeBody, }); - const response = await executeAppRoute( - handler, - request, - makeMatch(), - "/api/test", - makeAdapter(), - { modulePath: "/tmp/test/handler.ts", projectDir: "/tmp/test" }, + const response = await runWithExactSourceIntegrationPolicy( + normalizeSourceIntegrationPolicy({ allow: {} }), + async () => + await executeAppRoute( + handler, + request, + makeMatch(), + "/api/test", + makeAdapter(), + await isolatedRouteOptions( + "export function POST() { return new Response('ok'); }", + "body-oversized-app", + ), + ), ); // Should get an error response due to body size limit @@ -714,7 +626,7 @@ describe("routing/api/route-executor", () => { // Enable worker isolation Deno.env.set("WORKER_ISOLATION_ENABLED", "1"); Deno.env.set("WORKER_ISOLATION_API", "1"); - __resetPoolForTests(); + await __resetPoolForTests(); const handler = { POST: (_req: Request) => new Response("ok"), @@ -727,31 +639,31 @@ describe("routing/api/route-executor", () => { body: smallBody, }); - // This will fail at the worker execution level (module not found), - // but should NOT fail at the body size guard - const response = await executeAppRoute( - handler, - request, - makeMatch(), - "/api/test", - makeAdapter(), - { modulePath: "/tmp/test/handler.ts", projectDir: "/tmp/test" }, + const response = await runWithExactSourceIntegrationPolicy( + normalizeSourceIntegrationPolicy({ allow: {} }), + async () => + await executeAppRoute( + handler, + request, + makeMatch(), + "/api/test", + makeAdapter(), + await isolatedRouteOptions( + "export function POST() { return new Response('ok'); }", + "body-normal-app", + ), + ), ); - // The error should be about worker execution, not body size - const body = await response.json(); - const detail = body.detail ?? ""; - assert( - !detail.includes("too large"), - "should not reject small request bodies", - ); + assertEquals(response.status, 200); + assertEquals(await response.text(), "ok"); }); it("should reject oversized request bodies in isolated pages route execution", async () => { // Enable worker isolation Deno.env.set("WORKER_ISOLATION_ENABLED", "1"); Deno.env.set("WORKER_ISOLATION_API", "1"); - __resetPoolForTests(); + await __resetPoolForTests(); const handler = { POST: (_ctx: unknown) => new Response("ok"), @@ -764,14 +676,21 @@ describe("routing/api/route-executor", () => { body: largeBody, }); - const response = await executePagesRoute( - handler, - request, - makeMatch(), - "/api/test", - makeAdapter(), - undefined, - { modulePath: "/tmp/test/handler.ts", projectDir: "/tmp/test" }, + const response = await runWithExactSourceIntegrationPolicy( + normalizeSourceIntegrationPolicy({ allow: {} }), + async () => + await executePagesRoute( + handler, + request, + makeMatch(), + "/api/test", + makeAdapter(), + undefined, + await isolatedRouteOptions( + "export function POST() { return new Response('ok'); }", + "body-oversized-pages", + ), + ), ); assertEquals(response.status, 500); @@ -781,7 +700,7 @@ describe("routing/api/route-executor", () => { // Enable worker isolation Deno.env.set("WORKER_ISOLATION_ENABLED", "1"); Deno.env.set("WORKER_ISOLATION_API", "1"); - __resetPoolForTests(); + await __resetPoolForTests(); const handler = { POST: (_req: Request) => new Response("ok"), @@ -795,13 +714,20 @@ describe("routing/api/route-executor", () => { headers: { "content-length": String(20 * 1024 * 1024) }, }); - const response = await executeAppRoute( - handler, - request, - makeMatch(), - "/api/test", - makeAdapter(), - { modulePath: "/tmp/test/handler.ts", projectDir: "/tmp/test" }, + const response = await runWithExactSourceIntegrationPolicy( + normalizeSourceIntegrationPolicy({ allow: {} }), + async () => + await executeAppRoute( + handler, + request, + makeMatch(), + "/api/test", + makeAdapter(), + await isolatedRouteOptions( + "export function POST() { return new Response('ok'); }", + "body-declared-oversized", + ), + ), ); assertEquals(response.status, 500); @@ -810,7 +736,7 @@ describe("routing/api/route-executor", () => { it("should reject large body without Content-Length via fallback check", async () => { Deno.env.set("WORKER_ISOLATION_ENABLED", "1"); Deno.env.set("WORKER_ISOLATION_API", "1"); - __resetPoolForTests(); + await __resetPoolForTests(); const handler = { POST: (_req: Request) => new Response("ok"), @@ -834,13 +760,20 @@ describe("routing/api/route-executor", () => { } as RequestInit & { duplex: "half" }, ); - const response = await executeAppRoute( - handler, - request, - makeMatch(), - "/api/test", - makeAdapter(), - { modulePath: "/tmp/test/handler.ts", projectDir: "/tmp/test" }, + const response = await runWithExactSourceIntegrationPolicy( + normalizeSourceIntegrationPolicy({ allow: {} }), + async () => + await executeAppRoute( + handler, + request, + makeMatch(), + "/api/test", + makeAdapter(), + await isolatedRouteOptions( + "export function POST() { return new Response('ok'); }", + "body-stream-oversized", + ), + ), ); assertEquals(response.status, 500); @@ -849,7 +782,7 @@ describe("routing/api/route-executor", () => { it("should skip body size guard for requests without a body", async () => { Deno.env.set("WORKER_ISOLATION_ENABLED", "1"); Deno.env.set("WORKER_ISOLATION_API", "1"); - __resetPoolForTests(); + await __resetPoolForTests(); const handler = { GET: (_req: Request) => new Response("ok"), @@ -858,27 +791,30 @@ describe("routing/api/route-executor", () => { // GET request with no body — should pass the size guard const request = new Request("http://localhost/api/test", { method: "GET" }); - const response = await executeAppRoute( - handler, - request, - makeMatch(), - "/api/test", - makeAdapter(), - { modulePath: "/tmp/test/handler.ts", projectDir: "/tmp/test" }, + const response = await runWithExactSourceIntegrationPolicy( + normalizeSourceIntegrationPolicy({ allow: {} }), + async () => + await executeAppRoute( + handler, + request, + makeMatch(), + "/api/test", + makeAdapter(), + await isolatedRouteOptions( + "export function GET() { return new Response('ok'); }", + "body-empty", + ), + ), ); - // Error is about worker execution (module not found), not body size - const body = await response.json(); - assert( - !(body.detail ?? "").includes("too large"), - "should not reject request without body", - ); + assertEquals(response.status, 200); + assertEquals(await response.text(), "ok"); }); - it("should allow requests with malformed Content-Length header", async () => { + it("should reject malformed Content-Length headers", async () => { Deno.env.set("WORKER_ISOLATION_ENABLED", "1"); Deno.env.set("WORKER_ISOLATION_API", "1"); - __resetPoolForTests(); + await __resetPoolForTests(); const handler = { POST: (_req: Request) => new Response("ok"), @@ -891,52 +827,73 @@ describe("routing/api/route-executor", () => { headers: { "content-length": "not-a-number" }, }); - const response = await executeAppRoute( - handler, - request, - makeMatch(), - "/api/test", - makeAdapter(), - { modulePath: "/tmp/test/handler.ts", projectDir: "/tmp/test" }, + const response = await runWithExactSourceIntegrationPolicy( + normalizeSourceIntegrationPolicy({ allow: {} }), + async () => + await executeAppRoute( + handler, + request, + makeMatch(), + "/api/test", + makeAdapter(), + await isolatedRouteOptions( + "export function POST() { return new Response('ok'); }", + "body-invalid-content-length", + ), + ), ); - // Should not reject — NaN comparison passes through - const body = await response.json(); - assert( - !(body.detail ?? "").includes("too large"), - "should not reject malformed Content-Length", - ); + assertEquals(response.status, 500); }); }); describe("source policy propagation (isolated execution)", () => { - afterEach(() => { + afterEach(async () => { Deno.env.delete("WORKER_ISOLATION_ENABLED"); Deno.env.delete("WORKER_ISOLATION_API"); - __resetPoolForTests(); + await __resetPoolForTests(); }); it("restores the exact source integration policy inside the worker", async () => { Deno.env.set("WORKER_ISOLATION_ENABLED", "1"); Deno.env.set("WORKER_ISOLATION_API", "1"); - __resetPoolForTests(); + await __resetPoolForTests(); const policy = normalizeSourceIntegrationPolicy({ allow: { confluence: { allowedTools: ["get_page"] } }, }); const modulePath = new URL("./fixtures/source-policy-route.ts", import.meta.url).pathname; const projectDir = new URL("../../../", import.meta.url).pathname; + const sourcePolicyModuleUrl = new URL( + "../../integrations/source-policy-context.ts", + import.meta.url, + ).href; const response = await runWithExactSourceIntegrationPolicy( policy, - () => + async () => executeAppRoute( { GET: () => Response.json({ unreachable: true }) }, new Request("http://localhost/api/source-policy", { method: "GET" }), makeMatch("/api/source-policy", modulePath), "/api/source-policy", makeAdapter(), - { modulePath, projectDir, isLocalProject: true }, + { + modulePath, + projectDir, + isLocalProject: true, + preparedModule: await prepareModuleSource( + [ + `import { getActiveSourceIntegrationPolicy } from ${ + JSON.stringify(sourcePolicyModuleUrl) + };`, + "export function GET() {", + " return Response.json(getActiveSourceIntegrationPolicy());", + "}", + ].join("\n"), + ), + executionScopeId: "route-executor-source-policy", + }, ), ); @@ -946,16 +903,16 @@ describe("routing/api/route-executor", () => { }); describe("response helpers (isolated pages execution)", () => { - afterEach(() => { + afterEach(async () => { Deno.env.delete("WORKER_ISOLATION_ENABLED"); Deno.env.delete("WORKER_ISOLATION_API"); - __resetPoolForTests(); + await __resetPoolForTests(); }); it("drops ctx.text bodies for null-body statuses", async () => { Deno.env.set("WORKER_ISOLATION_ENABLED", "1"); Deno.env.set("WORKER_ISOLATION_API", "1"); - __resetPoolForTests(); + await __resetPoolForTests(); const modulePath = new URL( "./fixtures/null-body-pages-route.ts", @@ -965,7 +922,7 @@ describe("routing/api/route-executor", () => { const response = await runWithExactSourceIntegrationPolicy( normalizeSourceIntegrationPolicy({ allow: {} }), - () => + async () => executePagesRoute( { GET: () => new Response("unreachable") }, new Request("http://localhost/api/no-content", { method: "GET" }), @@ -973,7 +930,15 @@ describe("routing/api/route-executor", () => { "/api/no-content", makeAdapter(), undefined, - { modulePath, projectDir, isLocalProject: true }, + { + modulePath, + projectDir, + isLocalProject: true, + preparedModule: await prepareModuleSource( + "export function GET(ctx) { return ctx.text('ignored', { status: 204 }); }", + ), + executionScopeId: "route-executor-null-body", + }, ), ); diff --git a/src/routing/api/route-executor.ts b/src/routing/api/route-executor.ts index 2ddd4b306e..e53a8b4298 100644 --- a/src/routing/api/route-executor.ts +++ b/src/routing/api/route-executor.ts @@ -2,11 +2,14 @@ import type { FileSystemAdapter, RuntimeAdapter } from "#veryfront/platform/adap import { createContext, normalizeParams, parseCookies } from "./context-builder.ts"; import type { RouteMatch } from "./api-route-matcher.ts"; import { createError, errorToRFC9457Response, NOT_SUPPORTED, toError } from "#veryfront/errors"; +import { + detachThrowableForBoundary, + snapshotThrowableDiagnostic, +} from "#veryfront/errors/safe-diagnostics.ts"; import type { APIRoute, AppRouteContext, AppRouteHandler, - HTTPMethod, PagesRouteHandler, } from "./module-loader/types.ts"; import { @@ -16,40 +19,374 @@ import { import { isAbsolute, join } from "#veryfront/compat/path/index.ts"; import { withSpan } from "#veryfront/observability/tracing/otlp-setup.ts"; import { serverLogger as logger } from "#veryfront/utils"; -import { isDevelopment as isDevelopmentEnv } from "#veryfront/platform/environment.ts"; import type { HandlerContext } from "#veryfront/types"; import { getWorkerPool, isWorkerIsolationEnabled, } from "#veryfront/security/sandbox/worker-pool.ts"; +import { + resolveWorkerGeneration, + snapshotWorkerGenerationIdentity, +} from "#veryfront/security/sandbox/worker-generation.ts"; +import { deserializeWorkerError } from "#veryfront/security/sandbox/worker-error-boundary.ts"; import { MAX_WORKER_BODY_BYTES, + type PreparedWorkerModule, type SerializedRequest, type SerializedResponse, type WorkerResponse, + type WorkerRouteMethodsResponse, } from "#veryfront/security/sandbox/worker-types.ts"; import { requireActiveSourceIntegrationPolicy } from "#veryfront/integrations/source-policy-context.ts"; +import type { SourceIntegrationPolicyManifest } from "#veryfront/integrations/source-policy.ts"; +import { + normalizeRouteMethod, + resolveRouteHandlerExport, + STANDARD_ROUTE_METHODS, +} from "./route-methods.ts"; +import { + deserializeRouteResponse, + isTrustedRouteResponsePromise, + normalizeRouteHeadResponse, + normalizeRouteResponse, +} from "./response-normalization.ts"; +import { types as nodeUtilTypes } from "node:util"; +import { getTrustedProjectEnvSnapshot } from "#veryfront/platform/compat/process/env.ts"; +import { + PROJECT_ENV_SNAPSHOT_LIMITS, + type ProjectEnvSnapshot, +} from "#veryfront/platform/compat/process/project-env-contract.ts"; +import { + isExplicitHostProjectCodeExecutionAllowed, + isExplicitlyLocalProject, + readOwnDataProperty, +} from "#veryfront/security/project-locality.ts"; +import { isInfrastructureOnlyRequestHeader } from "#veryfront/security/http/application-request.ts"; + +const apply = Reflect.apply; +const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const getOwnPropertyDescriptors = Object.getOwnPropertyDescriptors; +const getPrototypeOf = Object.getPrototypeOf; +const objectCreate = Object.create; +const objectDefineProperty = Object.defineProperty; +const objectFreeze = Object.freeze; +const objectKeys = Object.keys; +const objectPrototypeHasOwnProperty = Object.prototype.hasOwnProperty; +const ownKeys = Reflect.ownKeys; +const objectPrototype = Object.prototype; +const EMPTY_PROJECT_ENV = objectFreeze( + objectCreate(null) as Record, +); +const numberIsSafeInteger = Number.isSafeInteger; +const NativePromise = Promise; +const promiseResolve = NativePromise.resolve; +const promiseReject = NativePromise.reject; +const promiseThen = NativePromise.prototype.then; +const NativeRequest = Request; +const NativeUint8Array = Uint8Array; +const NativeNumber = Number; +const NativeTextEncoder = TextEncoder; +const semanticTextEncoder = new NativeTextEncoder(); +const textEncoderEncode = NativeTextEncoder.prototype.encode; +const nativeCrypto = crypto; +const nativeSubtleCrypto = nativeCrypto.subtle; +const subtleDigest = nativeSubtleCrypto.digest; +const requestUrlGetter = getOwnPropertyDescriptor(NativeRequest.prototype, "url")!.get!; +const requestMethodGetter = getOwnPropertyDescriptor(NativeRequest.prototype, "method")!.get!; +const requestHeadersGetter = getOwnPropertyDescriptor(NativeRequest.prototype, "headers")!.get!; +const requestBodyGetter = getOwnPropertyDescriptor(NativeRequest.prototype, "body")!.get!; +const requestSignalGetter = getOwnPropertyDescriptor(NativeRequest.prototype, "signal")!.get!; +const headersGet = Headers.prototype.get; +const headersForEach = Headers.prototype.forEach; +const abortSignalAbortedGetter = getOwnPropertyDescriptor( + AbortSignal.prototype, + "aborted", +)!.get!; +const eventTargetAddEventListener = EventTarget.prototype.addEventListener; +const eventTargetRemoveEventListener = EventTarget.prototype.removeEventListener; +const streamGetReader = ReadableStream.prototype.getReader; +const streamCancel = ReadableStream.prototype.cancel; +const readerRead = ReadableStreamDefaultReader.prototype.read; +const readerCancel = ReadableStreamDefaultReader.prototype.cancel; +const readerReleaseLock = ReadableStreamDefaultReader.prototype.releaseLock; +const typedArrayPrototype = Object.getPrototypeOf(NativeUint8Array.prototype); +const typedArrayByteLengthGetter = getOwnPropertyDescriptor( + typedArrayPrototype, + "byteLength", +)!.get!; +const typedArrayBufferGetter = getOwnPropertyDescriptor( + typedArrayPrototype, + "buffer", +)!.get!; +const typedArrayByteOffsetGetter = getOwnPropertyDescriptor( + typedArrayPrototype, + "byteOffset", +)!.get!; +const typedArraySet = getOwnPropertyDescriptor(typedArrayPrototype, "set")!.value as ( + source: ArrayLike, + offset?: number, +) => void; +const arrayPush = Array.prototype.push; +const arrayIncludes = Array.prototype.includes; +const arraySort = Array.prototype.sort; +const arrayJoin = Array.prototype.join; +const arrayIsArray = Array.isArray; +const cryptoRandomUUID = nativeCrypto.randomUUID; +const stringCharCodeAt = String.prototype.charCodeAt; +const stringSlice = String.prototype.slice; +const stringToUpperCase = String.prototype.toUpperCase; +const stringPadStart = String.prototype.padStart; +const numberToString = Number.prototype.toString; +const regexpTest = RegExp.prototype.test; +const isNativeUint8Array = nodeUtilTypes.isUint8Array; +const isNativeProxy = nodeUtilTypes.isProxy; +const CONTENT_LENGTH_PATTERN = /^\d+$/; +const PROJECT_ENV_KEY_PATTERN = /^[^=\0]+$/; +const PROJECT_ENV_VALUE_PATTERN = /^[^\0]*$/; +const MAX_WORKER_BODY_BYTES_DECIMAL = `${MAX_WORKER_BODY_BYTES}`; +const BODY_COALESCE_BLOCK_BYTES = 64 * 1024; +const BODY_READ_YIELD_CHUNKS = 256; +const MAX_WORKER_BODY_SOURCE_CHUNKS = 16_384; +const MAX_CONSECUTIVE_EMPTY_BODY_CHUNKS = 4_096; +const nativeSetTimeout = setTimeout; + +function resolvePromise(value: T): Promise> { + return apply(promiseResolve, NativePromise, [value]) as Promise>; +} + +function getRequestUrl(request: Request): string { + return apply(requestUrlGetter, request, []) as string; +} + +function getRequestMethod(request: Request): string { + return apply(requestMethodGetter, request, []) as string; +} + +function getRequestHeaders(request: Request): Headers { + return apply(requestHeadersGetter, request, []) as Headers; +} + +function getRequestBody(request: Request): ReadableStream | null { + return apply(requestBodyGetter, request, []) as ReadableStream | null; +} + +function getRequestSignal(request: Request): AbortSignal { + return apply(requestSignalGetter, request, []) as AbortSignal; +} + +function getHeader(headers: Headers, name: string): string | null { + return apply(headersGet, headers, [name]) as string | null; +} + +function snapshotHeaders( + headers: Headers, +): [string, string][] { + const result: [string, string][] = []; + apply(headersForEach, headers, [ + (value: string, name: string) => { + if (isInfrastructureOnlyRequestHeader(name)) return; + apply(arrayPush, result, [[name, value]]); + }, + ]); + return result; +} + +function uppercaseMethod(method: string): string { + return apply(stringToUpperCase, method, []) as string; +} + +function randomUUID(): string { + return apply(cryptoRandomUUID, nativeCrypto, []) as string; +} + +function findSerializedHeader( + headers: readonly [string, string][], + expectedName: string, +): string | null { + for (let index = 0; index < headers.length; index++) { + const entry = headers[index]!; + if (entry[0] === expectedName) return entry[1]; + } + return null; +} + /** - * Read the current project env snapshot via the globalThis bridge registered by + * Read the current project env snapshot via the closure bridge registered by * server/project-env/storage.ts. This avoids a direct import from the server/ * layer (which would violate the layer architecture). */ -function getProjectEnvSnapshot(): Record | undefined { - const getter = (globalThis as Record).__vfProjectEnvSnapshotGetter as - | (() => Record | undefined) - | undefined; - return getter?.(); +function getProjectEnvSnapshot(): ProjectEnvSnapshot | undefined { + return getTrustedProjectEnvSnapshot(); +} + +function encodeSemanticMaterial(value: string): Uint8Array { + return apply(textEncoderEncode, semanticTextEncoder, [value]) as Uint8Array; +} + +function semanticByteLength(value: string): number { + return apply(typedArrayByteLengthGetter, encodeSemanticMaterial(value), []) as number; +} + +function appendSemanticPart(parts: string[], value: string): void { + objectDefineProperty(parts, parts.length, { + value, + enumerable: true, + configurable: true, + writable: true, + }); } -function isDevelopment(adapter: RuntimeAdapter): boolean { - const env = adapter.env.get("MODE") ?? - adapter.env.get("NODE_ENV") ?? - adapter.env.get("DENO_ENV"); +function appendFramed(parts: string[], value: string): void { + const length = apply(numberToString, value.length, [10]) as string; + appendSemanticPart(parts, `${length}:${value}`); +} + +function snapshotProjectEnvRecordForWorker( + raw: unknown, +): ProjectEnvSnapshot | undefined { + if (raw === undefined) return undefined; + if (typeof raw !== "object" || raw === null) { + throw createRequestBodyReadError("Project environment snapshot must be a plain data record"); + } + + const descriptors = getDataDescriptors(raw); + if (!descriptors) { + throw createRequestBodyReadError("Project environment snapshot must be a plain data record"); + } - if (!env) return isDevelopmentEnv(); + const reflectedKeys = ownKeys(raw); + const keys = objectKeys(descriptors); + if ( + reflectedKeys.length !== keys.length || + keys.length > PROJECT_ENV_SNAPSHOT_LIMITS.maxEntries + ) { + throw createRequestBodyReadError("Project environment snapshot is invalid or too large"); + } + apply(arraySort, keys, []); - const normalized = env.toLowerCase(); - return normalized === "development" || normalized === "dev"; + const output = objectCreate(null) as Record; + let totalBytes = 0; + for (let index = 0; index < keys.length; index++) { + const key = keys[index]!; + const descriptor = descriptors[key]; + const hasValue = descriptor !== undefined && + apply(objectPrototypeHasOwnProperty, descriptor, ["value"]) === true; + const value = hasValue ? descriptor.value : undefined; + if ( + descriptor?.enumerable !== true || + typeof value !== "string" || + key.length > PROJECT_ENV_SNAPSHOT_LIMITS.maxKeyChars || + value.length > PROJECT_ENV_SNAPSHOT_LIMITS.maxValueChars || + !apply(regexpTest, PROJECT_ENV_KEY_PATTERN, [key]) || + !apply(regexpTest, PROJECT_ENV_VALUE_PATTERN, [value]) + ) { + throw createRequestBodyReadError("Project environment snapshot contains an invalid entry"); + } + + totalBytes += semanticByteLength(key); + totalBytes += semanticByteLength(value); + if (totalBytes > PROJECT_ENV_SNAPSHOT_LIMITS.maxUtf8Bytes) { + throw createRequestBodyReadError("Project environment snapshot exceeds the worker limit"); + } + + objectDefineProperty(output, key, { + value, + enumerable: true, + configurable: false, + writable: false, + }); + } + return objectFreeze(output); +} + +function snapshotProjectEnvForWorker(): ProjectEnvSnapshot | undefined { + return snapshotProjectEnvRecordForWorker(getProjectEnvSnapshot()); +} + +/** @internal Captured-primordial project env snapshot regression hook. */ +export const __snapshotProjectEnvRecordForTests = snapshotProjectEnvRecordForWorker; + +function appendSourcePolicyMaterial( + parts: string[], + policy: SourceIntegrationPolicyManifest, +): void { + appendFramed(parts, "policy-v1"); + appendFramed(parts, policy.mode); + if (policy.mode === "unrestricted") return; + + const integrationKeys = objectKeys(policy.integrations); + apply(arraySort, integrationKeys, []); + for (let index = 0; index < integrationKeys.length; index++) { + const integration = integrationKeys[index]!; + const restriction = policy.integrations[integration]; + if (!restriction) { + throw createRequestBodyReadError("Source integration policy is invalid"); + } + appendFramed(parts, integration); + if (restriction.allowedToolIds === null) { + appendFramed(parts, "*"); + continue; + } + appendFramed(parts, "list"); + for (let toolIndex = 0; toolIndex < restriction.allowedToolIds.length; toolIndex++) { + appendFramed(parts, restriction.allowedToolIds[toolIndex]!); + } + } +} + +async function digestSemanticMaterial(material: string): Promise { + const bytes = encodeSemanticMaterial(material); + const digest = await apply(subtleDigest, nativeSubtleCrypto, [ + "SHA-256", + bytes, + ]) as ArrayBuffer; + const digestBytes = new NativeUint8Array(digest); + const digestByteLength = apply(typedArrayByteLengthGetter, digestBytes, []) as number; + const hex: string[] = []; + for (let index = 0; index < digestByteLength; index++) { + const encoded = apply(numberToString, digestBytes[index]!, [16]) as string; + appendSemanticPart(hex, apply(stringPadStart, encoded, [2, "0"]) as string); + } + return apply(arrayJoin, hex, [""]) as string; +} + +interface WorkerSemanticContext { + readonly projectEnv?: ProjectEnvSnapshot; + readonly sourceIntegrationPolicy: SourceIntegrationPolicyManifest; + readonly generation: string; +} + +async function snapshotWorkerSemanticContext(): Promise { + const projectEnv = snapshotProjectEnvForWorker(); + const sourceIntegrationPolicy = requireActiveSourceIntegrationPolicy(); + const parts: string[] = []; + appendFramed(parts, "env"); + if (projectEnv) { + const envKeys = objectKeys(projectEnv); + for (let index = 0; index < envKeys.length; index++) { + const key = envKeys[index]!; + appendFramed(parts, key); + appendFramed(parts, projectEnv[key]!); + } + } + appendSourcePolicyMaterial(parts, sourceIntegrationPolicy); + + return { + projectEnv, + sourceIntegrationPolicy, + generation: await digestSemanticMaterial(apply(arrayJoin, parts, ["|"]) as string), + }; +} + +async function resolveApiWorkerId( + baseScopeId: string, + generation: string, +): Promise { + const identity = snapshotWorkerGenerationIdentity(baseScopeId, generation); + if (!identity) { + throw new TypeError("API worker generation identity is required"); + } + return (await resolveWorkerGeneration("api", identity)).workerId; } /** @@ -59,13 +396,87 @@ function isDevelopment(adapter: RuntimeAdapter): boolean { function handleAPIError( error: unknown, pathname: string, - adapter: RuntimeAdapter, + isLocalProject: boolean, ): Response { - logger.error(`API route error in ${pathname}:`, error); + const detached = detachThrowableForBoundary(error); + logger.error(`API route error in ${pathname}:`, detached); + + const ctx = { isLocalProject } as HandlerContext; + const req = new NativeRequest(`http://localhost${pathname}`); + return errorToRFC9457Response(detached, ctx, req); +} - const ctx = { isLocalProject: isDevelopment(adapter) } as HandlerContext; - const req = new Request(`http://localhost${pathname}`); - return errorToRFC9457Response(error, ctx, req); +interface ExecuteRouteOptionsSnapshot { + readonly modulePath?: string; + readonly projectDir?: string; + readonly isLocalProject: boolean; + readonly allowHostProjectCodeExecution: boolean; + readonly preparedModule?: PreparedWorkerModule; + readonly executionScopeId?: string; +} + +function defineExecuteRouteOption( + snapshot: Record, + key: string, + value: unknown, +): void { + apply(objectDefineProperty, undefined, [ + snapshot, + key, + { + configurable: false, + enumerable: true, + value, + writable: false, + }, + ]); +} + +function snapshotExecuteRouteOptions( + options?: ExecuteRouteOptions, +): ExecuteRouteOptionsSnapshot { + const rawModulePath = readOwnDataProperty(options, "modulePath"); + const rawProjectDir = readOwnDataProperty(options, "projectDir"); + const rawPreparedModule = readOwnDataProperty(options, "preparedModule"); + const rawExecutionScopeId = readOwnDataProperty(options, "executionScopeId"); + const isLocalProject = isExplicitlyLocalProject(options); + const snapshot = objectCreate(null) as Record; + defineExecuteRouteOption( + snapshot, + "modulePath", + typeof rawModulePath === "string" ? rawModulePath : undefined, + ); + defineExecuteRouteOption( + snapshot, + "projectDir", + typeof rawProjectDir === "string" ? rawProjectDir : undefined, + ); + defineExecuteRouteOption( + snapshot, + "isLocalProject", + isLocalProject, + ); + defineExecuteRouteOption( + snapshot, + "allowHostProjectCodeExecution", + isLocalProject || isExplicitHostProjectCodeExecutionAllowed(options), + ); + defineExecuteRouteOption( + snapshot, + "preparedModule", + typeof rawPreparedModule === "object" && rawPreparedModule !== null + ? rawPreparedModule + : undefined, + ); + defineExecuteRouteOption( + snapshot, + "executionScopeId", + typeof rawExecutionScopeId === "string" && rawExecutionScopeId.length > 0 + ? rawExecutionScopeId + : undefined, + ); + + return apply(objectFreeze, undefined, [snapshot]) as ExecuteRouteOptionsSnapshot; } function createProjectScopedFs(fs: FileSystemAdapter, projectDir: string): FileSystemAdapter { @@ -76,6 +487,9 @@ function createProjectScopedFs(fs: FileSystemAdapter, projectDir: string): FileS readFileBytes: fs.readFileBytes ? (path: string) => fs.readFileBytes!(resolvePath(path)) : undefined, + readFileBytesBounded: fs.readFileBytesBounded + ? (path: string, byteLimit: number) => fs.readFileBytesBounded!(resolvePath(path), byteLimit) + : undefined, writeFile: (path: string, content: string) => fs.writeFile(resolvePath(path), content), exists: (path: string) => fs.exists(resolvePath(path)), readDir: (path: string) => fs.readDir(resolvePath(path)), @@ -90,170 +504,382 @@ function createProjectScopedFs(fs: FileSystemAdapter, projectDir: string): FileS }; } -/** - * Check if an object is a cross-context Response (e.g. Deno native Response - * when this code runs in the npm package context with a different constructor). - */ -function isCrossContextResponse( - value: unknown, -): value is { status: number; statusText: string; headers: Headers; body: ReadableStream | null } { - if (value == null || typeof value !== "object") return false; - const r = value as Record; - return ( - typeof r.status === "number" && - typeof r.headers === "object" && - r.headers !== null && - typeof (r.headers as Headers).get === "function" && - typeof r.text === "function" && - typeof r.arrayBuffer === "function" - ); -} +// --------------------------------------------------------------------------- +// Worker Isolation Helpers +// --------------------------------------------------------------------------- -function validateResponse(response: unknown): Response { - if (response instanceof Response) return response; +function checkContentLengthLimit(contentLength: string | null): number | null { + if (contentLength === null) return null; + if (!apply(regexpTest, CONTENT_LENGTH_PATTERN, [contentLength])) { + throw toError( + createError({ + type: "api", + message: "Invalid Content-Length for isolated execution", + }), + ); + } - // Normalize cross-context Response objects into a real Response so downstream - // code (toHeadResponse, applyCORSHeaders, withHeaders) always receives a - // genuine instance with correct body, headers, and status. - if (isCrossContextResponse(response)) { - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers: response.headers, - }); + let firstDigit = 0; + while ( + firstDigit < contentLength.length - 1 && + apply(stringCharCodeAt, contentLength, [firstDigit]) === 48 + ) { + firstDigit++; } + const normalized = apply(stringSlice, contentLength, [firstDigit]) as string; + const limit = MAX_WORKER_BODY_BYTES_DECIMAL; + const exceedsLimit = normalized.length > limit.length || + (normalized.length === limit.length && normalized > limit); + if (exceedsLimit) throw createRequestBodyTooLargeError(); + return NativeNumber(normalized); +} - throw toError( +function createRequestBodyTooLargeError(bytesRead?: number): Error { + const actual = bytesRead === undefined + ? "declared Content-Length exceeds the limit" + : `${bytesRead} bytes`; + return toError( createError({ type: "api", - message: "API handler must return a Response", + message: + `Request body too large for isolated execution (${actual}, limit ${MAX_WORKER_BODY_BYTES} bytes)`, }), ); } -function toHeadResponse(response: Response): Response { - return new Response(null, { status: response.status, headers: response.headers }); +function createRequestBodyReadError(message: string): Error { + return toError(createError({ type: "api", message })); } -// --------------------------------------------------------------------------- -// Worker Isolation Helpers -// --------------------------------------------------------------------------- +function createContentLengthMismatchError(): Error { + return createRequestBodyReadError( + "Request body does not match Content-Length for isolated execution", + ); +} + +function createRequestBodyAbortError(): Error { + return createRequestBodyReadError( + "Request body read aborted for isolated execution", + ); +} -function checkContentLengthLimit(request: Request): void { - const contentLength = request.headers.get("content-length"); - if (!contentLength) return; +function cancelBodyReader( + reader: ReadableStreamDefaultReader, + reason?: unknown, +): void { + void (async () => { + try { + await apply(readerCancel, reader, [reason]); + } catch { + // Cancellation is best effort after the primary body error is known. + } + })(); +} - const bytes = parseInt(contentLength, 10); - if (bytes > MAX_WORKER_BODY_BYTES) { - throw createError({ - type: "api", - message: `Request body too large for isolated execution (${ - (bytes / 1024 / 1024).toFixed(1) - } MB, limit ${MAX_WORKER_BODY_BYTES / 1024 / 1024} MB)`, - }); - } +function cancelBodyStream( + stream: ReadableStream, + reason: unknown, +): void { + void (async () => { + try { + await apply(streamCancel, stream, [reason]); + } catch { + // A locked or failed stream cannot be cancelled from this boundary. + } + })(); } -let warnedUntrustedInProcessExecution = false; +function isAbortSignalAborted(signal: AbortSignal): boolean { + return apply(abortSignalAbortedGetter, signal, []) as boolean; +} -export function __resetInProcessIsolationWarningForTests(): void { - warnedUntrustedInProcessExecution = false; +function createUint8ArrayView( + source: Uint8Array, + relativeOffset: number, + length: number, +): Uint8Array { + const buffer = apply(typedArrayBufferGetter, source, []) as ArrayBufferLike; + const byteOffset = apply(typedArrayByteOffsetGetter, source, []) as number; + return new NativeUint8Array(buffer, byteOffset + relativeOffset, length); } -function warnIfUntrustedInProcessExecution( - routeKind: "app" | "pages", - pathname: string, - options?: ExecuteRouteOptions, -): void { - if (options?.isLocalProject !== false) return; - if (isWorkerIsolationEnabled()) return; - if (warnedUntrustedInProcessExecution) return; +function yieldBodyReadTask(): Promise { + return new NativePromise((resolve) => { + apply(nativeSetTimeout, globalThis, [resolve, 0]); + }); +} - warnedUntrustedInProcessExecution = true; - try { - logger.warn( - "Untrusted project code is executing in-process with worker isolation disabled. Enable WORKER_ISOLATION_ENABLED=1 and WORKER_ISOLATION_API=1 to run project routes in a permission-restricted worker.", - { - modulePath: options.modulePath, - pathname, - projectDir: options.projectDir, - requiredEnv: ["WORKER_ISOLATION_ENABLED", "WORKER_ISOLATION_API"], - routeKind, - workerIsolationEnabled: false, +interface BodyReadAbortGate { + failure: Error | undefined; + rejectPending: ((reason: unknown) => void) | undefined; +} + +function waitForBodyRead( + pending: Promise, + gate: BodyReadAbortGate, +): Promise { + if (gate.failure) { + return apply(promiseReject, NativePromise, [gate.failure]) as Promise; + } + + return new NativePromise((resolve, reject) => { + if (gate.failure) { + reject(gate.failure); + return; + } + gate.rejectPending = reject; + apply(promiseThen, pending, [ + (value: T) => { + if (gate.rejectPending === reject) gate.rejectPending = undefined; + if (gate.failure) reject(gate.failure); + else resolve(value); }, - ); + (error: unknown) => { + if (gate.rejectPending === reject) gate.rejectPending = undefined; + reject(gate.failure ?? error); + }, + ]); + }); +} + +async function readBodyWithSizeGuard( + bodyStream: ReadableStream | null, + contentLength: string | null, + signal: AbortSignal, +): Promise { + let declaredLength: number | null; + try { + declaredLength = checkContentLengthLimit(contentLength); + } catch (error) { + if (bodyStream) cancelBodyStream(bodyStream, error); + throw error; + } + if (!bodyStream) { + if (declaredLength !== null && declaredLength !== 0) { + throw createContentLengthMismatchError(); + } + return null; + } + + let reader: ReadableStreamDefaultReader; + try { + reader = apply(streamGetReader, bodyStream, []) as ReadableStreamDefaultReader; } catch { - // A diagnostic warning must not prevent the API route from running. + throw createRequestBodyReadError( + "Request body is unavailable for isolated execution", + ); } -} -async function readBodyWithSizeGuard(request: Request): Promise { - if (!request.body) return null; + const blocks: Uint8Array[] = []; + let currentBlock: Uint8Array | null = null; + let currentBlockLength = 0; + let totalBytes = 0; + let sourceChunks = 0; + let consecutiveEmptyChunks = 0; + let chunksSinceYield = 0; + const abortGate: BodyReadAbortGate = { + failure: undefined, + rejectPending: undefined, + }; + const abortBodyRead = (): void => { + if (abortGate.failure) return; + const failure = createRequestBodyAbortError(); + abortGate.failure = failure; + const rejectPending = abortGate.rejectPending; + abortGate.rejectPending = undefined; + cancelBodyReader(reader, failure); + if (rejectPending) rejectPending(failure); + }; - // Fast path: reject before buffering if Content-Length is known - checkContentLengthLimit(request); + if (isAbortSignalAborted(signal)) { + abortBodyRead(); + } else { + apply(eventTargetAddEventListener, signal, ["abort", abortBodyRead]); + if (isAbortSignalAborted(signal)) abortBodyRead(); + } - const body = new Uint8Array(await request.arrayBuffer()); + try { + while (true) { + if (abortGate.failure) throw abortGate.failure; + let result: ReadableStreamReadResult; + try { + const pendingRead = apply(readerRead, reader, []) as Promise< + ReadableStreamReadResult + >; + result = await waitForBodyRead(pendingRead, abortGate); + } catch (error) { + if (abortGate.failure) throw abortGate.failure; + cancelBodyReader(reader); + throw createRequestBodyReadError( + `Failed to read request body for isolated execution: ${ + snapshotThrowableDiagnostic(error) + }`, + ); + } - // Fallback: check actual size for chunked/streaming bodies - if (body.byteLength > MAX_WORKER_BODY_BYTES) { - throw createError({ - type: "api", - message: `Request body too large for isolated execution (${ - (body.byteLength / 1024 / 1024).toFixed(1) - } MB, limit ${MAX_WORKER_BODY_BYTES / 1024 / 1024} MB)`, - }); + if ( + typeof result !== "object" || + result === null || + isNativeProxy(result) + ) { + cancelBodyReader(reader); + throw createRequestBodyReadError( + "Request body stream returned an invalid read result", + ); + } + const doneDescriptor = getOwnPropertyDescriptor(result, "done"); + const valueDescriptor = getOwnPropertyDescriptor(result, "value"); + if ( + !doneDescriptor || + !("value" in doneDescriptor) || + typeof doneDescriptor.value !== "boolean" + ) { + cancelBodyReader(reader); + throw createRequestBodyReadError( + "Request body stream returned an invalid read result", + ); + } + if (doneDescriptor.value) break; + + sourceChunks++; + if (sourceChunks > MAX_WORKER_BODY_SOURCE_CHUNKS) { + const failure = createRequestBodyReadError( + "Request body stream exceeded the chunk limit for isolated execution", + ); + cancelBodyReader(reader, failure); + throw failure; + } + + const chunk = valueDescriptor && "value" in valueDescriptor + ? valueDescriptor.value + : undefined; + if (!isNativeUint8Array(chunk)) { + cancelBodyReader(reader); + throw createRequestBodyReadError( + "Request body stream returned a non-byte chunk", + ); + } + + const chunkByteLength = apply(typedArrayByteLengthGetter, chunk, []) as number; + if (chunkByteLength > MAX_WORKER_BODY_BYTES - totalBytes) { + const bytesRead = totalBytes + chunkByteLength; + const failure = createRequestBodyTooLargeError(bytesRead); + cancelBodyReader(reader, failure); + throw failure; + } + if (declaredLength !== null && chunkByteLength > declaredLength - totalBytes) { + const failure = createContentLengthMismatchError(); + cancelBodyReader(reader, failure); + throw failure; + } + + chunksSinceYield++; + if (chunkByteLength === 0) { + consecutiveEmptyChunks++; + if (consecutiveEmptyChunks > MAX_CONSECUTIVE_EMPTY_BODY_CHUNKS) { + const failure = createRequestBodyReadError( + "Request body stream made no progress during isolated execution", + ); + cancelBodyReader(reader, failure); + throw failure; + } + } else { + consecutiveEmptyChunks = 0; + } + + if (chunksSinceYield >= BODY_READ_YIELD_CHUNKS) { + chunksSinceYield = 0; + await yieldBodyReadTask(); + if (abortGate.failure) throw abortGate.failure; + } + + if (chunkByteLength === 0) continue; + + totalBytes += chunkByteLength; + let chunkOffset = 0; + while (chunkOffset < chunkByteLength) { + if (currentBlock === null) { + currentBlock = new NativeUint8Array(BODY_COALESCE_BLOCK_BYTES); + currentBlockLength = 0; + } + const blockRemaining = BODY_COALESCE_BLOCK_BYTES - currentBlockLength; + const chunkRemaining = chunkByteLength - chunkOffset; + const copyLength = blockRemaining < chunkRemaining ? blockRemaining : chunkRemaining; + const sourceSlice = createUint8ArrayView(chunk, chunkOffset, copyLength); + apply(typedArraySet, currentBlock, [sourceSlice, currentBlockLength]); + currentBlockLength += copyLength; + chunkOffset += copyLength; + + if (currentBlockLength === BODY_COALESCE_BLOCK_BYTES) { + apply(arrayPush, blocks, [currentBlock]); + currentBlock = null; + currentBlockLength = 0; + } + } + } + + if (declaredLength !== null && totalBytes !== declaredLength) { + throw createContentLengthMismatchError(); + } + } finally { + apply(eventTargetRemoveEventListener, signal, ["abort", abortBodyRead]); + abortGate.rejectPending = undefined; + try { + apply(readerReleaseLock, reader, []); + } catch { + // The body result is already determined; lock release is best effort. + } } + const body = new NativeUint8Array(totalBytes); + let offset = 0; + for (let index = 0; index < blocks.length; index++) { + const block = blocks[index]!; + apply(typedArraySet, body, [block, offset]); + offset += BODY_COALESCE_BLOCK_BYTES; + } + if (currentBlock !== null && currentBlockLength > 0) { + const finalBlock = createUint8ArrayView(currentBlock, 0, currentBlockLength); + apply(typedArraySet, body, [finalBlock, offset]); + } return body; } -async function serializeRequest(request: Request): Promise { +async function serializeRequest( + request: Request, +): Promise { + const headers = getRequestHeaders(request); + const url = getRequestUrl(request); + const method = getRequestMethod(request); + const serializedHeaders = snapshotHeaders(headers); + const contentLength = getHeader(headers, "content-length"); + const bodyStream = getRequestBody(request); + const signal = getRequestSignal(request); + return { - url: request.url, - method: request.method, - headers: [...request.headers.entries()], - body: await readBodyWithSizeGuard(request), + url, + method, + headers: serializedHeaders, + body: await readBodyWithSizeGuard(bodyStream, contentLength, signal), }; } +/** @internal Captured-primordial request serialization regression hook. */ +export const __serializeRequestForTests = serializeRequest; + function deserializeResponse(s: SerializedResponse): Response { - return new Response(s.body as BodyInit | null, { - status: s.status, - statusText: s.statusText, - headers: s.headers, - }); + return deserializeRouteResponse(s); } function workerResponseToResponse( workerResponse: WorkerResponse, pathname: string, - adapter: RuntimeAdapter, + isLocalProject: boolean, ): Response { if (workerResponse.type === "error") { - const { error } = workerResponse; + const error = deserializeWorkerError(workerResponse.error); logger.error(`API route error in ${pathname} (worker):`, error.message); - - // If the worker serialized RFC 9457 fields, return them directly - // to preserve the original status code, type, and detail. - if (error.status && error.type) { - return Response.json( - { - type: error.type, - title: error.name, - status: error.status, - detail: error.detail ?? error.message, - instance: pathname, - }, - { status: error.status }, - ); - } - - const ctx = { isLocalProject: isDevelopment(adapter) } as HandlerContext; - const req = new Request(`http://localhost${pathname}`); - const err = new Error(error.message); - err.name = error.name; - return errorToRFC9457Response(err, ctx, req); + return handleAPIError(error, pathname, isLocalProject); } if (workerResponse.type === "result") { @@ -264,19 +890,123 @@ function workerResponseToResponse( throw NOT_SUPPORTED.create({ detail: `Unexpected worker response type: ${workerResponse.type}` }); } +const INVALID_WORKER_FIELD = Symbol("invalid-worker-field"); +type InvalidWorkerField = typeof INVALID_WORKER_FIELD; + +function getDataDescriptors(value: unknown): PropertyDescriptorMap | null { + if ( + typeof value !== "object" || + value === null || + apply(arrayIsArray, Array, [value]) || + isNativeProxy(value) + ) { + return null; + } + + try { + const prototype = getPrototypeOf(value); + if (prototype !== objectPrototype && prototype !== null) return null; + return getOwnPropertyDescriptors(value); + } catch { + return null; + } +} + +const MAX_WORKER_ROUTE_METHODS = 128; + +function snapshotWorkerRouteMethods( + response: WorkerRouteMethodsResponse, +): string[] | null { + const responseDescriptors = getDataDescriptors(response); + if (!responseDescriptors) return null; + const rawType = dataField(responseDescriptors, "type"); + const rawMethods = dataField(responseDescriptors, "methods"); + if ( + rawType !== "api-route-methods" || + !apply(arrayIsArray, Array, [rawMethods]) || + isNativeProxy(rawMethods) + ) { + return null; + } + + const methodsArray = rawMethods as unknown[]; + const lengthDescriptor = getOwnPropertyDescriptor(methodsArray, "length"); + const length = lengthDescriptor && "value" in lengthDescriptor + ? lengthDescriptor.value + : undefined; + if ( + typeof length !== "number" || + !numberIsSafeInteger(length) || + length < 1 || + length > MAX_WORKER_ROUTE_METHODS + ) { + return null; + } + + const methods: string[] = []; + for (let index = 0; index < length; index++) { + const descriptor = getOwnPropertyDescriptor(methodsArray, `${index}`); + if (!descriptor || !("value" in descriptor)) return null; + const method = descriptor.value; + if ( + typeof method !== "string" || + normalizeRouteMethod(method) !== method || + apply(arrayIncludes, methods, [method]) + ) { + return null; + } + apply(arrayPush, methods, [method]); + } + + const canonical: string[] = []; + const custom: string[] = []; + for (let index = 0; index < STANDARD_ROUTE_METHODS.length; index++) { + const method = STANDARD_ROUTE_METHODS[index]!; + if (apply(arrayIncludes, methods, [method])) { + apply(arrayPush, canonical, [method]); + } + } + for (let index = 0; index < methods.length; index++) { + const method = methods[index]!; + if (!apply(arrayIncludes, STANDARD_ROUTE_METHODS, [method])) { + apply(arrayPush, custom, [method]); + } + } + apply(arraySort, custom, []); + for (let index = 0; index < custom.length; index++) { + apply(arrayPush, canonical, [custom[index]!]); + } + if (canonical.length !== methods.length) return null; + for (let index = 0; index < methods.length; index++) { + if (methods[index] !== canonical[index]) return null; + } + return methods; +} + +function dataField( + descriptors: PropertyDescriptorMap, + key: string, +): unknown | InvalidWorkerField { + const descriptor = descriptors[key]; + if (!descriptor) return undefined; + return "value" in descriptor ? descriptor.value : INVALID_WORKER_FIELD; +} + // --------------------------------------------------------------------------- // Isolated Execution (Worker Path) // --------------------------------------------------------------------------- function executeAppRouteIsolated( + executionScopeId: string, + module: PreparedWorkerModule, modulePath: string, request: Request, match: RouteMatch, pathname: string, - adapter: RuntimeAdapter, projectDir: string, + isLocalProject: boolean, ): Promise { - const method = request.method.toUpperCase() as HTTPMethod; + const method = uppercaseMethod(getRequestMethod(request)); return withSpan( "api.executeAppRoute.isolated", @@ -284,27 +1014,33 @@ function executeAppRouteIsolated( try { const pool = getWorkerPool(); const serialized = await serializeRequest(request); + const semanticContext = await snapshotWorkerSemanticContext(); const workerResponse = await pool.execute( - projectDir, + await resolveApiWorkerId(executionScopeId, semanticContext.generation), [projectDir], { type: "execute-app-route", - id: crypto.randomUUID(), + id: randomUUID(), + module, modulePath, method, request: serialized, - params: match.params, + params: normalizeParams(match.params), projectDir, - sourceIntegrationPolicy: requireActiveSourceIntegrationPolicy(), - projectEnv: getProjectEnvSnapshot(), + sourceIntegrationPolicy: semanticContext.sourceIntegrationPolicy, + projectEnv: semanticContext.projectEnv, }, ); - const response = workerResponseToResponse(workerResponse, pathname, adapter); - return method === "HEAD" ? toHeadResponse(response) : response; + const response = workerResponseToResponse( + workerResponse, + pathname, + isLocalProject, + ); + return method === "HEAD" ? normalizeRouteHeadResponse(response) : response; } catch (error) { - return handleAPIError(error, pathname, adapter); + return handleAPIError(error, pathname, isLocalProject); } }, { @@ -317,47 +1053,58 @@ function executeAppRouteIsolated( } function executePagesRouteIsolated( + executionScopeId: string, + module: PreparedWorkerModule, modulePath: string, request: Request, match: RouteMatch, pathname: string, - adapter: RuntimeAdapter, projectDir: string, + isLocalProject: boolean, ): Promise { - const method = request.method as string; + const method = uppercaseMethod(getRequestMethod(request)); return withSpan( "api.executePagesRoute.isolated", async () => { try { const pool = getWorkerPool(); - const body = await readBodyWithSizeGuard(request); + const serialized = await serializeRequest(request); + const semanticContext = await snapshotWorkerSemanticContext(); const workerResponse = await pool.execute( - projectDir, + await resolveApiWorkerId(executionScopeId, semanticContext.generation), [projectDir], { type: "execute-pages-route", - id: crypto.randomUUID(), + id: randomUUID(), + module, modulePath, method, context: { - url: request.url, - method: request.method, - headers: [...request.headers.entries()], - body, + url: serialized.url, + method: serialized.method, + headers: serialized.headers, + body: serialized.body, params: match.params, - cookies: parseCookies(request.headers.get("cookie") ?? ""), + cookies: parseCookies( + findSerializedHeader(serialized.headers, "cookie") ?? "", + ), }, projectDir, - sourceIntegrationPolicy: requireActiveSourceIntegrationPolicy(), - projectEnv: getProjectEnvSnapshot(), + sourceIntegrationPolicy: semanticContext.sourceIntegrationPolicy, + projectEnv: semanticContext.projectEnv, }, ); - return workerResponseToResponse(workerResponse, pathname, adapter); + const response = workerResponseToResponse( + workerResponse, + pathname, + isLocalProject, + ); + return method === "HEAD" ? normalizeRouteHeadResponse(response) : response; } catch (error) { - return handleAPIError(error, pathname, adapter); + return handleAPIError(error, pathname, isLocalProject); } }, { @@ -380,6 +1127,98 @@ export interface ExecuteRouteOptions { projectDir?: string; /** Whether the handler module belongs to a trusted local development project. */ isLocalProject?: boolean; + /** + * Whether runtime trust resolution permits this project module to execute in + * the server process. Local development projects retain this capability + * through `isLocalProject`. + */ + allowHostProjectCodeExecution?: boolean; + /** Non-evaluated, policy-checked route source for worker execution. */ + preparedModule?: PreparedWorkerModule; + /** Opaque tenant/version/handler-lifetime worker isolation key. */ + executionScopeId?: string; +} + +export interface PreparedRouteExecutionOptions { + readonly executionScopeId: string; + readonly module: PreparedWorkerModule; + readonly modulePath: string; + readonly projectDir: string; + readonly isLocalProject: boolean; +} + +export function executePreparedAppRoute( + request: Request, + match: RouteMatch, + pathname: string, + options: PreparedRouteExecutionOptions, +): Promise { + return executeAppRouteIsolated( + options.executionScopeId, + options.module, + options.modulePath, + request, + match, + pathname, + options.projectDir, + options.isLocalProject, + ); +} + +export function executePreparedPagesRoute( + request: Request, + match: RouteMatch, + pathname: string, + options: PreparedRouteExecutionOptions, +): Promise { + return executePagesRouteIsolated( + options.executionScopeId, + options.module, + options.modulePath, + request, + match, + pathname, + options.projectDir, + options.isLocalProject, + ); +} + +export async function resolvePreparedRouteMethods( + requestedMethod: string | undefined, + options: Omit, +): Promise { + const semanticContext = await snapshotWorkerSemanticContext(); + const workerResponse = await getWorkerPool().execute( + await resolveApiWorkerId(options.executionScopeId, semanticContext.generation), + [options.projectDir], + { + type: "inspect-api-route-methods", + id: randomUUID(), + module: options.module, + modulePath: options.modulePath, + requestedMethod, + projectDir: options.projectDir, + sourceIntegrationPolicy: semanticContext.sourceIntegrationPolicy, + projectEnv: semanticContext.projectEnv, + }, + ); + + if (workerResponse.type === "error") { + throw deserializeWorkerError(workerResponse.error); + } + if (workerResponse.type !== "api-route-methods") { + throw createRequestBodyReadError( + "Worker returned an unexpected API route capability response", + ); + } + + const methods = snapshotWorkerRouteMethods(workerResponse); + if (!methods) { + throw createRequestBodyReadError( + "Worker returned an invalid API route capability response", + ); + } + return methods; } export function executeAppRoute( @@ -387,50 +1226,72 @@ export function executeAppRoute( request: Request, match: RouteMatch, pathname: string, - adapter: RuntimeAdapter, + _adapter: RuntimeAdapter, options?: ExecuteRouteOptions, ): Promise { - // Isolated path: execute in per-project Worker, fall back to main process on error - if ( - isWorkerIsolationEnabled() && - options?.modulePath && - options?.projectDir - ) { - return executeAppRouteIsolated( - options.modulePath, - request, - match, - pathname, - adapter, - options.projectDir, + const routeOptions = snapshotExecuteRouteOptions(options); + const isLocalProject = routeOptions.isLocalProject === true; + const isolationRequired = isWorkerIsolationEnabled() || + !routeOptions.allowHostProjectCodeExecution; + + // Routes without an explicit host-execution capability require prepared + // worker execution. Local development projects retain the legacy capability. + if (isolationRequired) { + if ( + routeOptions.modulePath && + routeOptions.projectDir && + routeOptions.preparedModule && + routeOptions.executionScopeId + ) { + return executeAppRouteIsolated( + routeOptions.executionScopeId, + routeOptions.preparedModule, + routeOptions.modulePath, + request, + match, + pathname, + routeOptions.projectDir, + isLocalProject, + ); + } + return resolvePromise( + handleAPIError( + createRequestBodyReadError( + "Isolated API execution requires prepared route source and an execution scope", + ), + pathname, + isLocalProject, + ), ); } - // Default path: execute in main process (existing behavior) - warnIfUntrustedInProcessExecution("app", pathname, options); - const method = request.method.toUpperCase() as HTTPMethod; + // Trusted local-development compatibility path. + const method = uppercaseMethod(getRequestMethod(request)); return withSpan( "api.executeAppRoute", async () => { - const handlerModule = handler as Record; - const handlerFn = handlerModule[method] as AppRouteHandler | undefined; - const defaultFn = handlerModule.default as AppRouteHandler | undefined; - - let resolvedFn = handlerFn ?? defaultFn; - - if (!resolvedFn && method === "HEAD") { - resolvedFn = handlerModule.GET as AppRouteHandler | undefined; - } + try { + const handlerModule = handler as Record; + const resolvedFn = resolveRouteHandlerExport(handlerModule, method) as + | AppRouteHandler + | undefined; - if (!resolvedFn) return createAppRouteMethodNotAllowed(handlerModule); + if (!resolvedFn) return createAppRouteMethodNotAllowed(handlerModule); - try { - const appContext: AppRouteContext = { params: normalizeParams(match.params) }; - const response = validateResponse(await resolvedFn(request, appContext)); - return method === "HEAD" ? toHeadResponse(response) : response; + const appContext: AppRouteContext = { + params: normalizeParams(match.params), + env: snapshotProjectEnvForWorker() ?? EMPTY_PROJECT_ENV, + }; + const pendingResult = resolvedFn(request, appContext); + const result = isTrustedRouteResponsePromise(pendingResult) + ? await pendingResult + : pendingResult; + return method === "HEAD" + ? normalizeRouteHeadResponse(result) + : normalizeRouteResponse(result); } catch (error) { - return handleAPIError(error, pathname, adapter); + return handleAPIError(error, pathname, isLocalProject); } }, { "http.method": method, "http.path": pathname, "api.route.pattern": match.route.pattern }, @@ -446,41 +1307,75 @@ export function executePagesRoute( projectDir?: string, options?: ExecuteRouteOptions, ): Promise { - // Isolated path: execute in per-project Worker, fall back to main process on error - if ( - isWorkerIsolationEnabled() && - options?.modulePath && - (options?.projectDir ?? projectDir) - ) { - return executePagesRouteIsolated( - options.modulePath, - request, - match, - pathname, - adapter, - options.projectDir ?? projectDir!, + const routeOptions = snapshotExecuteRouteOptions(options); + const isLocalProject = routeOptions.isLocalProject === true; + const isolationRequired = isWorkerIsolationEnabled() || + !routeOptions.allowHostProjectCodeExecution; + const isolatedProjectDir = routeOptions.projectDir ?? projectDir; + + // Routes without an explicit host-execution capability require prepared + // worker execution. Local development projects retain the legacy capability. + if (isolationRequired) { + if ( + routeOptions.modulePath && + isolatedProjectDir && + routeOptions.preparedModule && + routeOptions.executionScopeId + ) { + return executePagesRouteIsolated( + routeOptions.executionScopeId, + routeOptions.preparedModule, + routeOptions.modulePath, + request, + match, + pathname, + isolatedProjectDir, + isLocalProject, + ); + } + return resolvePromise( + handleAPIError( + createRequestBodyReadError( + "Isolated API execution requires prepared route source and an execution scope", + ), + pathname, + isLocalProject, + ), ); } - // Default path: execute in main process (existing behavior) - warnIfUntrustedInProcessExecution("pages", pathname, options); - const method = request.method as keyof APIRoute; + // Trusted local-development compatibility path. + const method = uppercaseMethod(getRequestMethod(request)); return withSpan( "api.executePagesRoute", async () => { - const methodHandler = handler[method] ?? handler.default; + try { + const methodHandler = resolveRouteHandlerExport( + handler as Record, + method, + ); - if (!methodHandler) { - return createPagesRouteMethodNotAllowed(handler as Record); - } + if (!methodHandler) { + return createPagesRouteMethodNotAllowed(handler as Record); + } - try { const fs = projectDir ? createProjectScopedFs(adapter.fs, projectDir) : adapter.fs; - const ctx = createContext(request, match, fs); - return validateResponse(await (methodHandler as PagesRouteHandler)(ctx)); + const ctx = createContext( + request, + match, + fs, + snapshotProjectEnvForWorker() ?? EMPTY_PROJECT_ENV, + ); + const pendingResult = (methodHandler as PagesRouteHandler)(ctx); + const result = isTrustedRouteResponsePromise(pendingResult) + ? await pendingResult + : pendingResult; + return method === "HEAD" + ? normalizeRouteHeadResponse(result) + : normalizeRouteResponse(result); } catch (error) { - return handleAPIError(error, pathname, adapter); + return handleAPIError(error, pathname, isLocalProject); } }, { "http.method": method, "http.path": pathname, "api.route.pattern": match.route.pattern }, diff --git a/src/routing/api/route-methods.test.ts b/src/routing/api/route-methods.test.ts new file mode 100644 index 0000000000..c0cfe81dc4 --- /dev/null +++ b/src/routing/api/route-methods.test.ts @@ -0,0 +1,46 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { + normalizeRouteMethod, + resolveExecutableRouteMethods, + resolveRouteHandlerExport, +} from "./route-methods.ts"; + +describe("routing/api/route-methods", () => { + it("uses exact, default, then GET resolution order for HEAD", () => { + const exact = () => "exact"; + const fallback = () => "default"; + const get = () => "get"; + + assertEquals( + resolveRouteHandlerExport({ HEAD: exact, default: fallback, GET: get }, "HEAD"), + exact, + ); + assertEquals( + resolveRouteHandlerExport({ default: fallback, GET: get }, "HEAD"), + fallback, + ); + assertEquals(resolveRouteHandlerExport({ GET: get }, "HEAD"), get); + }); + + it("uses one bounded token contract for custom execution and discovery", () => { + const fallback = () => "default"; + const routeModule = { default: fallback }; + + assertEquals(resolveRouteHandlerExport(routeModule, "propfind"), fallback); + assertEquals( + resolveExecutableRouteMethods(routeModule, "propfind").includes("PROPFIND"), + true, + ); + + const oversized = "X".repeat(65); + assertEquals(normalizeRouteMethod(oversized), null); + assertEquals(resolveRouteHandlerExport(routeModule, oversized), undefined); + assertEquals( + resolveExecutableRouteMethods(routeModule, oversized).includes(oversized), + false, + ); + assertEquals(normalizeRouteMethod("BAD METHOD"), null); + }); +}); diff --git a/src/routing/api/route-methods.ts b/src/routing/api/route-methods.ts new file mode 100644 index 0000000000..75071d7517 --- /dev/null +++ b/src/routing/api/route-methods.ts @@ -0,0 +1,131 @@ +/** + * Canonical HTTP-method resolution shared by in-process API execution, + * isolated workers, method-not-allowed responses, and capability discovery. + */ + +/** Standard methods advertised for a callable default route export. */ +export const STANDARD_ROUTE_METHODS = [ + "GET", + "HEAD", + "POST", + "PUT", + "PATCH", + "DELETE", + "OPTIONS", +] as const; + +const HTTP_METHOD_TOKEN_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Z]+$/; +const MAX_HTTP_METHOD_LENGTH = 64; +const apply = Reflect.apply; +const arrayIncludes = Array.prototype.includes; +const arrayPush = Array.prototype.push; +const arraySort = Array.prototype.sort; +const objectEntries = Object.entries; +const objectHasOwn = Object.hasOwn; +const regexpTest = RegExp.prototype.test; +const stringToUpperCase = String.prototype.toUpperCase; + +/** Snapshot one request method as a bounded, canonical HTTP token. */ +export function normalizeRouteMethod(method: unknown): string | null { + if (typeof method !== "string") return null; + + const normalized = apply(stringToUpperCase, method, []) as string; + if ( + normalized.length === 0 || + normalized.length > MAX_HTTP_METHOD_LENGTH || + !apply(regexpTest, HTTP_METHOD_TOKEN_PATTERN, [normalized]) + ) { + return null; + } + return normalized; +} + +function ownCallableExport( + routeModule: Record, + exportName: string, +): ((...args: unknown[]) => unknown) | undefined { + if (!apply(objectHasOwn, Object, [routeModule, exportName])) return undefined; + const candidate = routeModule[exportName]; + return typeof candidate === "function" ? candidate as (...args: unknown[]) => unknown : undefined; +} + +/** + * Resolve the function an API request executes. + * + * Compatibility order is intentional: an exact method export wins, then the + * default export, then GET supplies the conventional HEAD fallback. + */ +export function resolveRouteHandlerExport( + routeModule: Record, + method: unknown, +): ((...args: unknown[]) => unknown) | undefined { + const normalized = normalizeRouteMethod(method); + if (!normalized) return undefined; + + return ownCallableExport(routeModule, normalized) ?? + ownCallableExport(routeModule, "default") ?? + (normalized === "HEAD" ? ownCallableExport(routeModule, "GET") : undefined); +} + +/** + * Return the method surface the canonical resolver can execute. + * + * OPTIONS is always framework-reachable for a matched route. A default export + * supports the standard surface plus the one bounded custom method currently + * being probed (for example, a CORS PROPFIND preflight). + */ +export function resolveExecutableRouteMethods( + routeModule: Record, + requestedMethod?: unknown, + options: { includeFrameworkOptions?: boolean } = {}, +): string[] { + const methods: string[] = []; + const addMethod = (method: string): void => { + if (!apply(arrayIncludes, methods, [method])) { + apply(arrayPush, methods, [method]); + } + }; + const hasDefault = ownCallableExport(routeModule, "default") !== undefined; + + if (hasDefault) { + for (let index = 0; index < STANDARD_ROUTE_METHODS.length; index++) { + addMethod(STANDARD_ROUTE_METHODS[index]!); + } + const requested = normalizeRouteMethod(requestedMethod); + if (requested) addMethod(requested); + } else { + const entries = apply(objectEntries, Object, [routeModule]) as [string, unknown][]; + for (let index = 0; index < entries.length; index++) { + const entry = entries[index]!; + const exportName = entry[0]; + const value = entry[1]; + if ( + exportName === apply(stringToUpperCase, exportName, []) && + normalizeRouteMethod(exportName) === exportName && + typeof value === "function" + ) { + addMethod(exportName); + } + } + if (apply(arrayIncludes, methods, ["GET"])) addMethod("HEAD"); + if (options.includeFrameworkOptions !== false) addMethod("OPTIONS"); + } + + const standard: string[] = []; + const custom: string[] = []; + for (let index = 0; index < STANDARD_ROUTE_METHODS.length; index++) { + const method = STANDARD_ROUTE_METHODS[index]!; + if (apply(arrayIncludes, methods, [method])) apply(arrayPush, standard, [method]); + } + for (let index = 0; index < methods.length; index++) { + const method = methods[index]!; + if (!apply(arrayIncludes, STANDARD_ROUTE_METHODS, [method])) { + apply(arrayPush, custom, [method]); + } + } + apply(arraySort, custom, []); + for (let index = 0; index < custom.length; index++) { + apply(arrayPush, standard, [custom[index]!]); + } + return standard; +} diff --git a/src/runs/runs-client.test.ts b/src/runs/runs-client.test.ts index 8b6908c265..aa1719a6de 100644 --- a/src/runs/runs-client.test.ts +++ b/src/runs/runs-client.test.ts @@ -7,6 +7,7 @@ import { assertStringIncludes, } from "#veryfront/testing/assert"; import { deleteEnv, setEnv } from "#veryfront/platform/compat/process.ts"; +import { runWithVeryfrontCloudContext } from "#veryfront/provider"; import { createRunsClient, VeryfrontRunsClient } from "./runs-client.ts"; const originalFetch = globalThis.fetch; @@ -138,7 +139,7 @@ describe("VeryfrontRunsClient", () => { mockFetch([jsonResponse({ accepted: true, run: makeRun() }, 202)]); const client = new VeryfrontRunsClient({ - apiUrl: "https://api.test.com", + apiUrl: "https://93.184.216.34", authToken: "test-token", projectReference: "dreamy-haven", }); @@ -184,7 +185,7 @@ describe("VeryfrontRunsClient", () => { ]); const client = new VeryfrontRunsClient({ - apiUrl: "https://api.test.com", + apiUrl: "https://93.184.216.34", authToken: "test-token", projectReference: "dreamy-haven", }); @@ -223,7 +224,7 @@ describe("VeryfrontRunsClient", () => { ]); const client = new VeryfrontRunsClient({ - apiUrl: "https://api.test.com", + apiUrl: "https://93.184.216.34", authToken: "test-token", projectReference: "dreamy-haven", }); @@ -307,7 +308,7 @@ describe("VeryfrontRunsClient", () => { ]); const client = new VeryfrontRunsClient({ - apiUrl: "https://api.test.com", + apiUrl: "https://93.184.216.34", authToken: "test-token", projectReference: "dreamy-haven", }); @@ -332,11 +333,11 @@ describe("VeryfrontRunsClient", () => { }); assertEquals( call(0).url, - "https://api.test.com/projects/dreamy-haven/schedules?status=active&source_trigger_id=process-job-submissions", + "https://93.184.216.34/projects/dreamy-haven/schedules?status=active&source_trigger_id=process-job-submissions", ); assertEquals( call(1).url, - `https://api.test.com/projects/dreamy-haven/schedules/${scheduleId}/runs`, + `https://93.184.216.34/projects/dreamy-haven/schedules/${scheduleId}/runs`, ); assertEquals(call(1).init?.method, "POST"); assertEquals(headerValue(1, "Authorization"), "Bearer test-token"); @@ -356,7 +357,7 @@ describe("VeryfrontRunsClient", () => { }, 201), ]); const client = new VeryfrontRunsClient({ - apiUrl: "https://api.test.com", + apiUrl: "https://93.184.216.34", authToken: "test-token", projectReference: "dreamy-haven", retry: { @@ -392,7 +393,7 @@ describe("VeryfrontRunsClient", () => { }), ]); const client = new VeryfrontRunsClient({ - apiUrl: "https://api.test.com", + apiUrl: "https://93.184.216.34", authToken: "test-token", projectReference: "dreamy-haven", }); @@ -409,7 +410,7 @@ describe("VeryfrontRunsClient", () => { assertEquals(fetchCalls.length, 1); assertEquals( call(0).url, - "https://api.test.com/projects/dreamy-haven/schedules?status=active&source_trigger_id=missing-schedule", + "https://93.184.216.34/projects/dreamy-haven/schedules?status=active&source_trigger_id=missing-schedule", ); }); @@ -451,7 +452,7 @@ describe("VeryfrontRunsClient", () => { }, 201), ]); const client = new VeryfrontRunsClient({ - apiUrl: "https://api.test.com", + apiUrl: "https://93.184.216.34", authToken: "test-token", projectReference: "dreamy-haven", }); @@ -463,7 +464,7 @@ describe("VeryfrontRunsClient", () => { assertEquals(response.scheduleRun.schedule_id, matchingScheduleId); assertEquals( call(1).url, - `https://api.test.com/projects/dreamy-haven/schedules/${matchingScheduleId}/runs`, + `https://93.184.216.34/projects/dreamy-haven/schedules/${matchingScheduleId}/runs`, ); }); @@ -485,7 +486,7 @@ describe("VeryfrontRunsClient", () => { }), ]); const client = new VeryfrontRunsClient({ - apiUrl: "https://api.test.com", + apiUrl: "https://93.184.216.34", authToken: "test-token", projectReference: "dreamy-haven", }); @@ -506,7 +507,7 @@ describe("VeryfrontRunsClient", () => { mockFetch([jsonResponse({ accepted: true, run: makeRun() }, 202)]); const client = new VeryfrontRunsClient({ - apiUrl: "https://api.test.com", + apiUrl: "https://93.184.216.34", authToken: "test-token", projectReference: "dreamy-haven", }); @@ -540,7 +541,7 @@ describe("VeryfrontRunsClient", () => { ]); const client = new VeryfrontRunsClient({ - apiUrl: "https://api.test.com", + apiUrl: "https://93.184.216.34", authToken: "test-token", projectReference: "dreamy-haven", }); @@ -568,7 +569,7 @@ describe("VeryfrontRunsClient", () => { ]); const client = new VeryfrontRunsClient({ - apiUrl: "https://api.test.com", + apiUrl: "https://93.184.216.34", authToken: "test-token", projectReference: "dreamy-haven", }); @@ -588,7 +589,7 @@ describe("VeryfrontRunsClient", () => { }); it("uses environment defaults when config is omitted", async () => { - setEnv("VERYFRONT_API_URL", "https://api.env.test"); + setEnv("VERYFRONT_API_URL", "https://93.184.216.34"); setEnv("VERYFRONT_API_TOKEN", "env-token"); setEnv("VERYFRONT_PROJECT_SLUG", "env-project"); @@ -597,26 +598,55 @@ describe("VeryfrontRunsClient", () => { const client = new VeryfrontRunsClient(); await client.get("run_11111111-1111-4111-8111-111111111111"); - assertStringIncludes(call(0).url, "https://api.env.test/runs/"); + assertStringIncludes(call(0).url, "https://93.184.216.34/runs/"); assertEquals(headerValue(0, "Authorization"), "Bearer env-token"); }); + it("never pairs a request token with a source-selected cloud endpoint", async () => { + setEnv("VERYFRONT_API_URL", "https://93.184.216.34"); + setEnv("VERYFRONT_API_TOKEN", "host-token"); + mockFetch([jsonResponse(makeRun())]); + + await runWithVeryfrontCloudContext( + { + apiBaseUrl: "https://93.184.216.35", + projectSlug: "tenant-project", + }, + async () => { + const unpaired = new VeryfrontRunsClient(); + await assertRejects( + () => unpaired.get("run_11111111-1111-4111-8111-111111111111"), + Error, + "Runs auth not configured", + ); + + const requestScoped = new VeryfrontRunsClient(); + requestScoped.setRequestToken("request-token"); + await requestScoped.get("run_11111111-1111-4111-8111-111111111111"); + }, + ); + + assertEquals(fetchCalls.length, 1); + assertStringIncludes(call(0).url, "https://93.184.216.34/runs/"); + assertEquals(headerValue(0, "Authorization"), "Bearer request-token"); + }); + it("fails fast when auth is missing", async () => { const client = new VeryfrontRunsClient({ - apiUrl: "https://api.test.com", + apiUrl: "https://93.184.216.34", projectReference: "dreamy-haven", }); await assertRejects( () => client.list(), Error, - "Runs auth not configured", + "apiUrl requires an explicit authToken", ); }); it("fails fast when project reference is missing for project listing", async () => { const client = new VeryfrontRunsClient({ - apiUrl: "https://api.test.com", + apiUrl: "https://93.184.216.34", authToken: "test-token", }); diff --git a/src/runs/runs-client.ts b/src/runs/runs-client.ts index f7a65beb4b..9424c4ce32 100644 --- a/src/runs/runs-client.ts +++ b/src/runs/runs-client.ts @@ -1,4 +1,7 @@ -import { getVeryfrontCloudBootstrap } from "#veryfront/platform/cloud/resolver.ts"; +import { + getVeryfrontCloudBootstrap, + getVeryfrontCloudHostBootstrap, +} from "#veryfront/platform/cloud/resolver.ts"; import { requestWithRetry, type RetryConfig, @@ -419,15 +422,29 @@ export class VeryfrontRunsClient { }); } - private resolveApiUrl(): string { - return this.config.apiUrl ?? getVeryfrontCloudBootstrap().apiBaseUrl; - } + private resolveConnection(): { apiUrl: string; authToken: string } { + if (this.config.apiUrl && !this.config.authToken) { + throw API_CLIENT_ERROR.create({ + detail: + "Runs apiUrl requires an explicit authToken. A caller-selected endpoint cannot use request- or host-owned credentials.", + status: 401, + }); + } + if (this.config.apiUrl && this.config.authToken) { + return { apiUrl: this.config.apiUrl, authToken: this.config.authToken }; + } - private resolveAuthToken(): string { - const token = this.requestToken ?? this.config.authToken ?? - getVeryfrontCloudBootstrap().apiToken; - if (token) { - return token; + const host = getVeryfrontCloudHostBootstrap(); + if (this.config.authToken) { + return { apiUrl: host.apiBaseUrl, authToken: this.config.authToken }; + } + if (this.requestToken) { + return { apiUrl: host.apiBaseUrl, authToken: this.requestToken }; + } + + const bootstrap = getVeryfrontCloudBootstrap(); + if (bootstrap.apiToken) { + return { apiUrl: bootstrap.apiBaseUrl, authToken: bootstrap.apiToken }; } throw API_CLIENT_ERROR.create({ detail: @@ -458,14 +475,23 @@ export class VeryfrontRunsClient { body?: Record; } = {}, ): Promise { + const { apiUrl, authToken } = this.resolveConnection(); + const apiOrigin = new URL(apiUrl).origin; const raw = await requestWithRetry( - `${this.resolveApiUrl()}${path}`, - this.resolveAuthToken(), + `${apiUrl}${path}`, + authToken, this.retryConfig, { method: options.method, body: options.body == null ? undefined : JSON.stringify(options.body), }, + { + authorizeUrl: (target) => { + if (target.origin !== apiOrigin) { + throw new Error("Runs request blocked: destination origin is not authorized"); + } + }, + }, ); return schema.parse(raw); } diff --git a/src/schedule/discovery.ts b/src/schedule/discovery.ts index e5d5167c9a..0c0f489328 100644 --- a/src/schedule/discovery.ts +++ b/src/schedule/discovery.ts @@ -18,6 +18,8 @@ export interface ScheduleDiscoveryOptions { config?: VeryfrontConfig; /** Explicit schedule directory override relative to `projectDir`. */ schedulesDir?: string; + /** Explicit host-owned capability for a trusted local or dedicated runtime. */ + allowHostProjectCodeExecution?: boolean; } /** Valid schedules and bounded per-file discovery diagnostics. */ @@ -41,5 +43,6 @@ export async function discoverSchedules( sourceKind: "schedule", validate: isScheduleDefinition, normalize: normalizeScheduleDefinition, + allowHostProjectCodeExecution: options.allowHostProjectCodeExecution, }); } diff --git a/src/security/README.md b/src/security/README.md index 375cb3180d..af450fcea2 100644 --- a/src/security/README.md +++ b/src/security/README.md @@ -138,6 +138,27 @@ create time-of-check/time-of-use races on adapters without descriptor-relative filesystem operations. Production deployments must not grant project code independent write access to the host paths being served. +## Host outbound HTTP policy + +Framework-owned fetches of tenant-selected remote modules, OpenAPI and remote +MCP endpoints, OAuth provider endpoints, and project-configured model and +embedding provider base URLs pass through a DNS-pinned HTTP boundary. It admits only public +`http:` and `https:` destinations, rejects URL credentials, blocks loopback, +private, link-local, metadata, and other non-global addresses, and repeats both +the network and caller-specific allowlist checks before every redirect hop. +Cross-origin redirects do not retain authorization or cookie headers. + +`VERYFRONT_HOST_ALLOW_INTERNAL_EGRESS=1` is an operator-owned compatibility +override. It disables the private-network destination check for these host +fetches and must remain unset in a shared runtime. Project environment overlays +cannot enable it. URL scheme, URL-credential, redirect, and caller allowlist +checks remain active when the override is enabled. + +Cloud credentials are bound to their endpoint provenance: a request-scoped +Cloud base URL must carry a request-scoped token, and gateway fetches admit only +their configured origin with redirects rejected. A caller-selected endpoint +cannot inherit a host or request credential. + ## Internal worker isolation [`sandbox/`](./sandbox/) is an internal runtime implementation used by Routing, @@ -159,23 +180,57 @@ The worker pool provides: metadata, and other non-global destinations by default; and - deterministic cleanup of workers, streams, timers, and egress brokers. -Worker isolation is disabled unless `WORKER_ISOLATION_ENABLED` and the relevant -`WORKER_ISOLATION_API`, `WORKER_ISOLATION_DATA`, or `WORKER_ISOLATION_SSR` flag -are enabled. Defined invalid flags and pool limits are startup errors; they are -not silently replaced with defaults. - -`WORKER_ISOLATION_SSR=1` additionally requires explicit registration of -`@veryfront/ext-react-ssr`. That extension supplies a local, offline renderer +The `WORKER_ISOLATION_ENABLED` and surface-specific +`WORKER_ISOLATION_API`, `WORKER_ISOLATION_DATA`, and `WORKER_ISOLATION_SSR` +flags opt trusted local projects into worker execution. They cannot disable the +shared-runtime boundary. A dedicated single-project runtime may execute +prepared API source in its local worker pool. A shared multi-project/proxy +runtime never executes tenant API source in the host process or a same-process +Worker: API ownership returns the typed +`project-execution-unavailable` 503 response until the request is routed to a +genuinely external or dedicated isolated project runtime. Raw-path server-data +modules are local-only; remote data and renderer-backed module endpoints return +503 before resolving project modules. Shared-runtime CORS preflights never +import route modules to discover methods, and component-snippet requests fail +before source reads or compilation. Shared markdown previews likewise stop +before source reads or custom not-found rendering. +Defined invalid flags and pool limits are startup errors; they are not silently +replaced with defaults. + +OpenAPI metadata is currently attached to handler functions. Because reading +it requires route evaluation, runtime OpenAPI generation is available only for +explicitly trusted local projects; remote requests fail closed before route +discovery or import. + +Executable primitive discovery and root project middleware use the same +explicit host-execution capability. Local development and dedicated +single-project runtimes grant it at their host-owned entrypoints. Shared proxy +runtimes reject these operations before reading or evaluating tenant modules; +they must provide an isolated project runtime before enabling either surface. + +`WORKER_ISOLATION_SSR=1` additionally requires explicit registration of an +`IsolatedSsrRendererProvider`. The provider supplies a local, offline renderer bundle through the isolated-SSR contract. Core does not import React, and there -is no host-rendering or remote-import fallback; an SSR request fails closed with -an installation hint when the extension is absent. API and data workers do not -resolve or receive the renderer contract. +is no host-rendering or remote-import fallback. The current HTTP renderer does +not yet produce a generation-owned isolated page and layout graph, so remote +SSR and server-executing RSC endpoints return `503 Service Unavailable` before +resolving a renderer. API and data workers do not resolve or receive the +renderer contract. Deno Workers share the host process. Worker retirement is lifecycle hygiene, -not a hard per-worker memory or CPU boundary. A project can still create -host-process memory pressure or consume a worker thread until the host -terminates it. Strong memory, CPU, and process containment requires a -separately limited process or container. +not a hard per-worker memory or CPU boundary. They are therefore limited to +local development and dedicated single-project execution, where one project +cannot deny service to unrelated tenants. Shared multi-project execution must +use a separately limited external process or container; there is no operator +flag that reclassifies same-process Workers as a safe tenant boundary. + +Before application-controlled API handlers, project middleware, SSR/data +rendering, or RSC action authorization receives a request, the runtime creates +a detached application request. Public application credentials such as +`Authorization` and `Cookie` are retained. Infrastructure-only credentials, +project/source identity, trusted-proxy metadata, and `x-veryfront-*` control +headers are withheld. The original request remains available only to the +host-owned admission and routing pipeline. ## Internal-only files diff --git a/src/security/http/application-request.test.ts b/src/security/http/application-request.test.ts new file mode 100644 index 0000000000..f4483b544b --- /dev/null +++ b/src/security/http/application-request.test.ts @@ -0,0 +1,58 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { createApplicationRequest } from "./application-request.ts"; + +describe("security/http/application-request", () => { + it("retains application credentials and withholds infrastructure metadata", () => { + const application = createApplicationRequest( + new Request("https://tenant.example/api/private", { + headers: { + Authorization: "Bearer public-user", + Cookie: "session=public", + "x-application-role": "editor", + "proxy-authorization": "Basic infrastructure-proxy", + "x-forwarded-host": "trusted-proxy.example", + "x-project-id": "infrastructure-project", + "x-branch-name": "infrastructure-branch", + "x-release-id": "infrastructure-release", + "x-content-source-id": "infrastructure-source", + "x-environment-id": "infrastructure-environment", + "x-token": "host-secret", + "x-veryfront-future-control-secret": "future-secret", + }, + }), + ); + + assertEquals(application.headers.get("authorization"), "Bearer public-user"); + assertEquals(application.headers.get("cookie"), "session=public"); + assertEquals(application.headers.get("x-application-role"), "editor"); + assertEquals(application.headers.get("proxy-authorization"), null); + assertEquals(application.headers.get("x-forwarded-host"), null); + assertEquals(application.headers.get("x-project-id"), null); + assertEquals(application.headers.get("x-branch-name"), null); + assertEquals(application.headers.get("x-release-id"), null); + assertEquals(application.headers.get("x-content-source-id"), null); + assertEquals(application.headers.get("x-environment-id"), null); + assertEquals(application.headers.get("x-token"), null); + assertEquals(application.headers.get("x-veryfront-future-control-secret"), null); + }); + + it("detaches the request body and header list from the host request", async () => { + const host = new Request("https://tenant.example/api/private", { + method: "POST", + headers: { + "content-type": "application/json", + "x-application-value": "before", + }, + body: '{"ok":true}', + }); + const application = createApplicationRequest(host); + + application.headers.set("x-application-value", "after"); + assertEquals(application.headers.get("x-application-value"), "after"); + assertEquals(host.headers.get("x-application-value"), "before"); + assertEquals(await application.text(), '{"ok":true}'); + assertEquals(await host.text(), '{"ok":true}'); + }); +}); diff --git a/src/security/http/application-request.ts b/src/security/http/application-request.ts new file mode 100644 index 0000000000..72618dce68 --- /dev/null +++ b/src/security/http/application-request.ts @@ -0,0 +1,83 @@ +/** + * Headers used only between trusted Veryfront infrastructure components. + * + * These values must remain available to the host request pipeline, but they are + * never part of the application-facing HTTP contract. In particular, `x-token` + * may contain a service, static platform, or preview-user credential injected by + * the proxy for project filesystem access. + */ +const apply = Reflect.apply; +const NativeHeaders = Headers; +const NativeRequest = Request; +const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const headersAppend = NativeHeaders.prototype.append; +const headersForEach = NativeHeaders.prototype.forEach; +const requestClone = NativeRequest.prototype.clone; +const requestHeadersGetter = getOwnPropertyDescriptor( + NativeRequest.prototype, + "headers", +)?.get; +const stringToLowerCase = String.prototype.toLowerCase; +const stringStartsWith = String.prototype.startsWith; + +if (typeof requestHeadersGetter !== "function") { + throw new TypeError("Request.prototype.headers getter is unavailable"); +} + +export function isInfrastructureOnlyRequestHeader(name: string): boolean { + const normalized = apply(stringToLowerCase, name, []) as string; + if (apply(stringStartsWith, normalized, ["x-veryfront-"]) as boolean) { + return true; + } + if ( + apply(stringStartsWith, normalized, ["x-forwarded-"]) as boolean || + apply(stringStartsWith, normalized, ["x-project-"]) as boolean || + apply(stringStartsWith, normalized, ["x-branch-"]) as boolean + ) { + return true; + } + switch (normalized) { + case "cf-connecting-ip": + case "fastly-client-ip": + case "forwarded": + case "proxy-authorization": + case "true-client-ip": + case "x-authoritative": + case "x-content-source-id": + case "x-environment": + case "x-environment-id": + case "x-real-ip": + case "x-release-id": + case "x-token": + return true; + default: + return false; + } +} + +/** Copy only application-owned headers across the project-code boundary. */ +export function createApplicationRequestHeaders(headers: Headers): Headers { + const applicationHeaders = new NativeHeaders(); + apply(headersForEach, headers, [ + (value: string, name: string) => { + if (!isInfrastructureOnlyRequestHeader(name)) { + apply(headersAppend, applicationHeaders, [name, value]); + } + }, + ]); + return applicationHeaders; +} + +/** + * Detach a Request before exposing it to project-controlled code. + * + * Cloning first preserves the host-owned request body for later framework + * processing while giving project code an independent header list. + */ +export function createApplicationRequest(request: Request): Request { + const cloned = apply(requestClone, request, []) as Request; + const headers = apply(requestHeadersGetter!, cloned, []) as Headers; + return new NativeRequest(cloned, { + headers: createApplicationRequestHeaders(headers), + }); +} diff --git a/src/security/http/outbound-fetch.test.ts b/src/security/http/outbound-fetch.test.ts new file mode 100644 index 0000000000..5b98b6d8ae --- /dev/null +++ b/src/security/http/outbound-fetch.test.ts @@ -0,0 +1,159 @@ +import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { + guardedEgressFetch, + WorkerEgressBlockedError, +} from "#veryfront/security/sandbox/worker-egress-guard.ts"; +import { createOutboundFetchBoundary, OutboundRequestBlockedError } from "./outbound-fetch.ts"; + +function createTestBoundary(fetchImpl: typeof fetch) { + return createOutboundFetchBoundary({ + fetch: fetchImpl, + pinnedFetch: (url, _addresses, init) => fetchImpl(url, init), + }); +} + +describe("guardedOutboundFetch", () => { + it("rejects loopback and cloud metadata before invoking fetch", async () => { + let calls = 0; + const fetchImpl: typeof fetch = () => { + calls++; + return Promise.resolve(new Response("unexpected")); + }; + const boundary = createTestBoundary(fetchImpl); + + await assertRejects( + () => boundary.guardedFetch("http://127.0.0.1/private"), + OutboundRequestBlockedError, + "internal host", + ); + await assertRejects( + () => boundary.guardedFetch("http://169.254.169.254/metadata"), + OutboundRequestBlockedError, + "internal host", + ); + assertEquals(calls, 0); + }); + + it("rejects non-HTTP schemes and URL credentials", async () => { + let calls = 0; + const fetchImpl: typeof fetch = () => { + calls++; + return Promise.resolve(new Response("unexpected")); + }; + const boundary = createTestBoundary(fetchImpl); + await assertRejects( + () => boundary.guardedFetch("file:///private/config"), + OutboundRequestBlockedError, + "unsupported URL scheme", + ); + await assertRejects( + () => boundary.guardedFetch("https://user:secret@93.184.216.34/"), + OutboundRequestBlockedError, + "URL credentials are not allowed", + ); + assertEquals(calls, 0); + }); + + it("rejects a public hostname whose DNS answer is private", async () => { + let calls = 0; + await assertRejects( + () => + guardedEgressFetch("https://public.example/resource", undefined, { + fetchImpl: () => { + calls++; + return Promise.resolve(new Response("unexpected")); + }, + options: { resolveHost: () => Promise.resolve(["10.0.0.8"]) }, + }), + WorkerEgressBlockedError, + "blocked for host", + ); + assertEquals(calls, 0); + }); + + it("applies caller authorization to every redirect destination", async () => { + const seen: string[] = []; + const fetchImpl: typeof fetch = (input) => { + const url = String(input); + if (url.endsWith("/start")) { + return Promise.resolve( + new Response(null, { + status: 302, + headers: { location: "https://93.184.216.35/next" }, + }), + ); + } + return Promise.resolve(new Response("unexpected")); + }; + const boundary = createTestBoundary(fetchImpl); + + await assertRejects( + () => + boundary.guardedFetch("https://93.184.216.34/start", undefined, { + authorizeUrl(url) { + seen.push(url.href); + if (url.hostname !== "93.184.216.34") { + throw new OutboundRequestBlockedError("origin is not allowed"); + } + }, + }), + OutboundRequestBlockedError, + "origin is not allowed", + ); + assertEquals(seen, [ + "https://93.184.216.34/start", + "https://93.184.216.35/next", + ]); + }); + + it("preserves Request input semantics for origin-bound provider transports", async () => { + let captured: Request | undefined; + const fetchImpl: typeof fetch = async (input, init) => { + captured = new Request(input, init); + return Response.json({ ok: true }); + }; + const request = new Request("https://93.184.216.34/v1/messages", { + method: "POST", + headers: { "x-api-key": "provider-secret", "content-type": "application/json" }, + body: '{"message":"hello"}', + }); + + const providerFetch = createTestBoundary(fetchImpl).createOriginBoundFetch( + "https://93.184.216.34/v1", + ); + const response = await providerFetch(request); + + assertEquals(response.status, 200); + assertEquals(captured?.method, "POST"); + assertEquals(captured?.headers.get("x-api-key"), "provider-secret"); + assertEquals(await captured?.text(), '{"message":"hello"}'); + }); + + it("rejects provider redirects before API-key credentials can leave the origin", async () => { + let calls = 0; + const fetchImpl: typeof fetch = () => { + calls++; + return Promise.resolve( + new Response(null, { + status: 307, + headers: { location: "https://93.184.216.35/collect" }, + }), + ); + }; + const providerFetch = createTestBoundary(fetchImpl).createOriginBoundFetch( + "https://93.184.216.34/v1", + ); + await assertRejects( + () => + providerFetch("https://93.184.216.34/v1/messages", { + method: "POST", + headers: { "x-api-key": "provider-secret" }, + body: "payload", + }), + OutboundRequestBlockedError, + "unexpected redirect", + ); + assertEquals(calls, 1); + }); +}); diff --git a/src/security/http/outbound-fetch.ts b/src/security/http/outbound-fetch.ts new file mode 100644 index 0000000000..1001dc11a1 --- /dev/null +++ b/src/security/http/outbound-fetch.ts @@ -0,0 +1,210 @@ +/** + * Host-owned outbound HTTP boundary. + * + * Tenant-controlled URLs must use this transport instead of calling the host + * `fetch` directly. It reuses the DNS-pinned sandbox transport so validation + * and connection establishment cannot be separated by a DNS-rebinding window. + */ + +import { getHostEnv } from "#veryfront/platform/compat/process.ts"; +import { + guardedEgressFetch, + isInternalEgressOverrideEnabled, + WorkerEgressBlockedError, + type WorkerEgressFetch, + type WorkerEgressPinnedFetch, +} from "#veryfront/security/sandbox/worker-egress-guard.ts"; + +export const HOST_INTERNAL_EGRESS_OVERRIDE_ENV = "VERYFRONT_HOST_ALLOW_INTERNAL_EGRESS"; + +export class OutboundRequestBlockedError extends Error { + override name = "OutboundRequestBlockedError"; +} + +export interface GuardedOutboundFetchOptions { + /** Additional operator-owned URL policy, applied to every redirect hop. */ + authorizeUrl?: (url: URL) => void | Promise; +} + +/** Host-owned transport primitives used after outbound policy validation. */ +export interface OutboundFetchTransport { + fetch: WorkerEgressFetch; + pinnedFetch?: WorkerEgressPinnedFetch; +} + +/** Explicit host transport boundary used by runtime composition and tests. */ +export interface OutboundFetchBoundary { + guardedFetch( + input: RequestInfo | URL, + init?: RequestInit, + options?: GuardedOutboundFetchOptions, + ): Promise; + createOriginBoundFetch(baseUrl: string): typeof fetch; +} + +// Capture the host transport before tenant code can replace globalThis.fetch. +const capturedHostFetch = globalThis.fetch.bind(globalThis); + +function getTrustedHostTransport(): OutboundFetchTransport { + if (getHostEnv("DENO_TESTING") !== "1") { + // Omitting pinnedFetch is deliberate: Node and Bun then use the native + // address-pinned transport, while Deno uses its pinned SOCKS client. + return { fetch: capturedHostFetch }; + } + + // Tests explicitly opt into their current deterministic fetch replacement. + // The pinned seam receives only addresses that the egress guard validated, + // and production never selects this transport. + const fetchImpl = globalThis.fetch.bind(globalThis); + return { + fetch: fetchImpl, + pinnedFetch: (url, _addresses, init) => fetchImpl(url, init), + }; +} + +async function fetchWithHostTransport( + input: RequestInfo | URL, + init: RequestInit | undefined, + options: GuardedOutboundFetchOptions, + transport: OutboundFetchTransport, +): Promise { + return await guardedEgressFetch(input, init, { + fetchImpl: transport.fetch, + pinnedFetch: transport.pinnedFetch, + authorizeUrl: async (url) => { + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new OutboundRequestBlockedError( + `Outbound request blocked: unsupported URL scheme ${url.protocol}`, + ); + } + if (url.username.length > 0 || url.password.length > 0) { + throw new OutboundRequestBlockedError( + "Outbound request blocked: URL credentials are not allowed", + ); + } + await options.authorizeUrl?.(url); + }, + options: { + allowInternalEgress: isInternalEgressOverrideEnabled( + getHostEnv(HOST_INTERNAL_EGRESS_OVERRIDE_ENV), + ), + }, + }); +} + +function snapshotOutboundFetchTransport( + transport: OutboundFetchTransport, +): Readonly { + if (typeof transport.fetch !== "function") { + throw new TypeError("Outbound transport fetch must be a function"); + } + if (transport.pinnedFetch !== undefined && typeof transport.pinnedFetch !== "function") { + throw new TypeError("Outbound pinned transport must be a function"); + } + return Object.freeze({ + fetch: transport.fetch, + pinnedFetch: transport.pinnedFetch, + }); +} + +async function fetchWithBoundaryErrors( + input: RequestInfo | URL, + init: RequestInit | undefined, + options: GuardedOutboundFetchOptions, + transport: OutboundFetchTransport, +): Promise { + try { + return await fetchWithHostTransport(input, init, options, transport); + } catch (error) { + if (error instanceof WorkerEgressBlockedError) { + throw new OutboundRequestBlockedError( + error.message.replace(/^Worker\s+/u, "Outbound "), + { cause: error }, + ); + } + throw error; + } +} + +function createOriginBoundFetchWithTransport( + baseUrl: string, + transport: OutboundFetchTransport, +): typeof fetch { + const base = new URL(baseUrl); + if (base.protocol !== "http:" && base.protocol !== "https:") { + throw new TypeError("Provider base URL must use http: or https:"); + } + if (base.username || base.password) { + throw new TypeError("Provider base URL must not include credentials"); + } + + return async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const raw = input instanceof Request ? input.url : input instanceof URL ? input.href : input; + const target = new URL(raw, base); + // Keep a Request input intact so provider SDKs do not lose its method, + // headers, body, signal, or other request-level semantics at this boundary. + const guardedInput: RequestInfo | URL = input instanceof Request ? input : target; + return await fetchWithBoundaryErrors( + guardedInput, + { ...init, redirect: "error" }, + { + authorizeUrl(url) { + if (url.origin !== base.origin) { + throw new OutboundRequestBlockedError( + "Provider request blocked: destination origin is not authorized", + ); + } + }, + }, + transport, + ); + }; +} + +/** + * Create an outbound boundary from explicit host-owned transport primitives. + * + * @internal Runtime composition and deterministic tests use this seam. The + * default exports below never source their production transport from it. + */ +export function createOutboundFetchBoundary( + transport: OutboundFetchTransport, +): OutboundFetchBoundary { + const captured = snapshotOutboundFetchTransport(transport); + return Object.freeze({ + guardedFetch( + input: RequestInfo | URL, + init?: RequestInit, + options: GuardedOutboundFetchOptions = {}, + ): Promise { + return fetchWithBoundaryErrors(input, init, options, captured); + }, + createOriginBoundFetch(baseUrl: string): typeof fetch { + return createOriginBoundFetchWithTransport(baseUrl, captured); + }, + }); +} + +/** + * Fetch an HTTP resource through the host egress ceiling. + * + * Internal destinations are denied by default. Only the host process can + * enable the explicit override; project environment overlays cannot change + * `getHostEnv()`. + */ +export async function guardedOutboundFetch( + input: RequestInfo | URL, + init?: RequestInit, + options: GuardedOutboundFetchOptions = {}, +): Promise { + return await fetchWithBoundaryErrors(input, init, options, getTrustedHostTransport()); +} + +/** + * Create a credential-safe provider transport bound to one configured origin. + * Redirects are rejected rather than followed so provider-specific credential + * headers (for example `x-api-key`) can never cross an origin boundary. + */ +export function createOriginBoundOutboundFetch(baseUrl: string): typeof fetch { + return createOriginBoundFetchWithTransport(baseUrl, getTrustedHostTransport()); +} diff --git a/src/security/project-locality.test.ts b/src/security/project-locality.test.ts new file mode 100644 index 0000000000..50fdd16f3f --- /dev/null +++ b/src/security/project-locality.test.ts @@ -0,0 +1,84 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { isSharedProjectRuntime } from "./project-locality.ts"; + +describe("security/project-locality shared runtime topology", () => { + it("recognizes hosted-config and multi-project runtime boundaries", () => { + assertEquals( + isSharedProjectRuntime({ prepareHostedConfigContext: () => undefined }), + true, + ); + assertEquals( + isSharedProjectRuntime({ + adapter: { fs: { isMultiProjectMode: () => true } }, + }), + true, + ); + assertEquals( + isSharedProjectRuntime({ + adapter: { fs: { isMultiProjectMode: () => false } }, + }), + false, + ); + }); + + it("recognizes production-style prototype methods", () => { + class MultiProjectFs { + isMultiProjectMode(): boolean { + return true; + } + } + class SingleProjectFs { + isMultiProjectMode(): boolean { + return false; + } + } + + assertEquals( + isSharedProjectRuntime({ adapter: { fs: new MultiProjectFs() } }), + true, + ); + assertEquals( + isSharedProjectRuntime({ adapter: { fs: new SingleProjectFs() } }), + false, + ); + }); + + it("fails closed when a declared topology signal throws", () => { + assertEquals( + isSharedProjectRuntime({ + adapter: { + fs: { + isMultiProjectMode: () => { + throw new Error("topology unavailable"); + }, + }, + }, + }), + true, + ); + }); + + it("fails closed for accessor-backed, malformed, and ambiguous signals", () => { + const accessorBacked = Object.create(null); + Object.defineProperty(accessorBacked, "isMultiProjectMode", { + get: () => () => false, + }); + + assertEquals( + isSharedProjectRuntime({ adapter: { fs: accessorBacked } }), + true, + ); + assertEquals( + isSharedProjectRuntime({ adapter: { fs: { isMultiProjectMode: true } } }), + true, + ); + assertEquals( + isSharedProjectRuntime({ + adapter: { fs: { isMultiProjectMode: () => undefined } }, + }), + true, + ); + }); +}); diff --git a/src/security/project-locality.ts b/src/security/project-locality.ts index f8040658f9..27548ace28 100644 --- a/src/security/project-locality.ts +++ b/src/security/project-locality.ts @@ -1,7 +1,63 @@ const apply = Reflect.apply; const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const getPrototypeOf = Object.getPrototypeOf; const objectPrototypeHasOwnProperty = Object.prototype.hasOwnProperty; +type DataMethodLookup = + | { readonly kind: "absent" } + | { readonly kind: "invalid" } + | { readonly kind: "method"; readonly method: (...args: never[]) => unknown }; + +/** + * Resolve a data-property method without invoking accessors or ordinary + * property lookup traps. Class methods live on prototypes, so restricting this + * lookup to own properties would miss the production FSAdapterWrapper. + */ +function findDataMethod(value: unknown, key: PropertyKey): DataMethodLookup { + if ( + (typeof value !== "object" || value === null) && + typeof value !== "function" + ) { + return { kind: "absent" }; + } + + let owner: object | null = value as object; + for (let depth = 0; owner !== null && depth < 64; depth++) { + if (owner === Object.prototype) return { kind: "absent" }; + + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = apply(getOwnPropertyDescriptor, undefined, [ + owner, + key, + ]) as PropertyDescriptor | undefined; + } catch { + return { kind: "invalid" }; + } + + if (descriptor !== undefined) { + if ( + !apply(objectPrototypeHasOwnProperty, descriptor, ["value"]) || + typeof descriptor.value !== "function" + ) { + return { kind: "invalid" }; + } + return { + kind: "method", + method: descriptor.value as (...args: never[]) => unknown, + }; + } + + try { + owner = apply(getPrototypeOf, undefined, [owner]) as object | null; + } catch { + return { kind: "invalid" }; + } + } + + return owner === null ? { kind: "absent" } : { kind: "invalid" }; +} + /** * Read an own data property without invoking project-owned accessors. Missing, * inherited, accessor-backed, revoked, and throwing-descriptor values are @@ -61,3 +117,33 @@ export function isExplicitHostProjectCodeExecutionAllowed( ): boolean { return readOwnDataProperty(value, "allowHostProjectCodeExecution") === true; } + +/** + * Identify a shared multi-project/proxy runtime from host-owned context. + * + * This is deliberately independent from `isLocalProject`: a dedicated + * single-project runtime may use production source while still being allowed + * to execute that one project's code. Shared runtimes are identified by their + * hosted-config preparation boundary or an adapter that explicitly reports + * multi-project mode. + */ +export function isSharedProjectRuntime(value: unknown): boolean { + if (readOwnDataProperty(value, "prepareHostedConfigContext") !== undefined) { + return true; + } + + const adapter = readOwnDataProperty(value, "adapter"); + const fs = readOwnDataProperty(adapter, "fs"); + const lookup = findDataMethod(fs, "isMultiProjectMode"); + if (lookup.kind === "absent") return false; + if (lookup.kind === "invalid") return true; + + try { + const result = apply(lookup.method, fs, []); + return result === false ? false : true; + } catch { + // An ambiguous or broken topology signal must never unlock shared host + // execution. Callers can still fail closed through their locality guard. + return true; + } +} diff --git a/src/security/sandbox/project-worker.test.ts b/src/security/sandbox/project-worker.test.ts index 745257cbe0..6abc1fb9bf 100644 --- a/src/security/sandbox/project-worker.test.ts +++ b/src/security/sandbox/project-worker.test.ts @@ -1,14 +1,43 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assert, assertEquals, assertExists } from "#veryfront/testing/assert.ts"; +import { + assert, + assertEquals, + assertExists, + assertRejects, + assertThrows, +} from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { isDeno } from "#veryfront/platform/compat/runtime.ts"; import { ProjectWorker } from "./project-worker.ts"; import { buildWorkerPermissions } from "./worker-permissions.ts"; import type { WorkerPermissions } from "./worker-permissions.ts"; +import { + MAX_WORKER_REQUEST_ID_CHARS, + MAX_WORKER_SSR_CHUNK_BYTES, + MAX_WORKER_SSR_OUTPUT_BYTES, + MAX_WORKER_SSR_OUTPUT_CHUNKS, +} from "./worker-types.ts"; import { WORKER_INTERNAL_EGRESS_OVERRIDE_ENV } from "./worker-egress-guard.ts"; +import type { WorkerEgressBroker } from "./worker-egress-guard.ts"; +import { computeHash } from "#veryfront/utils"; +import { SERVICE_OVERLOADED, VeryfrontError } from "#veryfront/errors"; +import { validateDataResult } from "#veryfront/data/helpers.ts"; +import { fromFileUrl, toFileUrl } from "#veryfront/compat/path"; const testSuite = isDeno ? describe : describe.skip; const TEST_SOURCE_INTEGRATION_POLICY = { schemaVersion: 1, mode: "unrestricted" } as const; +const TEST_EMPTY_MODULE_SOURCE = "export {};"; +const TEST_EMPTY_PREPARED_MODULE = { + source: TEST_EMPTY_MODULE_SOURCE, + sha256: await computeHash(TEST_EMPTY_MODULE_SOURCE), +}; +const TEST_ISOLATED_SSR_RENDERER_MODULE_URL = new URL( + "../../../extensions/ext-react-ssr/src/worker-renderer.ts", + import.meta.url, +).href; +const TEST_ISOLATED_SSR_RENDERER_READ_PATHS = [ + fromFileUrl(new URL("../../../extensions/ext-react-ssr/src/", import.meta.url)), +]; const TEST_PERMISSIONS: WorkerPermissions = { read: true, @@ -18,16 +47,18 @@ const TEST_PERMISSIONS: WorkerPermissions = { run: false, ffi: false, sys: false, + import: false, }; const REAL_WORKER_PERMISSIONS: WorkerPermissions = { read: true, write: false, net: false, - env: [], + env: false, run: false, ffi: false, sys: false, + import: false, }; const TEST_WORKER_SCRIPT_URL = `data:application/typescript,${ @@ -38,7 +69,9 @@ const TEST_WORKER_SCRIPT_URL = `data:application/typescript,${ self.postMessage({ type: "pong", id: msg.id }); return; } + if (msg.type === "ssr-execution-open" || msg.type === "stream-credit") return; if (msg.type === "clear-cache") return; + if (msg.type === "render-ssr") return; self.postMessage({ type: "error", id: msg.id, @@ -53,14 +86,201 @@ function createTestWorker(projectId = "test-project"): ProjectWorker { projectId, permissions: TEST_PERMISSIONS, requestTimeoutMs: 5_000, + allowInternalEgress: false, workerScriptUrl: TEST_WORKER_SCRIPT_URL, }); } +function createScriptedWorker( + projectId: string, + script: string, + requestTimeoutMs = 5_000, +): ProjectWorker { + return new ProjectWorker({ + projectId, + permissions: TEST_PERMISSIONS, + requestTimeoutMs, + allowInternalEgress: false, + workerScriptUrl: `data:application/typescript,${encodeURIComponent(script)}`, + }); +} + +function createSSRScriptedWorker( + projectId: string, + behavior: string, + requestTimeoutMs = 5_000, +): ProjectWorker { + return createScriptedWorker( + projectId, + ` + // @ts-nocheck + const opens = new Map(); + const send = (open, type, sequence, extra = {}) => { + self.postMessage({ + type, + id: open.id, + generation: open.generation, + token: open.token, + sequence, + ...extra, + }); + }; + self.onmessage = (event) => { + const message = event.data; + if (message.type === "ping") { + self.postMessage({ type: "pong", id: message.id }); + return; + } + if (message.type === "ssr-execution-open") { + opens.set(message.id, message); + return; + } + const open = opens.get(message.id); + ${behavior} + }; + `, + requestTimeoutMs, + ); +} + +function createProductionSSRWorker( + projectId: string, + projectDir: string, +): ProjectWorker { + return new ProjectWorker({ + projectId, + permissions: buildWorkerPermissions([ + projectDir, + ...TEST_ISOLATED_SSR_RENDERER_READ_PATHS, + ]), + requestTimeoutMs: 30_000, + allowInternalEgress: false, + isolatedSsrRendererModuleUrl: TEST_ISOLATED_SSR_RENDERER_MODULE_URL, + }); +} + async function assertWorkerReady(worker: ProjectWorker): Promise { assertEquals(await worker.isHealthy(30_000), true); } +async function collectTightStream( + stream: ReadableStream, +): Promise { + const reader = stream.getReader(); + const frames: Uint8Array[] = []; + let total = 0; + try { + while (true) { + const result = await reader.read(); + if (result.done) break; + const frame = result.value; + assert(frame.byteLength > 0); + assert(frame.byteLength <= MAX_WORKER_SSR_CHUNK_BYTES); + assertEquals(frame.byteOffset, 0); + assert(frame.buffer instanceof ArrayBuffer); + assertEquals(frame.buffer.byteLength, frame.byteLength); + const resizable = Object.getOwnPropertyDescriptor( + ArrayBuffer.prototype, + "resizable", + )?.get; + if (resizable) { + assertEquals(Reflect.apply(resizable, frame.buffer, []), false); + } + frames.push(frame); + total += frame.byteLength; + } + } finally { + reader.releaseLock(); + } + + const bytes = new Uint8Array(total); + let offset = 0; + for (const frame of frames) { + bytes.set(frame, offset); + offset += frame.byteLength; + } + return bytes; +} + +async function waitForWorkerStatus( + worker: ProjectWorker, + status: "idle" | "busy" | "crashed" | "terminated", + timeoutMs = 1_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (worker.status !== status && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + assertEquals(worker.status, status); +} + +function makeScriptedSSRRequest( + id: string, + delivery: "string" | "stream" = "stream", +) { + return { + type: "render-ssr" as const, + id, + pageModulePath: "/nonexistent.ts", + layoutModulePaths: [], + pageProps: {}, + layoutProps: [], + delivery, + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + }; +} + +async function prepareModulePath(modulePath: string) { + const source = await Deno.readTextFile(modulePath); + return { source, sha256: await computeHash(source) }; +} + +async function executeIsolatedDataModule(source: string, id: string) { + const projectDir = await Deno.makeTempDir(); + const modulePath = await Deno.makeTempFile({ dir: projectDir, suffix: ".mjs" }); + await Deno.writeTextFile(modulePath, source); + const worker = new ProjectWorker({ + projectId: `test-data-result-${id}`, + permissions: buildWorkerPermissions([projectDir]), + requestTimeoutMs: 10_000, + allowInternalEgress: false, + }); + worker.start(); + + try { + await assertWorkerReady(worker); + return await worker.execute({ + type: "fetch-data", + id, + modulePath, + context: { + params: {}, + query: "", + request: { + url: "http://localhost/data", + method: "GET", + headers: [], + body: null, + }, + url: "http://localhost/data", + }, + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + }); + } finally { + worker.terminate(); + await Deno.remove(projectDir, { recursive: true }); + } +} + +function assertInvalidIsolatedDataResult( + response: Awaited>, +): void { + assertEquals(response.type, "error"); + if (response.type !== "error") throw new Error("expected error response"); + assertEquals(response.error.name, "TypeError"); + assert(response.error.message.includes("Invalid isolated data result")); +} + testSuite("ProjectWorker", () => { it("starts in idle state after start()", () => { const worker = createTestWorker(); @@ -92,6 +312,48 @@ testSuite("ProjectWorker", () => { assertEquals(worker.status, "terminated"); }); + it("shutdown is single-flight and waits for stalled broker work", async () => { + const worker = createTestWorker("test-quiescent-shutdown"); + worker.start(); + const brokerCompletion = Promise.withResolvers(); + let closeCalls = 0; + const broker: WorkerEgressBroker = { + config: { + socksProxy: { + hostname: "127.0.0.1", + port: 1, + username: "test", + password: "test", + }, + httpBroker: { url: "http://127.0.0.1:1/fetch", token: "test" }, + netAllowlist: ["127.0.0.1:1"], + }, + close() { + closeCalls++; + }, + closed: brokerCompletion.promise, + }; + (worker as unknown as { egressBroker: WorkerEgressBroker | null }).egressBroker = broker; + + const first = worker.shutdown(); + const second = worker.shutdown(); + assert(first === second); + assertEquals(worker.status, "terminated"); + assertEquals(closeCalls, 1); + + let settled = false; + void first.then(() => { + settled = true; + }); + await Promise.resolve(); + assertEquals(settled, false); + + brokerCompletion.resolve(); + await first; + assertEquals(settled, true); + assertEquals(closeCalls, 1); + }); + it("responds to health check", async () => { const worker = createTestWorker(); worker.start(); @@ -133,11 +395,134 @@ testSuite("ProjectWorker", () => { assertEquals(worker.projectId, "test-project"); }); + it("snapshots permissions at construction so later mutation cannot broaden the worker", async () => { + const secretPath = await Deno.makeTempFile(); + await Deno.writeTextFile(secretPath, "permission-snapshot-secret"); + const mutableRead: string[] = []; + const mutablePermissions: WorkerPermissions = { + read: mutableRead, + write: false, + net: false, + env: false, + run: false, + ffi: false, + sys: false, + import: false, + }; + const worker = new ProjectWorker({ + projectId: "test-permission-snapshot", + permissions: mutablePermissions, + requestTimeoutMs: 5_000, + allowInternalEgress: false, + workerScriptUrl: `data:application/typescript,${ + encodeURIComponent(` + self.onmessage = async (event) => { + const message = event.data; + if (message.type !== "ping") return; + let broadened = false; + try { + await Deno.readTextFile(${JSON.stringify(secretPath)}); + broadened = true; + } catch { + // Expected: the construction-time empty read scope is immutable. + } + self.postMessage({ + type: broadened ? "permission-broadened" : "pong", + id: message.id, + }); + }; + `) + }`, + }); + + mutableRead.push(secretPath); + mutablePermissions.read = true; + mutablePermissions.net = true; + + try { + worker.start(); + assertEquals(await worker.isHealthy(5_000), true); + } finally { + await worker.shutdown(); + await Deno.remove(secretPath); + } + }); + + it("canonicalizes and freezes permission arrays without retaining caller storage", () => { + const source = ["/project/b", "/project/a", "/project/b"]; + const worker = new ProjectWorker({ + projectId: "test-permission-canonicalization", + permissions: { ...TEST_PERMISSIONS, read: source }, + requestTimeoutMs: 5_000, + allowInternalEgress: false, + workerScriptUrl: TEST_WORKER_SCRIPT_URL, + }); + const captured = (worker as unknown as { + permissions: Readonly; + }).permissions; + + source.push("/project/c"); + assertEquals(captured.read, ["/project/a", "/project/b"]); + assertEquals(Object.isFrozen(captured), true); + assertEquals(Object.isFrozen(captured.read), true); + }); + + it("rejects hostile permission accessors without invoking them", () => { + let getterCalls = 0; + const permissions = Object.defineProperty( + { ...TEST_PERMISSIONS }, + "read", + { + enumerable: true, + get() { + getterCalls++; + return true; + }, + }, + ); + + assertThrows( + () => + new ProjectWorker({ + projectId: "test-permission-accessor", + permissions, + requestTimeoutMs: 5_000, + allowInternalEgress: false, + }), + TypeError, + "read must be an enumerable data property", + ); + assertEquals(getterCalls, 0); + }); + + it("rejects non-enumerable permission array entries", () => { + const read = ["/project/a"]; + Object.defineProperty(read, "0", { + configurable: true, + enumerable: false, + value: "/project/a", + writable: true, + }); + + assertThrows( + () => + new ProjectWorker({ + projectId: "test-permission-array-enumerability", + permissions: { ...TEST_PERMISSIONS, read }, + requestTimeoutMs: 5_000, + allowInternalEgress: false, + }), + TypeError, + "read contains a noncanonical entry", + ); + }); + it("rejects unrestricted network access for custom worker scripts", () => { const worker = new ProjectWorker({ projectId: "test-custom-worker-network", permissions: { ...TEST_PERMISSIONS, net: true }, requestTimeoutMs: 5_000, + allowInternalEgress: false, workerScriptUrl: TEST_WORKER_SCRIPT_URL, }); let startupError: unknown; @@ -153,9 +538,139 @@ testSuite("ProjectWorker", () => { "Custom project worker scripts cannot use unrestricted network permissions", ); }); + + it("rejects invalid request timeouts before starting a worker", () => { + for ( + const requestTimeoutMs of [ + 0, + -1, + 1.5, + Number.NaN, + Number.POSITIVE_INFINITY, + ] + ) { + assertThrows( + () => + new ProjectWorker({ + projectId: "invalid-timeout", + permissions: TEST_PERMISSIONS, + requestTimeoutMs, + allowInternalEgress: false, + }), + Error, + "requestTimeoutMs must be a positive safe integer", + ); + } + }); + + it("requires an explicit host-owned internal-egress decision", () => { + assertThrows( + () => + new ProjectWorker({ + projectId: "missing-internal-egress-policy", + permissions: TEST_PERMISSIONS, + requestTimeoutMs: 5_000, + allowInternalEgress: undefined as unknown as boolean, + }), + TypeError, + "allowInternalEgress must be a boolean", + ); + }); }); testSuite("ProjectWorker - error handling", () => { + it("cancels an uncaught child error and retires its pending generation once", async () => { + const worker = createScriptedWorker( + "test-uncaught-child-error", + ` + self.onmessage = (event) => { + if (event.data.type === "ping") { + self.postMessage({ type: "pong", id: event.data.id }); + return; + } + queueMicrotask(() => { + throw new Error("uncaught child worker failure"); + }); + }; + `, + ); + let idleNotifications = 0; + let rejections = 0; + const unsubscribe = worker.onIdle(() => idleNotifications++); + worker.start(); + + try { + const error = await worker.execute({ + type: "execute-app-route", + id: "uncaught-child-error", + module: TEST_EMPTY_PREPARED_MODULE, + modulePath: "/project/route.ts", + method: "GET", + request: { + url: "http://localhost/api/test", + method: "GET", + headers: [], + body: null, + }, + params: {}, + projectDir: "/project", + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + }).then( + () => undefined, + (cause: unknown) => { + rejections++; + return cause; + }, + ); + + assert(error instanceof Error); + await waitForWorkerStatus(worker, "crashed"); + await new Promise((resolve) => setTimeout(resolve, 25)); + assertEquals(worker.hasPendingRequests, false); + assertEquals(rejections, 1); + assertEquals(idleNotifications, 1); + } finally { + unsubscribe(); + worker.terminate(); + } + }); + + it("rejects malformed request ids before worker protocol admission", async () => { + const worker = createTestWorker("test-invalid-request-id"); + worker.start(); + try { + for (const id of ["", "x".repeat(MAX_WORKER_REQUEST_ID_CHARS + 1)]) { + await assertRejects( + () => + worker.execute({ + type: "execute-app-route", + id, + module: TEST_EMPTY_PREPARED_MODULE, + modulePath: "/project/route.ts", + method: "GET", + request: { + url: "http://localhost/api/test", + method: "GET", + headers: [], + body: null, + }, + params: {}, + projectDir: "/project", + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + }), + Error, + "Worker request id must be a non-empty string", + ); + } + + assertEquals(worker.hasPendingRequests, false); + assertEquals(worker.requestCount, 0); + assertEquals(worker.status, "idle"); + } finally { + worker.terminate(); + } + }); + it("rejects execute when worker is not started", async () => { const worker = createTestWorker(); @@ -163,6 +678,7 @@ testSuite("ProjectWorker - error handling", () => { await worker.execute({ type: "execute-app-route", id: "test-id", + module: TEST_EMPTY_PREPARED_MODULE, modulePath: "/nonexistent.ts", method: "GET", request: { @@ -180,15 +696,147 @@ testSuite("ProjectWorker - error handling", () => { assertExists(error); } }); + + it("cleans pending state and retires the worker on synchronous clone failure", async () => { + const worker = createTestWorker("test-clone-failure"); + worker.start(); + try { + const rejected = await worker.execute({ + type: "execute-app-route", + id: "invalid-clone", + module: { + source: "export function GET() {}", + sha256: "0".repeat(64), + }, + modulePath: "/project/route.ts", + method: "GET", + request: { + url: "http://localhost/api/test", + method: "GET", + headers: [], + body: null, + }, + params: { + invalid: (() => undefined) as unknown as string, + }, + projectDir: "/project", + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + }).then( + () => false, + () => true, + ); + + assertEquals(rejected, true); + assertEquals(worker.hasPendingRequests, false); + assertEquals(worker.status, "crashed"); + } finally { + worker.terminate(); + } + }); + + it("rejects existing requests when a later send proves the channel unusable", async () => { + const worker = createTestWorker("test-clone-failure-concurrent"); + worker.start(); + try { + const hanging = worker.execute({ + type: "render-ssr", + id: "hanging", + pageModulePath: "/project/page.ts", + layoutModulePaths: [], + pageProps: {}, + layoutProps: [], + delivery: "string", + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + }); + const invalid = worker.execute({ + type: "execute-app-route", + id: "invalid-clone", + module: { + source: "export function GET() {}", + sha256: "0".repeat(64), + }, + modulePath: "/project/route.ts", + method: "GET", + request: { + url: "http://localhost/api/test", + method: "GET", + headers: [], + body: null, + }, + params: { + invalid: (() => undefined) as unknown as string, + }, + projectDir: "/project", + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + }); + + const results = await Promise.allSettled([hanging, invalid]); + assertEquals(results[0]?.status, "rejected"); + assertEquals(results[1]?.status, "rejected"); + assertEquals(worker.hasPendingRequests, false); + assertEquals(worker.status, "crashed"); + } finally { + worker.terminate(); + } + }); + + it("fatally rejects a response that does not match the pending request type", async () => { + const script = `data:application/typescript,${ + encodeURIComponent(` + self.onmessage = (event) => { + const msg = event.data; + self.postMessage({ type: "data-result", id: msg.id, result: { props: {} } }); + }; + `) + }`; + const worker = new ProjectWorker({ + projectId: "test-response-type-mismatch", + permissions: TEST_PERMISSIONS, + requestTimeoutMs: 5_000, + allowInternalEgress: false, + workerScriptUrl: script, + }); + worker.start(); + try { + const rejected = await worker.execute({ + type: "execute-app-route", + id: "wrong-response", + module: { + source: "export function GET() {}", + sha256: "0".repeat(64), + }, + modulePath: "/project/route.ts", + method: "GET", + request: { + url: "http://localhost/api/test", + method: "GET", + headers: [], + body: null, + }, + params: {}, + projectDir: "/project", + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + }).then( + () => false, + () => true, + ); + + assertEquals(rejected, true); + assertEquals(worker.status, "crashed"); + assertEquals(worker.hasPendingRequests, false); + } finally { + worker.terminate(); + } + }); }); testSuite("ProjectWorker - clearModuleCache", () => { - it("clearModuleCache does not throw on running worker", () => { + it("retires a running worker because ESM modules cannot be evicted in-place", () => { const worker = createTestWorker("test-clear-cache"); worker.start(); try { worker.clearModuleCache(); - assertEquals(worker.status, "idle"); + assertEquals(worker.status, "terminated"); } finally { worker.terminate(); } @@ -260,6 +908,7 @@ testSuite("ProjectWorker - real worker request isolation", () => { projectId: `test-worker-${label}`, permissions: buildWorkerPermissions([projectDir]), requestTimeoutMs: 10_000, + allowInternalEgress: false, }); let timeout: number | undefined; @@ -270,6 +919,7 @@ testSuite("ProjectWorker - real worker request isolation", () => { worker.execute({ type: "execute-app-route", id: `worker-${label}`, + module: await prepareModulePath(modulePath), modulePath, method: "GET", request: { @@ -305,6 +955,7 @@ testSuite("ProjectWorker - real worker request isolation", () => { projectId: "test-unknown-request", permissions: REAL_WORKER_PERMISSIONS, requestTimeoutMs: 10_000, + allowInternalEgress: false, }); worker.start(); @@ -321,8 +972,8 @@ testSuite("ProjectWorker - real worker request isolation", () => { assertEquals(response.type, "error"); if (response.type !== "error") throw new Error("expected error response"); assertEquals(response.id, "unknown"); - assertEquals(response.error.name, "Error"); - assertEquals(response.error.message, "Unknown request type: unknown-request"); + assertEquals(response.error.name, "TypeError"); + assertEquals(response.error.message, "Invalid worker request type"); } finally { worker.terminate(); } @@ -355,6 +1006,7 @@ testSuite("ProjectWorker - real worker request isolation", () => { projectId: "test-synthetic-parent-message", permissions: buildWorkerPermissions([projectDir]), requestTimeoutMs: 10_000, + allowInternalEgress: false, }); worker.start(); @@ -363,6 +1015,7 @@ testSuite("ProjectWorker - real worker request isolation", () => { const response = await worker.execute({ type: "execute-app-route", id: requestId, + module: await prepareModulePath(modulePath), modulePath, method: "GET", request: { @@ -393,6 +1046,7 @@ testSuite("ProjectWorker - real worker request isolation", () => { projectId: "test-missing-source-policy", permissions: REAL_WORKER_PERMISSIONS, requestTimeoutMs: 10_000, + allowInternalEgress: false, }); const projectDir = Deno.cwd(); const modulePath = `${projectDir}/missing-project-module.ts`; @@ -406,6 +1060,7 @@ testSuite("ProjectWorker - real worker request isolation", () => { { type: "execute-app-route", id: "app-route", + module: TEST_EMPTY_PREPARED_MODULE, modulePath, method: "GET", request: serializedRequest, @@ -415,6 +1070,7 @@ testSuite("ProjectWorker - real worker request isolation", () => { { type: "execute-pages-route", id: "pages-route", + module: TEST_EMPTY_PREPARED_MODULE, modulePath, method: "GET", context: { request: serializedRequest, params: {}, cookies: {} }, @@ -431,6 +1087,15 @@ testSuite("ProjectWorker - real worker request isolation", () => { url: serializedRequest.url, }, }, + { + type: "render-ssr", + id: "ssr", + pageModulePath: modulePath, + layoutModulePaths: [], + pageProps: {}, + layoutProps: [], + delivery: "string", + }, ]; worker.start(); @@ -451,24 +1116,46 @@ testSuite("ProjectWorker - real worker request isolation", () => { } }); - it("does not leak projectEnv overlays across requests in the same worker", async () => { + it("passes immutable request env without granting process-global env access", async () => { const projectDir = await Deno.makeTempDir(); const modulePath = await Deno.makeTempFile({ dir: projectDir, suffix: ".mjs" }); + const projectKey = "VERYFRONT_TEST_TENANT_SECRET"; + const previousOverride = Deno.env.get(WORKER_INTERNAL_EGRESS_OVERRIDE_ENV); await Deno.writeTextFile( modulePath, ` - export function GET() { - return Response.json({ value: Deno.env.get("VERYFRONT_TEST_TENANT_SECRET") ?? null }); + export function GET(_request, context) { + let processEnvDenied = false; + try { + Deno.env.set(${JSON.stringify(WORKER_INTERNAL_EGRESS_OVERRIDE_ENV)}, "1"); + } catch { + processEnvDenied = true; + } + + let mutationDenied = false; + try { + context.env[${JSON.stringify(projectKey)}] = "mutated"; + } catch { + mutationDenied = true; + } + + return Response.json({ + value: context.env[${JSON.stringify(projectKey)}] ?? null, + frozen: Object.isFrozen(context.env), + mutationDenied, + processEnvDenied, + }); } `, ); + Deno.env.set(WORKER_INTERNAL_EGRESS_OVERRIDE_ENV, "0"); + const worker = new ProjectWorker({ projectId: "test-env-overlay-scope", - permissions: buildWorkerPermissions([projectDir], { - projectEnvKeys: ["VERYFRONT_TEST_TENANT_SECRET"], - }), + permissions: buildWorkerPermissions([projectDir]), requestTimeoutMs: 10_000, + allowInternalEgress: false, }); worker.start(); @@ -478,6 +1165,7 @@ testSuite("ProjectWorker - real worker request isolation", () => { const first = await worker.execute({ type: "execute-app-route", id: "first", + module: await prepareModulePath(modulePath), modulePath, method: "GET", request: { @@ -489,19 +1177,26 @@ testSuite("ProjectWorker - real worker request isolation", () => { params: {}, projectDir, sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, - projectEnv: { VERYFRONT_TEST_TENANT_SECRET: "tenant-a" }, + projectEnv: { [projectKey]: "tenant-a" }, }); assertEquals(first.type, "result"); if (first.type !== "result") throw new Error("expected result response"); assertEquals( JSON.parse(new TextDecoder().decode(first.response.body ?? new Uint8Array())), - { value: "tenant-a" }, + { + value: "tenant-a", + frozen: true, + mutationDenied: true, + processEnvDenied: true, + }, ); + assertEquals(Deno.env.get(WORKER_INTERNAL_EGRESS_OVERRIDE_ENV), "0"); const second = await worker.execute({ type: "execute-app-route", id: "second", + module: await prepareModulePath(modulePath), modulePath, method: "GET", request: { @@ -519,55 +1214,123 @@ testSuite("ProjectWorker - real worker request isolation", () => { if (second.type !== "result") throw new Error("expected result response"); assertEquals( JSON.parse(new TextDecoder().decode(second.response.body ?? new Uint8Array())), - { value: null }, + { + value: null, + frozen: true, + mutationDenied: true, + processEnvDenied: true, + }, ); + assertEquals(Deno.env.get(WORKER_INTERNAL_EGRESS_OVERRIDE_ENV), "0"); } finally { worker.terminate(); + if (previousOverride === undefined) { + Deno.env.delete(WORKER_INTERNAL_EGRESS_OVERRIDE_ENV); + } else { + Deno.env.set(WORKER_INTERNAL_EGRESS_OVERRIDE_ENV, previousOverride); + } await Deno.remove(projectDir, { recursive: true }); } }); - it("does not leak projectEnv overlays between queued back-to-back requests", async () => { + it("passes immutable request env to Pages route context", async () => { const projectDir = await Deno.makeTempDir(); const modulePath = await Deno.makeTempFile({ dir: projectDir, suffix: ".mjs" }); - const requestAKey = "VERYFRONT_TEST_REQUEST_A_SECRET"; - const requestBKey = "VERYFRONT_TEST_REQUEST_B_SECRET"; - + const projectKey = "VERYFRONT_TEST_PAGES_ENV"; await Deno.writeTextFile( modulePath, ` - const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - - export async function GET(request) { - const url = new URL(request.url); - if (url.searchParams.get("request") === "a") { - await sleep(100); - } - + export function GET(context) { return Response.json({ - request: url.searchParams.get("request"), - requestA: Deno.env.get(${JSON.stringify(requestAKey)}) ?? null, - requestB: Deno.env.get(${JSON.stringify(requestBKey)}) ?? null, + value: context.env[${JSON.stringify(projectKey)}] ?? null, + frozen: Object.isFrozen(context.env), }); } `, ); const worker = new ProjectWorker({ - projectId: "test-concurrent-env-overlay-scope", - permissions: buildWorkerPermissions([projectDir], { - projectEnvKeys: [requestAKey, requestBKey], - }), + projectId: "test-pages-request-env", + permissions: buildWorkerPermissions([projectDir]), requestTimeoutMs: 10_000, + allowInternalEgress: false, }); worker.start(); try { await assertWorkerReady(worker); - - const first = worker.execute({ - type: "execute-app-route", - id: "request-a", + const response = await worker.execute({ + type: "execute-pages-route", + id: "pages-request-env", + module: await prepareModulePath(modulePath), + modulePath, + method: "GET", + context: { + url: "http://localhost/api/pages-env", + method: "GET", + headers: [], + body: null, + params: {}, + cookies: {}, + }, + projectDir, + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + projectEnv: { [projectKey]: "pages-secret" }, + }); + + assertEquals(response.type, "result"); + if (response.type !== "result") throw new Error("expected result response"); + assertEquals( + JSON.parse(new TextDecoder().decode(response.response.body ?? new Uint8Array())), + { value: "pages-secret", frozen: true }, + ); + } finally { + worker.terminate(); + await Deno.remove(projectDir, { recursive: true }); + } + }); + + it("does not leak projectEnv overlays between queued back-to-back requests", async () => { + const projectDir = await Deno.makeTempDir(); + const modulePath = await Deno.makeTempFile({ dir: projectDir, suffix: ".mjs" }); + const requestAKey = "VERYFRONT_TEST_REQUEST_A_SECRET"; + const requestBKey = "VERYFRONT_TEST_REQUEST_B_SECRET"; + + await Deno.writeTextFile( + modulePath, + ` + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + + export async function GET(request, context) { + const url = new URL(request.url); + if (url.searchParams.get("request") === "a") { + await sleep(100); + } + + return Response.json({ + request: url.searchParams.get("request"), + requestA: context.env[${JSON.stringify(requestAKey)}] ?? null, + requestB: context.env[${JSON.stringify(requestBKey)}] ?? null, + }); + } + `, + ); + + const worker = new ProjectWorker({ + projectId: "test-concurrent-env-overlay-scope", + permissions: buildWorkerPermissions([projectDir]), + requestTimeoutMs: 10_000, + allowInternalEgress: false, + }); + + worker.start(); + try { + await assertWorkerReady(worker); + + const first = worker.execute({ + type: "execute-app-route", + id: "request-a", + module: await prepareModulePath(modulePath), modulePath, method: "GET", request: { @@ -585,6 +1348,7 @@ testSuite("ProjectWorker - real worker request isolation", () => { const second = worker.execute({ type: "execute-app-route", id: "request-b", + module: await prepareModulePath(modulePath), modulePath, method: "GET", request: { @@ -620,7 +1384,7 @@ testSuite("ProjectWorker - real worker request isolation", () => { } }); - it("denies host env secrets while allowing project env keys", async () => { + it("denies all Deno env access while exposing only request env in context", async () => { const projectDir = await Deno.makeTempDir(); const modulePath = await Deno.makeTempFile({ dir: projectDir, suffix: ".mjs" }); const hostKey = "VERYFRONT_TEST_HOST_ONLY_SECRET"; @@ -630,7 +1394,7 @@ testSuite("ProjectWorker - real worker request isolation", () => { await Deno.writeTextFile( modulePath, ` - export function GET() { + export function GET(_request, context) { let hostValue = null; let hostDenied = false; try { @@ -647,12 +1411,22 @@ testSuite("ProjectWorker - real worker request isolation", () => { objectDenied = true; } + let projectValue = null; + let projectDenied = false; + try { + projectValue = Deno.env.get(${JSON.stringify(projectKey)}) ?? null; + } catch { + projectDenied = true; + } + return Response.json({ hostValue, hostDenied, objectHostValue, objectDenied, - projectValue: Deno.env.get(${JSON.stringify(projectKey)}) ?? null, + projectValue, + projectDenied, + contextProjectValue: context.env[${JSON.stringify(projectKey)}] ?? null, }); } `, @@ -662,10 +1436,9 @@ testSuite("ProjectWorker - real worker request isolation", () => { const worker = new ProjectWorker({ projectId: "test-env-allowlist", - permissions: buildWorkerPermissions([projectDir], { - projectEnvKeys: [projectKey], - }), + permissions: buildWorkerPermissions([projectDir]), requestTimeoutMs: 10_000, + allowInternalEgress: false, }); worker.start(); @@ -675,6 +1448,7 @@ testSuite("ProjectWorker - real worker request isolation", () => { const response = await worker.execute({ type: "execute-app-route", id: "env-allowlist", + module: await prepareModulePath(modulePath), modulePath, method: "GET", request: { @@ -696,7 +1470,10 @@ testSuite("ProjectWorker - real worker request isolation", () => { assertEquals(body.hostValue, null); assertEquals(body.hostDenied, true); assertEquals(body.objectHostValue, null); - assertEquals(body.projectValue, "project-secret"); + assertEquals(body.objectDenied, true); + assertEquals(body.projectValue, null); + assertEquals(body.projectDenied, true); + assertEquals(body.contextProjectValue, "project-secret"); } finally { worker.terminate(); if (previousHostSecret === undefined) { @@ -708,6 +1485,173 @@ testSuite("ProjectWorker - real worker request isolation", () => { } }); + it("rejects cyclic fetch-data results before control-port serialization", async () => { + const projectDir = await Deno.makeTempDir(); + const modulePath = await Deno.makeTempFile({ dir: projectDir, suffix: ".mjs" }); + await Deno.writeTextFile( + modulePath, + ` + export function getServerData() { + const props = {}; + props.self = props; + return { props }; + } + `, + ); + + const worker = new ProjectWorker({ + projectId: "test-cyclic-data-result", + permissions: buildWorkerPermissions([projectDir]), + requestTimeoutMs: 10_000, + allowInternalEgress: false, + }); + worker.start(); + + try { + await assertWorkerReady(worker); + const response = await worker.execute({ + type: "fetch-data", + id: "cyclic-data-result", + modulePath, + context: { + params: {}, + query: "", + request: { + url: "http://localhost/data", + method: "GET", + headers: [], + body: null, + }, + url: "http://localhost/data", + }, + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + }); + + assertEquals(response.type, "error"); + if (response.type !== "error") throw new Error("expected error response"); + assertEquals(response.error.name, "TypeError"); + assert(response.error.message.includes("Invalid isolated data result")); + } finally { + worker.terminate(); + await Deno.remove(projectDir, { recursive: true }); + } + }); + + it("rejects unsupported fetch-data result values", async () => { + const response = await executeIsolatedDataModule( + `export function getServerData() { return { props: new Map([["key", "value"]]) }; }`, + "unsupported-data-result", + ); + assertInvalidIsolatedDataResult(response); + }); + + it("rejects oversized fetch-data result values", async () => { + const response = await executeIsolatedDataModule( + `export function getServerData() { + return { props: { value: "x".repeat(16 * 1024 * 1024 + 1) } }; + }`, + "oversized-data-result", + ); + assertInvalidIsolatedDataResult(response); + }); + + it("rejects malformed fetch-data result outcome combinations", async () => { + const response = await executeIsolatedDataModule( + `export function getServerData() { + return { props: {}, redirect: { destination: "/other" } }; + }`, + "malformed-data-result", + ); + assertInvalidIsolatedDataResult(response); + }); + + it("drops unknown fields from isolated data results before snapshotting", async () => { + const response = await executeIsolatedDataModule( + `export function getServerData() { + return { + redirect: { destination: "/other", permanent: false, ignored: "nested" }, + ignored: "top-level", + }; + }`, + "unknown-data-result-fields", + ); + + assertEquals(response.type, "data-result"); + if (response.type !== "data-result") throw new Error("expected data result response"); + assertEquals(response.result, { + redirect: { destination: "/other", permanent: false }, + }); + }); + + it("matches direct data validation for an empty redirect destination", async () => { + const directResult = validateDataResult( + { redirect: { destination: "", permanent: false } }, + "getServerData", + ); + const isolatedResponse = await executeIsolatedDataModule( + `export function getServerData() { + return { redirect: { destination: "", permanent: false } }; + }`, + "empty-redirect-destination", + ); + + assertEquals(isolatedResponse.type, "data-result"); + if (isolatedResponse.type !== "data-result") { + throw new Error("expected data result response"); + } + assertEquals(isolatedResponse.result, directResult); + }); + + it("treats own undefined isolated data-result fields as absent", async () => { + const response = await executeIsolatedDataModule( + `export function getServerData() { + return { + props: undefined, + redirect: undefined, + notFound: true, + revalidate: undefined, + }; + }`, + "undefined-data-result-fields", + ); + + assertEquals(response.type, "data-result"); + if (response.type !== "data-result") throw new Error("expected data result response"); + assertEquals(response.result, { notFound: true }); + }); + + it("rejects accessors even when their isolated data-result field is unknown", async () => { + const response = await executeIsolatedDataModule( + `export function getServerData() { + const result = { props: { ok: true } }; + Object.defineProperty(result, "ignored", { + enumerable: true, + get() { return "hostile"; }, + }); + return result; + }`, + "accessor-data-result-field", + ); + assertInvalidIsolatedDataResult(response); + }); + + it("preserves valid inactive controls and revalidation metadata", async () => { + const response = await executeIsolatedDataModule( + `export function getServerData() { + return { props: { ok: true }, notFound: false, revalidate: 30 }; + }`, + "valid-data-result", + ); + + assertEquals(response.type, "data-result"); + if (response.type !== "data-result") throw new Error("expected data result response"); + assertEquals(response.result, { + props: { ok: true }, + notFound: false, + revalidate: 30, + }); + }); + it("rejects direct Deno file reads outside scoped worker read permissions", async () => { const projectDir = await Deno.makeTempDir(); const outsideDir = await Deno.makeTempDir(); @@ -729,6 +1673,7 @@ testSuite("ProjectWorker - real worker request isolation", () => { projectId: "test-direct-deno-read-denied", permissions: buildWorkerPermissions([projectDir]), requestTimeoutMs: 10_000, + allowInternalEgress: false, }); worker.start(); @@ -738,6 +1683,7 @@ testSuite("ProjectWorker - real worker request isolation", () => { const response = await worker.execute({ type: "execute-app-route", id: "direct-read", + module: await prepareModulePath(modulePath), modulePath, method: "GET", request: { @@ -787,6 +1733,7 @@ testSuite("ProjectWorker - real worker request isolation", () => { projectId: "test-worker-egress-loopback-denied", permissions: buildWorkerPermissions([projectDir]), requestTimeoutMs: 10_000, + allowInternalEgress: false, }); worker.start(); @@ -796,6 +1743,7 @@ testSuite("ProjectWorker - real worker request isolation", () => { const response = await worker.execute({ type: "execute-app-route", id: "loopback-fetch", + module: await prepareModulePath(modulePath), modulePath, method: "GET", request: { @@ -849,6 +1797,7 @@ testSuite("ProjectWorker - real worker request isolation", () => { projectId: "test-worker-egress-loopback-connect-denied", permissions: buildWorkerPermissions([projectDir]), requestTimeoutMs: 10_000, + allowInternalEgress: false, }); worker.start(); @@ -858,6 +1807,7 @@ testSuite("ProjectWorker - real worker request isolation", () => { const response = await worker.execute({ type: "execute-app-route", id: "loopback-connect", + module: await prepareModulePath(modulePath), modulePath, method: "GET", request: { @@ -885,10 +1835,9 @@ testSuite("ProjectWorker - real worker request isolation", () => { } }); - it("allows project loopback fetches when internal egress override is enabled", async () => { + it("allows project loopback fetches only from the captured host egress policy", async () => { const projectDir = await Deno.makeTempDir(); const modulePath = await Deno.makeTempFile({ dir: projectDir, suffix: ".mjs" }); - const previousOverride = Deno.env.get(WORKER_INTERNAL_EGRESS_OVERRIDE_ENV); const loopbackServer = Deno.serve( { hostname: "127.0.0.1", port: 0, onListen: () => {} }, () => Response.json({ reachable: true }), @@ -905,12 +1854,11 @@ testSuite("ProjectWorker - real worker request isolation", () => { `, ); - Deno.env.set(WORKER_INTERNAL_EGRESS_OVERRIDE_ENV, "1"); - const worker = new ProjectWorker({ projectId: "test-worker-egress-loopback-override", permissions: buildWorkerPermissions([projectDir]), requestTimeoutMs: 10_000, + allowInternalEgress: true, }); worker.start(); @@ -920,6 +1868,7 @@ testSuite("ProjectWorker - real worker request isolation", () => { const response = await worker.execute({ type: "execute-app-route", id: "loopback-fetch-override", + module: await prepareModulePath(modulePath), modulePath, method: "GET", request: { @@ -942,11 +1891,6 @@ testSuite("ProjectWorker - real worker request isolation", () => { } finally { worker.terminate(); await loopbackServer.shutdown(); - if (previousOverride === undefined) { - Deno.env.delete(WORKER_INTERNAL_EGRESS_OVERRIDE_ENV); - } else { - Deno.env.set(WORKER_INTERNAL_EGRESS_OVERRIDE_ENV, previousOverride); - } await Deno.remove(projectDir, { recursive: true }); } }); @@ -994,6 +1938,7 @@ testSuite("ProjectWorker - real worker request isolation", () => { projectId: "test-worker-egress-post-307", permissions: buildWorkerPermissions([projectDir]), requestTimeoutMs: 10_000, + allowInternalEgress: true, }); worker.start(); try { @@ -1001,6 +1946,7 @@ testSuite("ProjectWorker - real worker request isolation", () => { const response = await worker.execute({ type: "execute-app-route", id: "post-307", + module: await prepareModulePath(modulePath), modulePath, method: "GET", request: { @@ -1075,6 +2021,7 @@ testSuite("ProjectWorker - real worker request isolation", () => { projectId: "test-worker-egress-dns-pinned-fetch", permissions: buildWorkerPermissions([projectDir]), requestTimeoutMs: 10_000, + allowInternalEgress: true, egressResolveHost: (hostname) => { assertEquals(hostname, "localhost"); resolutionCount++; @@ -1088,6 +2035,7 @@ testSuite("ProjectWorker - real worker request isolation", () => { const response = await worker.execute({ type: "execute-app-route", id: "dns-pinned-fetch", + module: await prepareModulePath(modulePath), modulePath, method: "GET", request: { @@ -1177,6 +2125,7 @@ testSuite("ProjectWorker - real worker request isolation", () => { projectId: "test-worker-egress-raw-tcp-pinned", permissions: buildWorkerPermissions([projectDir]), requestTimeoutMs: 10_000, + allowInternalEgress: true, egressResolveHost: (hostname) => { assertEquals(hostname, "socket.invalid"); resolutionCount++; @@ -1189,6 +2138,7 @@ testSuite("ProjectWorker - real worker request isolation", () => { const response = await worker.execute({ type: "execute-app-route", id: "raw-tcp-pinned", + module: await prepareModulePath(modulePath), modulePath, method: "GET", request: { @@ -1301,6 +2251,7 @@ testSuite("ProjectWorker - real worker request isolation", () => { projectId: "test-worker-egress-native-bypass-denied", permissions: buildWorkerPermissions([projectDir]), requestTimeoutMs: 10_000, + allowInternalEgress: false, }); worker.start(); try { @@ -1308,6 +2259,7 @@ testSuite("ProjectWorker - real worker request isolation", () => { const response = await worker.execute({ type: "execute-app-route", id: "native-bypass-denied", + module: await prepareModulePath(modulePath), modulePath, method: "GET", request: { @@ -1344,3 +2296,744 @@ testSuite("ProjectWorker - real worker request isolation", () => { } }); }); + +testSuite("ProjectWorker - executeStream", () => { + it("throws when worker is not started", () => { + const worker = createTestWorker("test-stream"); + let threw = false; + try { + worker.executeStream({ + type: "render-ssr", + id: "test-id", + pageModulePath: "/nonexistent.ts", + layoutModulePaths: [], + pageProps: {}, + layoutProps: [], + delivery: "stream", + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + }); + } catch { + threw = true; + } + assert(threw, "should throw when worker is not available"); + }); + + it("returns a ReadableStream when worker is started", async () => { + const worker = createTestWorker("test-stream"); + worker.start(); + try { + const stream = worker.executeStream({ + type: "render-ssr", + id: "test-id", + pageModulePath: "/nonexistent.ts", + layoutModulePaths: [], + pageProps: {}, + layoutProps: [], + delivery: "stream", + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + }); + assert(stream instanceof ReadableStream, "should return a ReadableStream"); + // Cancel the stream to clean up + await stream.cancel(); + } finally { + worker.terminate(); + } + }); + + it("terminates the worker generation and rejects concurrent work when the consumer cancels", async () => { + const worker = createTestWorker("test-stream-cancel"); + worker.start(); + try { + const concurrentOutcome = worker.execute({ + type: "render-ssr", + id: "concurrent-request", + pageModulePath: "/nonexistent.ts", + layoutModulePaths: [], + pageProps: {}, + layoutProps: [], + delivery: "string", + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + }).then( + () => "resolved", + () => "rejected", + ); + const stream = worker.executeStream({ + type: "render-ssr", + id: "cancelled-stream", + pageModulePath: "/nonexistent.ts", + layoutModulePaths: [], + pageProps: {}, + layoutProps: [], + delivery: "stream", + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + }); + + assertEquals(worker.status, "busy"); + assertEquals(worker.hasPendingRequests, true); + + await stream.cancel("downstream disconnected"); + + assertEquals(await concurrentOutcome, "rejected"); + assertEquals(worker.status, "terminated"); + assertEquals(worker.hasPendingRequests, false); + assertEquals(await worker.isHealthy(), false); + } finally { + worker.terminate(); + } + }); + + it("fails closed with an actionable error when no isolated SSR extension is configured", async () => { + const projectDir = await Deno.makeTempDir({ prefix: "vf-worker-ssr-missing-" }); + const pageModulePath = `${projectDir}/page.ts`; + await Deno.writeTextFile(pageModulePath, `export default function Page() { return "unused"; }`); + const worker = new ProjectWorker({ + projectId: "test-missing-ssr-renderer", + permissions: buildWorkerPermissions([projectDir]), + requestTimeoutMs: 30_000, + allowInternalEgress: false, + }); + worker.start(); + try { + await assertWorkerReady(worker); + const response = await worker.execute({ + type: "render-ssr", + id: "missing-renderer", + pageModulePath, + layoutModulePaths: [], + pageProps: {}, + layoutProps: [], + delivery: "string", + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + }); + assertEquals(response.type, "error"); + if (response.type !== "error") throw new Error("expected renderer configuration error"); + assert(response.error.message.includes("Install and register @veryfront/ext-react-ssr")); + } finally { + await worker.shutdown(); + await Deno.remove(projectDir, { recursive: true }); + } + }); + + it("preserves a sanitized renderer import diagnostic and detached cause", async () => { + const projectDir = await Deno.makeTempDir({ prefix: "vf-worker-ssr-import-" }); + const pageModulePath = `${projectDir}/page.ts`; + const rendererModulePath = `${projectDir}/renderer.ts`; + await Deno.writeTextFile(pageModulePath, `export default function Page() { return "unused"; }`); + await Deno.writeTextFile( + rendererModulePath, + `throw new Error("renderer import failed for https://user:secret@example.test/private");`, + ); + const worker = new ProjectWorker({ + projectId: "test-failed-ssr-renderer-import", + permissions: buildWorkerPermissions([projectDir]), + requestTimeoutMs: 30_000, + allowInternalEgress: false, + isolatedSsrRendererModuleUrl: toFileUrl(rendererModulePath).href, + }); + worker.start(); + try { + await assertWorkerReady(worker); + const response = await worker.execute({ + type: "render-ssr", + id: "failed-renderer-import", + pageModulePath, + layoutModulePaths: [], + pageProps: {}, + layoutProps: [], + delivery: "string", + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + }); + assertEquals(response.type, "error"); + if (response.type !== "error") throw new Error("expected renderer import error"); + assert( + response.error.message.includes( + "Isolated SSR renderer extension import failed: renderer import failed", + ), + ); + assertEquals(response.error.message.includes("secret"), false); + const serializedCause = response.error.problem?.cause; + assertEquals(serializedCause?.includes("secret"), false); + assertEquals(serializedCause?.includes("renderer import failed"), true); + } finally { + await worker.shutdown(); + await Deno.remove(projectDir, { recursive: true }); + } + }); + + it("streams production isolated SSR through the bounded continuation protocol", async () => { + const projectDir = await Deno.makeTempDir({ prefix: "vf-worker-ssr-stream-" }); + const pageModulePath = `${projectDir}/page.ts`; + await Deno.writeTextFile( + pageModulePath, + `export default function Page() { return "bounded worker stream"; }`, + ); + const worker = createProductionSSRWorker("test-real-stream-protocol", projectDir); + worker.start(); + try { + await assertWorkerReady(worker); + const stream = worker.executeStream({ + type: "render-ssr", + id: "real-stream", + pageModulePath, + layoutModulePaths: [], + pageProps: {}, + layoutProps: [], + delivery: "stream", + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + }); + + assertEquals(await new Response(stream).text(), "bounded worker stream"); + assertEquals(worker.status, "idle"); + assertEquals(worker.hasPendingRequests, false); + } finally { + worker.terminate(); + await Deno.remove(projectDir, { recursive: true }); + } + }); + + it("pairs more than 64 concurrent SSR admissions without a hidden wire cap", async () => { + const projectDir = await Deno.makeTempDir({ prefix: "vf-worker-ssr-concurrency-" }); + const pageModulePath = `${projectDir}/page.ts`; + await Deno.writeTextFile( + pageModulePath, + `export default function Page(props) { return "render-" + props.index; }`, + ); + const worker = createProductionSSRWorker("test-real-concurrent-admission", projectDir); + worker.start(); + try { + await assertWorkerReady(worker); + const responses = await Promise.all( + Array.from({ length: 65 }, (_, index) => + worker.execute({ + type: "render-ssr", + id: `concurrent-${index}`, + pageModulePath, + layoutModulePaths: [], + pageProps: { index }, + layoutProps: [], + delivery: "string", + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + })), + ); + assertEquals( + responses.map((response) => response.type === "ssr-result" ? response.html : response.type), + Array.from({ length: 65 }, (_, index) => `render-${index}`), + ); + assertEquals(worker.status, "idle"); + assertEquals(worker.hasPendingRequests, false); + } finally { + worker.terminate(); + await Deno.remove(projectDir, { recursive: true }); + } + }); + + it("keeps a 2.5 MiB React text node byte-identical across string and stream delivery", async () => { + const projectDir = await Deno.makeTempDir({ prefix: "vf-worker-ssr-large-" }); + const pageModulePath = `${projectDir}/page.ts`; + const textBytes = 2 * 1024 * 1024 + 512 * 1024 + 17; + const multibyteCharacters = Math.floor(textBytes / 2); + await Deno.writeTextFile( + pageModulePath, + `export default function Page() { + return "é".repeat(${multibyteCharacters}) + ${textBytes % 2 === 0 ? '""' : '"x"'}; + }`, + ); + const worker = createProductionSSRWorker("test-real-large-frame-splitting", projectDir); + worker.start(); + try { + await assertWorkerReady(worker); + const stringResponse = await worker.execute({ + type: "render-ssr", + id: "large-string", + pageModulePath, + layoutModulePaths: [], + pageProps: {}, + layoutProps: [], + delivery: "string", + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + }); + assertEquals(stringResponse.type, "ssr-result"); + if (stringResponse.type !== "ssr-result") { + throw new Error("expected an isolated SSR string result"); + } + const stringBytes = new TextEncoder().encode(stringResponse.html); + assertEquals(stringBytes.byteLength, textBytes); + + const streamedBytes = await collectTightStream( + worker.executeStream({ + type: "render-ssr", + id: "large-stream", + pageModulePath, + layoutModulePaths: [], + pageProps: {}, + layoutProps: [], + delivery: "stream", + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + }), + ); + assertEquals(streamedBytes.byteLength, textBytes); + assertEquals(streamedBytes, stringBytes); + assertEquals(streamedBytes[0], 0xc3); + assertEquals(streamedBytes.at(-1), "x".charCodeAt(0)); + assertEquals(worker.status, "idle"); + } finally { + worker.terminate(); + await Deno.remove(projectDir, { recursive: true }); + } + }); + + it("rejects oversized production SSR in the bounded string collector", async () => { + const projectDir = await Deno.makeTempDir({ prefix: "vf-worker-ssr-limit-" }); + const pageModulePath = `${projectDir}/page.ts`; + await Deno.writeTextFile( + pageModulePath, + `export default function Page() { return "x".repeat(${MAX_WORKER_SSR_OUTPUT_BYTES + 1}); }`, + ); + const worker = createProductionSSRWorker("test-real-string-limit", projectDir); + worker.start(); + try { + await assertWorkerReady(worker); + const error = await assertRejects( + () => + worker.execute({ + type: "render-ssr", + id: "real-string-limit", + pageModulePath, + layoutModulePaths: [], + pageProps: {}, + layoutProps: [], + delivery: "string", + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + }), + Error, + `Isolated SSR output exceeded ${MAX_WORKER_SSR_OUTPUT_BYTES} bytes`, + ); + assertEquals( + (error as Error & { slug?: string }).slug, + "ssr-output-limit-exceeded", + ); + assertEquals(worker.status, "idle"); + assertEquals(worker.hasPendingRequests, false); + assertEquals(await worker.isHealthy(), true); + } finally { + worker.terminate(); + await Deno.remove(projectDir, { recursive: true }); + } + }); + + it("tight-copies an offset byte view without retaining its 10 MiB backing buffer", async () => { + const worker = createSSRScriptedWorker( + "test-stream-tight-copy", + ` + if (message.type === "render-ssr") { + const backing = new ArrayBuffer(10 * 1024 * 1024); + const visible = new Uint8Array(backing, 4096, 1); + visible[0] = 73; + send(open, "stream-frame", 0, { chunk: visible }); + visible[0] = 99; + return; + } + if (message.type === "stream-credit") { + send(open, "stream-end", 1); + } + `, + ); + worker.start(); + try { + await assertWorkerReady(worker); + const reader = worker.executeStream(makeScriptedSSRRequest("tight-copy")).getReader(); + const first = await reader.read(); + assertEquals(first.done, false); + assertExists(first.value); + assertEquals(first.value, new Uint8Array([73])); + assertEquals(first.value.byteOffset, 0); + assert(first.value.buffer instanceof ArrayBuffer); + assertEquals(first.value.buffer.byteLength, 1); + assertEquals((await reader.read()).done, true); + reader.releaseLock(); + assertEquals(worker.status, "idle"); + } finally { + worker.terminate(); + } + }); + + it("rejects shared and growable stream backing memory before enqueue", async () => { + const assertUnsafeBackingRejected = async ( + projectId: string, + viewExpression: string, + ) => { + const worker = createSSRScriptedWorker( + projectId, + ` + if (message.type === "render-ssr") { + const chunk = ${viewExpression}; + send(open, "stream-frame", 0, { chunk }); + } + `, + ); + worker.start(); + try { + await assertWorkerReady(worker); + const error = await new Response( + worker.executeStream(makeScriptedSSRRequest(projectId)), + ).arrayBuffer().then( + () => undefined, + (cause: unknown) => cause, + ); + assert(error instanceof Error); + assertEquals(worker.status, "crashed"); + assertEquals(worker.hasPendingRequests, false); + } finally { + worker.terminate(); + } + }; + + await assertUnsafeBackingRejected( + "shared-backing", + "new Uint8Array(new SharedArrayBuffer(8))", + ); + + const ResizableArrayBuffer = ArrayBuffer as unknown as new ( + byteLength: number, + options: { maxByteLength: number }, + ) => ArrayBuffer; + const resizable = new ResizableArrayBuffer(8, { maxByteLength: 16 }) as + & ArrayBuffer + & { resizable?: boolean }; + if (resizable.resizable === true) { + await assertUnsafeBackingRejected( + "resizable-backing", + "new Uint8Array(new ArrayBuffer(8, { maxByteLength: 16 }))", + ); + } + + const GrowableSharedArrayBuffer = SharedArrayBuffer as unknown as new ( + byteLength: number, + options: { maxByteLength: number }, + ) => SharedArrayBuffer; + const growable = new GrowableSharedArrayBuffer(8, { + maxByteLength: 16, + }) as SharedArrayBuffer & { growable?: boolean }; + if (growable.growable === true) { + await assertUnsafeBackingRejected( + "growable-shared-backing", + "new Uint8Array(new SharedArrayBuffer(8, { maxByteLength: 16 }))", + ); + } + }); + + it("fails closed on an uncredited second frame", async () => { + const worker = createSSRScriptedWorker( + "test-stream-uncredited-frame", + ` + if (message.type === "render-ssr") { + send(open, "stream-frame", 0, { chunk: new Uint8Array([1]) }); + send(open, "stream-frame", 1, { chunk: new Uint8Array([2]) }); + } + `, + ); + worker.start(); + try { + await assertWorkerReady(worker); + const stream = worker.executeStream( + makeScriptedSSRRequest("uncredited"), + ); + await waitForWorkerStatus(worker, "crashed"); + const error = await new Response(stream).arrayBuffer().then( + () => undefined, + (cause: unknown) => cause, + ); + assert(error instanceof Error); + assertEquals(worker.status, "crashed"); + assertEquals(worker.hasPendingRequests, false); + } finally { + worker.terminate(); + } + }); + + it("holds exactly one max-sized frame at HWM and advances one credit at a time", async () => { + const worker = createSSRScriptedWorker( + "test-stream-one-frame-hwm", + ` + if (message.type === "render-ssr") { + send(open, "stream-frame", 0, { + chunk: new Uint8Array(${MAX_WORKER_SSR_CHUNK_BYTES}).fill(1), + }); + return; + } + if (message.type === "stream-credit" && message.sequence === 1) { + send(open, "stream-frame", 1, { + chunk: new Uint8Array(${MAX_WORKER_SSR_CHUNK_BYTES}).fill(2), + }); + return; + } + if (message.type === "stream-credit" && message.sequence === 2) { + send(open, "stream-end", 2); + } + `, + 3_000, + ); + worker.start(); + try { + await assertWorkerReady(worker); + const stream = worker.executeStream(makeScriptedSSRRequest("one-frame-hwm")); + await new Promise((resolve) => setTimeout(resolve, 25)); + assertEquals(worker.status, "busy"); + + const bytes = await collectTightStream(stream); + assertEquals(bytes.byteLength, 2 * MAX_WORKER_SSR_CHUNK_BYTES); + assertEquals(bytes[0], 1); + assertEquals(bytes[MAX_WORKER_SSR_CHUNK_BYTES - 1], 1); + assertEquals(bytes[MAX_WORKER_SSR_CHUNK_BYTES], 2); + assertEquals(bytes.at(-1), 2); + assertEquals(worker.status, "idle"); + } finally { + worker.terminate(); + } + }); + + it("rejects reordered frames and a frame-to-string terminal transition", async () => { + for ( + const [projectId, behavior] of [ + [ + "reordered-frame", + `send(open, "stream-frame", 1, { chunk: new Uint8Array([1]) });`, + ], + [ + "mixed-terminal", + ` + send(open, "stream-frame", 0, { chunk: new Uint8Array([1]) }); + send(open, "ssr-wire-result", 1, { html: "mixed" }); + `, + ], + ] as const + ) { + const worker = createSSRScriptedWorker( + projectId, + `if (message.type === "render-ssr") { ${behavior} }`, + ); + worker.start(); + try { + await assertWorkerReady(worker); + const error = await new Response( + worker.executeStream(makeScriptedSSRRequest(projectId)), + ).arrayBuffer().then( + () => undefined, + (cause: unknown) => cause, + ); + assert(error instanceof Error); + assertEquals(worker.status, "crashed"); + } finally { + worker.terminate(); + } + } + }); + + it("retires the generation after a duplicate terminal", async () => { + const worker = createSSRScriptedWorker( + "test-stream-duplicate-terminal", + ` + if (message.type === "render-ssr") { + send(open, "stream-end", 0); + send(open, "stream-end", 0); + } + `, + ); + worker.start(); + try { + await assertWorkerReady(worker); + assertEquals( + await new Response( + worker.executeStream(makeScriptedSSRRequest("duplicate-terminal")), + ).text(), + "", + ); + await waitForWorkerStatus(worker, "crashed"); + assertEquals(worker.hasPendingRequests, false); + } finally { + worker.terminate(); + } + }); + + it("rejects stale output when a caller reuses an id with a fresh token", async () => { + const worker = createSSRScriptedWorker( + "test-stream-reused-id", + ` + if (message.type === "render-ssr") { + globalThis.renderCount = (globalThis.renderCount ?? 0) + 1; + if (globalThis.renderCount === 1) { + globalThis.firstOpen = open; + send(open, "stream-end", 0); + } else { + send(globalThis.firstOpen, "stream-end", 0); + } + } + `, + ); + worker.start(); + try { + await assertWorkerReady(worker); + const id = "reused-id"; + assertEquals( + await new Response( + worker.executeStream(makeScriptedSSRRequest(id)), + ).text(), + "", + ); + assertEquals(worker.status, "idle"); + + const error = await new Response( + worker.executeStream(makeScriptedSSRRequest(id)), + ).arrayBuffer().then( + () => undefined, + (cause: unknown) => cause, + ); + assert(error instanceof Error); + assertEquals(worker.status, "crashed"); + assertEquals(worker.hasPendingRequests, false); + } finally { + worker.terminate(); + } + }); + + it("maps an authenticated output limit once and returns to reusable idle state", async () => { + const worker = createSSRScriptedWorker( + "test-stream-output-limit", + ` + if (message.type === "render-ssr") { + send(open, "ssr-output-limit", 0, { limit: "chunks" }); + } + `, + ); + let idleNotifications = 0; + worker.onIdle(() => idleNotifications++); + worker.start(); + try { + await assertWorkerReady(worker); + const error = await new Response( + worker.executeStream(makeScriptedSSRRequest("output-limit")), + ).arrayBuffer().then( + () => undefined, + (cause: unknown) => cause, + ); + assert(error instanceof Error); + assertEquals( + error.message, + `Isolated SSR output exceeded ${MAX_WORKER_SSR_OUTPUT_CHUNKS} chunks`, + ); + assertEquals( + (error as Error & { slug?: string }).slug, + "ssr-output-limit-exceeded", + ); + assertEquals(idleNotifications, 1); + assertEquals(worker.status, "idle"); + assertEquals(worker.hasPendingRequests, false); + assertEquals(await worker.isHealthy(), true); + assertEquals(idleNotifications, 1); + worker.terminate(); + assertEquals(idleNotifications, 1); + } finally { + worker.terminate(); + } + }); + + it("reconstructs registered streaming errors with a sanitized stack", async () => { + const serializedError = { + name: "VeryfrontError", + message: "project dependency overloaded", + stack: + "VeryfrontError: project dependency overloaded\n at postgres://admin:secret@db.internal/query:1:1", + problem: { + slug: SERVICE_OVERLOADED.slug, + category: SERVICE_OVERLOADED.category, + status: 429, + title: SERVICE_OVERLOADED.title, + suggestion: SERVICE_OVERLOADED.suggestion, + detail: "capacity exhausted", + }, + }; + const worker = createSSRScriptedWorker( + "test-stream-registered-error", + ` + if (message.type === "render-ssr") { + send(open, "ssr-wire-error", 0, { + error: ${JSON.stringify(serializedError)}, + }); + } + `, + ); + worker.start(); + try { + await assertWorkerReady(worker); + const error = await new Response( + worker.executeStream(makeScriptedSSRRequest("registered-error")), + ).arrayBuffer().then( + () => undefined, + (cause: unknown) => cause, + ); + + assert(error instanceof VeryfrontError); + assertEquals(error.slug, SERVICE_OVERLOADED.slug); + assertEquals(error.status, 429); + assertEquals(error.detail, "capacity exhausted"); + assert(error.stack?.includes("postgres://admin:[REDACTED]@db.internal/query")); + assertEquals(error.stack?.includes("secret"), false); + assertEquals(worker.status, "idle"); + assertEquals(worker.hasPendingRequests, false); + } finally { + worker.terminate(); + } + }); + + it("uses one absolute deadline after receiving frames from an active producer", async () => { + const worker = createSSRScriptedWorker( + "test-stream-absolute-timeout", + ` + if (message.type === "render-ssr") { + send(open, "stream-frame", 0, { chunk: new Uint8Array([1]) }); + return; + } + if (message.type === "stream-credit") { + setTimeout(() => { + send(open, "stream-frame", message.sequence, { + chunk: new Uint8Array([message.sequence & 255]), + }); + }, 2); + } + `, + 250, + ); + worker.start(); + try { + await assertWorkerReady(worker); + const reader = worker.executeStream( + makeScriptedSSRRequest("absolute-timeout"), + ).getReader(); + const first = await reader.read(); + assertEquals(first.done, false); + assertEquals(first.value, new Uint8Array([1])); + + let received = 1; + const error = await (async () => { + try { + while (!(await reader.read()).done) received++; + return undefined; + } catch (cause) { + return cause; + } + })(); + assert(received > 1); + assert(error instanceof Error); + assertEquals(error.message, "Worker stream timed out after 250ms"); + assertEquals( + (error as Error & { slug?: string }).slug, + "timeout-error", + ); + assertEquals(worker.status, "terminated"); + assertEquals(worker.hasPendingRequests, false); + reader.releaseLock(); + } finally { + worker.terminate(); + } + }); +}); diff --git a/src/security/sandbox/project-worker.ts b/src/security/sandbox/project-worker.ts index 7d606edd95..1600f14217 100644 --- a/src/security/sandbox/project-worker.ts +++ b/src/security/sandbox/project-worker.ts @@ -9,30 +9,274 @@ */ import { serverLogger } from "#veryfront/utils"; -import { isCompiledBinary } from "#veryfront/utils"; import { withSpan } from "#veryfront/observability/tracing/otlp-setup.ts"; -import { INVALID_ARGUMENT, TIMEOUT_ERROR, UNKNOWN_ERROR } from "#veryfront/errors"; -import { getHostEnv } from "#veryfront/platform/compat/process.ts"; +import { validateIsolatedSsrRendererModuleUrl } from "#veryfront/extensions/rendering/index.ts"; +import { + INVALID_ARGUMENT, + SSR_OUTPUT_LIMIT_EXCEEDED, + TIMEOUT_ERROR, + UNKNOWN_ERROR, +} from "#veryfront/errors"; +import { MAX_TIMER_DELAY_MS } from "#veryfront/utils/timer.ts"; import { - isInternalEgressOverrideEnabled, type ResolveWorkerHost, startWorkerEgressBroker, - WORKER_INTERNAL_EGRESS_OVERRIDE_ENV, type WorkerEgressBroker, } from "./worker-egress-guard.ts"; import type { WorkerPermissions } from "./worker-permissions.ts"; -import type { WorkerRequest, WorkerResponse } from "./worker-types.ts"; +import { deserializeWorkerError } from "./worker-error-boundary.ts"; +import type { + SerializedError, + WorkerRequest, + WorkerResponse, + WorkerSSRExecutionOpen, + WorkerSSROutputLimit, + WorkerSSRWireError, + WorkerSSRWireResult, + WorkerStreamCredit, + WorkerStreamEnd, + WorkerStreamFrame, +} from "./worker-types.ts"; +import { + MAX_WORKER_REQUEST_ID_CHARS, + MAX_WORKER_SSR_CHUNK_BYTES, + MAX_WORKER_SSR_OUTPUT_BYTES, + MAX_WORKER_SSR_OUTPUT_CHUNKS, +} from "./worker-types.ts"; const logger = serverLogger.component("project-worker"); +const textEncoder = new TextEncoder(); +const NativeMessageChannel = MessageChannel; +const apply = Reflect.apply; +const eventTargetAddEventListener = EventTarget.prototype.addEventListener; +const eventPreventDefault = Event.prototype.preventDefault; +const messagePortClose = MessagePort.prototype.close; +const messagePortPostMessage = MessagePort.prototype.postMessage; +const messagePortStart = MessagePort.prototype.start; +const arrayIncludes = Array.prototype.includes; +const arrayPush = Array.prototype.push; +const arraySort = Array.prototype.sort; +const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const getOwnPropertyDescriptors = Object.getOwnPropertyDescriptors; +const getPrototypeOf = Object.getPrototypeOf; +const ownKeys = Reflect.ownKeys; +const arrayIsArray = Array.isArray; +const freezeObject = Object.freeze; +const stringCharCodeAt = String.prototype.charCodeAt; +const stringTrim = String.prototype.trim; +const textEncoderEncode = TextEncoder.prototype.encode; +const arrayBufferPrototype = ArrayBuffer.prototype; +const uint8ArrayPrototype = Uint8Array.prototype; +const typedArrayPrototype = getPrototypeOf(uint8ArrayPrototype); +const typedArrayBufferGetter = typedArrayPrototype + ? getOwnPropertyDescriptor(typedArrayPrototype, "buffer")?.get + : undefined; +const typedArrayByteLengthGetter = typedArrayPrototype + ? getOwnPropertyDescriptor(typedArrayPrototype, "byteLength")?.get + : undefined; +const typedArrayByteOffsetGetter = typedArrayPrototype + ? getOwnPropertyDescriptor(typedArrayPrototype, "byteOffset")?.get + : undefined; +const arrayBufferByteLengthGetter = getOwnPropertyDescriptor( + arrayBufferPrototype, + "byteLength", +)?.get; +const arrayBufferResizableGetter = getOwnPropertyDescriptor( + arrayBufferPrototype, + "resizable", +)?.get; +const setBytes = Uint8Array.prototype.set; +let workerPostMessage: ((...args: never[]) => unknown) | undefined; + +function postWorkerMessage( + worker: Worker, + message: unknown, + transfer?: readonly Transferable[], +): void { + const postMessage = workerPostMessage ??= worker.postMessage as (...args: never[]) => unknown; + apply( + postMessage, + worker, + transfer === undefined ? [message] : [message, transfer], + ); +} + +function requireWorkerRequestId(value: unknown): string { + if ( + typeof value !== "string" || + value.length === 0 || + value.length > MAX_WORKER_REQUEST_ID_CHARS + ) { + throw INVALID_ARGUMENT.create({ + detail: + `Worker request id must be a non-empty string no longer than ${MAX_WORKER_REQUEST_ID_CHARS} characters`, + }); + } + return value; +} + +function requireRequestTimeoutMs(value: unknown): number { + if ( + typeof value !== "number" || + !Number.isSafeInteger(value) || + value < 1 || + value > MAX_TIMER_DELAY_MS + ) { + throw INVALID_ARGUMENT.create({ + detail: + `Project worker requestTimeoutMs must be a positive safe integer no greater than ${MAX_TIMER_DELAY_MS}`, + }); + } + return value; +} + +const WORKER_PERMISSION_KEYS = Object.freeze( + [ + "read", + "write", + "net", + "env", + "run", + "ffi", + "sys", + "import", + ] as const satisfies readonly (keyof WorkerPermissions)[], +); +const MAX_WORKER_PERMISSION_ENTRIES = 4_096; +const MAX_WORKER_PERMISSION_VALUE_CHARS = 16_384; +const MAX_WORKER_PERMISSION_UTF8_BYTES = 1024 * 1024; + +function invalidWorkerPermissions(detail: string): never { + throw new TypeError(`Project worker permissions ${detail}`); +} + +function containsAsciiControl(value: string): boolean { + for (let index = 0; index < value.length; index++) { + const codePoint = apply(stringCharCodeAt, value, [index]); + if (codePoint <= 0x1f || codePoint === 0x7f) return true; + } + return false; +} + +function snapshotPermissionScope( + value: unknown, + field: "read" | "env" | "import" | "net", +): boolean | readonly string[] { + if (typeof value === "boolean") return value; + + let descriptors: Record; + let prototype: object | null; + let isArray: boolean; + try { + isArray = arrayIsArray(value); + prototype = typeof value === "object" && value !== null ? getPrototypeOf(value) : null; + descriptors = typeof value === "object" && value !== null + ? getOwnPropertyDescriptors(value) + : {}; + } catch { + return invalidWorkerPermissions(`${field} scope could not be inspected`); + } + if (!isArray || prototype !== Array.prototype) { + return invalidWorkerPermissions(`${field} must be a boolean or plain string array`); + } + + const lengthDescriptor = descriptors.length; + const length = lengthDescriptor && "value" in lengthDescriptor + ? lengthDescriptor.value + : undefined; + if ( + !Number.isSafeInteger(length) || length < 0 || + length > MAX_WORKER_PERMISSION_ENTRIES || + ownKeys(descriptors).length !== length + 1 + ) { + return invalidWorkerPermissions(`${field} must be a bounded dense string array`); + } + + const values: string[] = []; + let utf8Bytes = 0; + for (let index = 0; index < length; index++) { + const descriptor = descriptors[String(index)]; + const entry = descriptor?.enumerable && "value" in descriptor ? descriptor.value : undefined; + if ( + typeof entry !== "string" || entry.length === 0 || + entry.length > MAX_WORKER_PERMISSION_VALUE_CHARS || + apply(stringTrim, entry, []) !== entry || containsAsciiControl(entry) + ) { + return invalidWorkerPermissions(`${field} contains a noncanonical entry`); + } + utf8Bytes += apply(textEncoderEncode, textEncoder, [entry]).byteLength; + if (utf8Bytes > MAX_WORKER_PERMISSION_UTF8_BYTES) { + return invalidWorkerPermissions(`${field} exceeds its byte budget`); + } + if (!apply(arrayIncludes, values, [entry])) { + apply(arrayPush, values, [entry]); + } + } + + apply(arraySort, values, []); + return freezeObject(values); +} + +function snapshotWorkerPermissions(value: unknown): Readonly { + if (value === null || typeof value !== "object" || arrayIsArray(value)) { + return invalidWorkerPermissions("must be a plain object"); + } + + let descriptors: Record; + let prototype: object | null; + try { + prototype = getPrototypeOf(value); + descriptors = getOwnPropertyDescriptors(value); + } catch { + return invalidWorkerPermissions("could not be inspected"); + } + if (prototype !== Object.prototype && prototype !== null) { + return invalidWorkerPermissions("must be a plain object"); + } + + const keys = ownKeys(descriptors); + if ( + keys.length !== WORKER_PERMISSION_KEYS.length || + !WORKER_PERMISSION_KEYS.every((key) => keys.includes(key)) + ) { + return invalidWorkerPermissions("must contain exactly the supported fields"); + } + + const readValue = (key: keyof WorkerPermissions): unknown => { + const descriptor = descriptors[key]; + if (!descriptor || !descriptor.enumerable || !("value" in descriptor)) { + return invalidWorkerPermissions(`${key} must be an enumerable data property`); + } + return descriptor.value; + }; + const requireBoolean = (key: "write" | "net" | "run" | "ffi" | "sys"): boolean => { + const candidate = readValue(key); + if (typeof candidate !== "boolean") { + return invalidWorkerPermissions(`${key} must be a boolean`); + } + return candidate; + }; + + return freezeObject({ + read: snapshotPermissionScope(readValue("read"), "read"), + write: requireBoolean("write"), + net: requireBoolean("net"), + env: snapshotPermissionScope(readValue("env"), "env"), + run: requireBoolean("run"), + ffi: requireBoolean("ffi"), + sys: requireBoolean("sys"), + import: snapshotPermissionScope(readValue("import"), "import"), + }); +} // Intersection with the DOM `WorkerOptions` so the value is assignable to the // `Worker` constructor without suppression — Deno reads the extra `deno` field // at runtime even though the DOM lib type doesn't declare it. type ScopedWorkerPermissions = Omit & { - net: string[] | boolean; + net: readonly string[] | boolean; }; type ExtendedWorkerOptions = WorkerOptions & { - deno?: { permissions: ScopedWorkerPermissions }; + deno?: { permissions: Readonly }; }; export interface ProjectWorkerOptions { @@ -40,14 +284,161 @@ export interface ProjectWorkerOptions { permissions: WorkerPermissions; requestTimeoutMs: number; workerScriptUrl?: string; + /** Extension-owned renderer module imported only for isolated SSR requests. */ + isolatedSsrRendererModuleUrl?: string; /** Override for deterministic egress resolution tests. */ egressResolveHost?: ResolveWorkerHost; + /** Host-owned policy snapshot. Project code must never be able to change it. */ + allowInternalEgress: boolean; } interface PendingRequest { resolve: (value: WorkerResponse) => void; reject: (error: Error) => void; timer: ReturnType; + expectedTypes: readonly string[]; + ssr?: SSRWireState; +} + +interface StreamHandler { + state: SSRWireState; + onFrame: (chunk: Uint8Array) => void; + onEnd: () => void; + onError: (error: Error) => void; +} + +interface SSRWireState { + readonly generation: string; + readonly token: string; + readonly delivery: "string" | "stream"; + expectedSequence: number; + creditAvailable: boolean; + frameBuffered: boolean; + terminal: boolean; + outputBytes: number; + outputFrames: number; +} + +type SSRWireMessage = + | WorkerStreamFrame + | WorkerStreamEnd + | WorkerSSROutputLimit + | WorkerSSRWireResult + | WorkerSSRWireError; + +function createSSROutputByteLimitError(): Error { + return SSR_OUTPUT_LIMIT_EXCEEDED.create({ + detail: `Isolated SSR output exceeded ${MAX_WORKER_SSR_OUTPUT_BYTES} bytes`, + }); +} + +function createSSROutputChunkLimitError(): Error { + return SSR_OUTPUT_LIMIT_EXCEEDED.create({ + detail: `Isolated SSR output exceeded ${MAX_WORKER_SSR_OUTPUT_CHUNKS} chunks`, + }); +} + +function isOversizedSSRHtml(value: string): boolean { + return value.length > MAX_WORKER_SSR_OUTPUT_BYTES || + textEncoder.encode(value).byteLength > MAX_WORKER_SSR_OUTPUT_BYTES; +} + +function readOwnDataProperty(value: object, key: string): unknown { + const descriptor = getOwnPropertyDescriptor(value, key); + return descriptor && "value" in descriptor ? descriptor.value : undefined; +} + +function isSSRWireMessage(value: unknown): value is SSRWireMessage { + if (value === null || typeof value !== "object") return false; + const prototype = getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return false; + const type = readOwnDataProperty(value, "type"); + return type === "stream-frame" || + type === "stream-end" || + type === "ssr-output-limit" || + type === "ssr-wire-result" || + type === "ssr-wire-error"; +} + +function hasValidSSRWireEnvelope( + message: SSRWireMessage, + id: string, + state: SSRWireState, +): boolean { + const messageId = readOwnDataProperty(message, "id"); + const generation = readOwnDataProperty(message, "generation"); + const token = readOwnDataProperty(message, "token"); + const sequence = readOwnDataProperty(message, "sequence"); + return messageId === id && + generation === state.generation && + token === state.token && + typeof sequence === "number" && + Number.isSafeInteger(sequence) && + sequence >= 0 && + sequence <= MAX_WORKER_SSR_OUTPUT_CHUNKS && + sequence === state.expectedSequence; +} + +/** + * Copy an untrusted worker view into a fixed, offset-zero ArrayBuffer. + * + * A view backed by SharedArrayBuffer or a resizable ArrayBuffer is rejected: + * either could change after accounting. Offset views over large fixed buffers + * are accepted, but only their visible bytes are retained. + */ +function copyTightFixedWorkerFrame(value: unknown): Uint8Array { + if ( + value === null || + typeof value !== "object" || + getPrototypeOf(value) !== uint8ArrayPrototype || + !typedArrayBufferGetter || + !typedArrayByteLengthGetter || + !typedArrayByteOffsetGetter || + !arrayBufferByteLengthGetter + ) { + throw new TypeError("Worker returned a non-native isolated SSR frame"); + } + + const buffer = apply(typedArrayBufferGetter, value, []) as unknown; + if ( + buffer === null || + typeof buffer !== "object" || + getPrototypeOf(buffer) !== arrayBufferPrototype + ) { + throw new TypeError("Worker returned a shared isolated SSR frame"); + } + if ( + arrayBufferResizableGetter && + apply(arrayBufferResizableGetter, buffer, []) === true + ) { + throw new TypeError("Worker returned a resizable isolated SSR frame"); + } + + const byteLength = apply(typedArrayByteLengthGetter, value, []) as number; + const byteOffset = apply(typedArrayByteOffsetGetter, value, []) as number; + const bufferByteLength = apply(arrayBufferByteLengthGetter, buffer, []) as number; + if ( + !Number.isSafeInteger(byteLength) || + !Number.isSafeInteger(byteOffset) || + byteLength < 0 || + byteOffset < 0 || + byteOffset > bufferByteLength || + byteLength > bufferByteLength - byteOffset + ) { + throw new TypeError("Worker returned an invalid isolated SSR frame view"); + } + + const source = value as Uint8Array; + const copy = new Uint8Array(byteLength); + apply(setBytes, copy, [source]); + return copy; +} + +function isSerializedWorkerError(value: unknown): value is SerializedError { + return value !== null && + typeof value === "object" && + typeof readOwnDataProperty(value, "name") === "string" && + typeof readOwnDataProperty(value, "message") === "string"; } /** @@ -55,26 +446,59 @@ interface PendingRequest { */ export type WorkerStatus = "idle" | "busy" | "crashed" | "terminated"; +function expectedResponseTypes(request: WorkerRequest): readonly string[] { + switch (request.type) { + case "execute-app-route": + case "execute-pages-route": + return ["result", "prepared-module-capacity", "error"]; + case "inspect-api-route-methods": + return ["api-route-methods", "prepared-module-capacity", "error"]; + case "fetch-data": + return ["data-result", "error"]; + case "render-ssr": + return ["ssr-wire-result", "ssr-wire-error", "ssr-output-limit"]; + default: + // Runtime callers can still cross the TypeScript boundary. The worker + // owns validation and reports unknown request kinds as a typed error. + return ["error"]; + } +} + export class ProjectWorker { readonly projectId: string; private worker: Worker | null = null; + private controlPort: MessagePort | null = null; + private workerGeneration: string | null = null; private pending = new Map(); + private streamHandlers = new Map(); + private idleListeners = new Set<() => void>(); + private suppressIdleNotifications = false; private requestTimeoutMs: number; - private permissions: WorkerPermissions; + private readonly permissions: Readonly; private workerScriptUrl?: string; + private readonly isolatedSsrRendererModuleUrl?: string; private egressResolveHost?: ResolveWorkerHost; + private readonly allowInternalEgress: boolean; private egressBroker: WorkerEgressBroker | null = null; + private shutdownPromise: Promise | null = null; private _requestCount = 0; private _lastActivityAt = Date.now(); private _status: WorkerStatus = "idle"; constructor(options: ProjectWorkerOptions) { this.projectId = options.projectId; - this.permissions = options.permissions; - this.requestTimeoutMs = options.requestTimeoutMs; + this.permissions = snapshotWorkerPermissions(options.permissions); + this.requestTimeoutMs = requireRequestTimeoutMs(options.requestTimeoutMs); this.workerScriptUrl = options.workerScriptUrl; + this.isolatedSsrRendererModuleUrl = options.isolatedSsrRendererModuleUrl === undefined + ? undefined + : validateIsolatedSsrRendererModuleUrl(options.isolatedSsrRendererModuleUrl); this.egressResolveHost = options.egressResolveHost; + if (typeof options.allowInternalEgress !== "boolean") { + throw new TypeError("Project worker allowInternalEgress must be a boolean"); + } + this.allowInternalEgress = options.allowInternalEgress; } get status(): WorkerStatus { @@ -90,7 +514,18 @@ export class ProjectWorker { } get hasPendingRequests(): boolean { - return this.pending.size > 0; + return this.pending.size > 0 || this.streamHandlers.size > 0; + } + + /** Subscribe to the transition where all worker protocol work has settled. */ + onIdle(listener: () => void): () => void { + this.idleListeners.add(listener); + let subscribed = true; + return () => { + if (!subscribed) return; + subscribed = false; + this.idleListeners.delete(listener); + }; } /** @@ -98,83 +533,117 @@ export class ProjectWorker { */ start(): void { if (this.worker) return; + if (this.shutdownPromise) { + throw INVALID_ARGUMENT.create({ + message: "A terminated project worker cannot be restarted", + }); + } if (this.workerScriptUrl && this.permissions.net) { throw INVALID_ARGUMENT.create({ message: "Custom project worker scripts cannot use unrestricted network permissions", }); } - const allowInternalEgress = isInternalEgressOverrideEnabled( - getHostEnv(WORKER_INTERNAL_EGRESS_OVERRIDE_ENV), - ); - let workerPermissions: ScopedWorkerPermissions = this.permissions; + const allowInternalEgress = this.allowInternalEgress; + let workerPermissions: Readonly = this.permissions; if (this.permissions.net === true) { this.egressBroker = startWorkerEgressBroker({ allowInternalEgress, resolveHost: this.egressResolveHost, }); - workerPermissions = { + workerPermissions = freezeObject({ ...this.permissions, - net: this.egressBroker.config.netAllowlist, - }; + net: snapshotPermissionScope(this.egressBroker.config.netAllowlist, "net"), + }); } try { const workerUrl = this.getWorkerScriptUrl(); const workerOptions: ExtendedWorkerOptions = { type: "module", - name: `project-worker-${this.projectId}`, + name: "project-worker", deno: { permissions: workerPermissions }, }; this.worker = new Worker(workerUrl, workerOptions); + this.workerGeneration = crypto.randomUUID(); + const startedWorker = this.worker; this._status = "idle"; - this.worker.onmessage = (event: MessageEvent) => { - this.handleMessage(event.data); - }; - - this.worker.onerror = (event) => { - logger.error("Worker error", { - projectId: this.projectId, - error: event.message ?? String(event), - }); - this._status = "crashed"; - this.egressBroker?.close(); - this.egressBroker = null; - this.rejectAllPending("Worker crashed"); - }; + if (this.workerScriptUrl) { + startedWorker.onmessage = (event: MessageEvent) => { + if (this.worker !== startedWorker) return; + this.handleMessage(event.data); + }; + apply(eventTargetAddEventListener, startedWorker, [ + "messageerror", + () => { + if (this.worker !== startedWorker) return; + this.failWorker("crashed", "Worker message could not be deserialized"); + }, + ]); + } else { + const channel = new NativeMessageChannel(); + this.controlPort = channel.port1; + const controlPort = this.controlPort; + apply(eventTargetAddEventListener, controlPort, [ + "message", + (event: MessageEvent) => { + if (this.controlPort !== controlPort || this.worker !== startedWorker) return; + this.handleMessage(event.data); + }, + ]); + apply(eventTargetAddEventListener, controlPort, [ + "messageerror", + () => { + if (this.controlPort !== controlPort || this.worker !== startedWorker) return; + this.failWorker("crashed", "Worker control message could not be deserialized"); + }, + ]); + apply(messagePortStart, controlPort, []); - if (!this.workerScriptUrl) { - this.worker.postMessage({ - type: "initialize-egress", - options: { - allowInternalEgress, - socksProxy: this.egressBroker?.config.socksProxy, - httpBroker: this.egressBroker?.config.httpBroker, + postWorkerMessage( + startedWorker, + { + type: "initialize-egress", + ...(this.isolatedSsrRendererModuleUrl === undefined + ? {} + : { rendererModuleUrl: this.isolatedSsrRendererModuleUrl }), + options: { + allowInternalEgress, + socksProxy: this.egressBroker?.config.socksProxy, + httpBroker: this.egressBroker?.config.httpBroker, + }, + controlPort: channel.port2, }, - }); + [channel.port2], + ); } + + startedWorker.onerror = (event) => { + apply(eventPreventDefault, event, []); + if (this.worker !== startedWorker) return; + logger.error("Worker error"); + this.failWorker("crashed", "Worker crashed"); + }; } catch (error) { - try { - this.worker?.terminate(); - } catch { - // Preserve the startup error while still closing the egress broker. - } - this.worker = null; - this.egressBroker?.close(); - this.egressBroker = null; - this._status = "terminated"; + this.beginShutdown("terminated", "Worker startup failed"); throw error; } - logger.debug("Worker started", { projectId: this.projectId }); + logger.debug("Worker started"); } /** * Execute a request in this worker. Returns a typed response. */ execute(request: WorkerRequest): Promise { + let requestId: string; + try { + requestId = requireWorkerRequestId(request.id); + } catch (error) { + return Promise.reject(error); + } return withSpan( "worker.execute", () => { @@ -183,30 +652,261 @@ export class ProjectWorker { UNKNOWN_ERROR.create({ detail: `Worker not available (status: ${this._status})` }), ); } + if (this.pending.has(requestId)) { + return Promise.reject(UNKNOWN_ERROR.create({ detail: "Duplicate worker request id" })); + } + if (request.type === "render-ssr" && request.delivery !== "string") { + return Promise.reject( + INVALID_ARGUMENT.create({ + detail: "ProjectWorker.execute requires string SSR delivery", + }), + ); + } this._requestCount++; this._lastActivityAt = Date.now(); this._status = "busy"; return new Promise((resolve, reject) => { + const ssr = request.type === "render-ssr" ? this.createSSRWireState("string") : undefined; const timer = setTimeout(() => { - this.pending.delete(request.id); - this.updateIdleStatus(); - reject( - TIMEOUT_ERROR.create({ - detail: `Worker request timed out after ${this.requestTimeoutMs}ms`, - }), - ); + this.pending.delete(requestId); + const timeoutError = TIMEOUT_ERROR.create({ + detail: `Worker request timed out after ${this.requestTimeoutMs}ms`, + }); + this.terminate(); + reject(timeoutError); }, this.requestTimeoutMs); - this.pending.set(request.id, { resolve, reject, timer }); - this.worker!.postMessage(request); + this.pending.set(requestId, { + resolve, + reject, + timer, + expectedTypes: expectedResponseTypes(request), + ssr, + }); + try { + if (ssr) this.postSSRExecutionOpen(requestId, ssr); + this.postToWorker(request); + } catch { + clearTimeout(timer); + this.pending.delete(requestId); + const sendError = UNKNOWN_ERROR.create({ + detail: "Worker request could not be sent", + }); + this.failWorker("crashed", "Worker control channel failed"); + reject(sendError); + } }); }, { - "worker.projectId": this.projectId, "worker.requestType": request.type, - "worker.requestId": request.id, + }, + ); + } + + /** + * Execute a streaming request. Returns a ReadableStream that yields + * chunks as they arrive from the Worker via postMessage. + * + * Each execution uses an authenticated, sequenced one-credit protocol. A + * string result is never accepted after streaming delivery begins. + */ + executeStream(request: WorkerRequest): ReadableStream { + const requestId = requireWorkerRequestId(request.id); + if (request.type !== "render-ssr" || request.delivery !== "stream") { + throw INVALID_ARGUMENT.create({ + detail: "ProjectWorker.executeStream requires streaming SSR delivery", + }); + } + if (!this.worker || this._status === "crashed" || this._status === "terminated") { + throw UNKNOWN_ERROR.create({ detail: `Worker not available (status: ${this._status})` }); + } + + if (this.pending.has(requestId) || this.streamHandlers.has(requestId)) { + throw UNKNOWN_ERROR.create({ detail: "Duplicate worker request id" }); + } + + this._requestCount++; + this._lastActivityAt = Date.now(); + this._status = "busy"; + + const state = this.createSSRWireState("stream"); + let cancelRequest: (() => void) | undefined; + let grantCreditForDemand: (() => void) | undefined; + + return new ReadableStream( + { + start: (controller) => { + let settled = false; + const clearRegistration = () => { + clearTimeout(timer); + this.streamHandlers.delete(requestId); + this.pending.delete(requestId); + }; + + const settle = ( + outcome: "close" | "error" | "cancel", + error?: Error, + retireWorker = false, + ) => { + if (settled) return; + settled = true; + state.terminal = true; + clearRegistration(); + if (outcome === "close") { + controller.close(); + } else if (outcome === "error") { + controller.error( + error ?? + UNKNOWN_ERROR.create({ detail: "Isolated SSR stream failed" }), + ); + } + if (retireWorker) { + this.failWorker( + "terminated", + error?.message ?? "Isolated SSR stream was cancelled", + ); + } else { + this.updateIdleStatus(); + } + }; + + const timer = setTimeout(() => { + const timeoutError = TIMEOUT_ERROR.create({ + detail: `Worker stream timed out after ${this.requestTimeoutMs}ms`, + }); + settle("error", timeoutError, true); + }, this.requestTimeoutMs); + + const grantCredit = () => { + if ( + settled || + state.terminal || + !state.frameBuffered || + state.creditAvailable + ) { + return; + } + if ((controller.desiredSize ?? 0) <= 0) return; + + // State changes before posting so repeated pull() calls cannot + // manufacture more than one credit for this sequence. + state.frameBuffered = false; + state.creditAvailable = true; + try { + this.postToWorker( + { + type: "stream-credit", + id: requestId, + generation: state.generation, + token: state.token, + sequence: state.expectedSequence, + } satisfies WorkerStreamCredit, + ); + } catch { + settle( + "error", + UNKNOWN_ERROR.create({ + detail: "Worker stream credit could not be sent", + }), + true, + ); + } + }; + grantCreditForDemand = grantCredit; + + cancelRequest = () => { + // No worker-side cancel RPC exists. Retiring the worker is the only + // boundary that guarantees project rendering stops when the + // downstream consumer disconnects. + settle("cancel", undefined, true); + }; + + this.streamHandlers.set(requestId, { + state, + onFrame: (chunk) => { + if (settled) return; + + const chunkBytes = chunk.byteLength; + state.outputFrames += 1; + if (state.outputFrames > MAX_WORKER_SSR_OUTPUT_CHUNKS) { + settle("error", createSSROutputChunkLimitError(), true); + return; + } + if ( + chunkBytes === 0 || + chunkBytes > MAX_WORKER_SSR_CHUNK_BYTES + ) { + settle( + "error", + UNKNOWN_ERROR.create({ + detail: "Worker returned an invalid isolated SSR frame size", + }), + true, + ); + return; + } + if ( + chunkBytes > MAX_WORKER_SSR_OUTPUT_BYTES - state.outputBytes + ) { + settle("error", createSSROutputByteLimitError(), true); + return; + } + state.outputBytes += chunkBytes; + + // copyTightFixedWorkerFrame() guarantees this queue never retains + // an offset, oversized, shared, or resizable backing allocation. + controller.enqueue(chunk); + state.frameBuffered = true; + grantCredit(); + }, + onEnd: () => { + settle("close"); + }, + onError: (error: Error) => { + settle("error", error); + }, + }); + + this.pending.set(requestId, { + resolve: () => { + settle( + "error", + UNKNOWN_ERROR.create({ + detail: "Worker returned a non-stream response for streaming SSR", + }), + true, + ); + }, + reject: (error) => { + settle("error", error); + }, + timer, + expectedTypes: expectedResponseTypes(request), + ssr: state, + }); + + try { + this.postSSRExecutionOpen(requestId, state); + this.postToWorker(request); + } catch { + const sendError = UNKNOWN_ERROR.create({ + detail: "Worker stream request could not be sent", + }); + settle("error", sendError, true); + } + }, + pull: () => { + grantCreditForDemand?.(); + }, + cancel: () => { + cancelRequest?.(); + }, + }, + { + highWaterMark: 1, + size: () => 1, }, ); } @@ -239,9 +939,17 @@ export class ProjectWorker { resolve(false); }, timer, + expectedTypes: ["pong"], }); - this.worker!.postMessage({ type: "ping", id }); + try { + this.postToWorker({ type: "ping", id }); + } catch { + clearTimeout(timer); + this.pending.delete(id); + this.failWorker("crashed", "Worker health message could not be sent"); + resolve(false); + } }); } @@ -250,94 +958,385 @@ export class ProjectWorker { */ clearModuleCache(): void { if (!this.worker || this._status === "crashed" || this._status === "terminated") return; - this.worker.postMessage({ type: "clear-cache" }); + // ESM imports cannot be evicted from an existing worker isolate. Retiring + // the worker is the only honest invalidation boundary for file-based data + // and SSR modules. + this.terminate(); } /** * Terminate the worker. Rejects all pending requests. */ terminate(): void { - if (!this.worker) return; + void this.shutdown(); + logger.debug("Worker terminated"); + } - this._status = "terminated"; - this.rejectAllPending("Worker terminated"); + /** + * Terminate the worker and wait until all worker-owned resources are closed. + * + * The returned promise is single-flight and never rejects: teardown failures + * are logged after every close attempt, while callers still receive a + * deterministic quiescence boundary. + */ + shutdown(): Promise { + return this.beginShutdown("terminated", "Worker terminated"); + } - try { - this.worker.terminate(); - } catch (error) { - logger.debug("Worker terminate failed", { - projectId: this.projectId, - error, - }); + // ----------------------------------------------------------------------- + // Private + // ----------------------------------------------------------------------- + + private createSSRWireState(delivery: "string" | "stream"): SSRWireState { + if (!this.workerGeneration) { + throw UNKNOWN_ERROR.create({ detail: "Worker generation is not available" }); } + return { + generation: this.workerGeneration, + token: crypto.randomUUID(), + delivery, + expectedSequence: 0, + creditAvailable: true, + frameBuffered: false, + terminal: false, + outputBytes: 0, + outputFrames: 0, + }; + } + + private postSSRExecutionOpen(id: string, state: SSRWireState): void { + this.postToWorker( + { + type: "ssr-execution-open", + id, + generation: state.generation, + token: state.token, + delivery: state.delivery, + } satisfies WorkerSSRExecutionOpen, + ); + } + + private postToWorker(message: unknown): void { + if (this.controlPort) { + apply(messagePortPostMessage, this.controlPort, [message]); + return; + } + if (!this.worker) { + throw UNKNOWN_ERROR.create({ detail: "Worker not available" }); + } + postWorkerMessage(this.worker, message); + } + + private failWorker(status: "crashed" | "terminated", reason: string): void { + void this.beginShutdown(status, reason); + } + private beginShutdown( + status: "crashed" | "terminated", + reason: string, + ): Promise { + if (this.shutdownPromise) return this.shutdownPromise; + + const completion = Promise.withResolvers(); + this.shutdownPromise = completion.promise; + + const worker = this.worker; + const egressBroker = this.egressBroker; + const hadActiveWork = this._status === "busy" || + this.pending.size !== 0 || + this.streamHandlers.size !== 0; this.worker = null; - this.egressBroker?.close(); + this.workerGeneration = null; this.egressBroker = null; - logger.debug("Worker terminated", { projectId: this.projectId }); - } + this._status = status; - // ----------------------------------------------------------------------- - // Private - // ----------------------------------------------------------------------- + this.suppressIdleNotifications = true; + try { + this.rejectAllPending(reason); + } finally { + this.suppressIdleNotifications = false; + } - private getWorkerScriptUrl(): string { - if (this.workerScriptUrl) return this.workerScriptUrl; + if (worker) { + try { + worker.terminate(); + } catch (error) { + logger.debug("Worker terminate failed", { error }); + } + } + + this.closeControlPort(); + if (egressBroker) { + try { + egressBroker.close(); + } catch (error) { + logger.debug("Worker egress broker close failed", { error }); + } + } + if (hadActiveWork) this.notifyIdleListeners(); - // In compiled binary mode, use a data URL because blob URLs don't work - // See: deno-sandbox.ts for the same pattern - if (isCompiledBinary()) { - // For compiled binaries, we'd need to inline the worker script. - // For now, fall through to the import.meta.resolve path which works - // in development and standard Deno execution. + if (!egressBroker) { + completion.resolve(); + return completion.promise; } - // Use import.meta.resolve to get the absolute URL of the worker script. - // This works in both `deno run` and `deno compile` contexts. + void egressBroker.closed.then( + () => completion.resolve(), + (error) => { + logger.debug("Worker egress broker shutdown failed", { error }); + completion.resolve(); + }, + ); + return completion.promise; + } + + private closeControlPort(): void { + if (!this.controlPort) return; + try { + apply(messagePortClose, this.controlPort, []); + } catch { + // Worker termination still closes the underlying transport. + } + this.controlPort = null; + } + + private getWorkerScriptUrl(): string { + if (this.workerScriptUrl) return this.workerScriptUrl; + // The binary build explicitly includes this module in its VFS. return import.meta.resolve("./worker-script.ts"); } - private handleMessage( - data: - | WorkerResponse - | { type: "worker-exit" } - | { type: "pong"; id: string }, - ): void { - if (data.type === "worker-exit") { + private handleMessage(data: unknown): void { + if (typeof data !== "object" || data === null) { + this.failWorker("crashed", "Worker returned an invalid control message"); + return; + } + const type = readOwnDataProperty(data, "type"); + if (typeof type !== "string") { + this.failWorker("crashed", "Worker returned an invalid control message"); + return; + } + + if (type === "worker-exit") { this.terminate(); return; } - if (data.type === "pong") { - const pending = this.pending.get((data as { id: string }).id); + if (type === "pong") { + const id = readOwnDataProperty(data, "id"); + if (typeof id !== "string") { + this.failWorker("crashed", "Worker returned an invalid health response"); + return; + } + const pending = this.pending.get(id); if (pending) { + if (!apply(arrayIncludes, pending.expectedTypes, ["pong"])) { + this.failWorker("crashed", "Worker returned a response for the wrong request type"); + return; + } clearTimeout(pending.timer); pending.resolve(data as unknown as WorkerResponse); - this.pending.delete((data as { id: string }).id); + this.pending.delete(id); } return; } - const response = data as WorkerResponse; - const pending = this.pending.get(response.id); + if (isSSRWireMessage(data)) { + this.handleSSRWireMessage(data); + return; + } + + const id = readOwnDataProperty(data, "id"); + if (typeof id !== "string") { + this.failWorker("crashed", "Worker returned an invalid control response"); + return; + } + const pending = this.pending.get(id); if (!pending) { logger.warn("Received response for unknown request", { - projectId: this.projectId, - id: response.id, + responseType: type, }); return; } + if (pending.ssr) { + this.failWorker("crashed", "Worker mixed the generic and isolated SSR protocols"); + return; + } + if (!apply(arrayIncludes, pending.expectedTypes, [type])) { + this.failWorker("crashed", "Worker returned a response for the wrong request type"); + return; + } clearTimeout(pending.timer); - this.pending.delete(response.id); + this.pending.delete(id); this.updateIdleStatus(); - pending.resolve(response); + pending.resolve(data as WorkerResponse); + } + + private handleSSRWireMessage(message: SSRWireMessage): void { + const id = readOwnDataProperty(message, "id"); + if (typeof id !== "string") { + this.failWorker("crashed", "Worker returned an invalid isolated SSR envelope"); + return; + } + const pending = this.pending.get(id); + const state = pending?.ssr; + if (!pending || !state || state.terminal) { + this.failWorker("crashed", "Worker returned a stale isolated SSR message"); + return; + } + if (!hasValidSSRWireEnvelope(message, id, state)) { + this.failWorker("crashed", "Worker returned a mismatched isolated SSR message"); + return; + } + + if (message.type === "stream-frame") { + const handler = this.streamHandlers.get(id); + if ( + state.delivery !== "stream" || + !handler || + handler.state !== state || + !state.creditAvailable || + state.frameBuffered + ) { + this.failWorker("crashed", "Worker emitted an uncredited isolated SSR frame"); + return; + } + + let chunk: Uint8Array; + try { + chunk = copyTightFixedWorkerFrame( + readOwnDataProperty(message, "chunk"), + ); + } catch { + this.failWorker("crashed", "Worker returned an unsafe isolated SSR frame"); + return; + } + state.creditAvailable = false; + state.expectedSequence += 1; + handler.onFrame(chunk); + return; + } + + if (!state.creditAvailable || state.frameBuffered) { + this.failWorker("crashed", "Worker emitted an uncredited isolated SSR terminal"); + return; + } + + if (message.type === "stream-end") { + const handler = this.streamHandlers.get(id); + if ( + state.delivery !== "stream" || + !handler || + handler.state !== state + ) { + this.failWorker("crashed", "Worker returned an invalid isolated SSR stream terminal"); + return; + } + state.terminal = true; + handler.onEnd(); + return; + } + + if (message.type === "ssr-wire-result") { + if ( + state.delivery !== "string" || + state.expectedSequence !== 0 || + typeof readOwnDataProperty(message, "html") !== "string" + ) { + this.failWorker("crashed", "Worker returned an invalid isolated SSR string terminal"); + return; + } + const html = readOwnDataProperty(message, "html") as string; + state.terminal = true; + clearTimeout(pending.timer); + this.pending.delete(id); + if (isOversizedSSRHtml(html)) { + pending.reject(createSSROutputByteLimitError()); + this.failWorker("terminated", "Worker exceeded the isolated SSR output boundary"); + return; + } + this.updateIdleStatus(); + pending.resolve({ type: "ssr-result", id, html }); + return; + } + + if (message.type === "ssr-output-limit") { + const limit = readOwnDataProperty(message, "limit"); + if (limit !== "bytes" && limit !== "chunks") { + this.failWorker("crashed", "Worker returned an invalid isolated SSR output limit"); + return; + } + state.terminal = true; + const error = limit === "bytes" + ? createSSROutputByteLimitError() + : createSSROutputChunkLimitError(); + if (state.delivery === "stream") { + const handler = this.streamHandlers.get(id); + if (!handler || handler.state !== state) { + this.failWorker("crashed", "Worker returned an unexpected isolated SSR output limit"); + return; + } + handler.onError(error); + } else { + clearTimeout(pending.timer); + this.pending.delete(id); + pending.reject(error); + this.updateIdleStatus(); + } + return; + } + + if (message.type === "ssr-wire-error") { + const serialized = readOwnDataProperty(message, "error"); + if (!isSerializedWorkerError(serialized)) { + this.failWorker("crashed", "Worker returned an invalid isolated SSR error"); + return; + } + const error = deserializeWorkerError(serialized); + state.terminal = true; + if (state.delivery === "stream") { + const handler = this.streamHandlers.get(id); + if (!handler || handler.state !== state) { + this.failWorker("crashed", "Worker returned an unexpected isolated SSR error"); + return; + } + handler.onError(error); + } else { + clearTimeout(pending.timer); + this.pending.delete(id); + this.updateIdleStatus(); + pending.resolve({ + type: "error", + id, + error: serialized, + }); + } + } } private updateIdleStatus(): void { - if (this.pending.size === 0 && this._status === "busy") { - this._status = "idle"; + if (this.pending.size !== 0 || this.streamHandlers.size !== 0) return; + if (this._status !== "busy") return; + this._status = "idle"; + this.notifyIdleListeners(); + } + + private notifyIdleListeners(): void { + if ( + this.suppressIdleNotifications || + this.pending.size !== 0 || + this.streamHandlers.size !== 0 + ) { + return; + } + for (const listener of [...this.idleListeners]) { + try { + listener(); + } catch { + // Lifecycle observers cannot interfere with worker cleanup. + } } } @@ -347,5 +1346,11 @@ export class ProjectWorker { pending.reject(UNKNOWN_ERROR.create({ detail: reason })); this.pending.delete(id); } + + // Clean up stream handlers + for (const [id, handler] of this.streamHandlers) { + handler.onError(UNKNOWN_ERROR.create({ detail: reason })); + this.streamHandlers.delete(id); + } } } diff --git a/src/security/sandbox/telemetry-redaction.fixture.ts b/src/security/sandbox/telemetry-redaction.fixture.ts new file mode 100644 index 0000000000..89b78a98ab --- /dev/null +++ b/src/security/sandbox/telemetry-redaction.fixture.ts @@ -0,0 +1,100 @@ +import { computeHash } from "#veryfront/utils"; +import { ProjectWorker } from "./project-worker.ts"; +import { buildWorkerPermissions } from "./worker-permissions.ts"; + +const FAULTS = { + uncaught: { + tenantMarker: "VF_TENANT_UNCAUGHT_SECRET_7f3c", + sourceMarker: "VF_PRIVATE_SOURCE_UNCAUGHT_8e2b", + }, + rejection: { + tenantMarker: "VF_TENANT_REJECTION_SECRET_3d6a", + sourceMarker: "VF_PRIVATE_SOURCE_REJECTION_9c4e", + }, +} as const; + +type FaultKind = keyof typeof FAULTS; + +const faultKind = Deno.args[0] as FaultKind; +const fault = FAULTS[faultKind]; +if (!fault) throw new TypeError("Unknown worker fault fixture"); + +const projectDir = await Deno.makeTempDir(); +const modulePath = `/tenants/${fault.tenantMarker}/private-route.ts`; +const scheduleFault = faultKind === "uncaught" + ? `queueMicrotask(() => { + throw new Error(${JSON.stringify(`${fault.tenantMarker} ${modulePath}`)}); + });` + : `void Promise.reject( + new Error(${JSON.stringify(`${fault.tenantMarker} ${modulePath}`)}), + );`; +const source = ` + Event.prototype.preventDefault = () => { + throw new Error("Project event intrinsic must not run"); + }; + export function GET() { + ${scheduleFault} + return new Promise(() => {}); + } + // ${fault.sourceMarker} +`; +const worker = new ProjectWorker({ + projectId: `telemetry-${faultKind}`, + permissions: buildWorkerPermissions([projectDir]), + requestTimeoutMs: 5_000, + allowInternalEgress: false, +}); +let idleNotifications = 0; +let rejectionCount = 0; +let resolved = false; +const unsubscribe = worker.onIdle(() => idleNotifications++); + +try { + worker.start(); + if (!await worker.isHealthy(30_000)) { + throw new Error("Worker fault fixture did not become healthy"); + } + + await worker.execute({ + type: "execute-app-route", + id: `telemetry-${faultKind}`, + module: { + source, + sha256: await computeHash(source), + }, + modulePath, + method: "GET", + request: { + url: "http://localhost/private", + method: "GET", + headers: [], + body: null, + }, + params: {}, + projectDir, + sourceIntegrationPolicy: { + schemaVersion: 1, + mode: "unrestricted", + }, + }).then( + () => { + resolved = true; + }, + () => { + rejectionCount++; + }, + ); + + await new Promise((resolve) => setTimeout(resolve, 25)); + console.log(JSON.stringify({ + hasPendingRequests: worker.hasPendingRequests, + idleNotifications, + rejectionCount, + resolved, + status: worker.status, + })); +} finally { + unsubscribe(); + worker.terminate(); + await Deno.remove(projectDir, { recursive: true }); +} diff --git a/src/security/sandbox/telemetry-redaction.test.ts b/src/security/sandbox/telemetry-redaction.test.ts new file mode 100644 index 0000000000..ba32fedf29 --- /dev/null +++ b/src/security/sandbox/telemetry-redaction.test.ts @@ -0,0 +1,153 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals, assertExists, assertRejects } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { isDeno } from "#veryfront/platform/compat/runtime.ts"; +import { DenoAdapter } from "#veryfront/platform/adapters/runtime/deno/adapter.ts"; +import { clearConfigCache } from "#veryfront/config"; +import { + __registerLogRecordEmitter, + __resetLogRecordEmitterForTests, + type LogEntry, + refreshLoggerConfig, +} from "#veryfront/utils/logger/index.ts"; +import { SecurityConfigLoader } from "../http/config.ts"; + +const testSuite = isDeno ? describe : describe.skip; + +type WorkerFaultKind = "uncaught" | "rejection"; + +interface WorkerFaultSubprocessResult { + readonly stderr: string; + readonly summary: { + readonly hasPendingRequests: boolean; + readonly idleNotifications: number; + readonly rejectionCount: number; + readonly resolved: boolean; + readonly status: string; + }; +} + +async function runWorkerFaultSubprocess( + faultKind: WorkerFaultKind, +): Promise { + const command = new Deno.Command(Deno.execPath(), { + args: [ + "run", + "--quiet", + "--no-check", + "--allow-all", + "--unstable-worker-options", + new URL("./telemetry-redaction.fixture.ts", import.meta.url).pathname, + faultKind, + ], + cwd: Deno.cwd(), + env: { + LOG_FORMAT: "text", + LOG_LEVEL: "ERROR", + }, + stdout: "piped", + stderr: "piped", + }); + const output = await command.output(); + const stdout = new TextDecoder().decode(output.stdout).trim(); + const stderr = new TextDecoder().decode(output.stderr); + assertEquals(output.code, 0, stderr); + const summaryLine = stdout.split("\n").at(-1); + if (!summaryLine) throw new Error("Worker fault fixture returned no summary"); + return { + stderr, + summary: JSON.parse(summaryLine), + } as WorkerFaultSubprocessResult; +} + +function assertContainedWorkerFault( + result: WorkerFaultSubprocessResult, + expected: { + readonly modulePath: string; + readonly sourceMarker: string; + readonly tenantMarker: string; + }, +): void { + assertEquals(result.stderr.includes(expected.tenantMarker), false); + assertEquals(result.stderr.includes(expected.modulePath), false); + assertEquals(result.stderr.includes(expected.sourceMarker), false); + assertEquals( + result.stderr.includes(encodeURIComponent(expected.sourceMarker)), + false, + ); + assertEquals(result.summary, { + hasPendingRequests: false, + idleNotifications: 1, + rejectionCount: 1, + resolved: false, + status: "terminated", + }); +} + +testSuite("security worker telemetry redaction", () => { + it("suppresses uncaught project diagnostics at process stderr", async () => { + const tenantMarker = "VF_TENANT_UNCAUGHT_SECRET_7f3c"; + assertContainedWorkerFault( + await runWorkerFaultSubprocess("uncaught"), + { + tenantMarker, + modulePath: `/tenants/${tenantMarker}/private-route.ts`, + sourceMarker: "VF_PRIVATE_SOURCE_UNCAUGHT_8e2b", + }, + ); + }); + + it("suppresses unhandled project rejection diagnostics at process stderr", async () => { + const tenantMarker = "VF_TENANT_REJECTION_SECRET_3d6a"; + assertContainedWorkerFault( + await runWorkerFaultSubprocess("rejection"), + { + tenantMarker, + modulePath: `/tenants/${tenantMarker}/private-route.ts`, + sourceMarker: "VF_PRIVATE_SOURCE_REJECTION_9c4e", + }, + ); + }); + + it("does not emit config-loader failure details or project paths", async () => { + const tenantMarker = `config-${crypto.randomUUID()}`; + const projectDir = `/tenants/${tenantMarker}`; + const entries: LogEntry[] = []; + const adapter = new DenoAdapter(); + adapter.fs.exists = () => + Promise.reject(new Error(`failed to inspect ${projectDir}: ${tenantMarker}`)); + const previousLogLevel = Deno.env.get("LOG_LEVEL"); + const originalConsoleDebug = console.debug; + + try { + clearConfigCache(); + Deno.env.set("LOG_LEVEL", "DEBUG"); + refreshLoggerConfig(); + __registerLogRecordEmitter((entry) => entries.push(entry)); + console.debug = () => {}; + + const loader = new SecurityConfigLoader(projectDir, adapter); + await assertRejects( + () => loader.ensureLoaded(), + Error, + tenantMarker, + ); + + const diagnostic = entries.find( + (entry) => entry.message === "Failed to load security config; will retry on next request", + ); + assertExists(diagnostic); + const serialized = JSON.stringify(diagnostic); + assertEquals(serialized.includes(tenantMarker), false); + assertEquals(serialized.includes(projectDir), false); + assertEquals(diagnostic.error, undefined); + } finally { + clearConfigCache(); + console.debug = originalConsoleDebug; + __resetLogRecordEmitterForTests(); + if (previousLogLevel === undefined) Deno.env.delete("LOG_LEVEL"); + else Deno.env.set("LOG_LEVEL", previousLogLevel); + refreshLoggerConfig(); + } + }); +}); diff --git a/src/security/sandbox/worker-egress-guard.test.ts b/src/security/sandbox/worker-egress-guard.test.ts index 9113cb2ed3..731a94bafc 100644 --- a/src/security/sandbox/worker-egress-guard.test.ts +++ b/src/security/sandbox/worker-egress-guard.test.ts @@ -1,5 +1,5 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; +import { assert, assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { assertWorkerEgressAllowed, @@ -9,9 +9,36 @@ import { guardedWorkerConnectTls, isInternalEgressIp, isInternalEgressOverrideEnabled, + startWorkerEgressBroker, + startWorkerEgressSocksProxy, WORKER_INTERNAL_EGRESS_OVERRIDE_ENV, WorkerEgressBlockedError, } from "./worker-egress-guard.ts"; +import type { WorkerEgressFetch } from "./worker-egress-guard.ts"; + +async function beforeDeadline( + operation: Promise, + message: string, + timeoutMs = 2_000, +): Promise { + let timeout: number | undefined; + const deadline = new Promise((_resolve, reject) => { + timeout = setTimeout(() => reject(new Error(message)), timeoutMs); + }); + try { + return await Promise.race([operation, deadline]); + } finally { + clearTimeout(timeout); + } +} + +function closeTestConnection(connection: Deno.Conn): void { + try { + connection.close(); + } catch { + // The proxy may already have closed the peer during admission or shutdown. + } +} describe("worker-egress-guard", () => { it("identifies loopback, metadata, private, and link-local addresses", () => { @@ -269,11 +296,273 @@ describe("worker-egress-guard", () => { }); }); +describe("worker-egress-guard admission and shutdown", () => { + it("caps simultaneous SOCKS handshakes and drains admitted handlers on close", async () => { + const proxy = startWorkerEgressSocksProxy({ allowInternalEgress: true }); + const connections: Deno.TcpConn[] = []; + + try { + connections.push( + ...await beforeDeadline( + Promise.all( + Array.from( + { length: 64 }, + () => + Deno.connect({ + hostname: proxy.config.hostname, + port: proxy.config.port, + }), + ), + ), + "SOCKS admission flood did not connect in time", + ), + ); + + const outcomes = await beforeDeadline( + Promise.all( + connections.map(async (connection): Promise<"admitted" | "rejected"> => { + const greeting = new Uint8Array([0x05, 0x01, 0x02]); + let written = 0; + try { + while (written < greeting.length) { + written += await connection.write(greeting.subarray(written)); + } + const response = new Uint8Array(2); + const read = await connection.read(response); + return read === 2 && response[0] === 0x05 && response[1] === 0x02 + ? "admitted" + : "rejected"; + } catch { + return "rejected"; + } + }), + ), + "SOCKS admission decisions did not settle in time", + ); + + const admitted = outcomes.filter((outcome) => outcome === "admitted").length; + const rejected = outcomes.length - admitted; + assert(admitted > 0, "the proxy must admit work below its cap"); + assert(admitted <= 32, "the proxy admitted more than its structural cap"); + assert(rejected >= 32, "the proxy did not reject the excess flood"); + + proxy.close(); + await beforeDeadline(proxy.closed, "SOCKS proxy did not drain after close"); + const endOfStreams = await beforeDeadline( + Promise.all( + connections.map((connection) => connection.read(new Uint8Array(1)).catch(() => null)), + ), + "SOCKS connections remained open after proxy drain", + ); + assertEquals(endOfStreams.every((read) => read === null), true); + } finally { + proxy.close(); + await proxy.closed; + for (const connection of connections) closeTestConnection(connection); + } + }); + + it("rejects broker requests above the per-worker admission cap", async () => { + const releaseResponses = Promise.withResolvers(); + const admissionFilled = Promise.withResolvers(); + let activeTargets = 0; + let peakTargets = 0; + const targetServer = Deno.serve( + { hostname: "127.0.0.1", port: 0, onListen: () => {} }, + async () => { + activeTargets++; + peakTargets = Math.max(peakTargets, activeTargets); + if (activeTargets === 32) admissionFilled.resolve(); + try { + await releaseResponses.promise; + return new Response("ok"); + } finally { + activeTargets--; + } + }, + ); + const targetAddress = targetServer.addr; + if (targetAddress.transport !== "tcp") { + await targetServer.shutdown(); + throw new Error("expected a TCP target server"); + } + + const broker = startWorkerEgressBroker({ allowInternalEgress: true }); + const requests = Array.from( + { length: 64 }, + () => + guardedEgressFetch(`http://127.0.0.1:${targetAddress.port}/`, undefined, { + options: { httpBroker: broker.config.httpBroker }, + }), + ); + const settledRequests = Promise.allSettled(requests); + + try { + await beforeDeadline( + admissionFilled.promise, + "broker did not fill its bounded admission window", + ); + releaseResponses.resolve(); + const results = await beforeDeadline( + settledRequests, + "broker flood did not settle after releasing admitted requests", + ); + const fulfilled = results.filter( + (result): result is PromiseFulfilledResult => result.status === "fulfilled", + ); + const rejected = results.filter( + (result): result is PromiseRejectedResult => result.status === "rejected", + ); + + assertEquals(peakTargets, 32); + assertEquals(fulfilled.length, 32); + assertEquals(rejected.length, 32); + assertEquals( + rejected.every((result) => + result.reason instanceof WorkerEgressBlockedError && + result.reason.message.includes("admission limit") + ), + true, + ); + await Promise.all( + fulfilled.map((result) => result.value.body?.cancel().catch(() => undefined)), + ); + } finally { + releaseResponses.resolve(); + broker.close(); + await beforeDeadline( + Promise.all([broker.closed, settledRequests]).then(() => undefined), + "broker flood did not drain during cleanup", + ); + await targetServer.shutdown(); + } + }); + + it("aborts and drains a broker request stalled during SOCKS resolution", async () => { + const resolutionStarted = Promise.withResolvers(); + const broker = startWorkerEgressBroker({ + resolveHost: () => { + resolutionStarted.resolve(); + return new Promise(() => {}); + }, + }); + const pending = guardedEgressFetch("http://stalled.invalid/", undefined, { + options: { httpBroker: broker.config.httpBroker }, + }).then( + () => null, + (error: unknown) => error, + ); + + try { + await beforeDeadline( + resolutionStarted.promise, + "stalled broker request did not reach host resolution", + ); + broker.close(); + await beforeDeadline(broker.closed, "broker did not drain its stalled request"); + assert(await pending instanceof Error); + } finally { + broker.close(); + await broker.closed; + } + }); +}); + describe("worker-egress-guard guardedEgressFetch redirect handling", () => { function redirectTo(location: string, status = 302): Response { return new Response(null, { status, headers: { location } }); } + it("uses an injected pinned transport only after validating resolved addresses", async () => { + let fallbackFetchCalls = 0; + let pinnedFetchCalls = 0; + const response = await guardedEgressFetch( + "https://public.example/resource", + undefined, + { + fetchImpl: () => { + fallbackFetchCalls++; + return Promise.resolve(new Response("unexpected")); + }, + pinnedFetch(url, addresses, init) { + pinnedFetchCalls++; + assertEquals(url.href, "https://public.example/resource"); + assertEquals(addresses, ["93.184.216.34"]); + assertEquals(init.redirect, "manual"); + return Promise.resolve(new Response("pinned")); + }, + options: { + resolveHost: () => Promise.resolve(["93.184.216.34"]), + }, + }, + ); + + assertEquals(await response.text(), "pinned"); + assertEquals(pinnedFetchCalls, 1); + assertEquals(fallbackFetchCalls, 0); + }); + + it("rejects unsafe addresses before invoking an injected pinned transport", async () => { + let pinnedFetchCalls = 0; + await assertRejects( + () => + guardedEgressFetch("https://public.example/resource", undefined, { + pinnedFetch() { + pinnedFetchCalls++; + return Promise.resolve(new Response("unexpected")); + }, + options: { + resolveHost: () => Promise.resolve(["10.0.0.8"]), + }, + }), + WorkerEgressBlockedError, + "blocked for host", + ); + assertEquals(pinnedFetchCalls, 0); + }); + + it("cancels a late pinned response when the transport ignores abort", async () => { + const controller = new AbortController(); + const transportStarted = Promise.withResolvers(); + const lateResponse = Promise.withResolvers(); + const bodyCancelled = Promise.withResolvers(); + const pending = guardedEgressFetch( + "https://public.example/resource", + { signal: controller.signal }, + { + pinnedFetch() { + transportStarted.resolve(); + return lateResponse.promise; + }, + options: { + resolveHost: () => Promise.resolve(["93.184.216.34"]), + }, + }, + ).then( + () => null, + (error: unknown) => error, + ); + + await transportStarted.promise; + const abortReason = new Error("test abort"); + controller.abort(abortReason); + assertEquals(await pending, abortReason); + + lateResponse.resolve( + new Response( + new ReadableStream({ + cancel() { + bodyCancelled.resolve(); + }, + }), + ), + ); + await beforeDeadline( + bodyCancelled.promise, + "late pinned response body was not cancelled", + ); + }); + it("keeps non-network fetch schemes out of the HTTP broker", async () => { let seenInput = ""; const response = await guardedEgressFetch("data:text/plain,hello", undefined, { @@ -328,7 +617,7 @@ describe("worker-egress-guard guardedEgressFetch redirect handling", () => { it("blocks a public URL that redirects to an internal address", async () => { let calls = 0; - const fetchImpl: typeof fetch = (input) => { + const fetchImpl: WorkerEgressFetch = (input) => { calls++; const url = input instanceof Request ? input.url : String(input); if (url.startsWith("http://93.184.216.34")) { @@ -346,7 +635,7 @@ describe("worker-egress-guard guardedEgressFetch redirect handling", () => { }); it("follows a public -> public redirect chain and returns the final response", async () => { - const fetchImpl: typeof fetch = (input) => { + const fetchImpl: WorkerEgressFetch = (input) => { const url = input instanceof Request ? input.url : String(input); if (url === "http://93.184.216.34/a") { return Promise.resolve(redirectTo("http://93.184.216.35/b")); @@ -365,7 +654,8 @@ describe("worker-egress-guard guardedEgressFetch redirect handling", () => { }); it("returns the redirect unfollowed when redirect mode is 'manual'", async () => { - const fetchImpl: typeof fetch = () => Promise.resolve(redirectTo("http://169.254.169.254/x")); + const fetchImpl: WorkerEgressFetch = () => + Promise.resolve(redirectTo("http://169.254.169.254/x")); const res = await guardedEgressFetch( "http://93.184.216.34/a", { redirect: "manual" }, @@ -376,7 +666,7 @@ describe("worker-egress-guard guardedEgressFetch redirect handling", () => { it("cancels an unexposed redirect body when redirect mode is 'error'", async () => { let cancellations = 0; - const fetchImpl: typeof fetch = () => + const fetchImpl: WorkerEgressFetch = () => Promise.resolve( new Response( new ReadableStream({ @@ -402,7 +692,7 @@ describe("worker-egress-guard guardedEgressFetch redirect handling", () => { it("throws after exceeding the maximum redirect count", async () => { let cancellations = 0; - const fetchImpl: typeof fetch = () => + const fetchImpl: WorkerEgressFetch = () => Promise.resolve( new Response( new ReadableStream({ @@ -420,12 +710,21 @@ describe("worker-egress-guard guardedEgressFetch redirect handling", () => { assertEquals(cancellations, 21); }); - it("strips Authorization and Cookie on a cross-origin redirect", async () => { - const seen: Array<{ auth: string | null; cookie: string | null }> = []; - const fetchImpl: typeof fetch = (input, init) => { + it("strips bearer, cookie, and provider credentials on a cross-origin redirect", async () => { + const credentialHeaders = [ + "authorization", + "cookie", + "proxy-authorization", + "x-api-key", + "api-key", + "x-auth-token", + "x-goog-api-key", + ] as const; + const seen: Array> = []; + const fetchImpl: WorkerEgressFetch = (input, init) => { const url = input instanceof Request ? input.url : String(input); const headers = new Headers(init?.headers); - seen.push({ auth: headers.get("authorization"), cookie: headers.get("cookie") }); + seen.push(Object.fromEntries(credentialHeaders.map((name) => [name, headers.get(name)]))); if (url === "http://93.184.216.34/start") { return Promise.resolve(redirectTo("http://93.184.216.35/landing")); } @@ -434,17 +733,27 @@ describe("worker-egress-guard guardedEgressFetch redirect handling", () => { const res = await guardedEgressFetch( "http://93.184.216.34/start", - { headers: { Authorization: "Bearer secret", Cookie: "sid=abc" } }, + { + headers: Object.fromEntries( + credentialHeaders.map((name) => [name, `${name}-secret`]), + ), + }, { fetchImpl }, ); assertEquals(res.status, 200); - assertEquals(seen[0], { auth: "Bearer secret", cookie: "sid=abc" }); - assertEquals(seen[1], { auth: null, cookie: null }); + assertEquals( + seen[0], + Object.fromEntries(credentialHeaders.map((name) => [name, `${name}-secret`])), + ); + assertEquals( + seen[1], + Object.fromEntries(credentialHeaders.map((name) => [name, null])), + ); }); it("preserves Authorization on a same-origin redirect", async () => { const seen: Array = []; - const fetchImpl: typeof fetch = (input, init) => { + const fetchImpl: WorkerEgressFetch = (input, init) => { const url = input instanceof Request ? input.url : String(input); seen.push(new Headers(init?.headers).get("authorization")); if (url === "http://93.184.216.34/a") { @@ -485,7 +794,7 @@ describe("worker-egress-guard guardedEgressFetch redirect handling", () => { for (const testCase of cases) { const seen: Array<{ method: string | undefined; body: BodyInit | null | undefined }> = []; let calls = 0; - const fetchImpl: typeof fetch = (_input, init) => { + const fetchImpl: WorkerEgressFetch = (_input, init) => { seen.push({ method: init?.method, body: init?.body }); calls++; return Promise.resolve( @@ -511,7 +820,7 @@ describe("worker-egress-guard guardedEgressFetch redirect handling", () => { it("removes request body headers when a redirect downgrades to GET", async () => { const seenHeaders: Headers[] = []; let calls = 0; - const fetchImpl: typeof fetch = (_input, init) => { + const fetchImpl: WorkerEgressFetch = (_input, init) => { seenHeaders.push(new Headers(init?.headers)); calls++; return Promise.resolve( @@ -551,7 +860,7 @@ describe("worker-egress-guard guardedEgressFetch redirect handling", () => { it("blocks a redirect to a non-http(s) scheme (e.g. file://)", async () => { let calls = 0; - const fetchImpl: typeof fetch = (input) => { + const fetchImpl: WorkerEgressFetch = (input) => { calls++; const url = input instanceof Request ? input.url : String(input); if (url.startsWith("http://93.184.216.34")) { @@ -571,7 +880,7 @@ describe("worker-egress-guard guardedEgressFetch redirect handling", () => { it("preserves the abort signal across redirect hops", async () => { const controller = new AbortController(); const seenSignals: Array = []; - const fetchImpl: typeof fetch = (input, init) => { + const fetchImpl: WorkerEgressFetch = (input, init) => { const url = input instanceof Request ? input.url : String(input); seenSignals.push(init?.signal); if (url === "http://93.184.216.34/a") { @@ -595,7 +904,7 @@ describe("worker-egress-guard guardedEgressFetch redirect handling", () => { // compare against request.signal, not controller.signal. const request = new Request("http://93.184.216.34/a", { signal: controller.signal }); let seenSignal: AbortSignal | null | undefined; - const fetchImpl: typeof fetch = (_input, init) => { + const fetchImpl: WorkerEgressFetch = (_input, init) => { seenSignal = init?.signal; return Promise.resolve(new Response("ok", { status: 200 })); }; diff --git a/src/security/sandbox/worker-egress-guard.ts b/src/security/sandbox/worker-egress-guard.ts index 0af79b3ba8..966b7bee9a 100644 --- a/src/security/sandbox/worker-egress-guard.ts +++ b/src/security/sandbox/worker-egress-guard.ts @@ -7,9 +7,9 @@ * @module security/sandbox/worker-egress-guard */ -import { getHostEnv } from "#veryfront/platform/compat/process.ts"; import { resolveHostAddresses } from "#veryfront/platform/compat/dns.ts"; -import { getDenoRuntime } from "#veryfront/platform/compat/runtime.ts"; +import { getDenoRuntime, isBun, isNode } from "#veryfront/platform/compat/runtime.ts"; +import { fetchWithPinnedAddresses } from "#veryfront/platform/compat/http/pinned-fetch.ts"; export const WORKER_INTERNAL_EGRESS_OVERRIDE_ENV = "VERYFRONT_WORKER_ALLOW_INTERNAL_EGRESS"; @@ -38,6 +38,10 @@ export interface WorkerEgressGuardOptions { httpBroker?: WorkerEgressHttpBrokerConfig; } +export type InstalledWorkerEgressGuardOptions = WorkerEgressGuardOptions & { + allowInternalEgress: boolean; +}; + export type WorkerEgressTcpConnect = (options: Deno.ConnectOptions) => Promise; export type WorkerEgressTcpListen = (options: Deno.ListenOptions) => Deno.TcpListener; export type WorkerEgressStartTls = ( @@ -311,10 +315,6 @@ function getConnectHostname(options: unknown): string | null { return typeof hostname === "string" ? hostname : "127.0.0.1"; } -function getAllowInternalEgress(): boolean { - return isInternalEgressOverrideEnabled(getHostEnv(WORKER_INTERNAL_EGRESS_OVERRIDE_ENV)); -} - function getPinnedEgressRuntime( override?: Partial, ): PinnedEgressRuntime { @@ -343,6 +343,25 @@ function safeClose(connection: { close(): void }): void { } } +const MAX_WORKER_EGRESS_SOCKS_CONNECTIONS = 32; +const MAX_WORKER_EGRESS_BROKER_REQUESTS = 32; +const SOCKS_HANDSHAKE_TIMEOUT_MS = 10_000; +const SOCKS_RELAY_BUFFER_BYTES = 16 * 1024; + +function trackHandler( + handlers: Set>, + operation: Promise, +): void { + const tracked = operation.finally(() => handlers.delete(tracked)); + handlers.add(tracked); +} + +async function drainHandlers(handlers: Set>): Promise { + while (handlers.size > 0) { + await Promise.all(handlers); + } +} + async function readExactly(connection: Deno.Conn, length: number): Promise { const result = new Uint8Array(length); let offset = 0; @@ -403,7 +422,7 @@ async function relayTcpConnections( const relay = async (source: Deno.Conn, destination: Deno.Conn): Promise => { try { - const buffer = new Uint8Array(16 * 1024); + const buffer = new Uint8Array(SOCKS_RELAY_BUFFER_BYTES); while (true) { const read = await source.read(buffer); if (read === null) { @@ -656,6 +675,7 @@ interface PinnedSocksTunnel { client: Deno.HttpClient; abort(): void; closeListener(): void; + closed: Promise; } function randomCredential(): string { @@ -685,6 +705,7 @@ function startPinnedSocksTunnel( const password = encoder.encode(passwordText); const controller = new AbortController(); const connections = new Set(); + const handlers = new Set>(); let accepting = true; let claimed = false; @@ -698,7 +719,6 @@ function startPinnedSocksTunnel( closeListener(); controller.abort(new Error("Pinned egress tunnel closed")); for (const connection of connections) safeClose(connection); - connections.clear(); }; const handleConnection = async (downstream: Deno.TcpConn): Promise => { @@ -709,9 +729,10 @@ function startPinnedSocksTunnel( handshakeExpired = true; handshakeController.abort(new Error("SOCKS proxy handshake timed out")); safeClose(downstream); - }, 10_000); + }, SOCKS_HANDSHAKE_TIMEOUT_MS); let upstream: Deno.TcpConn | undefined; let responseStarted = false; + let ownsClaim = false; try { const authenticated = await authenticateSocksClient(downstream, username, password); if (!authenticated || claimed) return; @@ -726,7 +747,11 @@ function startPinnedSocksTunnel( } claimed = true; + ownsClaim = true; closeListener(); + for (const connection of connections) { + if (connection !== downstream) safeClose(connection); + } clearTimeout(handshakeTimeout); if (handshakeExpired) return; upstream = await connectFirstAddress( @@ -758,6 +783,7 @@ function startPinnedSocksTunnel( safeClose(upstream); connections.delete(upstream); } + if (ownsClaim) abort(); } }; @@ -765,14 +791,23 @@ function startPinnedSocksTunnel( while (accepting && !claimed) { try { const connection = await listener.accept(); - void handleConnection(connection); + if (handlers.size >= MAX_WORKER_EGRESS_SOCKS_CONNECTIONS) { + safeClose(connection); + continue; + } + trackHandler(handlers, handleConnection(connection)); } catch { if (accepting) abort(); return; } } }; - void acceptLoop(); + const acceptTask = acceptLoop(); + const closed = (async () => { + await acceptTask; + await drainHandlers(handlers); + connections.clear(); + })(); let client: Deno.HttpClient; try { @@ -788,12 +823,14 @@ function startPinnedSocksTunnel( throw error; } - return { client, abort, closeListener }; + return { client, abort, closeListener, closed }; } export interface WorkerEgressSocksProxy { config: WorkerEgressSocksProxyConfig; close(): void; + /** Resolves after the listener and every admitted connection have stopped. */ + closed: Promise; } export function startWorkerEgressSocksProxy( @@ -819,6 +856,7 @@ export function startWorkerEgressSocksProxy( const password = encoder.encode(config.password); const controller = new AbortController(); const connections = new Set(); + const handlers = new Set>(); let open = true; const close = () => { @@ -827,7 +865,6 @@ export function startWorkerEgressSocksProxy( controller.abort(new Error("Worker egress proxy closed")); safeClose(listener); for (const connection of connections) safeClose(connection); - connections.clear(); }; const handleConnection = async (downstream: Deno.TcpConn): Promise => { @@ -838,7 +875,7 @@ export function startWorkerEgressSocksProxy( handshakeExpired = true; handshakeController.abort(new Error("SOCKS proxy handshake timed out")); safeClose(downstream); - }, 10_000); + }, SOCKS_HANDSHAKE_TIMEOUT_MS); let upstream: Deno.TcpConn | undefined; let responseStarted = false; try { @@ -891,15 +928,24 @@ export function startWorkerEgressSocksProxy( while (open) { try { const connection = await listener.accept(); - void handleConnection(connection); + if (handlers.size >= MAX_WORKER_EGRESS_SOCKS_CONNECTIONS) { + safeClose(connection); + continue; + } + trackHandler(handlers, handleConnection(connection)); } catch { if (open) close(); } } }; - void acceptLoop(); + const acceptTask = acceptLoop(); + const closed = (async () => { + await acceptTask; + await drainHandlers(handlers); + connections.clear(); + })(); - return { config, close }; + return { config, close, closed }; } const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); @@ -923,6 +969,19 @@ const HOP_BY_HOP_HEADERS = [ "upgrade", ] as const; +// Fetch only strips a small set of credentials automatically. Provider SDKs +// also use API-key headers, so the guard must remove those before a +// cross-origin redirect can be followed. +const CROSS_ORIGIN_CREDENTIAL_HEADERS = [ + "authorization", + "cookie", + "proxy-authorization", + "x-api-key", + "api-key", + "x-auth-token", + "x-goog-api-key", +] as const; + function stripHopByHopHeaders(headers: Headers): void { for (const name of HOP_BY_HOP_HEADERS) headers.delete(name); } @@ -941,7 +1000,7 @@ function createSocksHttpClient( } async function fetchThroughHttpBroker( - fetchImpl: typeof fetch, + fetchImpl: WorkerEgressFetch, broker: WorkerEgressHttpBrokerConfig, targetUrl: string, init: RequestInit, @@ -991,12 +1050,35 @@ async function fetchThroughHttpBroker( return response; } +/** Fetch shape consumed by the worker egress guard. */ +export type WorkerEgressFetch = ( + input: RequestInfo | URL, + init?: RequestInit, +) => Promise; + +/** DNS-pinned transport seam used after the guard validates every address. */ +export type WorkerEgressPinnedFetch = ( + url: URL, + addresses: readonly string[], + init: RequestInit, +) => Promise; + /** Dependencies for {@link guardedEgressFetch} (injectable for tests). */ export interface GuardedEgressFetchDeps { /** Underlying fetch implementation (defaults to the global `fetch`). */ - fetchImpl?: typeof fetch; + fetchImpl?: WorkerEgressFetch; + /** + * Trusted DNS-pinned transport replacement. The guard still resolves and + * validates every address before invoking this seam. + */ + pinnedFetch?: WorkerEgressPinnedFetch; /** Egress options applied to the initial URL and every redirect hop. */ options?: WorkerEgressGuardOptions; + /** + * Optional caller-owned policy applied to the initial URL and every + * redirect destination before a connection is opened. + */ + authorizeUrl?: (url: URL) => void | Promise; /** Captured runtime primitives used to establish the DNS-pinned tunnel. */ runtime?: Partial; } @@ -1066,10 +1148,12 @@ export async function guardedEgressFetch( for (let hop = 0;; hop++) { const parsedUrl = new URL(url); + await deps.authorizeUrl?.(parsedUrl); const hostname = getUrlHostname(parsedUrl); let tunnel: PinnedSocksTunnel | undefined; let client: Deno.HttpClient | undefined; let response: Response; + let pinnedResponse: Promise | undefined; const isNetworkRequest = hostname !== null && (parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:"); const requestInit: RequestInit = { @@ -1098,18 +1182,37 @@ export async function guardedEgressFetch( : parsedUrl.protocol === "https:" ? 443 : 80; - tunnel = startPinnedSocksTunnel(hostname, addresses, port, getRuntime()); - client = tunnel.client; + if (deps.pinnedFetch || (isNode || isBun) && deps.runtime === undefined) { + const pinnedFetch = deps.pinnedFetch ?? fetchWithPinnedAddresses; + pinnedResponse = Promise.resolve().then(() => + pinnedFetch(parsedUrl, addresses, requestInit) + ); + } else { + tunnel = startPinnedSocksTunnel(hostname, addresses, port, getRuntime()); + client = tunnel.client; + } } } + const pendingResponse = pinnedResponse + ? pinnedResponse + : Promise.resolve().then(() => + doFetch(url, { + ...requestInit, + ...(client ? { client } : {}), + }) + ); try { - response = await doFetch(url, { - ...requestInit, - ...(client ? { client } : {}), - }); + response = await waitForOperation(pendingResponse, requestInit.signal ?? undefined); } catch (error) { tunnel?.abort(); + // A non-cooperative fetch can resolve after the request has already + // timed out. Its body is no longer observable, so release it without + // keeping the listener/client alive for the late result. + void pendingResponse.then( + (lateResponse) => lateResponse.body?.cancel().catch(() => undefined), + () => undefined, + ); throw error; } finally { client?.close(); @@ -1157,9 +1260,7 @@ export async function guardedEgressFetch( // platform fetch this guard replaces, so a redirect target cannot receive // the caller's Authorization/Cookie. if (nextUrl.origin !== new URL(url).origin) { - headers.delete("authorization"); - headers.delete("cookie"); - headers.delete("proxy-authorization"); + for (const header of CROSS_ORIGIN_CREDENTIAL_HEADERS) headers.delete(header); } url = nextUrl.href; didRedirect = true; @@ -1200,6 +1301,100 @@ export interface WorkerEgressBrokerConfig { export interface WorkerEgressBroker { config: WorkerEgressBrokerConfig; close(): void; + /** Resolves after both listeners and every admitted request have stopped. */ + closed: Promise; +} + +interface BrokerRequestLease { + readonly signal: AbortSignal; + readonly completion: Promise; + setBodyCancel(cancel: () => Promise): void; + release(): void; + abort(reason: Error): void; +} + +function createBrokerRequestLease(onRelease: () => void): BrokerRequestLease { + const controller = new AbortController(); + const { promise: completion, resolve } = Promise.withResolvers(); + let bodyCancel: (() => Promise) | undefined; + let released = false; + + const release = () => { + if (released) return; + released = true; + bodyCancel = undefined; + onRelease(); + resolve(); + }; + + return { + signal: controller.signal, + completion, + setBodyCancel(cancel) { + if (released) { + void cancel().catch(() => undefined); + return; + } + bodyCancel = cancel; + }, + release, + abort(reason) { + if (!controller.signal.aborted) controller.abort(reason); + if (bodyCancel) { + void bodyCancel().then(release, release); + } + }, + }; +} + +function holdBrokerResponse( + response: Response, + lease: BrokerRequestLease, +): Response { + if (!response.body) { + lease.release(); + return response; + } + + const reader = response.body.getReader(); + lease.setBodyCancel(() => reader.cancel(new Error("Worker egress broker closed"))); + + const body = new ReadableStream({ + async pull(controller) { + try { + const result = await reader.read(); + if (result.done) { + lease.release(); + controller.close(); + return; + } + controller.enqueue(result.value); + } catch (error) { + lease.release(); + controller.error(error); + } + }, + async cancel(reason) { + try { + await reader.cancel(reason); + } finally { + lease.release(); + } + }, + }); + + return new Response(body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); +} + +function brokerErrorResponse(message: string, status: number): Response { + return Response.json( + { message }, + { status, headers: { [BROKER_ERROR_HEADER]: "1" } }, + ); } export function startWorkerEgressBroker( @@ -1217,13 +1412,18 @@ export function startWorkerEgressBroker( const tokenBytes = new TextEncoder().encode(token); const controller = new AbortController(); const fetchImpl = globalThis.fetch.bind(globalThis); + const activeRequests = new Set(); + let open = true; - const handler = async (request: Request): Promise => { + const handleAdmittedRequest = async ( + request: Request, + signal: AbortSignal, + ): Promise => { const receivedToken = new TextEncoder().encode(request.headers.get(BROKER_AUTH_HEADER) ?? ""); if (!constantTimeEqual(receivedToken, tokenBytes)) { - return Response.json( - { message: "Worker network egress broker authentication failed" }, - { status: 403, headers: { [BROKER_ERROR_HEADER]: "1" } }, + return brokerErrorResponse( + "Worker network egress broker authentication failed", + 403, ); } @@ -1233,9 +1433,9 @@ export function startWorkerEgressBroker( targetUrl = new URL(target ?? ""); if (targetUrl.protocol !== "http:" && targetUrl.protocol !== "https:") throw new Error(); } catch { - return Response.json( - { message: "Worker network egress blocked: invalid broker target" }, - { status: 400, headers: { [BROKER_ERROR_HEADER]: "1" } }, + return brokerErrorResponse( + "Worker network egress blocked: invalid broker target", + 400, ); } @@ -1256,7 +1456,7 @@ export function startWorkerEgressBroker( headers, body: request.method === "GET" || request.method === "HEAD" ? undefined : request.body, redirect: "manual", - signal: request.signal, + signal, }, { fetchImpl, @@ -1295,11 +1495,47 @@ export function startWorkerEgressBroker( const message = error instanceof WorkerEgressBlockedError ? error.message : "Worker network egress blocked or failed"; - return Response.json( - { message }, - { status: 502, headers: { [BROKER_ERROR_HEADER]: "1" } }, + return brokerErrorResponse(message, 502); + } + }; + + const runAdmittedRequest = async ( + request: Request, + lease: BrokerRequestLease, + ): Promise => { + try { + const signal = AbortSignal.any([ + request.signal, + controller.signal, + lease.signal, + ]); + const response = await handleAdmittedRequest(request, signal); + if (lease.signal.aborted || controller.signal.aborted) { + await response.body?.cancel().catch(() => undefined); + lease.release(); + return brokerErrorResponse("Worker egress broker closed", 503); + } + return holdBrokerResponse(response, lease); + } catch { + lease.release(); + return brokerErrorResponse("Worker network egress blocked or failed", 502); + } + }; + + const handler = (request: Request): Response | Promise => { + if (!open) { + return brokerErrorResponse("Worker egress broker closed", 503); + } + if (activeRequests.size >= MAX_WORKER_EGRESS_BROKER_REQUESTS) { + return brokerErrorResponse( + "Worker network egress blocked: broker request admission limit reached", + 429, ); } + + const lease = createBrokerRequestLease(() => activeRequests.delete(lease)); + activeRequests.add(lease); + return runAdmittedRequest(request, lease); }; let server: Deno.HttpServer; @@ -1329,9 +1565,28 @@ export function startWorkerEgressBroker( token, }; const close = () => { - controller.abort(); + if (!open) return; + open = false; + const reason = new Error("Worker egress broker closed"); + controller.abort(reason); + for (const request of activeRequests) request.abort(reason); socks.close(); }; + const serverFinished = server.finished; + const closed = (async () => { + let serverFailed = false; + let serverError: unknown; + try { + await serverFinished; + } catch (error) { + serverFailed = true; + serverError = error; + } + close(); + await Promise.all([...activeRequests].map((request) => request.completion)); + await socks.closed; + if (serverFailed) throw serverError; + })(); return { config: { socksProxy: socks.config, @@ -1342,6 +1597,7 @@ export function startWorkerEgressBroker( ], }, close, + closed, }; } @@ -1416,16 +1672,17 @@ export async function guardedWorkerConnectTls( } } -export function installWorkerEgressGuard(options: WorkerEgressGuardOptions = {}): void { +export function installWorkerEgressGuard( + options: InstalledWorkerEgressGuardOptions, +): void { const globalRecord = globalThis as typeof globalThis & Record; if (globalRecord[guardInstalled]) return; + if (typeof options.allowInternalEgress !== "boolean") { + throw new TypeError("Worker egress allowInternalEgress must be a boolean"); + } const runtime = getPinnedEgressRuntime(); - - const baseOptions = { - ...options, - allowInternalEgress: options.allowInternalEgress ?? getAllowInternalEgress(), - }; + const baseOptions = { ...options }; const originalFetch = globalThis.fetch.bind(globalThis); const fetchWrapper = async ( diff --git a/src/security/sandbox/worker-error-boundary.test.ts b/src/security/sandbox/worker-error-boundary.test.ts new file mode 100644 index 0000000000..862820ab45 --- /dev/null +++ b/src/security/sandbox/worker-error-boundary.test.ts @@ -0,0 +1,228 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assert, assertEquals, assertInstanceOf } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { SERVICE_OVERLOADED, VeryfrontError } from "#veryfront/errors"; +import { deserializeWorkerError } from "./worker-error-boundary.ts"; + +describe("worker error boundary", () => { + it("preserves registered identity and sanitized diagnostics", () => { + const error = deserializeWorkerError({ + message: "dependency overloaded", + name: "VeryfrontError", + stack: + "VeryfrontError: dependency overloaded\n at postgres://admin:secret@db.internal/query:1:1", + problem: { + slug: SERVICE_OVERLOADED.slug, + category: SERVICE_OVERLOADED.category, + status: 429, + title: SERVICE_OVERLOADED.title, + suggestion: SERVICE_OVERLOADED.suggestion, + detail: "upstream capacity exhausted", + cause: "queue is full", + instance: "/requests/data-1", + }, + }); + + assertInstanceOf(error, VeryfrontError); + assertEquals(error.slug, SERVICE_OVERLOADED.slug); + assertEquals(error.status, 429); + assertEquals(error.detail, "upstream capacity exhausted"); + assertEquals(error.cause, "queue is full"); + assertEquals(error.instance, "/requests/data-1"); + assertEquals(error.message, "dependency overloaded"); + assert(error.stack?.includes("postgres://admin:[REDACTED]@db.internal/query")); + assertEquals(error.stack?.includes("secret"), false); + }); + + it("fails closed on forged registered metadata", () => { + const error = deserializeWorkerError({ + message: "project failure", + name: "VeryfrontError", + stack: "VeryfrontError: project failure", + problem: { + slug: SERVICE_OVERLOADED.slug, + category: "GENERAL", + status: 418, + title: "Forged title", + suggestion: "Trust project metadata", + detail: "forged detail", + }, + }); + + assertInstanceOf(error, Error); + assertEquals(error instanceof VeryfrontError, false); + assertEquals(error.name, "VeryfrontError"); + assertEquals(error.message, "project failure"); + }); + + it("does not invoke serialized error accessors", () => { + let accessorCalls = 0; + const serialized = Object.defineProperties({}, { + message: { + enumerable: true, + get() { + accessorCalls++; + throw new Error("message accessor must not run"); + }, + }, + name: { + enumerable: true, + value: "VeryfrontError", + }, + problem: { + enumerable: true, + get() { + accessorCalls++; + throw new Error("problem accessor must not run"); + }, + }, + }); + + const error = deserializeWorkerError(serialized); + + assertEquals(accessorCalls, 0); + assertEquals(error instanceof VeryfrontError, false); + assertEquals(error.name, "VeryfrontError"); + assertEquals(error.message, "Unknown error"); + }); + + it("does not invoke registered metadata accessors", () => { + let accessorCalls = 0; + const problem = Object.defineProperties({}, { + slug: { + enumerable: true, + get() { + accessorCalls++; + throw new Error("slug accessor must not run"); + }, + }, + category: { enumerable: true, value: SERVICE_OVERLOADED.category }, + status: { enumerable: true, value: SERVICE_OVERLOADED.status }, + title: { enumerable: true, value: SERVICE_OVERLOADED.title }, + suggestion: { enumerable: true, value: SERVICE_OVERLOADED.suggestion }, + detail: { enumerable: true, value: "must not be trusted" }, + }); + + const error = deserializeWorkerError({ + message: "project failure", + name: "VeryfrontError", + problem, + }); + + assertEquals(accessorCalls, 0); + assertEquals(error instanceof VeryfrontError, false); + assertEquals(error.message, "project failure"); + }); + + it("fails closed on a revoked serialized-error proxy", () => { + const { proxy, revoke } = Proxy.revocable({}, {}); + revoke(); + + const error = deserializeWorkerError(proxy); + + assertEquals(error.name, "Error"); + assertEquals(error.message, "Unknown error"); + }); + + it("does not read inherited descriptor-map entries", () => { + const originalMessage = Object.getOwnPropertyDescriptor(Object.prototype, "message"); + let prototypeGetterCalls = 0; + let error = new Error("decoder did not run"); + + try { + Object.defineProperty(Object.prototype, "message", { + configurable: true, + get() { + prototypeGetterCalls++; + return { value: "prototype-forged-message" }; + }, + }); + error = deserializeWorkerError({}); + } finally { + if (originalMessage) { + Object.defineProperty(Object.prototype, "message", originalMessage); + } else { + delete (Object.prototype as { message?: unknown }).message; + } + } + + assertEquals(prototypeGetterCalls, 0); + assertEquals(error.message, "Unknown error"); + }); + + it("does not read an inherited value from an accessor descriptor", () => { + const originalValue = Object.getOwnPropertyDescriptor(Object.prototype, "value"); + let serializedAccessorCalls = 0; + let prototypeGetterCalls = 0; + const serialized = Object.defineProperty({}, "message", { + enumerable: true, + get() { + serializedAccessorCalls++; + return "accessor-backed-message"; + }, + }); + let error = new Error("decoder did not run"); + + try { + Object.defineProperty(Object.prototype, "value", { + configurable: true, + get() { + prototypeGetterCalls++; + return "prototype-forged-message"; + }, + }); + error = deserializeWorkerError(serialized); + } finally { + if (originalValue) { + Object.defineProperty(Object.prototype, "value", originalValue); + } else { + delete (Object.prototype as { value?: unknown }).value; + } + } + + assertEquals(serializedAccessorCalls, 0); + assertEquals(prototypeGetterCalls, 0); + assertEquals(error.message, "Unknown error"); + }); + + it("does not forge registered identity from descriptor-map prototypes", () => { + const poisonedFields: readonly (readonly [string, unknown])[] = [ + ["slug", SERVICE_OVERLOADED.slug], + ["category", SERVICE_OVERLOADED.category], + ["status", 429], + ["title", SERVICE_OVERLOADED.title], + ["suggestion", SERVICE_OVERLOADED.suggestion], + ]; + const originals = poisonedFields.map(([key]) => + Object.getOwnPropertyDescriptor(Object.prototype, key) + ); + let error = new Error("decoder did not run"); + + try { + for (const [key, value] of poisonedFields) { + Object.defineProperty(Object.prototype, key, { + configurable: true, + value: { value }, + writable: true, + }); + } + error = deserializeWorkerError({ + message: "project failure", + name: "VeryfrontError", + problem: {}, + }); + } finally { + poisonedFields.forEach(([key], index) => { + const original = originals[index]; + if (original) { + Object.defineProperty(Object.prototype, key, original); + } else { + delete (Object.prototype as Record)[key]; + } + }); + } + + assertEquals(error instanceof VeryfrontError, false); + assertEquals(error.message, "project failure"); + }); +}); diff --git a/src/security/sandbox/worker-error-boundary.ts b/src/security/sandbox/worker-error-boundary.ts new file mode 100644 index 0000000000..e4f271438d --- /dev/null +++ b/src/security/sandbox/worker-error-boundary.ts @@ -0,0 +1,179 @@ +import { ERROR_REGISTRY, type RegisteredError } from "#veryfront/errors"; +import { + sanitizeDiagnosticText, + sanitizeStackDiagnosticText, +} from "#veryfront/errors/safe-diagnostics.ts"; +import { types as nodeUtilTypes } from "node:util"; + +const apply = Reflect.apply; +const arrayIsArray = Array.isArray; +const getOwnPropertyDescriptors = Object.getOwnPropertyDescriptors; +const getPrototypeOf = Object.getPrototypeOf; +const numberIsSafeInteger = Number.isSafeInteger; +const objectDefineProperty = Object.defineProperty; +const objectPrototype = Object.prototype; +const objectPrototypeHasOwnProperty = Object.prototype.hasOwnProperty; +const isNativeProxy = nodeUtilTypes.isProxy; + +const INVALID_WORKER_FIELD = Symbol("invalid-worker-field"); +type InvalidWorkerField = typeof INVALID_WORKER_FIELD; + +interface WorkerErrorSnapshot { + readonly message: string; + readonly name: string; + readonly stack?: string; + readonly definition?: RegisteredError; + readonly status?: number; + readonly detail?: string; + readonly cause?: string; + readonly instance?: string; +} + +function getDataDescriptors(value: unknown): PropertyDescriptorMap | null { + if ( + typeof value !== "object" || + value === null || + isNativeProxy(value) || + apply(arrayIsArray, Array, [value]) + ) { + return null; + } + + try { + const prototype = getPrototypeOf(value); + if (prototype !== objectPrototype && prototype !== null) return null; + return getOwnPropertyDescriptors(value); + } catch { + return null; + } +} + +function dataField( + descriptors: PropertyDescriptorMap, + key: string, +): unknown | InvalidWorkerField { + if (!apply(objectPrototypeHasOwnProperty, descriptors, [key])) return undefined; + const descriptor = descriptors[key]; + if (!descriptor) return INVALID_WORKER_FIELD; + return apply(objectPrototypeHasOwnProperty, descriptor, ["value"]) + ? descriptor.value + : INVALID_WORKER_FIELD; +} + +function optionalDiagnostic( + descriptors: PropertyDescriptorMap, + key: string, +): string | undefined | InvalidWorkerField { + const value = dataField(descriptors, key); + if (value === INVALID_WORKER_FIELD) return value; + if (value === undefined) return undefined; + return typeof value === "string" ? sanitizeDiagnosticText(value) : INVALID_WORKER_FIELD; +} + +function snapshotSerializedWorkerError(serialized: unknown): WorkerErrorSnapshot { + const descriptors = getDataDescriptors(serialized); + if (!descriptors) { + return { message: "Unknown error", name: "Error" }; + } + + const rawMessage = dataField(descriptors, "message"); + const rawName = dataField(descriptors, "name"); + const rawStack = dataField(descriptors, "stack"); + const message = typeof rawMessage === "string" + ? sanitizeDiagnosticText(rawMessage) + : "Unknown error"; + const name = typeof rawName === "string" ? sanitizeDiagnosticText(rawName) : "Error"; + const stack = typeof rawStack === "string" ? sanitizeStackDiagnosticText(rawStack) : undefined; + + const problem = dataField(descriptors, "problem"); + const problemDescriptors = getDataDescriptors(problem); + if (!problemDescriptors) return { message, name, stack }; + + const slug = dataField(problemDescriptors, "slug"); + if ( + typeof slug !== "string" || + !apply(objectPrototypeHasOwnProperty, ERROR_REGISTRY, [slug]) + ) { + return { message, name, stack }; + } + + const definition = ERROR_REGISTRY[slug as keyof typeof ERROR_REGISTRY]; + const category = dataField(problemDescriptors, "category"); + const status = dataField(problemDescriptors, "status"); + const title = dataField(problemDescriptors, "title"); + const suggestion = dataField(problemDescriptors, "suggestion"); + if ( + category !== definition.category || + title !== definition.title || + suggestion !== definition.suggestion || + typeof status !== "number" || + !numberIsSafeInteger(status) || + status < 400 || + status >= 600 + ) { + return { message, name, stack }; + } + + const detail = optionalDiagnostic(problemDescriptors, "detail"); + const cause = optionalDiagnostic(problemDescriptors, "cause"); + const instance = optionalDiagnostic(problemDescriptors, "instance"); + if ( + detail === INVALID_WORKER_FIELD || + cause === INVALID_WORKER_FIELD || + instance === INVALID_WORKER_FIELD + ) { + return { message, name, stack }; + } + + return { + message, + name, + stack, + definition, + status, + detail, + cause, + instance, + }; +} + +function applySerializedStack(error: Error, stack: unknown): void { + if (typeof stack !== "string") return; + try { + apply(objectDefineProperty, Object, [ + error, + "stack", + { + configurable: true, + value: stack, + writable: true, + }, + ]); + } catch { + // The shared boundary still returns a safe error without a stack. + } +} + +/** + * Decode one worker-owned error snapshot without trusting project metadata or + * invoking accessors across the host boundary. + */ +export function deserializeWorkerError(serialized: unknown): Error { + const snapshot = snapshotSerializedWorkerError(serialized); + if (snapshot.definition) { + const error = snapshot.definition.create({ + message: snapshot.message, + status: snapshot.status, + detail: snapshot.detail, + cause: snapshot.cause, + instance: snapshot.instance, + }); + applySerializedStack(error, snapshot.stack); + return error; + } + + const error = new Error(snapshot.message); + error.name = snapshot.name; + applySerializedStack(error, snapshot.stack); + return error; +} diff --git a/src/security/sandbox/worker-generation.test.ts b/src/security/sandbox/worker-generation.test.ts new file mode 100644 index 0000000000..294fcbd3a6 --- /dev/null +++ b/src/security/sandbox/worker-generation.test.ts @@ -0,0 +1,167 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { + assertEquals, + assertMatch, + assertNotEquals, + assertRejects, + assertThrows, +} from "#veryfront/testing/assert.ts"; +import { + digestWorkerGenerationMaterial, + isWorkerGenerationInScope, + resolveWorkerGeneration, + snapshotWorkerGenerationIdentity, +} from "./worker-generation.ts"; + +Deno.test("worker generation identities are exact, bounded, and deterministic", async () => { + assertEquals(snapshotWorkerGenerationIdentity(undefined, undefined), undefined); + assertThrows( + () => snapshotWorkerGenerationIdentity("scope", undefined), + TypeError, + "must be supplied together", + ); + assertThrows( + () => snapshotWorkerGenerationIdentity(undefined, "release"), + TypeError, + "must be supplied together", + ); + assertThrows( + () => snapshotWorkerGenerationIdentity("", "release"), + TypeError, + "scopeId must be a non-empty string", + ); + assertThrows( + () => snapshotWorkerGenerationIdentity("scope", "x".repeat(1025)), + TypeError, + "generationId must be a non-empty string", + ); + await assertRejects( + () => resolveWorkerGeneration("invalid" as never), + TypeError, + 'must be "api", "data", or "ssr"', + ); + + const identity = snapshotWorkerGenerationIdentity("data-scope", "release-a"); + const first = await resolveWorkerGeneration("data", identity); + const again = await resolveWorkerGeneration("data", identity); + const ssr = await resolveWorkerGeneration("ssr", identity); + const changed = await resolveWorkerGeneration( + "data", + snapshotWorkerGenerationIdentity("data-scope", "release-b"), + ); + + assertEquals(first, again); + assertMatch( + first.workerId, + /^veryfront-worker:v1:kind=4:data:scope=\d+:[A-Za-z0-9_-]+:generation=64:[0-9a-f]{64}$/, + ); + assertEquals(first.reusable, true); + assertNotEquals(changed.workerId, first.workerId); + assertNotEquals(ssr.workerId, first.workerId); + assertEquals(isWorkerGenerationInScope(first.workerId, "data-scope"), true); + assertEquals(isWorkerGenerationInScope(ssr.workerId, "data-scope"), true); + assertEquals(first.workerId.includes("data-scope"), false); +}); + +Deno.test("unversioned worker generations are single-use", async () => { + const first = await resolveWorkerGeneration("ssr"); + const second = await resolveWorkerGeneration("ssr"); + + assertMatch(first.workerId, /^ssr-ephemeral-[0-9a-f-]{36}$/); + assertEquals(first.reusable, false); + assertNotEquals(second.workerId, first.workerId); + assertEquals(isWorkerGenerationInScope(first.workerId, "scope"), false); +}); + +Deno.test("scope matching is exact for delimiters and nested scope names", async () => { + const parentScope = "tenant"; + const nestedScope = "tenant:generation:child"; + const delimitedScope = "tenant|scope=4:data:雪"; + const generationId = "release:generation:v1"; + + const parent = await resolveWorkerGeneration( + "data", + snapshotWorkerGenerationIdentity(parentScope, generationId), + ); + const nested = await resolveWorkerGeneration( + "data", + snapshotWorkerGenerationIdentity(nestedScope, generationId), + ); + const delimited = await resolveWorkerGeneration( + "ssr", + snapshotWorkerGenerationIdentity(delimitedScope, generationId), + ); + + assertNotEquals(parent.workerId, nested.workerId); + assertEquals(isWorkerGenerationInScope(parent.workerId, parentScope), true); + assertEquals(isWorkerGenerationInScope(parent.workerId, nestedScope), false); + assertEquals(isWorkerGenerationInScope(nested.workerId, nestedScope), true); + assertEquals(isWorkerGenerationInScope(nested.workerId, parentScope), false); + assertEquals(isWorkerGenerationInScope(delimited.workerId, delimitedScope), true); + assertEquals(isWorkerGenerationInScope(delimited.workerId, `${delimitedScope}:child`), false); +}); + +Deno.test("API worker generations use framed identities with exact scope ownership", async () => { + const scope = "api:tenant"; + const siblingScope = "api:tenant-other"; + const nestedScope = `${scope}:child`; + const generation = await resolveWorkerGeneration( + "api", + snapshotWorkerGenerationIdentity(scope, "release-a"), + ); + const nestedGeneration = await resolveWorkerGeneration( + "api", + snapshotWorkerGenerationIdentity(nestedScope, "release-a"), + ); + const malformedGeneration = `${generation.workerId.slice(0, -1)}z`; + + assertMatch( + generation.workerId, + /^veryfront-worker:v1:kind=3:api:scope=\d+:[A-Za-z0-9_-]+:generation=64:[0-9a-f]{64}$/, + ); + assertEquals(isWorkerGenerationInScope(generation.workerId, scope), true); + assertEquals(isWorkerGenerationInScope(generation.workerId, siblingScope), false); + assertEquals(isWorkerGenerationInScope(generation.workerId, nestedScope), false); + assertEquals(isWorkerGenerationInScope(nestedGeneration.workerId, scope), false); + assertEquals(isWorkerGenerationInScope(malformedGeneration, scope), false); +}); + +Deno.test("worker identity preserves otherwise-replaced UTF-16 code units", async () => { + const loneSurrogate = "\ud800"; + const replacementCharacter = "\ufffd"; + const surrogateScope = await resolveWorkerGeneration( + "data", + snapshotWorkerGenerationIdentity(loneSurrogate, loneSurrogate), + ); + const replacementScope = await resolveWorkerGeneration( + "data", + snapshotWorkerGenerationIdentity(replacementCharacter, replacementCharacter), + ); + const replacementGeneration = await resolveWorkerGeneration( + "data", + snapshotWorkerGenerationIdentity(loneSurrogate, replacementCharacter), + ); + + assertNotEquals(surrogateScope.workerId, replacementScope.workerId); + assertNotEquals(surrogateScope.workerId, replacementGeneration.workerId); + assertEquals(isWorkerGenerationInScope(surrogateScope.workerId, loneSurrogate), true); + assertEquals( + isWorkerGenerationInScope(surrogateScope.workerId, replacementCharacter), + false, + ); +}); + +Deno.test("semantic generation digests preserve otherwise-replaced UTF-16 code units", async () => { + assertNotEquals( + await digestWorkerGenerationMaterial("\ud800"), + await digestWorkerGenerationMaterial("\ufffd"), + ); +}); + +Deno.test("unframed generation identities are never treated as pool keys", () => { + const scope = "api:scope"; + assertEquals( + isWorkerGenerationInScope(`${scope}:generation:${"a".repeat(64)}`, scope), + false, + ); +}); diff --git a/src/security/sandbox/worker-generation.ts b/src/security/sandbox/worker-generation.ts new file mode 100644 index 0000000000..3001f71d94 --- /dev/null +++ b/src/security/sandbox/worker-generation.ts @@ -0,0 +1,193 @@ +import { base64urlEncodeBytes } from "#veryfront/utils/base64url.ts"; + +const MAX_WORKER_GENERATION_ID_LENGTH = 1024; +const WORKER_KEY_PREFIX = "veryfront-worker:v1"; +const SHA_256_HEX_LENGTH = 64; +const SHA_256_FRAME_PREFIX = `${SHA_256_HEX_LENGTH}:`; +const LOWERCASE_HEX_PATTERN = /^[0-9a-f]+$/; +const WORKER_KINDS = ["api", "data", "ssr"] as const; + +export type WorkerKind = (typeof WORKER_KINDS)[number]; + +export interface WorkerGenerationIdentity { + readonly scopeId: string; + readonly generationId: string; +} + +export interface ResolvedWorkerGeneration { + readonly workerId: string; + readonly reusable: boolean; +} + +function requireGenerationField(value: unknown, label: string): string { + if ( + typeof value !== "string" || + value.length === 0 || + value.length > MAX_WORKER_GENERATION_ID_LENGTH + ) { + throw new TypeError( + `${label} must be a non-empty string no longer than ${MAX_WORKER_GENERATION_ID_LENGTH} characters`, + ); + } + return value; +} + +function requireWorkerKind(kind: unknown): WorkerKind { + if (kind !== "api" && kind !== "data" && kind !== "ssr") { + throw new TypeError('Worker kind must be "api", "data", or "ssr"'); + } + return kind; +} + +/** + * Snapshot the host-owned identity that proves one worker generation may + * safely retain an imported module graph. + * + * Omitting both fields deliberately selects a single-use worker. Supplying + * only one cannot establish an invalidation boundary and therefore fails + * closed. + */ +export function snapshotWorkerGenerationIdentity( + scopeId: unknown, + generationId: unknown, +): Readonly | undefined { + if (scopeId === undefined && generationId === undefined) return undefined; + if (scopeId === undefined || generationId === undefined) { + throw new TypeError( + "Worker generation scopeId and generationId must be supplied together", + ); + } + + return Object.freeze({ + scopeId: requireGenerationField(scopeId, "Worker generation scopeId"), + generationId: requireGenerationField( + generationId, + "Worker generation generationId", + ), + }); +} + +async function sha256Hex(value: string): Promise { + const digest = new Uint8Array( + await crypto.subtle.digest("SHA-256", encodeExactString(value)), + ); + return digest.toHex(); +} + +/** + * Hash arbitrarily sized semantic material with the exact UTF-16 encoding used + * by worker identities. This keeps env and policy values out of pool keys + * without introducing TextEncoder replacement-character collisions. + */ +export function digestWorkerGenerationMaterial(value: string): Promise { + if (typeof value !== "string") { + throw new TypeError("Worker generation semantic material must be a string"); + } + return sha256Hex(value); +} + +/** + * Encode every JavaScript UTF-16 code unit without normalization or + * replacement. TextEncoder would collapse lone surrogate values to U+FFFD, + * which is unsuitable for an identity key. + */ +function encodeExactString(value: string): Uint8Array { + const encoded = new Uint8Array(new ArrayBuffer(value.length * 2)); + for (let index = 0; index < value.length; index++) { + const codeUnit = value.charCodeAt(index); + encoded[index * 2] = codeUnit >>> 8; + encoded[index * 2 + 1] = codeUnit & 0xff; + } + return encoded; +} + +function frame(value: string): string { + return `${value.length}:${value}`; +} + +function encodeScope(scopeId: string): string { + return base64urlEncodeBytes(encodeExactString(scopeId)); +} + +function reusableWorkerPrefix(kind: WorkerKind, scopeId: string): string { + return `${WORKER_KEY_PREFIX}:kind=${frame(kind)}:scope=${ + frame(encodeScope(scopeId)) + }:generation=`; +} + +function isValidGenerationScope(scopeId: unknown): scopeId is string { + return typeof scopeId === "string" && + scopeId.length > 0 && + scopeId.length <= MAX_WORKER_GENERATION_ID_LENGTH; +} + +function matchesFramedGeneration(workerId: string, scopeId: string): boolean { + for (const kind of WORKER_KINDS) { + const prefix = reusableWorkerPrefix(kind, scopeId); + if (!workerId.startsWith(prefix)) continue; + + const digestFrame = workerId.slice(prefix.length); + if (!digestFrame.startsWith(SHA_256_FRAME_PREFIX)) return false; + return isSha256Hex(digestFrame.slice(SHA_256_FRAME_PREFIX.length)); + } + return false; +} + +function isSha256Hex(value: string): boolean { + return value.length === SHA_256_HEX_LENGTH && + LOWERCASE_HEX_PATTERN.test(value); +} + +/** + * Return whether an exact reusable worker identity belongs to `scopeId`. + * + * This is intentionally a complete-key match rather than a raw prefix check. + * It is used by WorkerPool retirement, where confusing nested scopes would + * otherwise terminate unrelated tenant work. + */ +export function isWorkerGenerationInScope( + workerId: unknown, + scopeId: unknown, +): boolean { + if (typeof workerId !== "string" || !isValidGenerationScope(scopeId)) { + return false; + } + + return matchesFramedGeneration(workerId, scopeId); +} + +/** + * Resolve the exact WorkerPool key for one immutable source generation. + * + * The versioned, length-framed identity includes the execution kind and an + * exact opaque encoding of the scope. The generation digest keeps release and + * branch identities out of telemetry. Without a proven generation, a unique + * single-use key prevents stale dependency graphs from crossing requests. + */ +export async function resolveWorkerGeneration( + kind: WorkerKind, + identity?: Readonly, +): Promise> { + const workerKind = requireWorkerKind(kind); + if (!identity) { + return Object.freeze({ + workerId: `${workerKind}-ephemeral-${crypto.randomUUID()}`, + reusable: false, + }); + } + + const snapshot = snapshotWorkerGenerationIdentity( + identity.scopeId, + identity.generationId, + ); + if (!snapshot) { + throw new TypeError("Reusable worker generation identity is required"); + } + + return Object.freeze({ + workerId: `${reusableWorkerPrefix(workerKind, snapshot.scopeId)}${ + frame(await sha256Hex(snapshot.generationId)) + }`, + reusable: true, + }); +} diff --git a/src/security/sandbox/worker-isolation.bench.ts b/src/security/sandbox/worker-isolation.bench.ts index b1dbe28850..ee79d9fac5 100644 --- a/src/security/sandbox/worker-isolation.bench.ts +++ b/src/security/sandbox/worker-isolation.bench.ts @@ -124,6 +124,7 @@ const TEST_PERMISSIONS: WorkerPermissions = { run: false, ffi: false, sys: false, + import: false, }; Deno.bench({ @@ -135,6 +136,7 @@ Deno.bench({ projectId: "bench-health", permissions: TEST_PERMISSIONS, requestTimeoutMs: 5_000, + allowInternalEgress: false, }); worker.start(); await worker.isHealthy(5_000); diff --git a/src/security/sandbox/worker-permissions.test.ts b/src/security/sandbox/worker-permissions.test.ts index 2fd967396b..87c2274e51 100644 --- a/src/security/sandbox/worker-permissions.test.ts +++ b/src/security/sandbox/worker-permissions.test.ts @@ -1,7 +1,9 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals } from "#veryfront/testing/assert.ts"; +import { assert, assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; -import { buildWorkerPermissions, FRAMEWORK_WORKER_ENV_ALLOWLIST } from "./worker-permissions.ts"; +import { join } from "#veryfront/compat/path/index.ts"; +import { getFrameworkRootFromMeta } from "#veryfront/platform/compat/vfs-paths.ts"; +import { buildWorkerPermissions } from "./worker-permissions.ts"; describe("worker-permissions", () => { it("builds permissions with read paths", () => { @@ -9,26 +11,20 @@ describe("worker-permissions", () => { assertEquals(perms.read, ["/tmp/project-a", "/cache"]); assertEquals(perms.write, false); assertEquals(perms.net, true); - assertEquals(perms.env, [...FRAMEWORK_WORKER_ENV_ALLOWLIST]); + assertEquals(perms.env, false); assertEquals(perms.run, false); assertEquals(perms.ffi, false); assertEquals(perms.sys, false); + assertEquals(perms.import, false); }); - it("allows only framework and project env keys", () => { + it("denies process-global env access in compiled workers", () => { const perms = buildWorkerPermissions(["/tmp/project-a"], { - projectEnvKeys: [ - "VERYFRONT_TEST_PROJECT_SECRET", - "NODE_ENV", - "", - " VERYFRONT_TEST_PROJECT_SECRET ", - ], + isCompiledBinary: true, + compiledReadPaths: ["/tmp/deno-compile-abc/dist/framework-src"], }); - assertEquals(perms.env, [ - ...FRAMEWORK_WORKER_ENV_ALLOWLIST, - "VERYFRONT_TEST_PROJECT_SECRET", - ]); + assertEquals(perms.env, false); }); it("builds permissions with empty read paths", () => { @@ -45,13 +41,70 @@ describe("worker-permissions", () => { assertEquals(perms.read, ["/tmp/project-a", "/tmp/deno-compile-abc/dist/framework-src"]); }); - it("always denies write, run, ffi, sys", () => { + it("never grants compiled workers shared cache or DENO_DIR roots", () => { + const cacheKey = "VERYFRONT_CACHE_DIR"; + const denoDirKey = "DENO_DIR"; + const previousCache = Deno.env.get(cacheKey); + const previousDenoDir = Deno.env.get(denoDirKey); + const sharedCache = "/tmp/vf-shared-cache-sentinel"; + const sharedDenoDir = "/tmp/vf-shared-deno-dir-sentinel"; + Deno.env.set(cacheKey, sharedCache); + Deno.env.set(denoDirKey, sharedDenoDir); + + try { + const perms = buildWorkerPermissions(["/tmp/project-a"], { + isCompiledBinary: true, + }); + assert(Array.isArray(perms.read)); + assert( + !perms.read.some((path) => path === sharedCache || path.startsWith(`${sharedCache}/`)), + ); + assert( + !perms.read.some((path) => path === sharedDenoDir || path.startsWith(`${sharedDenoDir}/`)), + ); + } finally { + if (previousCache === undefined) Deno.env.delete(cacheKey); + else Deno.env.set(cacheKey, previousCache); + if (previousDenoDir === undefined) Deno.env.delete(denoDirKey); + else Deno.env.set(denoDirKey, previousDenoDir); + } + }); + + it("limits default compiled support reads to immutable framework directories", () => { + const frameworkRoot = getFrameworkRootFromMeta(import.meta.url); + const perms = buildWorkerPermissions(["/tmp/project-a"], { + isCompiledBinary: true, + }); + + assert(Array.isArray(perms.read)); + assertEquals(perms.read, [ + "/tmp/project-a", + join(frameworkRoot, "src"), + join(frameworkRoot, "dist", "framework-src"), + ]); + assert(!perms.read.includes(frameworkRoot)); + }); + + it("fails closed when a compiled framework read scope is unavailable", () => { + assertThrows( + () => + buildWorkerPermissions(["/tmp/project-a"], { + isCompiledBinary: true, + compiledReadPaths: [], + }), + TypeError, + "framework read scope is unavailable", + ); + }); + + it("always denies write, run, ffi, sys, and remote imports", () => { const perms = buildWorkerPermissions(["/anything"]); assertEquals(perms.write, false); assertEquals(perms.run, false); assertEquals(perms.ffi, false); assertEquals(perms.sys, false); - assertEquals(perms.env, [...FRAMEWORK_WORKER_ENV_ALLOWLIST]); + assertEquals(perms.env, false); + assertEquals(perms.import, false); }); it("defers data fetcher network scoping to ProjectWorker", () => { @@ -70,5 +123,6 @@ describe("worker-permissions", () => { assertEquals(perms1.run, perms2.run); assertEquals(perms1.ffi, perms2.ffi); assertEquals(perms1.sys, perms2.sys); + assertEquals(perms1.import, perms2.import); }); }); diff --git a/src/security/sandbox/worker-permissions.ts b/src/security/sandbox/worker-permissions.ts index c90ae74864..81898c8bfc 100644 --- a/src/security/sandbox/worker-permissions.ts +++ b/src/security/sandbox/worker-permissions.ts @@ -8,26 +8,22 @@ */ import { getFrameworkRootFromMeta } from "#veryfront/platform/compat/vfs-paths.ts"; -import { getHostEnv } from "#veryfront/platform/compat/process.ts"; -import { - getCacheBaseDir, - getHttpBundleCacheDir, - getMdxEsmCacheDir, -} from "#veryfront/utils/cache-dir.ts"; -import { WORKER_INTERNAL_EGRESS_OVERRIDE_ENV } from "./worker-egress-guard.ts"; +import { join } from "#veryfront/compat/path/index.ts"; /** * Deno Worker permission object. * See: https://docs.deno.com/runtime/fundamentals/permissions/ */ export interface WorkerPermissions { - read: string[] | boolean; + read: readonly string[] | boolean; write: boolean; net: boolean; - env: string[] | boolean; + env: readonly string[] | boolean; run: boolean; ffi: boolean; sys: boolean; + /** Remote module loading is always denied; extension worker graphs must be local. */ + import: readonly string[] | boolean; } interface WorkerPermissionOptions { @@ -35,22 +31,8 @@ interface WorkerPermissionOptions { isCompiledBinary?: boolean; /** Override for tests that need deterministic compiled-binary support paths. */ compiledReadPaths?: string[]; - /** Project-configured env keys that route code may read inside the worker. */ - projectEnvKeys?: Iterable; } -export const FRAMEWORK_WORKER_ENV_ALLOWLIST = [ - "NODE_ENV", - "DENO_ENV", - "VERYFRONT_ENV", - "LOG_LEVEL", - "LOG_FORMAT", - "NO_COLOR", - "FORCE_COLOR", - "CI", - WORKER_INTERNAL_EGRESS_OVERRIDE_ENV, -] as const; - // Cache compiled binary check — Deno.execPath() is a syscall that never changes at runtime const _isCompiledBinary = (() => { try { @@ -74,43 +56,21 @@ function normalizeReadPaths(paths: Array): string[] { return [...unique]; } -function normalizeEnvKeys(keys: Iterable): string[] { - const unique = new Set(); - for (const key of keys) { - if (!key) continue; - const trimmed = key.trim(); - if (!trimmed) continue; - unique.add(trimmed); - } - return [...unique]; -} - -export function buildWorkerEnvAllowlist( - projectEnvKeys: Iterable = [], -): string[] { - return normalizeEnvKeys([ - ...FRAMEWORK_WORKER_ENV_ALLOWLIST, - ...projectEnvKeys, - ]); -} - function getDefaultCompiledReadPaths(): string[] { + const frameworkRoot = getFrameworkRootFromMeta(import.meta.url); return normalizeReadPaths([ - getFrameworkRootFromMeta(import.meta.url), - getCacheBaseDir(), - getMdxEsmCacheDir(), - getHttpBundleCacheDir(), - getHostEnv("DENO_DIR"), + join(frameworkRoot, "src"), + join(frameworkRoot, "dist", "framework-src"), ]); } /** * Build scoped permissions for a project worker. * - * - read: restricted to the project temp dir (transformed modules) and cache dirs + * - read: restricted to exact project roots and immutable framework source dirs * - write: denied (workers produce output via postMessage, not filesystem) * - net: broker-scoped by ProjectWorker before user code starts - * - env: restricted to framework keys and the project's configured env keys + * - env: denied; request-owned project env travels through handler contexts * - run: denied (no subprocess spawning from user code) * - ffi: denied (no native code from user code) * - sys: denied (no system info access from user code) @@ -121,10 +81,17 @@ export function buildWorkerPermissions( ): WorkerPermissions { const isCompiledBinary = options.isCompiledBinary ?? _isCompiledBinary; const normalizedReadPaths = normalizeReadPaths(readPaths); - const env = buildWorkerEnvAllowlist(options.projectEnvKeys); + // Deno's env permission is read/write and process-global across Workers. A + // project Worker must never receive it, even for an apparently read-only + // allowlist. Request-owned project env is transported in the worker protocol + // instead of mutating the host process environment. + const env = false; if (isCompiledBinary) { const compiledReadPaths = options.compiledReadPaths ?? getDefaultCompiledReadPaths(); + if (compiledReadPaths.length === 0) { + throw new TypeError("Compiled worker framework read scope is unavailable"); + } const scopedReadPaths = normalizeReadPaths([...normalizedReadPaths, ...compiledReadPaths]); return { read: scopedReadPaths.length > 0 ? scopedReadPaths : false, @@ -134,6 +101,7 @@ export function buildWorkerPermissions( run: false, ffi: false, sys: false, + import: false, }; } @@ -145,5 +113,6 @@ export function buildWorkerPermissions( run: false, ffi: false, sys: false, + import: false, }; } diff --git a/src/security/sandbox/worker-pool.test.ts b/src/security/sandbox/worker-pool.test.ts index 71401b47c9..6fa311932e 100644 --- a/src/security/sandbox/worker-pool.test.ts +++ b/src/security/sandbox/worker-pool.test.ts @@ -3,23 +3,347 @@ import { assert, assertEquals, assertExists, + assertInstanceOf, assertRejects, assertThrows, } from "#veryfront/testing/assert.ts"; import { afterEach, beforeEach, describe, it } from "#veryfront/testing/bdd.ts"; import { isDeno } from "#veryfront/platform/compat/runtime.ts"; import { VeryfrontError } from "#veryfront/errors/types.ts"; +import { runWithProjectEnv } from "#veryfront/server/project-env/storage.ts"; +import type { ProjectWorker, ProjectWorkerOptions } from "./project-worker.ts"; import { __resetPoolForTests, + getWorkerPool, isDataIsolationEnabled, + isSSRIsolationEnabled, isWorkerIsolationEnabled, WorkerPool, + type WorkerPoolDependencies, } from "./worker-pool.ts"; -import { MAX_WORKER_BODY_BYTES } from "./worker-types.ts"; +import type { + RenderSSRRequest, + WorkerPoolConfig, + WorkerRequest, + WorkerResponse, +} from "./worker-types.ts"; +import { DEFAULT_WORKER_POOL_CONFIG, MAX_WORKER_BODY_BYTES } from "./worker-types.ts"; +import { WORKER_INTERNAL_EGRESS_OVERRIDE_ENV } from "./worker-egress-guard.ts"; +import { resolveWorkerGeneration, snapshotWorkerGenerationIdentity } from "./worker-generation.ts"; +import { fromFileUrl } from "#veryfront/compat/path"; // Worker isolation only works in Deno (requires Deno Worker permissions API) const testSuite = isDeno ? describe : describe.skip; const TEST_SOURCE_INTEGRATION_POLICY = { schemaVersion: 1, mode: "unrestricted" } as const; +const TEST_PREPARED_MODULE = { + source: "export function GET() { return new Response('ok'); }", + sha256: "0".repeat(64), +} as const; +const TEST_ISOLATED_SSR_RENDERER_PROVIDER = Object.freeze({ + moduleUrl: new URL( + "../../../extensions/ext-react-ssr/src/worker-renderer.ts", + import.meta.url, + ).href, + readRootUrls: Object.freeze([ + new URL("../../../extensions/ext-react-ssr/src/", import.meta.url).href, + ]), +}); + +interface ControlledWorkerBehavior { + completeStreamsSynchronously?: boolean; + notifyIdleOnSubscription?: boolean; + shutdownCompletion?: Promise; +} + +function deferred(): { + promise: Promise; + resolve: (value: T) => void; +} { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +function makeRequest( + id: string, + projectEnv?: Record, + modulePath = "/tmp/module.ts", +): WorkerRequest { + return { + type: "execute-app-route", + id, + module: TEST_PREPARED_MODULE, + modulePath, + method: "GET", + request: { + url: "http://localhost/test", + method: "GET", + headers: [], + body: null, + }, + params: {}, + projectDir: "/tmp", + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + projectEnv, + }; +} + +function makeSSRRequest( + id: string, + overrides: Partial = {}, +): RenderSSRRequest { + return { + type: "render-ssr", + id, + pageModulePath: "/tmp/page.tsx", + layoutModulePaths: [], + pageProps: {}, + layoutProps: [], + delivery: "stream", + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + ...overrides, + }; +} + +class ControlledWorker { + readonly projectId: string; + readonly allowInternalEgress: boolean | undefined; + readonly permissions: ProjectWorkerOptions["permissions"]; + readonly isolatedSsrRendererModuleUrl: string | undefined; + status: "idle" | "busy" | "crashed" | "terminated" = "idle"; + requestCount = 0; + terminateCalls = 0; + healthCheckCalls = 0; + healthCheckResult: boolean | Promise = true; + private pending = new Map void; + reject: (error: Error) => void; + }>(); + private streams = new Map>(); + private idleListeners = new Set<() => void>(); + private readonly behavior: ControlledWorkerBehavior; + private shutdownPromise: Promise | null = null; + + constructor( + options: ProjectWorkerOptions, + behavior: ControlledWorkerBehavior = {}, + ) { + this.projectId = options.projectId; + this.allowInternalEgress = options.allowInternalEgress; + this.permissions = options.permissions; + this.isolatedSsrRendererModuleUrl = options.isolatedSsrRendererModuleUrl; + this.behavior = behavior; + } + + get hasPendingRequests(): boolean { + return this.pending.size > 0 || this.streams.size > 0; + } + + get idleListenerCount(): number { + return this.idleListeners.size; + } + + start(): void {} + + onIdle(listener: () => void): () => void { + this.idleListeners.add(listener); + if (this.behavior.notifyIdleOnSubscription && !this.hasPendingRequests) { + listener(); + } + return () => { + this.idleListeners.delete(listener); + }; + } + + execute(request: WorkerRequest): Promise { + this.requestCount++; + this.status = "busy"; + return new Promise((resolve, reject) => { + this.pending.set(request.id, { resolve, reject }); + }); + } + + executeStream(request: WorkerRequest): ReadableStream { + this.requestCount++; + this.status = "busy"; + return new ReadableStream({ + start: (controller) => { + if (this.behavior.completeStreamsSynchronously) { + controller.enqueue(new Uint8Array([9])); + controller.close(); + this.status = "idle"; + return; + } + this.streams.set(request.id, controller); + }, + cancel: () => { + this.streams.delete(request.id); + this.updateIdle(); + }, + }); + } + + complete(id: string): void { + const pending = this.pending.get(id); + assertExists(pending, `request "${id}" must be pending`); + this.pending.delete(id); + this.updateIdle(); + pending.resolve({ + type: "result", + id, + response: { + status: 200, + statusText: "OK", + headers: [], + body: null, + }, + }); + } + + reject(id: string, error: Error): void { + const pending = this.pending.get(id); + assertExists(pending, `request "${id}" must be pending`); + this.pending.delete(id); + this.updateIdle(); + pending.reject(error); + } + + reachPreparedModuleCapacity(id: string): void { + const pending = this.pending.get(id); + assertExists(pending, `request "${id}" must be pending`); + this.pending.delete(id); + this.updateIdle(); + pending.resolve({ + type: "prepared-module-capacity", + id, + }); + } + + completeStream(id: string, chunks: Uint8Array[] = []): void { + const controller = this.streams.get(id); + assertExists(controller, `stream "${id}" must be pending`); + for (const chunk of chunks) controller.enqueue(chunk); + this.streams.delete(id); + controller.close(); + this.updateIdle(); + } + + becomeTerminal(status: "crashed" | "terminated"): void { + this.status = status; + for (const [, pending] of this.pending) { + pending.reject(new Error(`worker ${status}`)); + } + this.pending.clear(); + for (const [, controller] of this.streams) { + controller.error(new Error(`worker ${status}`)); + } + this.streams.clear(); + this.notifyIdle(); + } + + async isHealthy(): Promise { + this.healthCheckCalls++; + return await this.healthCheckResult; + } + + terminate(): void { + void this.shutdown(); + } + + shutdown(): Promise { + if (this.shutdownPromise) return this.shutdownPromise; + this.shutdownPromise = this.behavior.shutdownCompletion ?? Promise.resolve(); + this.terminateCalls++; + this.status = "terminated"; + for (const [, pending] of this.pending) { + pending.reject(new Error("worker terminated")); + } + this.pending.clear(); + for (const [, controller] of this.streams) { + controller.error(new Error("worker terminated")); + } + this.streams.clear(); + this.notifyIdle(); + return this.shutdownPromise; + } + + private updateIdle(): void { + if (this.pending.size !== 0 || this.streams.size !== 0) return; + if (this.status === "busy") this.status = "idle"; + this.notifyIdle(); + } + + private notifyIdle(): void { + if (this.pending.size !== 0 || this.streams.size !== 0) return; + for (const listener of [...this.idleListeners]) listener(); + } +} + +function createControlledPool( + config: Partial = {}, + behavior: ControlledWorkerBehavior = {}, + dependencies: Pick< + WorkerPoolDependencies, + "getHeapUsedPercent" | "resolveIsolatedSsrRendererProvider" + > = {}, +): { + pool: WorkerPool; + workers: Map; +} { + const workers = new Map(); + const pool = new WorkerPool( + { + maxPoolSize: 3, + idleTimeoutMs: 60_000, + requestTimeoutMs: 5_000, + healthCheckIntervalMs: 60_000, + maxRequestsPerWorker: 100, + maxWorkerAgeMs: 600_000, + ...config, + }, + { + resolveIsolatedSsrRendererProvider: () => TEST_ISOLATED_SSR_RENDERER_PROVIDER, + ...dependencies, + createWorker(options) { + const worker = new ControlledWorker(options, behavior); + const generations = workers.get(options.projectId) ?? []; + generations.push(worker); + workers.set(options.projectId, generations); + return worker as unknown as ProjectWorker; + }, + }, + ); + return { pool, workers }; +} + +function latestWorker( + workers: Map, + projectId: string, +): ControlledWorker { + const generations = workers.get(projectId); + assertExists(generations); + const worker = generations.at(-1); + assertExists(worker); + return worker; +} + +async function waitForWorkerGeneration( + workers: Map, + projectId: string, + count: number, +): Promise { + for (let turn = 0; turn < 20; turn++) { + if ((workers.get(projectId)?.length ?? 0) >= count) return; + await Promise.resolve(); + } + throw new Error(`worker generation ${count} was not created`); +} + +async function runHealthCheck(pool: WorkerPool): Promise { + await (pool as unknown as { checkHealth(): Promise }).checkHealth(); +} testSuite("WorkerPool", () => { let pool: WorkerPool; @@ -34,8 +358,8 @@ testSuite("WorkerPool", () => { }); }); - afterEach(() => { - pool.shutdown(); + afterEach(async () => { + await pool.shutdown(); }); it("creates a worker for a new project", () => { @@ -47,6 +371,96 @@ testSuite("WorkerPool", () => { assertEquals(stats.poolSize, 1); }); + it("passes the resolved internal-egress decision to every worker", async () => { + const controlled = createControlledPool( + { allowInternalEgress: true } as Partial, + ); + await pool.shutdown(); + pool = controlled.pool; + + const worker = pool.getOrCreateWorker("internal-egress-project", []); + assertEquals( + (worker as unknown as ControlledWorker).allowInternalEgress, + true, + ); + }); + + it("does not resolve the isolated SSR provider for API admission", async () => { + let resolverCalls = 0; + const controlled = createControlledPool({}, {}, { + resolveIsolatedSsrRendererProvider: () => { + resolverCalls++; + throw new Error("API admission must not resolve the SSR extension"); + }, + }); + await pool.shutdown(); + pool = controlled.pool; + + const pending = pool.execute("api-only", ["/tmp"], makeRequest("api-request")); + const worker = latestWorker(controlled.workers, "api-only"); + worker.complete("api-request"); + assertEquals((await pending).type, "result"); + assertEquals(resolverCalls, 0); + assertEquals(worker.isolatedSsrRendererModuleUrl, undefined); + }); + + it("rejects malformed isolated SSR provider accessors without executing them", async () => { + let getterCalls = 0; + const malformedProvider = Object.defineProperties({}, { + moduleUrl: { + enumerable: true, + get() { + getterCalls++; + return TEST_ISOLATED_SSR_RENDERER_PROVIDER.moduleUrl; + }, + }, + readRootUrls: { + enumerable: true, + value: TEST_ISOLATED_SSR_RENDERER_PROVIDER.readRootUrls, + }, + }); + const controlled = createControlledPool({}, {}, { + resolveIsolatedSsrRendererProvider: () => malformedProvider, + }); + await pool.shutdown(); + pool = controlled.pool; + + assertThrows( + () => pool.executeStream("ssr-malformed", ["/tmp"], makeSSRRequest("ssr-request")), + TypeError, + "moduleUrl must be a data property", + ); + assertEquals(getterCalls, 0); + assertEquals(pool.getStats().poolSize, 0); + }); + + it("adds canonical extension read roots and module URL only to SSR workers", async () => { + const controlled = createControlledPool(); + await pool.shutdown(); + pool = controlled.pool; + + const stream = pool.executeStream( + "ssr-permissions", + ["/tmp"], + makeSSRRequest("ssr-permissions-request"), + ); + const worker = latestWorker(controlled.workers, "ssr-permissions"); + const readPermissions = worker.permissions.read; + assert(Array.isArray(readPermissions)); + assert( + TEST_ISOLATED_SSR_RENDERER_PROVIDER.readRootUrls.every((rootUrl) => + readPermissions.includes(Deno.realPathSync(fromFileUrl(rootUrl))) + ), + ); + assertEquals( + worker.isolatedSsrRendererModuleUrl, + TEST_ISOLATED_SSR_RENDERER_PROVIDER.moduleUrl, + ); + + worker.completeStream("ssr-permissions-request"); + await new Response(stream).arrayBuffer(); + }); + it("returns the same worker for the same project", () => { const w1 = pool.getOrCreateWorker("project-a", []); const w2 = pool.getOrCreateWorker("project-a", []); @@ -56,16 +470,6 @@ testSuite("WorkerPool", () => { assertEquals(stats.poolSize, 1); }); - it("recreates a worker when the project env key set changes", () => { - const w1 = pool.getOrCreateWorker("project-a", [], ["PROJECT_SECRET_A"]); - const w2 = pool.getOrCreateWorker("project-a", [], ["PROJECT_SECRET_A"]); - const w3 = pool.getOrCreateWorker("project-a", [], ["PROJECT_SECRET_B"]); - - assertEquals(w1, w2); - assert(w1 !== w3, "worker permissions must be rebuilt for changed env keys"); - assertEquals(pool.getStats().poolSize, 1); - }); - it("creates separate workers for different projects", () => { pool.getOrCreateWorker("project-a", []); pool.getOrCreateWorker("project-b", []); @@ -113,22 +517,40 @@ testSuite("WorkerPool", () => { assertEquals(stats.workers["project-a"].hasPending, false); }); - it("shutdown terminates all workers", () => { + it("shutdown is single-flight and waits for every worker to quiesce", async () => { + const gate = deferred(); + const controlled = createControlledPool({}, { shutdownCompletion: gate.promise }); + await pool.shutdown(); + pool = controlled.pool; pool.getOrCreateWorker("project-a", []); pool.getOrCreateWorker("project-b", []); - pool.shutdown(); + const first = pool.shutdown(); + const second = pool.shutdown(); + assert(first === second); + assertEquals(pool.getStats().poolSize, 0); + assertEquals(latestWorker(controlled.workers, "project-a").terminateCalls, 1); + assertEquals(latestWorker(controlled.workers, "project-b").terminateCalls, 1); - const stats = pool.getStats(); - assertEquals(stats.poolSize, 0); + let settled = false; + void first.then(() => { + settled = true; + }); + await Promise.resolve(); + assertEquals(settled, false); + + gate.resolve(); + await first; + assertEquals(settled, true); }); it("rejects execute when modulePath is outside allowed read paths", async () => { - await assertRejects( + const error = await assertRejects( () => pool.execute("project-a", ["/allowed/path"], { type: "execute-app-route", id: "test-id", + module: TEST_PREPARED_MODULE, modulePath: "/etc/passwd", method: "GET", request: { url: "http://localhost/api/test", method: "GET", headers: [], body: null }, @@ -137,8 +559,10 @@ testSuite("WorkerPool", () => { sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, }), VeryfrontError, - "outside allowed read paths", - ); + "outside the allowed project boundary", + ) as VeryfrontError; + assert(!error.message.includes("/etc/passwd")); + assert(!error.message.includes("project-a")); }); it("allows execute when modulePath is within allowed read paths", () => { @@ -189,8 +613,8 @@ testSuite("WorkerPool - RFC 9457 error metadata", () => { }); }); - afterEach(() => { - pool.shutdown(); + afterEach(async () => { + await pool.shutdown(); }); it("execute-app-route request includes projectDir", () => { @@ -207,130 +631,783 @@ testSuite("WorkerPool - RFC 9457 error metadata", () => { }); }); -testSuite("WorkerPool - warm recycling", () => { +testSuite("WorkerPool - bounded admission and retirement", () => { let pool: WorkerPool; - afterEach(() => { - pool?.shutdown(); + afterEach(async () => { + await pool?.shutdown(); }); - it("old worker handles triggering request, replacement created in background", async () => { - pool = new WorkerPool({ - maxPoolSize: 3, - idleTimeoutMs: 60_000, - requestTimeoutMs: 5_000, - healthCheckIntervalMs: 60_000, - maxRequestsPerWorker: 1, // Recycle after 1 request - maxWorkerAgeMs: 600_000, + it("rejects same-worker requests at the active ceiling and admits after settlement", async () => { + const controlled = createControlledPool(); + pool = controlled.pool; + + const first = pool.execute("scope-a", ["/tmp"], makeRequest("a-1")); + const workerA = latestWorker(controlled.workers, "scope-a"); + + const overload = await assertRejects( + () => pool.execute("scope-a", ["/tmp"], makeRequest("a-2")), + VeryfrontError, + "active request capacity reached", + ); + assert(overload instanceof VeryfrontError); + assertEquals(overload.slug, "service-overloaded"); + assertEquals(workerA.requestCount, 1); + assertEquals(pool.getStats().workers["scope-a"]?.activeRequests, 1); + + workerA.complete("a-1"); + await first; + + const second = pool.execute("scope-a", ["/tmp"], makeRequest("a-2")); + assertEquals(workerA.requestCount, 2); + assertEquals(pool.getStats().workers["scope-a"]?.activeRequests, 1); + + workerA.complete("a-2"); + await second; + assertEquals(pool.getStats().workers["scope-a"]?.activeRequests, 0); + }); + + it("releases same-worker capacity after the worker request rejects", async () => { + const controlled = createControlledPool(); + pool = controlled.pool; + + const failed = pool.execute("scope-a", ["/tmp"], makeRequest("a-1")); + const workerA = latestWorker(controlled.workers, "scope-a"); + await assertRejects( + () => pool.execute("scope-a", ["/tmp"], makeRequest("a-2")), + VeryfrontError, + "active request capacity reached", + ); + + workerA.reject("a-1", new Error("worker request failed")); + await assertRejects(() => failed, Error, "worker request failed"); + + const retry = pool.execute("scope-a", ["/tmp"], makeRequest("a-2")); + workerA.complete("a-2"); + assertEquals((await retry).type, "result"); + assertEquals(pool.getStats().workers["scope-a"]?.activeRequests, 0); + }); + + it("holds the active ceiling for streams until worker protocol settlement", async () => { + const controlled = createControlledPool(); + pool = controlled.pool; + + const stream = pool.executeStream("scope-a", ["/tmp"], makeSSRRequest("stream-a")); + const workerA = latestWorker(controlled.workers, "scope-a"); + + assertThrows( + () => pool.executeStream("scope-a", ["/tmp"], makeSSRRequest("stream-b")), + VeryfrontError, + "active request capacity reached", + ); + + workerA.completeStream("stream-a", [new Uint8Array([4, 2])]); + assertEquals(pool.getStats().workers["scope-a"]?.activeRequests, 0); + + const admitted = pool.executeStream( + "scope-a", + ["/tmp"], + makeSSRRequest("stream-b"), + ); + assertEquals(latestWorker(controlled.workers, "scope-a"), workerA); + workerA.completeStream("stream-b"); + await new Response(admitted).arrayBuffer(); + assertEquals( + new Uint8Array(await new Response(stream).arrayBuffer()), + new Uint8Array([4, 2]), + ); + }); + + it("replaces an idle SSR-capable generation before API admission", async () => { + let rendererResolverCalls = 0; + const controlled = createControlledPool({}, {}, { + resolveIsolatedSsrRendererProvider: () => { + rendererResolverCalls++; + return TEST_ISOLATED_SSR_RENDERER_PROVIDER; + }, }); + pool = controlled.pool; + + const stream = pool.executeStream("scope-a", ["/tmp"], makeSSRRequest("stream-a")); + const rendererWorker = latestWorker(controlled.workers, "scope-a"); + rendererWorker.completeStream("stream-a"); + await new Response(stream).arrayBuffer(); + + const api = pool.execute("scope-a", ["/tmp"], makeRequest("api-a")); + const apiWorker = latestWorker(controlled.workers, "scope-a"); + assert(apiWorker !== rendererWorker); + assertEquals(rendererWorker.terminateCalls, 1); + assertEquals(apiWorker.isolatedSsrRendererModuleUrl, undefined); + assertEquals(rendererResolverCalls, 1); + apiWorker.complete("api-a"); + await api; + }); - const makeRequest = (id: string) => ({ - type: "execute-app-route" as const, - id, - modulePath: "/tmp/nonexistent.ts", - method: "GET", - request: { - url: "http://localhost/test", - method: "GET", - headers: [] as [string, string][], - body: null, + it("validates every direct pool resource and timer boundary", () => { + const invalidConfigs: Array> = [ + { maxPoolSize: 0 }, + { idleTimeoutMs: -1 }, + { requestTimeoutMs: 0 }, + { healthCheckIntervalMs: 0 }, + { maxRequestsPerWorker: 0 }, + { maxWorkerAgeMs: -1 }, + ]; + + for (const config of invalidConfigs) { + assertThrows( + () => new WorkerPool(config), + TypeError, + "Worker pool", + ); + } + }); + + it("rejects unknown, inherited, and accessor-backed pool options", () => { + let getterCalls = 0; + const accessorConfig = {} as Record; + Object.defineProperty(accessorConfig, "maxPoolSize", { + enumerable: true, + get() { + getterCalls++; + return 1; }, - params: {}, - projectDir: "/tmp", - sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, }); - // Create initial worker - const worker1 = pool.getOrCreateWorker("project-recycle", ["/tmp"]); - assertExists(worker1); + assertThrows( + () => new WorkerPool({ unknown: true } as never), + TypeError, + "unsupported option", + ); + assertThrows( + () => new WorkerPool(Object.create({ maxPoolSize: 1 })), + TypeError, + "plain object", + ); + assertThrows( + () => new WorkerPool(accessorConfig as Partial), + TypeError, + "own data property", + ); + assertEquals(getterCalls, 0); + }); + + it("rejects a new scope at capacity without interrupting the active scope", async () => { + const controlled = createControlledPool({ maxPoolSize: 1 }); + pool = controlled.pool; + + const active = pool.execute("scope-a", ["/tmp"], makeRequest("a-1")); + const workerA = latestWorker(controlled.workers, "scope-a"); + + await assertRejects( + () => pool.execute("scope-b", ["/tmp"], makeRequest("b-1")), + VeryfrontError, + "capacity reached", + ); + assertEquals(workerA.terminateCalls, 0); + assertEquals(workerA.hasPendingRequests, true); + + workerA.complete("a-1"); + const response = await active; + assertEquals(response.type, "result"); + assertEquals(workerA.terminateCalls, 0); + + const workerB = pool.getOrCreateWorker("scope-b", ["/tmp"]); + assert(workerB !== (workerA as unknown as ProjectWorker)); + assertEquals(workerA.terminateCalls, 1); + }); + + it("holds one atomic admission until the worker protocol settles", async () => { + const controlled = createControlledPool({ maxPoolSize: 1 }); + pool = controlled.pool; + + const stream = pool.executeStream("scope-a", ["/tmp"], makeSSRRequest("stream-a")); + const workerA = latestWorker(controlled.workers, "scope-a"); + assertEquals(workerA.hasPendingRequests, true); + + await assertRejects( + () => pool.execute("scope-b", ["/tmp"], makeRequest("b-1")), + VeryfrontError, + "capacity reached", + ); + + pool.evictWorker("scope-a"); + workerA.completeStream("stream-a", [new Uint8Array([1, 2, 3])]); + // Actual worker completion releases pool admission even though the + // consumer has not drained its already-buffered bytes. + assertEquals(workerA.terminateCalls, 1); + assertEquals(pool.getStats().workers["scope-a"], undefined); + + const reader = stream.getReader(); + assertEquals(await reader.read(), { + done: false, + value: new Uint8Array([1, 2, 3]), + }); + assertEquals(await reader.read(), { done: true, value: undefined }); + + assertEquals(workerA.terminateCalls, 1); + }); + + it("releases admission on worker completion before an unread stream drains", async () => { + const controlled = createControlledPool({ maxPoolSize: 1 }); + pool = controlled.pool; + + const stream = pool.executeStream("scope-a", ["/tmp"], makeSSRRequest("stream-a")); + const workerA = latestWorker(controlled.workers, "scope-a"); + workerA.completeStream("stream-a", [new Uint8Array([7, 8])]); + + const workerB = pool.getOrCreateWorker("scope-b", ["/tmp"]); + assert(workerB !== (workerA as unknown as ProjectWorker)); + assertEquals(workerA.terminateCalls, 1); + + const buffered = await new Response(stream).arrayBuffer(); + assertEquals(new Uint8Array(buffered), new Uint8Array([7, 8])); + }); + + it("releases admission when a stream completes before idle subscription", async () => { + const controlled = createControlledPool( + { maxPoolSize: 1 }, + { completeStreamsSynchronously: true }, + ); + pool = controlled.pool; + + const stream = pool.executeStream("scope-a", ["/tmp"], makeSSRRequest("stream-a")); + const workerA = latestWorker(controlled.workers, "scope-a"); + assertEquals(workerA.hasPendingRequests, false); + + const workerB = pool.getOrCreateWorker("scope-b", ["/tmp"]); + assert(workerB !== (workerA as unknown as ProjectWorker)); + assertEquals(workerA.terminateCalls, 1); + + const buffered = await new Response(stream).arrayBuffer(); + assertEquals(new Uint8Array(buffered), new Uint8Array([9])); + }); + + it("unsubscribes an idle listener that fires synchronously during registration", async () => { + const controlled = createControlledPool( + { maxPoolSize: 1 }, + { + completeStreamsSynchronously: true, + notifyIdleOnSubscription: true, + }, + ); + pool = controlled.pool; + + const stream = pool.executeStream("scope-a", ["/tmp"], makeSSRRequest("stream-a")); + const workerA = latestWorker(controlled.workers, "scope-a"); + + // Only the pool entry's long-lived lifecycle listener remains. The + // per-stream listener returned its unsubscribe after firing synchronously. + assertEquals(workerA.idleListenerCount, 1); + assertEquals(new Uint8Array(await new Response(stream).arrayBuffer()), new Uint8Array([9])); + }); + + it("keeps project env changes request-owned without replacing the worker", async () => { + const controlled = createControlledPool({ maxPoolSize: 1 }); + pool = controlled.pool; + + const active = pool.execute( + "scope-a", + ["/tmp"], + makeRequest("a-1", { PROJECT_SECRET_A: "one" }), + ); + const workerA = latestWorker(controlled.workers, "scope-a"); + + workerA.complete("a-1"); + await active; + + const second = pool.execute( + "scope-a", + ["/tmp"], + makeRequest("a-2", { PROJECT_SECRET_B: "two" }), + ); + assertEquals(latestWorker(controlled.workers, "scope-a"), workerA); + assertEquals(pool.getStats().workers["scope-a"]?.retiring, false); + workerA.complete("a-2"); + await second; + assertEquals(workerA.terminateCalls, 0); + }); + + it("defers changed read permissions until the busy worker settles", async () => { + const controlled = createControlledPool({ maxPoolSize: 1 }); + pool = controlled.pool; + + const active = pool.execute( + "scope-a", + ["/tmp/project-a"], + makeRequest("a-1", undefined, "/tmp/project-a/module.ts"), + ); + const workerA = latestWorker(controlled.workers, "scope-a"); + + await assertRejects( + () => + pool.execute( + "scope-a", + ["/tmp/project-b"], + makeRequest("a-2", undefined, "/tmp/project-b/module.ts"), + ), + VeryfrontError, + "changed permissions", + ); + assertEquals(workerA.terminateCalls, 0); + assertEquals(pool.getStats().workers["scope-a"]?.retiring, true); + + workerA.complete("a-1"); + await active; + assertEquals(workerA.terminateCalls, 1); + + const workerB = pool.getOrCreateWorker("scope-a", ["/tmp/project-b"]); + assert(workerB !== (workerA as unknown as ProjectWorker)); + }); + + it("reuses a worker for canonically equivalent read roots", () => { + const controlled = createControlledPool(); + pool = controlled.pool; + + const workerA = pool.getOrCreateWorker("scope-a", [ + "/tmp/project", + "/tmp/project/nested", + ]); + const samePermissions = pool.getOrCreateWorker("scope-a", [ + "/tmp/project/other/..", + ]); + + assertEquals(samePermissions, workerA); + assertEquals(controlled.workers.get("scope-a")?.length, 1); + }); + + it("rejects sibling path prefixes outside the allowed read root", async () => { + const controlled = createControlledPool(); + pool = controlled.pool; + + await assertRejects( + () => + pool.execute( + "scope-a", + ["/tmp/project"], + makeRequest("a-1", undefined, "/tmp/project-evil/module.ts"), + ), + VeryfrontError, + "outside the allowed project boundary", + ); + assertEquals(pool.getStats().poolSize, 0); + }); + + it("does not include tenant identifiers or module paths in boundary logs", async () => { + const projectId = "tenant-private-identifier-97"; + const modulePath = "/tmp/private-module-name-53/route.ts"; + const originalWarn = console.warn; + let output = ""; + console.warn = (...args: unknown[]) => { + output += args.map(String).join(" "); + }; - // First execute: increments requestCount to 1 (recycle check sees 0, so no recycle yet) try { - await pool.execute("project-recycle", ["/tmp"], makeRequest("req-1")); - } catch { - // Worker errors on module not found — requestCount still incremented + const controlled = createControlledPool(); + pool = controlled.pool; + await assertRejects( + () => + pool.execute( + projectId, + ["/tmp/allowed-project"], + makeRequest("redacted-log", undefined, modulePath), + ), + VeryfrontError, + "outside the allowed project boundary", + ); + } finally { + console.warn = originalWarn; } - assertEquals(worker1.requestCount, 1); - // Second execute: recycle check sees requestCount=1 >= threshold=1, triggers warm recycle + assertEquals(output.includes(projectId), false); + assertEquals(output.includes(modulePath), false); + }); + + it("rejects an existing module path that escapes through a symlink", async () => { + const controlled = createControlledPool(); + pool = controlled.pool; + const testRoot = await Deno.makeTempDir({ prefix: "vf-worker-pool-path-" }); + const allowedRoot = `${testRoot}/allowed`; + const outsideRoot = `${testRoot}/outside`; + const linkPath = `${allowedRoot}/outside-link`; + const escapedModule = `${linkPath}/module.ts`; + try { - await pool.execute("project-recycle", ["/tmp"], makeRequest("req-2")); - } catch { - // Expected error + await Deno.mkdir(allowedRoot); + await Deno.mkdir(outsideRoot); + await Deno.writeTextFile(`${outsideRoot}/module.ts`, "export {};"); + await Deno.symlink(outsideRoot, linkPath, { type: "dir" }); + + await assertRejects( + () => + pool.execute( + "scope-a", + [allowedRoot], + makeRequest("a-1", undefined, escapedModule), + ), + VeryfrontError, + "outside the allowed project boundary", + ); + assertEquals(pool.getStats().poolSize, 0); + } finally { + await Deno.remove(testRoot, { recursive: true }); } + }); - // Allow the .finally() callback to run and create the replacement - await new Promise((r) => setTimeout(r, 100)); + it("validates every SSR page and layout module path", () => { + const controlled = createControlledPool(); + pool = controlled.pool; + const escapedLayout = "/tmp/project/../project-evil/layout.tsx"; - // After the .finally() callback, a replacement worker should exist - const worker2 = pool.getOrCreateWorker("project-recycle", ["/tmp"]); - assertExists(worker2); + const error = assertThrows( + () => + pool.executeStream( + "scope-a", + ["/tmp/project"], + makeSSRRequest("ssr-1", { + pageModulePath: "/tmp/project/page.tsx", + layoutModulePaths: [escapedLayout], + }), + ), + VeryfrontError, + "outside the allowed project boundary", + ); - // The replacement should be a different instance than the original - assert(worker1 !== worker2, "should have created a new worker after recycling"); - assertEquals(pool.getStats().poolSize, 1); + assertInstanceOf(error, VeryfrontError); + assert(!error.message.includes(escapedLayout)); + assert(!error.message.includes("scope-a")); + assertEquals(pool.getStats().poolSize, 0); }); - it("recycling guard prevents concurrent replacements", async () => { - pool = new WorkerPool({ - maxPoolSize: 5, - idleTimeoutMs: 60_000, - requestTimeoutMs: 5_000, - healthCheckIntervalMs: 60_000, + it("rejects an SSR page module that escapes through a symlink", async () => { + const controlled = createControlledPool(); + pool = controlled.pool; + const testRoot = await Deno.makeTempDir({ prefix: "vf-worker-pool-ssr-path-" }); + const allowedRoot = `${testRoot}/allowed`; + const outsideRoot = `${testRoot}/outside`; + const linkPath = `${allowedRoot}/outside-link`; + + try { + await Deno.mkdir(allowedRoot); + await Deno.mkdir(outsideRoot); + await Deno.writeTextFile(`${outsideRoot}/page.tsx`, "export default null;"); + await Deno.symlink(outsideRoot, linkPath, { type: "dir" }); + + assertThrows( + () => + pool.executeStream( + "scope-a", + [allowedRoot], + makeSSRRequest("ssr-1", { + pageModulePath: `${linkPath}/page.tsx`, + }), + ), + VeryfrontError, + "outside the allowed project boundary", + ); + assertEquals(pool.getStats().poolSize, 0); + } finally { + await Deno.remove(testRoot, { recursive: true }); + } + }); + + it("defers SSR read-root changes until the active stream settles", async () => { + const controlled = createControlledPool({ maxPoolSize: 1 }); + pool = controlled.pool; + const firstStream = pool.executeStream( + "scope-a", + ["/tmp/project-a"], + makeSSRRequest("ssr-a", { + pageModulePath: "/tmp/project-a/page.tsx", + }), + ); + const workerA = latestWorker(controlled.workers, "scope-a"); + + assertThrows( + () => + pool.executeStream( + "scope-a", + ["/tmp/project-b"], + makeSSRRequest("ssr-b", { + pageModulePath: "/tmp/project-b/page.tsx", + }), + ), + VeryfrontError, + "changed permissions", + ); + assertEquals(workerA.terminateCalls, 0); + + workerA.completeStream("ssr-a"); + await new Response(firstStream).arrayBuffer(); + assertEquals(workerA.terminateCalls, 1); + assertEquals(pool.getStats().workers["scope-a"], undefined); + }); + + it("retires once when overlapping admission reaches the request limit", async () => { + const controlled = createControlledPool({ maxRequestsPerWorker: 1, - maxWorkerAgeMs: 600_000, + maxWorkerAgeMs: 0, }); + pool = controlled.pool; + + const first = pool.execute("scope-a", ["/tmp"], makeRequest("a-1")); + const workerA = latestWorker(controlled.workers, "scope-a"); + + await Promise.all([ + assertRejects( + () => pool.execute("scope-a", ["/tmp"], makeRequest("a-2")), + VeryfrontError, + "lifecycle limit", + ), + assertRejects( + () => pool.execute("scope-a", ["/tmp"], makeRequest("a-3")), + VeryfrontError, + "retiring", + ), + ]); + assertEquals(workerA.terminateCalls, 0); + + workerA.complete("a-1"); + await first; + assertEquals(workerA.terminateCalls, 1); + assertEquals(pool.getStats().poolSize, 0); - const makeRequest = (id: string) => ({ - type: "execute-app-route" as const, - id, - modulePath: "/tmp/nonexistent.ts", - method: "GET", - request: { - url: "http://localhost/test", - method: "GET", - headers: [] as [string, string][], - body: null, - }, - params: {}, - projectDir: "/tmp", - sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, - }); + const replacement = pool.getOrCreateWorker("scope-a", ["/tmp"]); + assert(replacement !== (workerA as unknown as ProjectWorker)); + assertEquals(controlled.workers.get("scope-a")?.length, 2); + }); - // First request: increments requestCount to 1 - const worker1 = pool.getOrCreateWorker("project-guard", ["/tmp"]); - try { - await pool.execute("project-guard", ["/tmp"], makeRequest("req-1")); - } catch { /* expected */ } - assertEquals(worker1.requestCount, 1); + it("retries one serialized prepared request after capacity rollover", async () => { + const controlled = createControlledPool({ maxPoolSize: 1 }); + pool = controlled.pool; - // Fire two concurrent requests that both trigger recycle - const p1 = pool.execute("project-guard", ["/tmp"], makeRequest("req-2")).catch(() => {}); - const p2 = pool.execute("project-guard", ["/tmp"], makeRequest("req-3")).catch(() => {}); - await Promise.all([p1, p2]); + const execution = pool.execute("scope-a", ["/tmp"], makeRequest("a-1")); + const workerA = latestWorker(controlled.workers, "scope-a"); - // Allow .finally() callbacks to run - await new Promise((r) => setTimeout(r, 100)); + workerA.reachPreparedModuleCapacity("a-1"); - // Only one replacement worker should exist (guard prevented double replacement) - assertEquals(pool.getStats().poolSize, 1); + await waitForWorkerGeneration(controlled.workers, "scope-a", 2); + const workerB = latestWorker(controlled.workers, "scope-a"); + assert(workerB !== workerA); + assertEquals(workerA.terminateCalls, 1); + + workerB.complete("a-1"); + assertEquals((await execution).type, "result"); + assertEquals(controlled.workers.get("scope-a")?.length, 2); }); - it("does not recycle when under maxRequestsPerWorker", () => { - pool = new WorkerPool({ - maxPoolSize: 3, - idleTimeoutMs: 60_000, - requestTimeoutMs: 5_000, - healthCheckIntervalMs: 60_000, - maxRequestsPerWorker: 100, - maxWorkerAgeMs: 600_000, - }); + it("bounds prepared-module capacity rollover to one fresh generation", async () => { + const controlled = createControlledPool({ maxPoolSize: 1 }); + pool = controlled.pool; + + const execution = pool.execute( + "scope-a", + ["/tmp"], + makeRequest("capacity-twice"), + ); + const workerA = latestWorker(controlled.workers, "scope-a"); + workerA.reachPreparedModuleCapacity("capacity-twice"); + + await waitForWorkerGeneration(controlled.workers, "scope-a", 2); + const workerB = latestWorker(controlled.workers, "scope-a"); + workerB.reachPreparedModuleCapacity("capacity-twice"); + + await assertRejects( + () => execution, + VeryfrontError, + "capacity was reached again", + ); + assertEquals(controlled.workers.get("scope-a")?.length, 2); + }); + + it("skips health pings while a worker has pending application work", async () => { + const controlled = createControlledPool(); + pool = controlled.pool; + + const active = pool.execute("scope-a", ["/tmp"], makeRequest("a-1")); + const workerA = latestWorker(controlled.workers, "scope-a"); + + await runHealthCheck(pool); + assertEquals(workerA.healthCheckCalls, 0); + assertEquals(workerA.terminateCalls, 0); + + workerA.complete("a-1"); + await active; + await runHealthCheck(pool); + assertEquals(workerA.healthCheckCalls, 1); + }); + + it("ignores a stale asynchronous health result after generation replacement", async () => { + const controlled = createControlledPool(); + pool = controlled.pool; + + const oldGeneration = pool.getOrCreateWorker("scope-a", []); + const workerA = latestWorker(controlled.workers, "scope-a"); + const healthResult = deferred(); + workerA.healthCheckResult = healthResult.promise; + + const checkingHealth = runHealthCheck(pool); + await Promise.resolve(); + assertEquals(workerA.healthCheckCalls, 1); + + workerA.becomeTerminal("crashed"); + const newGeneration = pool.getOrCreateWorker("scope-a", []); + assert(newGeneration !== oldGeneration); + const workerB = latestWorker(controlled.workers, "scope-a"); + + healthResult.resolve(false); + await checkingHealth; + + assertEquals(workerB.terminateCalls, 0); + assertExists(pool.getStats().workers["scope-a"]); + assertEquals(controlled.workers.get("scope-a")?.length, 2); + }); + + it("defers explicit eviction and terminates exactly once after settlement", async () => { + const controlled = createControlledPool(); + pool = controlled.pool; + + const active = pool.execute("scope-a", ["/tmp"], makeRequest("a-1")); + const workerA = latestWorker(controlled.workers, "scope-a"); - const worker1 = pool.getOrCreateWorker("project-no-recycle", []); - const worker2 = pool.getOrCreateWorker("project-no-recycle", []); + pool.evictWorker("scope-a"); + pool.evictWorker("scope-a"); + assertEquals(workerA.terminateCalls, 0); + assertEquals(pool.getStats().workers["scope-a"]?.retiring, true); - // Same worker returned (no recycle needed) - assert(worker1 === worker2, "should return the same worker when under threshold"); + workerA.complete("a-1"); + await active; + assertEquals(workerA.terminateCalls, 1); + assertEquals(pool.getStats().workers["scope-a"], undefined); + + pool.evictWorker("scope-a"); + assertEquals(workerA.terminateCalls, 1); + }); + + it("observes idle settlement for direct worker consumers without polling", async () => { + const controlled = createControlledPool(); + pool = controlled.pool; + + pool.getOrCreateWorker("scope-a", ["/tmp"]); + const workerA = latestWorker(controlled.workers, "scope-a"); + const externalRequest = workerA.execute(makeRequest("external-1")); + + pool.evictWorker("scope-a"); + assertEquals(workerA.terminateCalls, 0); + assertEquals(pool.getStats().workers["scope-a"]?.retiring, true); + + workerA.complete("external-1"); + await externalRequest; + assertEquals(workerA.terminateCalls, 1); + assertEquals(pool.getStats().workers["scope-a"], undefined); + }); + + it("evicts an exact API scope and only its framed generation keys", async () => { + const controlled = createControlledPool({ maxPoolSize: 8 }); + pool = controlled.pool; + const scope = "scope-a"; + const nestedScope = `${scope}:generation:nested`; + const busyGeneration = (await resolveWorkerGeneration( + "api", + snapshotWorkerGenerationIdentity(scope, "release-busy"), + )).workerId; + const idleGeneration = (await resolveWorkerGeneration( + "api", + snapshotWorkerGenerationIdentity(scope, "release-idle"), + )).workerId; + const nestedGeneration = (await resolveWorkerGeneration( + "api", + snapshotWorkerGenerationIdentity(nestedScope, "release-nested"), + )).workerId; + const unrelatedGeneration = (await resolveWorkerGeneration( + "api", + snapshotWorkerGenerationIdentity("scope-a-other", "release-other"), + )).workerId; + const malformedGeneration = `${scope}:generation:${"z".repeat(64)}`; + + pool.getOrCreateWorker(scope, []); + pool.getOrCreateWorker(idleGeneration, []); + pool.getOrCreateWorker(nestedGeneration, []); + pool.getOrCreateWorker(unrelatedGeneration, []); + pool.getOrCreateWorker(malformedGeneration, []); + const active = pool.execute( + busyGeneration, + ["/tmp"], + makeRequest("generation-1"), + ); + const busyWorker = latestWorker(controlled.workers, busyGeneration); + + pool.evictWorkerScope(scope); + + const duringRetirement = pool.getStats(); + assertEquals(duringRetirement.workers[scope], undefined); + assertEquals(duringRetirement.workers[idleGeneration], undefined); + assertEquals(duringRetirement.workers[busyGeneration]?.retiring, true); + assertExists(duringRetirement.workers[nestedGeneration]); + assertExists(duringRetirement.workers[unrelatedGeneration]); + assertExists(duringRetirement.workers[malformedGeneration]); + assertEquals(busyWorker.terminateCalls, 0); + + busyWorker.complete("generation-1"); + await active; + assertEquals(busyWorker.terminateCalls, 1); + assertEquals(pool.getStats().workers[busyGeneration], undefined); + }); + + it("does not interpret unframed worker keys as generation identities", () => { + const controlled = createControlledPool({ maxPoolSize: 4 }); + pool = controlled.pool; + const scope = "api:scope"; + const nestedScope = `${scope}:generation:nested`; + const generation = `${scope}:generation:${"a".repeat(64)}`; + const nestedGeneration = `${nestedScope}:generation:${"b".repeat(64)}`; + + pool.getOrCreateWorker(generation, []); + pool.getOrCreateWorker(nestedGeneration, []); + + pool.evictWorkerScope(scope); + + assertExists(pool.getStats().workers[generation]); + assertExists(pool.getStats().workers[nestedGeneration]); + }); + + it("replaces crashed and timed-out terminal generations without stale cleanup", () => { + const controlled = createControlledPool(); + pool = controlled.pool; + + const crashed = pool.getOrCreateWorker("scope-crash", []); + const crashedControl = latestWorker(controlled.workers, "scope-crash"); + crashedControl.becomeTerminal("crashed"); + const afterCrash = pool.getOrCreateWorker("scope-crash", []); + assert(afterCrash !== crashed); + assertEquals(crashedControl.terminateCalls, 1); + + const timedOut = pool.getOrCreateWorker("scope-timeout", []); + const timedOutControl = latestWorker(controlled.workers, "scope-timeout"); + timedOutControl.becomeTerminal("terminated"); + const afterTimeout = pool.getOrCreateWorker("scope-timeout", []); + assert(afterTimeout !== timedOut); + assertEquals(timedOutControl.terminateCalls, 1); + }); + + it("retires only idle workers when real host heap pressure is high", async () => { + const controlled = createControlledPool( + { maxPoolSize: 4 }, + {}, + { getHeapUsedPercent: () => 75 }, + ); + pool = controlled.pool; + + for (const scope of ["scope-a", "scope-b", "scope-c", "scope-d"]) { + pool.getOrCreateWorker(scope, ["/tmp"]); + } + + await runHealthCheck(pool); + + assertEquals(pool.getStats().poolSize, 3); + const terminated = [...controlled.workers.values()] + .flat() + .filter((worker) => worker.terminateCalls === 1); + assertEquals(terminated.length, 1); }); }); @@ -340,8 +1417,57 @@ describe("MAX_WORKER_BODY_BYTES", () => { }); }); +describe("worker pool defaults", () => { + it("publishes an immutable host policy", () => { + assertEquals(Object.isFrozen(DEFAULT_WORKER_POOL_CONFIG), true); + assertThrows( + () => { + (DEFAULT_WORKER_POOL_CONFIG as { maxPoolSize: number }).maxPoolSize = 1; + }, + TypeError, + ); + assertEquals(DEFAULT_WORKER_POOL_CONFIG.maxPoolSize, 20); + }); +}); + +describe("worker pool test reset", () => { + afterEach(async () => { + await __resetPoolForTests(); + }); + + it("does not resolve before the detached singleton is quiescent", async () => { + await __resetPoolForTests(); + const singleton = getWorkerPool(); + const shutdown = singleton.shutdown.bind(singleton); + const gate = deferred(); + let shutdownCalls = 0; + + singleton.shutdown = async () => { + shutdownCalls++; + await gate.promise; + await shutdown(); + }; + + const reset = __resetPoolForTests(); + let settled = false; + void reset.then(() => { + settled = true; + }); + + try { + await Promise.resolve(); + assertEquals(shutdownCalls, 1); + assertEquals(settled, false); + } finally { + gate.resolve(); + await reset; + } + assertEquals(settled, true); + }); +}); + describe("Feature flag caching", () => { - afterEach(() => { + afterEach(async () => { try { Deno.env.delete("WORKER_ISOLATION_ENABLED"); } catch { /* ok */ } @@ -354,42 +1480,45 @@ describe("Feature flag caching", () => { try { Deno.env.delete("WORKER_ISOLATION_SSR"); } catch { /* ok */ } - __resetPoolForTests(); + try { + Deno.env.delete("WORKER_MAX_POOL_SIZE"); + } catch { /* ok */ } + try { + Deno.env.delete("WORKER_REQUEST_TIMEOUT_MS"); + } catch { /* ok */ } + await __resetPoolForTests(); }); - it("returns false when master switch is off", () => { - __resetPoolForTests(); + it("returns false when master switch is off", async () => { + await __resetPoolForTests(); assertEquals(isWorkerIsolationEnabled(), false); assertEquals(isDataIsolationEnabled(), false); + assertEquals(isSSRIsolationEnabled(), false); }); - it("returns true for API isolation when both flags set", () => { - __resetPoolForTests(); + it("returns true for API isolation when both flags set", async () => { + await __resetPoolForTests(); Deno.env.set("WORKER_ISOLATION_ENABLED", "1"); Deno.env.set("WORKER_ISOLATION_API", "1"); assertEquals(isWorkerIsolationEnabled(), true); }); - it("returns true for data isolation when both flags set", () => { - __resetPoolForTests(); + it("returns true for data isolation when both flags set", async () => { + await __resetPoolForTests(); Deno.env.set("WORKER_ISOLATION_ENABLED", "1"); Deno.env.set("WORKER_ISOLATION_DATA", "1"); assertEquals(isDataIsolationEnabled(), true); }); - it("fails closed when the removed SSR isolation flag is enabled", () => { - __resetPoolForTests(); + it("returns true for SSR isolation when both flags set", async () => { + await __resetPoolForTests(); Deno.env.set("WORKER_ISOLATION_ENABLED", "1"); Deno.env.set("WORKER_ISOLATION_SSR", "1"); - assertThrows( - () => isWorkerIsolationEnabled(), - Error, - "WORKER_ISOLATION_SSR is unsupported", - ); + assertEquals(isSSRIsolationEnabled(), true); }); - it("caches flag results across calls", () => { - __resetPoolForTests(); + it("caches flag results across calls", async () => { + await __resetPoolForTests(); Deno.env.set("WORKER_ISOLATION_ENABLED", "1"); Deno.env.set("WORKER_ISOLATION_API", "1"); assertEquals(isWorkerIsolationEnabled(), true); @@ -399,13 +1528,128 @@ describe("Feature flag caching", () => { assertEquals(isWorkerIsolationEnabled(), true); }); - it("__resetPoolForTests clears cached flags", () => { - __resetPoolForTests(); + it("ignores malicious project overlays for host isolation policy", async () => { + await __resetPoolForTests(); + Deno.env.set("WORKER_ISOLATION_ENABLED", "1"); + Deno.env.set("WORKER_ISOLATION_API", "1"); + Deno.env.set("WORKER_ISOLATION_DATA", "1"); + Deno.env.set("WORKER_ISOLATION_SSR", "1"); + + runWithProjectEnv( + { + WORKER_ISOLATION_ENABLED: "0", + WORKER_ISOLATION_API: "0", + WORKER_ISOLATION_DATA: "0", + WORKER_ISOLATION_SSR: "0", + }, + () => { + assertEquals(isWorkerIsolationEnabled(), true); + assertEquals(isDataIsolationEnabled(), true); + assertEquals(isSSRIsolationEnabled(), true); + }, + ); + + await __resetPoolForTests(); + Deno.env.set("WORKER_ISOLATION_ENABLED", "0"); + runWithProjectEnv( + { + WORKER_ISOLATION_ENABLED: "1", + WORKER_ISOLATION_API: "1", + WORKER_ISOLATION_DATA: "1", + WORKER_ISOLATION_SSR: "1", + }, + () => { + assertEquals(isWorkerIsolationEnabled(), false); + assertEquals(isDataIsolationEnabled(), false); + assertEquals(isSSRIsolationEnabled(), false); + }, + ); + }); + + it("ignores project overlays and applies host pool limits", async () => { + await __resetPoolForTests(); + Deno.env.set("WORKER_MAX_POOL_SIZE", "2"); + Deno.env.set("WORKER_REQUEST_TIMEOUT_MS", "1234"); + + runWithProjectEnv( + { + WORKER_MAX_POOL_SIZE: "999", + WORKER_REQUEST_TIMEOUT_MS: "1", + }, + () => { + const singleton = getWorkerPool(); + const config = (singleton as unknown as { config: WorkerPoolConfig }).config; + assertEquals(singleton.getStats().maxPoolSize, 2); + assertEquals(config.requestTimeoutMs, 1234); + }, + ); + }); + + it("snapshots the host internal-egress decision when the singleton resolves", async () => { + await __resetPoolForTests(); + Deno.env.set(WORKER_INTERNAL_EGRESS_OVERRIDE_ENV, "1"); + try { + const first = getWorkerPool(); + const firstConfig = (first as unknown as { + config: WorkerPoolConfig & { allowInternalEgress?: boolean }; + }).config; + assertEquals(firstConfig.allowInternalEgress, true); + + Deno.env.set(WORKER_INTERNAL_EGRESS_OVERRIDE_ENV, "0"); + assertEquals(getWorkerPool(), first); + assertEquals(firstConfig.allowInternalEgress, true); + + await __resetPoolForTests(); + const second = getWorkerPool(); + const secondConfig = (second as unknown as { + config: WorkerPoolConfig & { allowInternalEgress?: boolean }; + }).config; + assertEquals(secondConfig.allowInternalEgress, false); + } finally { + await __resetPoolForTests(); + Deno.env.delete(WORKER_INTERNAL_EGRESS_OVERRIDE_ENV); + } + }); + + it("fails closed for invalid host pool limits", async () => { + for ( + const [name, value] of [ + ["WORKER_MAX_POOL_SIZE", "0"], + ["WORKER_REQUEST_TIMEOUT_MS", "Infinity"], + ] as const + ) { + await __resetPoolForTests(); + Deno.env.set(name, value); + try { + assertThrows( + () => getWorkerPool(), + RangeError, + `${name} must be a positive safe integer`, + ); + } finally { + Deno.env.delete(name); + } + } + }); + + it("fails closed for invalid host isolation flags", async () => { + await __resetPoolForTests(); + Deno.env.set("WORKER_ISOLATION_ENABLED", "treu"); + + assertThrows( + () => isWorkerIsolationEnabled(), + TypeError, + "WORKER_ISOLATION_ENABLED must be one of", + ); + }); + + it("__resetPoolForTests clears cached flags", async () => { + await __resetPoolForTests(); Deno.env.set("WORKER_ISOLATION_ENABLED", "1"); Deno.env.set("WORKER_ISOLATION_API", "1"); assertEquals(isWorkerIsolationEnabled(), true); - __resetPoolForTests(); + await __resetPoolForTests(); try { Deno.env.delete("WORKER_ISOLATION_ENABLED"); } catch { /* ok */ } diff --git a/src/security/sandbox/worker-pool.ts b/src/security/sandbox/worker-pool.ts index 85a0ce04d8..b3be7d0656 100644 --- a/src/security/sandbox/worker-pool.ts +++ b/src/security/sandbox/worker-pool.ts @@ -1,113 +1,535 @@ /** * Worker Pool Manager * - * Manages a pool of per-project Deno Workers for tenant-isolated code execution. - * Uses LRU eviction when the pool exceeds its capacity, idle timeout for - * cleanup, and health checks for reliability. + * Manages a bounded pool of per-project Deno Workers for tenant-isolated code + * execution. Idle workers may be evicted using LRU ordering; active workers are + * never terminated to admit different work. When every slot is active, new + * admissions fail explicitly with SERVICE_OVERLOADED. + * + * Deno Workers share the host process. Retiring a worker is useful lifecycle + * hygiene, but it is not a hard memory-containment boundary for retained ESM + * state or arbitrary top-level allocations. Hard limits require a separate + * process or container with an enforced memory limit. * * @module security/sandbox/worker-pool */ import { serverLogger } from "#veryfront/utils"; -import { getEnvBoolean, getEnvNumber, unrefTimer } from "#veryfront/platform/compat/process.ts"; +import { getHeapStats } from "#veryfront/utils/memory/index.ts"; +import { getHostEnv, unrefTimer } from "#veryfront/platform/compat/process.ts"; +import { isNotFoundError } from "#veryfront/platform/compat/fs.ts"; import { withSpan } from "#veryfront/observability/tracing/otlp-setup.ts"; -import { NOT_SUPPORTED, SECURITY_VIOLATION } from "#veryfront/errors"; -import { ProjectWorker } from "./project-worker.ts"; -import { buildWorkerEnvAllowlist, buildWorkerPermissions } from "./worker-permissions.ts"; -import type { WorkerPoolConfig, WorkerRequest, WorkerResponse } from "./worker-types.ts"; +import { SECURITY_VIOLATION, SERVICE_OVERLOADED } from "#veryfront/errors"; +import { basename, dirname, resolve as resolvePath } from "#veryfront/compat/path"; +import { fromFileUrl, toFileUrl } from "#veryfront/compat/path"; +import { isWithinDirectory } from "#veryfront/security/path-validation.ts"; +import { resolve as resolveExtensionContract } from "#veryfront/extensions/contracts.ts"; +import { + IsolatedSsrRendererProviderName, + snapshotIsolatedSsrRendererProvider, +} from "#veryfront/extensions/rendering/index.ts"; +import { MAX_TIMER_DELAY_MS } from "#veryfront/utils/timer.ts"; +import { ProjectWorker, type ProjectWorkerOptions } from "./project-worker.ts"; +import { + isInternalEgressOverrideEnabled, + WORKER_INTERNAL_EGRESS_OVERRIDE_ENV, +} from "./worker-egress-guard.ts"; +import { isWorkerGenerationInScope } from "./worker-generation.ts"; +import { buildWorkerPermissions } from "./worker-permissions.ts"; +import type { + RenderSSRRequest, + WorkerPoolConfig, + WorkerRequest, + WorkerResponse, +} from "./worker-types.ts"; import { DEFAULT_WORKER_POOL_CONFIG } from "./worker-types.ts"; const logger = serverLogger.component("worker-pool"); +const apply = Reflect.apply; +const stringToLowerCase = String.prototype.toLowerCase; +const stringTrim = String.prototype.trim; +const numberFromString = Number; +const numberIsSafeInteger = Number.isSafeInteger; +const MAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER; +const SERIALIZED_WORKER_REQUEST_CAPACITY = 1; +const HOST_HEAP_EVICTION_THRESHOLD_PERCENT = 70; +const HOST_HEAP_EVICTION_FRACTION = 0.25; +const WORKER_POOL_CONFIG_KEYS = new Set([ + "maxPoolSize", + "idleTimeoutMs", + "requestTimeoutMs", + "healthCheckIntervalMs", + "maxRequestsPerWorker", + "maxWorkerAgeMs", + "allowInternalEgress", +]); +const nativeRealPathSync = typeof Deno !== "undefined" && + typeof Deno.realPathSync === "function" + ? Deno.realPathSync.bind(Deno) + : undefined; interface PoolEntry { worker: ProjectWorker; lastAccessedAt: number; createdAt: number; - projectEnvKeys: string[]; + readPaths: string[]; + rendererModuleUrl: string | null; + activeRequests: number; + retirementRequested: boolean; + retirementReason?: string; + releaseIdleListener: () => void; + shutdown: Promise | null; + healthCheckInFlight: boolean; + preparedModuleCapacityReached: boolean; + retired: Promise; + resolveRetired: () => void; + retirementSettled: boolean; +} + +type ResolvedWorkerPoolConfig = Required; + +/** @internal Construction seam for deterministic lifecycle tests. */ +export interface WorkerPoolDependencies { + /** + * Test/integration seam for constructing the managed worker. Production uses + * ProjectWorker directly. + */ + createWorker?: (options: ProjectWorkerOptions) => ProjectWorker; + /** Test seam for deterministic host-memory pressure behavior. */ + getHeapUsedPercent?: () => number; + /** Test seam for the extension contract resolved only on SSR admission. */ + resolveIsolatedSsrRendererProvider?: () => unknown; +} + +interface IsolatedSsrRendererAdmission { + readonly moduleUrl: string; + readonly readPaths: readonly string[]; +} + +function canonicalizePath(path: string): string { + const resolved = resolvePath(path); + if (!nativeRealPathSync) return resolved; + + const unresolvedSegments: string[] = []; + let candidate = resolved; + + while (true) { + try { + const physicalAncestor = nativeRealPathSync(candidate); + return resolvePath(physicalAncestor, ...unresolvedSegments); + } catch (error) { + if (!isNotFoundError(error)) throw error; + } + + const rawParent = dirname(candidate); + const parent = /^[A-Za-z]:$/.test(rawParent) && /^[A-Za-z]:\//.test(candidate) + ? `${rawParent}/` + : rawParent; + if (parent === candidate) return resolved; + + const segment = basename(candidate); + if (!segment || segment === "." || segment === "..") return resolved; + unresolvedSegments.unshift(segment); + candidate = parent; + } +} + +function getHostEnvBoolean(key: string, fallback = false): boolean { + const value = getHostEnv(key); + if (value === undefined) return fallback; + + const trimmed = apply(stringTrim, value, []); + const normalized = apply(stringToLowerCase, trimmed, []); + switch (normalized) { + case "1": + case "true": + case "yes": + return true; + case "0": + case "false": + case "no": + return false; + default: + throw new TypeError( + `${key} must be one of 1, 0, true, false, yes, or no`, + ); + } +} + +function getHostEnvInteger( + key: string, + fallback: number, + maximum = MAX_SAFE_INTEGER, +): number { + const value = getHostEnv(key); + if (value === undefined) return fallback; + + const parsed = numberFromString(value); + if ( + !numberIsSafeInteger(parsed) || + parsed < 1 || + parsed > maximum + ) { + throw new RangeError( + `${key} must be a positive safe integer no greater than ${maximum}`, + ); + } + return parsed; +} + +function requirePositivePoolInteger( + name: string, + value: unknown, + maximum = MAX_SAFE_INTEGER, +): number { + if ( + typeof value !== "number" || + !numberIsSafeInteger(value) || + value < 1 || + value > maximum + ) { + throw new TypeError( + `Worker pool ${name} must be a positive safe integer no greater than ${maximum}`, + ); + } + return value; +} + +function requireNonNegativePoolInteger( + name: string, + value: unknown, + maximum = MAX_SAFE_INTEGER, +): number { + if ( + typeof value !== "number" || + !numberIsSafeInteger(value) || + value < 0 || + value > maximum + ) { + throw new TypeError( + `Worker pool ${name} must be a non-negative safe integer no greater than ${maximum}`, + ); + } + return value; +} + +function valueOrDefault(value: T | undefined, fallback: T): T { + return value === undefined ? fallback : value; } -function extractProjectEnvKeys(request: WorkerRequest): string[] { - if (!("projectEnv" in request) || !request.projectEnv) return []; - return Object.keys(request.projectEnv); +function snapshotWorkerPoolConfig( + value: unknown, +): Readonly> { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new TypeError("Worker pool config must be a plain object"); + } + + let prototype: object | null; + let keys: Array; + try { + prototype = Object.getPrototypeOf(value); + keys = Reflect.ownKeys(value); + } catch { + throw new TypeError("Worker pool config could not be inspected safely"); + } + if (prototype !== null && prototype !== Object.prototype) { + throw new TypeError("Worker pool config must be a plain object"); + } + + const snapshot = Object.create(null) as Record; + for (const key of keys) { + if (typeof key !== "string" || !WORKER_POOL_CONFIG_KEYS.has(key)) { + throw new TypeError( + `Worker pool config contains an unsupported ${ + typeof key === "string" ? `option: ${key}` : "symbol" + }`, + ); + } + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = Object.getOwnPropertyDescriptor(value, key); + } catch { + throw new TypeError(`Worker pool config.${key} could not be inspected safely`); + } + if (!descriptor || !("value" in descriptor)) { + throw new TypeError(`Worker pool config.${key} must be an own data property`); + } + snapshot[key] = descriptor.value; + } + return Object.freeze(snapshot) as Readonly>; } -function normalizeProjectEnvKeys(keys: Iterable): string[] { - const frameworkEnvKeyCount = buildWorkerEnvAllowlist([]).length; - return buildWorkerEnvAllowlist(keys).slice(frameworkEnvKeyCount); +function requirePoolBoolean(name: string, value: unknown): boolean { + if (typeof value !== "boolean") { + throw new TypeError(`Worker pool ${name} must be a boolean`); + } + return value; } -function sameEnvKeySet(left: readonly string[], right: readonly string[]): boolean { +function normalizeReadPaths(paths: Iterable): string[] { + const unique = new Set(); + for (const path of paths) { + if (!path) continue; + const trimmed = path.trim(); + if (!trimmed) continue; + unique.add(canonicalizePath(trimmed)); + } + + const canonicalRoots = [...unique].sort((left, right) => { + if (left.length !== right.length) return left.length - right.length; + return left < right ? -1 : left > right ? 1 : 0; + }); + + return canonicalRoots.filter((candidate, index) => { + for (let rootIndex = 0; rootIndex < index; rootIndex++) { + const root = canonicalRoots[rootIndex]; + if (root && isWithinDirectory(root, candidate)) return false; + } + return true; + }); +} + +function sameOrderedPaths(left: readonly string[], right: readonly string[]): boolean { if (left.length !== right.length) return false; - const rightSet = new Set(right); - return left.every((key) => rightSet.has(key)); + return left.every((path, index) => path === right[index]); +} + +function captureIsolatedSsrRendererAdmission(value: unknown): IsolatedSsrRendererAdmission { + const provider = snapshotIsolatedSsrRendererProvider(value); + let modulePath: string; + let readPaths: string[]; + try { + modulePath = canonicalizePath(fromFileUrl(provider.moduleUrl)); + readPaths = normalizeReadPaths( + provider.readRootUrls.map((rootUrl) => fromFileUrl(rootUrl)), + ); + } catch (cause) { + throw new TypeError("Isolated SSR renderer provider contains an invalid local path", { + cause, + }); + } + + for (const readPath of readPaths) { + if (dirname(readPath) === readPath) { + throw new TypeError("Isolated SSR renderer read roots must not grant filesystem-root access"); + } + let metadata: Deno.FileInfo; + try { + metadata = Deno.statSync(readPath); + } catch (cause) { + throw new TypeError("Isolated SSR renderer read root is unavailable", { cause }); + } + if (!metadata.isDirectory) { + throw new TypeError("Isolated SSR renderer read roots must be directories"); + } + } + + let moduleMetadata: Deno.FileInfo; + try { + moduleMetadata = Deno.statSync(modulePath); + } catch (cause) { + throw new TypeError("Isolated SSR renderer module is unavailable", { cause }); + } + if (!moduleMetadata.isFile) { + throw new TypeError("Isolated SSR renderer moduleUrl must identify a file"); + } + if (!readPaths.some((readPath) => isWithinDirectory(readPath, modulePath))) { + throw new TypeError("Isolated SSR renderer moduleUrl is outside its declared read roots"); + } + + return Object.freeze({ + moduleUrl: toFileUrl(modulePath).href, + readPaths: Object.freeze(readPaths), + }); +} + +function isPreparedApiRequest(request: WorkerRequest): boolean { + return request.type === "execute-app-route" || + request.type === "execute-pages-route" || + request.type === "inspect-api-route-methods"; } export class WorkerPool { private pool = new Map(); - private recycling = new Set(); - private config: WorkerPoolConfig; + private workerShutdowns = new Set>(); + private readonly config: ResolvedWorkerPoolConfig; + private readonly createWorker: (options: ProjectWorkerOptions) => ProjectWorker; + private readonly getHeapUsedPercent: () => number; + private readonly resolveIsolatedSsrRendererProvider: () => unknown; + private shuttingDown = false; + private shutdownPromise: Promise | null = null; private cleanupInterval: ReturnType | undefined; private healthCheckInterval: ReturnType | undefined; - constructor(config: Partial = {}) { - this.config = { ...DEFAULT_WORKER_POOL_CONFIG, ...config }; + constructor( + config: Partial = {}, + dependencies: WorkerPoolDependencies = {}, + ) { + const input = snapshotWorkerPoolConfig(config); + this.config = { + maxPoolSize: requirePositivePoolInteger( + "maxPoolSize", + valueOrDefault( + input.maxPoolSize, + DEFAULT_WORKER_POOL_CONFIG.maxPoolSize, + ), + ), + idleTimeoutMs: requireNonNegativePoolInteger( + "idleTimeoutMs", + valueOrDefault( + input.idleTimeoutMs, + DEFAULT_WORKER_POOL_CONFIG.idleTimeoutMs, + ), + MAX_TIMER_DELAY_MS, + ), + requestTimeoutMs: requirePositivePoolInteger( + "requestTimeoutMs", + valueOrDefault( + input.requestTimeoutMs, + DEFAULT_WORKER_POOL_CONFIG.requestTimeoutMs, + ), + MAX_TIMER_DELAY_MS, + ), + healthCheckIntervalMs: requirePositivePoolInteger( + "healthCheckIntervalMs", + valueOrDefault( + input.healthCheckIntervalMs, + DEFAULT_WORKER_POOL_CONFIG.healthCheckIntervalMs, + ), + MAX_TIMER_DELAY_MS, + ), + maxRequestsPerWorker: requirePositivePoolInteger( + "maxRequestsPerWorker", + valueOrDefault( + input.maxRequestsPerWorker, + DEFAULT_WORKER_POOL_CONFIG.maxRequestsPerWorker, + ), + ), + maxWorkerAgeMs: requireNonNegativePoolInteger( + "maxWorkerAgeMs", + valueOrDefault( + input.maxWorkerAgeMs, + DEFAULT_WORKER_POOL_CONFIG.maxWorkerAgeMs, + ), + MAX_TIMER_DELAY_MS, + ), + allowInternalEgress: requirePoolBoolean( + "allowInternalEgress", + valueOrDefault( + input.allowInternalEgress, + DEFAULT_WORKER_POOL_CONFIG.allowInternalEgress, + ), + ), + }; + this.createWorker = dependencies.createWorker ?? ((options) => new ProjectWorker(options)); + this.getHeapUsedPercent = dependencies.getHeapUsedPercent ?? + (() => getHeapStats().heapUsedPercent); + this.resolveIsolatedSsrRendererProvider = dependencies.resolveIsolatedSsrRendererProvider ?? + (() => resolveExtensionContract(IsolatedSsrRendererProviderName)); this.startCleanup(); this.startHealthChecks(); } /** * Get or create a worker for the given project. + * + * This is a low-level lookup without an admission lease. Production request + * paths should use `execute` or `executeStream` so acquisition and work + * registration are atomic with respect to eviction. */ getOrCreateWorker( projectId: string, readPaths: string[], - projectEnvKeys: Iterable = [], ): ProjectWorker { - const normalizedProjectEnvKeys = normalizeProjectEnvKeys(projectEnvKeys); + return this.getOrCreateWorkerForAdmission(projectId, readPaths); + } + + private getOrCreateWorkerForAdmission( + projectId: string, + readPaths: string[], + renderer?: IsolatedSsrRendererAdmission, + ): ProjectWorker { + if (this.shuttingDown) { + throw this.createOverloadError("Worker pool is shutting down"); + } + + const normalizedReadPaths = normalizeReadPaths(readPaths); + const rendererModuleUrl = renderer?.moduleUrl ?? null; const existing = this.pool.get(projectId); - if ( - existing && existing.worker.status !== "crashed" && existing.worker.status !== "terminated" - ) { - if (!sameEnvKeySet(existing.projectEnvKeys, normalizedProjectEnvKeys)) { - existing.worker.terminate(); - this.pool.delete(projectId); + if (existing) { + const readPathsChanged = !sameOrderedPaths(existing.readPaths, normalizedReadPaths); + const rendererChanged = existing.rendererModuleUrl !== rendererModuleUrl; + + if (this.isTerminal(existing)) { + this.requestRetirement(projectId, existing, "terminal"); + } else if (readPathsChanged || rendererChanged) { + this.requestRetirement(projectId, existing, "read_paths_changed"); + if (this.pool.get(projectId) === existing) { + throw this.createOverloadError( + "Worker is finishing active requests before applying changed permissions", + ); + } + } else if (existing.retirementRequested) { + this.tryFinalizeRetirement(projectId, existing); + if (this.pool.get(projectId) === existing) { + throw this.createOverloadError( + "Worker is retiring and cannot accept new requests", + ); + } + } else if (this.shouldRecycle(existing)) { + this.requestRetirement(projectId, existing, this.recycleReason(existing)); + if (this.pool.get(projectId) === existing) { + throw this.createOverloadError( + "Worker reached its lifecycle limit and is finishing active requests", + ); + } } else { existing.lastAccessedAt = Date.now(); return existing.worker; } } - // If an existing entry is crashed/terminated, clean it up - if (existing && this.pool.has(projectId)) { - existing.worker.terminate(); - this.pool.delete(projectId); - } - - // Evict LRU if at capacity - this.evictIfNeeded(); + this.ensureCapacityForAdmission(); - const permissions = buildWorkerPermissions(readPaths, { - projectEnvKeys: normalizedProjectEnvKeys, - }); - const worker = new ProjectWorker({ + const permissions = buildWorkerPermissions(normalizedReadPaths); + const worker = this.createWorker({ projectId, permissions, requestTimeoutMs: this.config.requestTimeoutMs, + allowInternalEgress: this.config.allowInternalEgress, + isolatedSsrRendererModuleUrl: rendererModuleUrl ?? undefined, }); worker.start(); const now = Date.now(); - this.pool.set(projectId, { + let resolveRetired!: () => void; + const retired = new Promise((resolve) => { + resolveRetired = resolve; + }); + const entry: PoolEntry = { worker, lastAccessedAt: now, createdAt: now, - projectEnvKeys: normalizedProjectEnvKeys, + readPaths: normalizedReadPaths, + rendererModuleUrl, + activeRequests: 0, + retirementRequested: false, + releaseIdleListener: () => {}, + shutdown: null, + healthCheckInFlight: false, + preparedModuleCapacityReached: false, + retired, + resolveRetired, + retirementSettled: false, + }; + entry.releaseIdleListener = worker.onIdle(() => { + this.handleWorkerIdle(projectId, entry); }); + this.pool.set(projectId, entry); logger.debug("Worker created", { - projectId, poolSize: this.pool.size, }); @@ -123,71 +545,201 @@ export class WorkerPool { readPaths: string[], request: WorkerRequest, ): Promise { - // Validate modulePath is within allowed read paths (defense-in-depth) - if ("modulePath" in request && request.modulePath) { - const modulePath = request.modulePath; - const isAllowed = readPaths.some((p) => modulePath.startsWith(p)); - if (!isAllowed) { - return Promise.reject( - SECURITY_VIOLATION.create({ - detail: - `Module path "${modulePath}" is outside allowed read paths for project "${projectId}"`, - }), - ); - } + try { + this.validateRequestModulePaths(readPaths, request); + } catch (error) { + return Promise.reject(error); } return withSpan( "workerPool.execute", async () => { - const projectEnvKeys = extractProjectEnvKeys(request); - const worker = this.getOrCreateWorker(projectId, readPaths, projectEnvKeys); - - // Check if worker should be recycled (request count or age) - const entry = this.pool.get(projectId); - const shouldRecycle = worker.requestCount >= this.config.maxRequestsPerWorker || - (entry && Date.now() - entry.createdAt > this.config.maxWorkerAgeMs); - - if (shouldRecycle && !this.recycling.has(projectId)) { - this.recycling.add(projectId); - - logger.debug("Recycling worker", { - projectId, - requestCount: worker.requestCount, - ageMs: entry ? Date.now() - entry.createdAt : 0, - reason: worker.requestCount >= this.config.maxRequestsPerWorker - ? "request_count" - : "age", - }); - - // Warm replacement: let the old worker handle this last request, - // then evict it and create a replacement after the request settles. - // This avoids cold-start latency for the caller AND prevents the - // old worker from being terminated while it still has pending work. - const result = worker.execute(request); - - void result.then( - () => { - this.evictWorker(projectId); - this.getOrCreateWorker(projectId, readPaths, projectEnvKeys); - this.recycling.delete(projectId); - }, - () => { - this.evictWorker(projectId); - this.getOrCreateWorker(projectId, readPaths, projectEnvKeys); - this.recycling.delete(projectId); - }, - ); - - return result; + const renderer = request.type === "render-ssr" + ? captureIsolatedSsrRendererAdmission( + this.resolveIsolatedSsrRendererProvider(), + ) + : undefined; + const admittedReadPaths = renderer ? [...readPaths, ...renderer.readPaths] : readPaths; + const canRetryCapacity = isPreparedApiRequest(request); + let capacityRolloverConsumed = false; + + while (true) { + const retiringEntry = this.pool.get(projectId); + if ( + canRetryCapacity && + retiringEntry?.preparedModuleCapacityReached + ) { + if (capacityRolloverConsumed) { + throw this.createOverloadError( + "Prepared API module capacity was reached again after worker rollover", + ); + } + capacityRolloverConsumed = true; + await retiringEntry.retired; + } + + let entry: PoolEntry; + try { + entry = this.admitRequest(projectId, admittedReadPaths, renderer); + } catch (error) { + const current = this.pool.get(projectId); + if ( + canRetryCapacity && + !capacityRolloverConsumed && + current?.preparedModuleCapacityReached + ) { + capacityRolloverConsumed = true; + await current.retired; + continue; + } + throw error; + } + + let response: WorkerResponse; + try { + response = await entry.worker.execute(request); + if (response.type === "prepared-module-capacity") { + this.markPreparedModuleCapacityReached(projectId, entry); + } + } finally { + this.completeRequest(projectId, entry); + } + + if (response.type !== "prepared-module-capacity") return response; + + if (!canRetryCapacity) { + throw this.createOverloadError( + "Worker returned an invalid prepared-module capacity signal", + ); + } + if (capacityRolloverConsumed) { + throw this.createOverloadError( + "Prepared API module capacity was reached again after worker rollover", + ); + } + + capacityRolloverConsumed = true; + await entry.retired; } - - return worker.execute(request); }, - { "workerPool.projectId": projectId }, + { "workerPool.requestType": request.type }, ); } + /** + * Atomically admit and execute a streaming request. + * + * The pool admission is held until the worker protocol completes, or until + * the consumer cancels or encounters an error. Already-buffered chunks remain + * readable independently after protocol completion releases the admission. + * This closes the get-or-create/execute gap for streaming callers. + */ + executeStream( + projectId: string, + readPaths: string[], + request: RenderSSRRequest, + ): ReadableStream { + this.validateRequestModulePaths(readPaths, request); + const renderer = captureIsolatedSsrRendererAdmission( + this.resolveIsolatedSsrRendererProvider(), + ); + const entry = this.admitRequest( + projectId, + [...readPaths, ...renderer.readPaths], + renderer, + ); + + let source: ReadableStream; + try { + source = entry.worker.executeStream(request); + } catch (error) { + this.completeRequest(projectId, entry); + throw error; + } + + let reader: ReadableStreamDefaultReader; + try { + reader = source.getReader(); + } catch (error) { + this.completeRequest(projectId, entry); + throw error; + } + + let admissionReleased = false; + let readerReleased = false; + let releaseIdleListener = () => {}; + + const releaseAdmission = () => { + if (admissionReleased) return; + admissionReleased = true; + releaseIdleListener(); + this.completeRequest(projectId, entry); + }; + const releaseReader = () => { + if (readerReleased) return; + readerReleased = true; + try { + reader.releaseLock(); + } catch { + // A pending read holds the lock until it settles. Admission release is + // independent and still occurs from the worker-idle signal. + } + }; + + try { + const unsubscribe = entry.worker.onIdle(releaseAdmission); + releaseIdleListener = unsubscribe; + + // A custom source may finish synchronously before listener registration. + // Also clean up correctly if an onIdle implementation invokes the + // callback synchronously while it is being registered. + if (admissionReleased) { + unsubscribe(); + } else if (!entry.worker.hasPendingRequests) { + releaseAdmission(); + } + } catch (error) { + void reader.cancel(error).catch(() => {}); + releaseAdmission(); + releaseReader(); + throw error; + } + + try { + return new ReadableStream({ + pull: async (controller) => { + try { + const result = await reader.read(); + if (result.done) { + releaseAdmission(); + releaseReader(); + controller.close(); + return; + } + controller.enqueue(result.value); + } catch (error) { + releaseAdmission(); + releaseReader(); + controller.error(error); + } + }, + cancel: async (reason) => { + try { + await reader.cancel(reason); + } finally { + releaseAdmission(); + releaseReader(); + } + }, + }); + } catch (error) { + void reader.cancel(error).catch(() => {}); + releaseAdmission(); + releaseReader(); + throw error; + } + } + /** * Evict a specific project's worker. */ @@ -195,23 +747,41 @@ export class WorkerPool { const entry = this.pool.get(projectId); if (!entry) return; - entry.worker.terminate(); - this.pool.delete(projectId); - - logger.debug("Worker evicted", { projectId, poolSize: this.pool.size }); + this.requestRetirement(projectId, entry, "explicit"); } /** - * Get pool statistics for monitoring. + * Retire every worker belonging to one logical execution scope. + * + * Generation ownership is matched using the complete versioned, framed + * identity, never a raw scope prefix. Busy generations finish their current + * requests before eviction. */ + evictWorkerScope(scopeId: string): void { + if (!scopeId) return; + + for (const [projectId, entry] of [...this.pool.entries()]) { + if ( + projectId !== scopeId && + !isWorkerGenerationInScope(projectId, scopeId) + ) { + continue; + } + if (this.pool.get(projectId) !== entry) continue; + this.requestRetirement(projectId, entry, "scope_eviction"); + } + } + + /** Get pool statistics for monitoring. */ getStats(): { poolSize: number; maxPoolSize: number; - memoryBudgetMb: number; workers: Record; @@ -220,6 +790,8 @@ export class WorkerPool { status: string; requestCount: number; hasPending: boolean; + activeRequests: number; + retiring: boolean; idleMs: number; ageMs: number; }> = {}; @@ -230,6 +802,8 @@ export class WorkerPool { status: entry.worker.status, requestCount: entry.worker.requestCount, hasPending: entry.worker.hasPendingRequests, + activeRequests: entry.activeRequests, + retiring: entry.retirementRequested, idleMs: now - entry.lastAccessedAt, ageMs: now - entry.createdAt, }; @@ -238,7 +812,6 @@ export class WorkerPool { return { poolSize: this.pool.size, maxPoolSize: this.config.maxPoolSize, - memoryBudgetMb: this.config.memoryBudgetMb, workers, }; } @@ -249,7 +822,7 @@ export class WorkerPool { getMetrics(): { /** Current number of active workers */ workerPoolSize: number; - /** Number of workers at capacity (max pool size) */ + /** Configured maximum worker count */ workerPoolCapacity: number; /** Total requests processed across all workers */ totalRequestsProcessed: number; @@ -278,18 +851,35 @@ export class WorkerPool { } /** - * Shutdown the pool. Terminates all workers and stops timers. + * Shutdown the pool and wait for every managed worker to become quiescent. + * Concurrent calls share one completion promise. */ - shutdown(): void { + shutdown(): Promise { + if (this.shutdownPromise) return this.shutdownPromise; + + const completion = Promise.withResolvers(); + this.shutdownPromise = completion.promise; + this.shuttingDown = true; + if (this.cleanupInterval) clearInterval(this.cleanupInterval); if (this.healthCheckInterval) clearInterval(this.healthCheckInterval); + this.cleanupInterval = undefined; + this.healthCheckInterval = undefined; - for (const [, entry] of this.pool) { - entry.worker.terminate(); + const entries = [...this.pool.values()]; + this.pool.clear(); + for (const entry of entries) { + entry.releaseIdleListener(); + const shutdown = this.terminateEntry(entry); + void shutdown.then(() => this.settleRetirement(entry)); } - this.pool.clear(); - logger.debug("Worker pool shut down"); + void this.drainWorkerShutdowns().then(() => { + for (const entry of entries) this.settleRetirement(entry); + logger.debug("Worker pool shut down"); + completion.resolve(); + }); + return completion.promise; } // ----------------------------------------------------------------------- @@ -316,62 +906,281 @@ export class WorkerPool { private evictIdleWorkers(): void { const now = Date.now(); - for (const [projectId, entry] of this.pool) { - const idleTime = now - entry.lastAccessedAt; + for (const [projectId, entry] of [...this.pool.entries()]) { + if (this.pool.get(projectId) !== entry) continue; - if (idleTime > this.config.idleTimeoutMs && !entry.worker.hasPendingRequests) { - entry.worker.terminate(); - this.pool.delete(projectId); + if (entry.retirementRequested) { + this.tryFinalizeRetirement(projectId, entry); + continue; + } - logger.debug("Evicted idle worker", { - projectId, - idleMs: idleTime, - poolSize: this.pool.size, - }); + const idleTime = now - entry.lastAccessedAt; + + if (idleTime > this.config.idleTimeoutMs && !this.isBusy(entry)) { + this.requestRetirement(projectId, entry, "idle_timeout"); } } } - private evictIfNeeded(): void { + private ensureCapacityForAdmission(): void { if (this.pool.size < this.config.maxPoolSize) return; - // Find the LRU entry that has no pending requests - let lruId: string | null = null; + let lruId: string | undefined; + let lruEntry: PoolEntry | undefined; let lruTime = Infinity; for (const [projectId, entry] of this.pool) { - if (!entry.worker.hasPendingRequests && entry.lastAccessedAt < lruTime) { + if ( + (this.isTerminal(entry) || !this.isBusy(entry)) && + entry.lastAccessedAt < lruTime + ) { lruTime = entry.lastAccessedAt; lruId = projectId; + lruEntry = entry; } } - if (lruId) { - this.evictWorker(lruId); - } else { - // All workers have pending requests — force evict the oldest - for (const [projectId, entry] of this.pool) { - if (entry.lastAccessedAt < lruTime) { - lruTime = entry.lastAccessedAt; - lruId = projectId; - } + if (lruId && lruEntry) { + this.requestRetirement(lruId, lruEntry, "capacity_lru"); + } + + if (this.pool.size >= this.config.maxPoolSize) { + throw this.createOverloadError( + `Worker pool capacity reached (${this.pool.size}/${this.config.maxPoolSize}); all workers are busy or retiring`, + ); + } + } + + private validateRequestModulePaths(readPaths: string[], request: WorkerRequest): void { + const modulePaths: string[] = []; + if ("modulePath" in request && request.modulePath) { + modulePaths.push(request.modulePath); + } + if (request.type === "render-ssr") { + modulePaths.push(request.pageModulePath, ...request.layoutModulePaths); + } + if (modulePaths.length === 0) return; + + let normalizedReadPaths: string[] = []; + try { + normalizedReadPaths = normalizeReadPaths(readPaths); + } catch { + // Every module fails closed if its permission roots cannot be resolved. + } + + for (const requestedPath of modulePaths) { + let modulePath = requestedPath; + let isAllowed = false; + try { + modulePath = canonicalizePath(requestedPath); + isAllowed = requestedPath.length > 0 && + normalizedReadPaths.some((readPath) => isWithinDirectory(readPath, modulePath)); + } catch { + // Canonicalization failures fail closed through the same public error. } - if (lruId) this.evictWorker(lruId); + + if (isAllowed) continue; + + logger.warn("Worker module path rejected by read boundary", { + requestType: request.type, + }); + throw SECURITY_VIOLATION.create({ + detail: "Worker module path is outside the allowed project boundary", + }); + } + } + + private admitRequest( + projectId: string, + readPaths: string[], + renderer?: IsolatedSsrRendererAdmission, + ): PoolEntry { + const worker = this.getOrCreateWorkerForAdmission(projectId, readPaths, renderer); + const entry = this.pool.get(projectId); + if (!entry || entry.worker !== worker || entry.retirementRequested) { + throw this.createOverloadError( + "Worker changed while the request was being admitted", + ); + } + if (entry.activeRequests >= SERIALIZED_WORKER_REQUEST_CAPACITY) { + throw this.createOverloadError( + `Worker active request capacity reached (${entry.activeRequests}/${SERIALIZED_WORKER_REQUEST_CAPACITY})`, + ); + } + + entry.activeRequests++; + entry.lastAccessedAt = Date.now(); + return entry; + } + + private shouldRecycle(entry: PoolEntry): boolean { + return entry.worker.requestCount >= this.config.maxRequestsPerWorker || + Date.now() - entry.createdAt >= this.config.maxWorkerAgeMs; + } + + private recycleReason(entry: PoolEntry): string { + return entry.worker.requestCount >= this.config.maxRequestsPerWorker + ? "request_count_limit" + : "worker_age_limit"; + } + + private isTerminal(entry: PoolEntry): boolean { + return entry.worker.status === "crashed" || entry.worker.status === "terminated"; + } + + private isBusy(entry: PoolEntry): boolean { + return entry.activeRequests > 0 || + entry.worker.hasPendingRequests || + entry.healthCheckInFlight; + } + + private completeRequest(projectId: string, entry: PoolEntry): void { + if (entry.activeRequests > 0) entry.activeRequests--; + if (this.pool.get(projectId) !== entry) return; + + if (this.isTerminal(entry)) { + this.requestRetirement(projectId, entry, "terminal"); + return; + } + + if (entry.retirementRequested) { + this.tryFinalizeRetirement(projectId, entry); + } + } + + private markPreparedModuleCapacityReached( + projectId: string, + entry: PoolEntry, + ): void { + entry.preparedModuleCapacityReached = true; + this.requestRetirement( + projectId, + entry, + "prepared_module_capacity", + ); + } + + private requestRetirement(projectId: string, entry: PoolEntry, reason: string): void { + if (this.pool.get(projectId) !== entry) return; + + if (!entry.retirementRequested) { + entry.retirementRequested = true; + entry.retirementReason = reason; + logger.debug("Worker retirement requested", { + reason, + pending: this.isBusy(entry), + }); } + + this.tryFinalizeRetirement(projectId, entry); + } + + private tryFinalizeRetirement(projectId: string, entry: PoolEntry): boolean { + if (this.pool.get(projectId) !== entry) return true; + if (!this.isTerminal(entry) && this.isBusy(entry)) return false; + + if (this.pool.get(projectId) !== entry) return true; + + this.pool.delete(projectId); + entry.releaseIdleListener(); + const shutdown = this.terminateEntry(entry); + void shutdown.then(() => { + this.settleRetirement(entry); + logger.debug("Worker retired", { + reason: entry.retirementReason ?? "unspecified", + poolSize: this.pool.size, + }); + }); + return true; + } + + private settleRetirement(entry: PoolEntry): void { + if (entry.retirementSettled) return; + entry.retirementSettled = true; + entry.resolveRetired(); + } + + private handleWorkerIdle(projectId: string, entry: PoolEntry): void { + if (this.pool.get(projectId) !== entry) return; + if (this.isTerminal(entry)) { + this.requestRetirement(projectId, entry, "terminal"); + return; + } + if (entry.retirementRequested) { + this.tryFinalizeRetirement(projectId, entry); + } + } + + private terminateEntry(entry: PoolEntry): Promise { + if (entry.shutdown) return entry.shutdown; + + let workerShutdown: Promise; + try { + workerShutdown = Promise.resolve(entry.worker.shutdown()); + } catch (error) { + logger.debug("Worker termination failed", { error }); + workerShutdown = Promise.resolve(); + } + + const normalized = workerShutdown.catch((error) => { + logger.debug("Worker termination failed", { error }); + }); + const tracked = normalized.finally(() => this.workerShutdowns.delete(tracked)); + entry.shutdown = tracked; + this.workerShutdowns.add(tracked); + return tracked; + } + + private async drainWorkerShutdowns(): Promise { + while (this.workerShutdowns.size > 0) { + await Promise.all([...this.workerShutdowns]); + } + } + + private createOverloadError(detail: string) { + return SERVICE_OVERLOADED.create({ detail }); } private async checkHealth(): Promise { - for (const [projectId, entry] of this.pool) { - if (entry.worker.status === "crashed" || entry.worker.status === "terminated") { - this.pool.delete(projectId); + for (const [projectId, entry] of [...this.pool.entries()]) { + if (this.pool.get(projectId) !== entry) continue; + + if (this.isTerminal(entry)) { + this.requestRetirement(projectId, entry, "terminal"); + continue; + } + + if (entry.retirementRequested) { + this.tryFinalizeRetirement(projectId, entry); + continue; + } + + // A ping shares the worker protocol and pending-request map. Do not add a + // health request while application work is already in flight. + if (this.isBusy(entry) || entry.healthCheckInFlight) continue; + + entry.healthCheckInFlight = true; + let healthy = false; + try { + healthy = await entry.worker.isHealthy(); + } catch { + healthy = false; + } finally { + entry.healthCheckInFlight = false; + } + + // The await above may span eviction and re-creation of this project key. + // Never let an old health result act on a newer worker generation. + if (this.pool.get(projectId) !== entry) continue; + + if (entry.retirementRequested) { + this.tryFinalizeRetirement(projectId, entry); continue; } - const healthy = await entry.worker.isHealthy(); if (!healthy) { - logger.warn("Worker failed health check", { projectId }); - entry.worker.terminate(); - this.pool.delete(projectId); + logger.warn("Worker failed health check"); + this.requestRetirement(projectId, entry, "health_check_failed"); } } @@ -380,38 +1189,41 @@ export class WorkerPool { } /** - * Evict workers when the process is under memory pressure. - * Uses the global heap stats — if heap usage is above a threshold, - * evict idle workers starting with the oldest to free memory. + * Best-effort idle-worker retirement under host-process heap pressure. + * + * This can drop pool references but cannot guarantee that retained ESM state + * or top-level allocations are reclaimed. It is operational pressure relief, + * not a per-worker memory limit. */ private evictUnderMemoryPressure(): void { - // Lazy import to avoid circular deps — this is only called during health checks try { - // deno-lint-ignore no-explicit-any - const { getHeapStats } = (globalThis as any).__veryfront_heap_stats ?? {}; - if (!getHeapStats) return; - - const { heapUsedPercent } = getHeapStats(); - if (heapUsedPercent < 70) return; // Only act above 70% + const heapUsedPercent = this.getHeapUsedPercent(); + if (!Number.isFinite(heapUsedPercent) || heapUsedPercent < 0) return; + if (heapUsedPercent < HOST_HEAP_EVICTION_THRESHOLD_PERCENT) return; // Sort workers by last access time (oldest first) const entries = [...this.pool.entries()] - .filter(([, e]) => !e.worker.hasPendingRequests) + .filter(([, entry]) => !this.isBusy(entry)) .sort(([, a], [, b]) => a.lastAccessedAt - b.lastAccessedAt); - // Evict up to 25% of idle workers - const toEvict = Math.max(1, Math.ceil(entries.length * 0.25)); + const toEvict = Math.max( + 1, + Math.ceil(entries.length * HOST_HEAP_EVICTION_FRACTION), + ); for (let i = 0; i < toEvict && i < entries.length; i++) { - const projectId = entries[i]![0]; - this.evictWorker(projectId); - logger.debug("Evicted worker due to memory pressure", { - projectId, + const [projectId, entry] = entries[i]!; + if (this.pool.get(projectId) !== entry) continue; + + this.requestRetirement(projectId, entry, "host_memory_pressure"); + logger.debug("Retired worker due to host memory pressure", { heapUsedPercent, poolSize: this.pool.size, }); } - } catch { - // getHeapStats may not be available in all environments + } catch (error) { + logger.debug("Host heap statistics unavailable for worker eviction", { + error, + }); } } } @@ -424,17 +1236,16 @@ export class WorkerPool { let _flagsResolved = false; let _apiIsolation = false; let _dataIsolation = false; +let _ssrIsolation = false; function resolveFlags(): void { if (_flagsResolved) return; - const master = getEnvBoolean("WORKER_ISOLATION_ENABLED", false); - if (master && getEnvBoolean("WORKER_ISOLATION_SSR", false)) { - throw NOT_SUPPORTED.create({ - detail: "WORKER_ISOLATION_SSR is unsupported; SSR uses the bounded main-process renderer", - }); - } - _apiIsolation = master && getEnvBoolean("WORKER_ISOLATION_API", false); - _dataIsolation = master && getEnvBoolean("WORKER_ISOLATION_DATA", false); + // Isolation is host-owned security policy. Project env overlays must never + // enable or disable it for the framework process. + const master = getHostEnvBoolean("WORKER_ISOLATION_ENABLED", false); + _apiIsolation = master && getHostEnvBoolean("WORKER_ISOLATION_API", false); + _dataIsolation = master && getHostEnvBoolean("WORKER_ISOLATION_DATA", false); + _ssrIsolation = master && getHostEnvBoolean("WORKER_ISOLATION_SSR", false); _flagsResolved = true; } @@ -456,33 +1267,75 @@ export function isDataIsolationEnabled(): boolean { return _dataIsolation; } +/** + * Whether worker isolation is enabled for SSR rendering. + * Controlled by WORKER_ISOLATION_SSR=1 (requires WORKER_ISOLATION_ENABLED=1). + */ +export function isSSRIsolationEnabled(): boolean { + resolveFlags(); + return _ssrIsolation; +} + /** Lazy singleton — created on first use when isolation is enabled */ let _pool: WorkerPool | null = null; export function getWorkerPool(): WorkerPool { if (!_pool) { _pool = new WorkerPool({ - maxPoolSize: getEnvNumber("WORKER_MAX_POOL_SIZE") ?? DEFAULT_WORKER_POOL_CONFIG.maxPoolSize, - idleTimeoutMs: getEnvNumber("WORKER_IDLE_TIMEOUT_MS") ?? + // Pool limits are framework-owned configuration, not tenant input. + maxPoolSize: getHostEnvInteger( + "WORKER_MAX_POOL_SIZE", + DEFAULT_WORKER_POOL_CONFIG.maxPoolSize, + ), + idleTimeoutMs: getHostEnvInteger( + "WORKER_IDLE_TIMEOUT_MS", DEFAULT_WORKER_POOL_CONFIG.idleTimeoutMs, - requestTimeoutMs: getEnvNumber("WORKER_REQUEST_TIMEOUT_MS") ?? + MAX_TIMER_DELAY_MS, + ), + requestTimeoutMs: getHostEnvInteger( + "WORKER_REQUEST_TIMEOUT_MS", DEFAULT_WORKER_POOL_CONFIG.requestTimeoutMs, - maxRequestsPerWorker: getEnvNumber("WORKER_MAX_REQUESTS_PER_WORKER") ?? + MAX_TIMER_DELAY_MS, + ), + maxRequestsPerWorker: getHostEnvInteger( + "WORKER_MAX_REQUESTS_PER_WORKER", DEFAULT_WORKER_POOL_CONFIG.maxRequestsPerWorker, - maxWorkerAgeMs: getEnvNumber("WORKER_MAX_AGE_MS") ?? + ), + maxWorkerAgeMs: getHostEnvInteger( + "WORKER_MAX_AGE_MS", DEFAULT_WORKER_POOL_CONFIG.maxWorkerAgeMs, - memoryBudgetMb: getEnvNumber("WORKER_MEMORY_BUDGET_MB") ?? - DEFAULT_WORKER_POOL_CONFIG.memoryBudgetMb, + MAX_TIMER_DELAY_MS, + ), + allowInternalEgress: isInternalEgressOverrideEnabled( + getHostEnv(WORKER_INTERNAL_EGRESS_OVERRIDE_ENV), + ), }); } return _pool; } -/** Reset the singleton and cached flags — for testing only */ -export function __resetPoolForTests(): void { - _pool?.shutdown(); +/** + * Retire an existing worker scope without constructing the lazy singleton. + * + * Rendering/data owners call this when their source-generation cache is + * invalidated or disposed. Active requests finish before retirement. + */ +export function evictWorkerScopeIfPresent(scopeId: string): void { + _pool?.evictWorkerScope(scopeId); +} + +/** + * Reset the singleton and cached flags — for testing only. + * + * Callers must await the returned promise before changing worker-related host + * configuration or starting another test so the detached pool is quiescent. + */ +export function __resetPoolForTests(): Promise { + const pool = _pool; _pool = null; _flagsResolved = false; _apiIsolation = false; _dataIsolation = false; + _ssrIsolation = false; + return pool?.shutdown() ?? Promise.resolve(); } diff --git a/src/security/sandbox/worker-script.test.ts b/src/security/sandbox/worker-script.test.ts index 422bddec20..a5269b8993 100644 --- a/src/security/sandbox/worker-script.test.ts +++ b/src/security/sandbox/worker-script.test.ts @@ -1,14 +1,74 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assert, assertEquals, assertExists, assertRejects } from "#veryfront/testing/assert.ts"; +import { + assert, + assertEquals, + assertExists, + assertRejects, + assertThrows, +} from "#veryfront/testing/assert.ts"; import { afterEach, describe, it } from "#veryfront/testing/bdd.ts"; import { join } from "node:path"; +import { API_ROUTE_ERROR } from "#veryfront/errors"; +import { ERROR_DIAGNOSTIC_MAX_LENGTH_CHARS } from "#veryfront/errors/safe-diagnostics.ts"; +import { + MAX_WORKER_MODULE_SOURCE_BYTES, + MAX_WORKER_RETAINED_MODULE_SOURCE_BYTES, + type PreparedWorkerModule, +} from "./worker-types.ts"; import { - clearModuleCache, + assertIsolatedSsrDependencySnapshotSupported, + getPreparedModuleRetentionStats, loadModule, + loadPreparedModule, makeProjectPathGuard, + sanitizeWorkerDataModuleStack, serializeError, + snapshotWorkerRequest, } from "./worker-script.ts"; +const TEST_SOURCE_INTEGRATION_POLICY = { + schemaVersion: 1, + mode: "unrestricted", +} as const; + +async function prepareWorkerModule( + source: string, +): Promise { + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(source), + ); + return { + source, + sha256: new Uint8Array(digest).toHex(), + }; +} + +function waitForPortMessage( + port: MessagePort, + predicate: (message: unknown) => boolean, +): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + port.removeEventListener("message", onMessage); + reject(new Error("Timed out waiting for worker control message")); + }, 5_000); + const onMessage = (event: MessageEvent) => { + if (!predicate(event.data)) return; + clearTimeout(timeout); + port.removeEventListener("message", onMessage); + resolve(event.data); + }; + port.addEventListener("message", onMessage); + }); +} + +function hasMessageIdentity(message: unknown, id: string): boolean { + return typeof message === "object" && + message !== null && + (message as { id?: unknown }).id === id; +} + describe("worker-script makeProjectPathGuard", () => { it("allows a real file inside the project", async () => { const projectDir = await Deno.makeTempDir(); @@ -23,6 +83,20 @@ describe("worker-script makeProjectPathGuard", () => { } }); + it("allows the canonical project root itself", async () => { + const projectDir = await Deno.makeTempDir(); + try { + const guard = makeProjectPathGuard(projectDir); + assertEquals(await guard("."), await Deno.realPath(projectDir)); + assertEquals( + await guard(projectDir), + await Deno.realPath(projectDir), + ); + } finally { + await Deno.remove(projectDir, { recursive: true }); + } + }); + it("rejects plain ../ traversal", async () => { const projectDir = await Deno.makeTempDir(); try { @@ -50,13 +124,31 @@ describe("worker-script makeProjectPathGuard", () => { } }); + it("rejects a missing target beneath a symlinked parent outside the project", async () => { + const projectDir = await Deno.makeTempDir(); + const outsideDir = await Deno.makeTempDir(); + try { + await Deno.symlink(outsideDir, join(projectDir, "link")); + + const guard = makeProjectPathGuard(projectDir); + await assertRejects( + () => guard("link/not-yet-created.txt"), + Error, + "escapes project directory", + ); + } finally { + await Deno.remove(projectDir, { recursive: true }); + await Deno.remove(outsideDir, { recursive: true }); + } + }); + it("allows a not-yet-existing path that is lexically contained", async () => { const projectDir = await Deno.makeTempDir(); try { const guard = makeProjectPathGuard(projectDir); const resolved = await guard("nested/new-file.txt"); - // The target doesn't exist so it can't be canonicalized; it is still - // accepted (lexically contained) and points at the nested path. + // The nearest existing ancestor is canonicalized and the missing suffix + // is reattached within that physical root. assert(resolved.endsWith(join("nested", "new-file.txt"))); } finally { await Deno.remove(projectDir, { recursive: true }); @@ -72,10 +164,9 @@ describe("worker-script serializeError", () => { assertEquals(serialized.message, "boom"); assertEquals(serialized.name, "Error"); assertExists(serialized.stack); - // No RFC 9457 fields on a plain Error - assertEquals(serialized.type, undefined); - assertEquals(serialized.status, undefined); - assertEquals(serialized.detail, undefined); + assertEquals(Object.hasOwn(serialized, "type"), false); + assertEquals(Object.hasOwn(serialized, "status"), false); + assertEquals(Object.hasOwn(serialized, "detail"), false); }); it("preserves the subclass name for custom Error types", () => { @@ -87,7 +178,7 @@ describe("worker-script serializeError", () => { assertEquals(serialized.message, "bad type"); }); - it("preserves RFC 9457 fields from VFError-like errors", () => { + it("does not trust RFC 9457 fields attached to a plain project error", () => { const err = Object.assign(new Error("not found"), { type: "https://veryfront.dev/errors/not-found", status: 404, @@ -96,22 +187,59 @@ describe("worker-script serializeError", () => { const serialized = serializeError(err); assertEquals(serialized.message, "not found"); - assertEquals(serialized.type, "https://veryfront.dev/errors/not-found"); - assertEquals(serialized.status, 404); - assertEquals(serialized.detail, "Resource was not located"); + assertEquals(serialized.problem?.slug, "unknown-error"); + assertEquals(serialized.problem?.status, 500); + assertEquals(serialized.problem?.detail, "not found"); + }); + + it("preserves a detached registered problem snapshot", () => { + const serialized = serializeError(API_ROUTE_ERROR.create({ + message: "route failed", + detail: "private route detail", + })); + + assertEquals(serialized.message, "route failed"); + assertEquals(serialized.problem, { + slug: "api-route-error", + category: "ROUTE", + status: 500, + title: "API route definition error", + suggestion: "Review API route configuration", + detail: "private route detail", + cause: undefined, + instance: undefined, + }); + assertExists(serialized.stack); }); - it("ignores RFC 9457 fields of the wrong type", () => { - const err = Object.assign(new Error("oops"), { - type: 123, // not a string - status: "500", // not a number - detail: { nested: true }, // not a string + it("fails closed without invoking traps on an Error proxy", () => { + let trapCalls = 0; + const hostile = new Proxy(new Error("must not escape"), { + get() { + trapCalls++; + throw new Error("hostile getter"); + }, + getOwnPropertyDescriptor(target, property) { + trapCalls++; + return Reflect.getOwnPropertyDescriptor(target, property); + }, + getPrototypeOf(target) { + trapCalls++; + return Reflect.getPrototypeOf(target); + }, + ownKeys(target) { + trapCalls++; + return Reflect.ownKeys(target); + }, }); - const serialized = serializeError(err); - assertEquals(serialized.type, undefined); - assertEquals(serialized.status, undefined); - assertEquals(serialized.detail, undefined); + const serialized = serializeError(hostile); + + assertEquals(trapCalls, 0); + assertEquals(serialized.message, "Unknown error"); + assertEquals(serialized.problem?.slug, "unknown-error"); + assertEquals(serialized.problem?.status, 500); + assertEquals(serialized.problem?.detail, "Unknown error"); }); it("serializes a non-Error value via String() with name 'Error'", () => { @@ -128,6 +256,26 @@ describe("worker-script serializeError", () => { assertEquals(nullSerialized.message, "null"); }); + it("does not invoke project conversion hooks on thrown objects", () => { + let conversionCount = 0; + const hostile = { + [Symbol.toPrimitive]() { + conversionCount++; + throw new Error("project conversion hook ran"); + }, + toString() { + conversionCount++; + throw new Error("project toString hook ran"); + }, + }; + + const serialized = serializeError(hostile); + + assertEquals(conversionCount, 0); + assertEquals(serialized.message, "Unknown error"); + assertEquals(serialized.problem?.slug, "unknown-error"); + }); + it("serializes the top-level Error even when it has a nested cause", () => { const root = new Error("root cause"); const wrapper = new Error("wrapper failure", { cause: root }); @@ -139,13 +287,346 @@ describe("worker-script serializeError", () => { // The serialized shape does not carry a `cause` field. assertEquals((serialized as unknown as Record).cause, undefined); }); + + it("bounds worker-owned diagnostic fields before transport", () => { + const oversized = "x".repeat(ERROR_DIAGNOSTIC_MAX_LENGTH_CHARS * 4); + const error = new Error(oversized); + error.name = oversized; + + const serialized = serializeError(error); + + assert(serialized.message.length <= ERROR_DIAGNOSTIC_MAX_LENGTH_CHARS); + assert(serialized.name.length <= ERROR_DIAGNOSTIC_MAX_LENGTH_CHARS); + assert( + (serialized.problem?.detail?.length ?? 0) <= ERROR_DIAGNOSTIC_MAX_LENGTH_CHARS, + ); + }); +}); + +describe("worker-script request snapshots", () => { + it("deeply detaches a prepared API request before it is queued", () => { + const original = { + type: "execute-app-route", + id: "snapshot-app", + module: { + source: "export function GET() {}", + sha256: "a".repeat(64), + }, + modulePath: "/project/app/api/route.ts", + method: "GET", + request: { + url: "http://localhost/api/test", + method: "GET", + headers: [["x-test", "before"]], + body: new Uint8Array([1, 2, 3]), + }, + params: { slug: "before" }, + projectDir: "/project", + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + projectEnv: { TENANT_VALUE: "before" }, + }; + + const snapshot = snapshotWorkerRequest(original); + original.module.source = "export function POST() {}"; + original.request.headers[0]![1] = "after"; + original.request.body[0] = 9; + original.params.slug = "after"; + original.projectEnv.TENANT_VALUE = "after"; + + assertEquals(snapshot.type, "execute-app-route"); + if (snapshot.type !== "execute-app-route") { + throw new Error("expected app route snapshot"); + } + assertEquals(snapshot.module.source, "export function GET() {}"); + assertEquals(snapshot.request.headers, [["x-test", "before"]]); + assertEquals(snapshot.request.body, new Uint8Array([1, 2, 3])); + assertEquals(snapshot.params, { slug: "before" }); + assertEquals(snapshot.projectEnv, { TENANT_VALUE: "before" }); + }); + + it("rejects array-valued App Router params after the host boundary", () => { + assertThrows( + () => + snapshotWorkerRequest({ + type: "execute-app-route", + id: "unflattened-app-params", + module: { + source: "export function GET() {}", + sha256: "a".repeat(64), + }, + modulePath: "/project/app/api/route.ts", + method: "GET", + request: { + url: "http://localhost/api/test", + method: "GET", + headers: [], + body: null, + }, + params: { slug: ["before"] }, + projectDir: "/project", + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + }), + TypeError, + "Invalid worker request params", + ); + }); + + it("requires a non-empty logical module identity", () => { + const request = { + type: "inspect-api-route-methods", + id: "missing-logical-id", + module: { + source: "export function GET() {}", + sha256: "a".repeat(64), + }, + projectDir: "/project", + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + }; + + assertThrows( + () => snapshotWorkerRequest(request), + TypeError, + "Invalid worker request payload", + ); + }); + + it("uses stable typed protocol errors for invalid request contracts", () => { + const unknownTypeError = assertThrows( + () => + snapshotWorkerRequest({ + type: "unknown-request", + id: "unknown", + }), + TypeError, + "Invalid worker request type", + ); + assertEquals(serializeError(unknownTypeError).name, "TypeError"); + + const missingPolicyError = assertThrows( + () => + snapshotWorkerRequest({ + type: "inspect-api-route-methods", + id: "missing-policy", + module: { + source: "export function GET() {}", + sha256: "a".repeat(64), + }, + modulePath: "/project/app/api/route.ts", + projectDir: "/project", + }), + TypeError, + "Invalid source integration policy manifest", + ); + const serialized = serializeError(missingPolicyError); + assertEquals(serialized.name, "TypeError"); + assertEquals( + serialized.message, + "Invalid source integration policy manifest", + ); + }); + + it("rejects oversized aggregate header and parameter collections", () => { + const request = { + type: "execute-app-route", + id: "aggregate-input-bounds", + module: { + source: "export function GET() {}", + sha256: "a".repeat(64), + }, + modulePath: "/project/app/api/route.ts", + method: "GET", + request: { + url: "http://localhost/api/test", + method: "GET", + headers: Array.from( + { length: 17 }, + (_, index) => [`x-${index}`, "v".repeat(64 * 1024)], + ), + body: null, + }, + params: {}, + projectDir: "/project", + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + }; + + assertThrows( + () => snapshotWorkerRequest(request), + TypeError, + "Invalid worker request headers", + ); + + request.request.headers = []; + request.params = Object.fromEntries( + Array.from( + { length: 5 }, + (_, index) => [`param-${index}`, Array(4_096).fill("")], + ), + ); + assertThrows( + () => snapshotWorkerRequest(request), + TypeError, + "Invalid worker request params", + ); + }); + + it("bounds source-policy segment sizes before canonicalization", () => { + assertThrows( + () => + snapshotWorkerRequest({ + type: "inspect-api-route-methods", + id: "policy-bounds", + module: { + source: "export function GET() {}", + sha256: "a".repeat(64), + }, + modulePath: "/project/app/api/route.ts", + projectDir: "/project", + sourceIntegrationPolicy: { + schemaVersion: 1, + mode: "allowlist", + integrations: { + github: { + allowedToolIds: ["x".repeat(257)], + }, + }, + }, + }), + TypeError, + "Invalid source integration policy manifest", + ); + }); + + it("preserves the legacy fetch-data protocol through strict snapshotting", () => { + const snapshot = snapshotWorkerRequest({ + type: "fetch-data", + id: "fetch-data", + modulePath: "/project/page.ts", + context: { + params: { slug: "one" }, + query: "page=2", + request: { + url: "http://localhost/page?page=2", + method: "GET", + headers: [], + body: null, + }, + url: "http://localhost/page?page=2", + }, + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + }); + + assertEquals(snapshot.type, "fetch-data"); + if (snapshot.type !== "fetch-data") { + throw new Error("expected fetch-data snapshot"); + } + assertEquals(snapshot.context.params, { slug: "one" }); + assertEquals(snapshot.context.query, "page=2"); + }); + + it("preserves the legacy render-ssr protocol through strict snapshotting", () => { + const snapshot = snapshotWorkerRequest({ + type: "render-ssr", + id: "render-ssr", + pageModulePath: "/project/page.mjs", + layoutModulePaths: ["/project/layout.mjs"], + pageProps: { title: "safe", nested: { count: 1 } }, + layoutProps: [{ theme: "dark" }], + delivery: "string", + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + }); + + assertEquals(snapshot.type, "render-ssr"); + if (snapshot.type !== "render-ssr") { + throw new Error("expected render-ssr snapshot"); + } + assertEquals(snapshot.pageProps, { + title: "safe", + nested: { count: 1 }, + }); + assertEquals(snapshot.layoutProps, [{ theme: "dark" }]); + }); + + it("preserves flag-off renderer identity and fails closed for enabled snapshots", () => { + const flagOff = snapshotWorkerRequest({ + type: "render-ssr", + id: "render-ssr-off", + pageModulePath: "/project/page.mjs", + layoutModulePaths: [], + pageProps: {}, + layoutProps: [], + delivery: "string", + dependencyPinningCacheKey: "off", + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + }); + assertEquals(flagOff.type, "render-ssr"); + if (flagOff.type !== "render-ssr") { + throw new Error("expected render-ssr snapshot"); + } + assertEquals( + (flagOff as typeof flagOff & { dependencyPinningCacheKey?: string }) + .dependencyPinningCacheKey, + "off", + ); + + assertThrows( + () => + snapshotWorkerRequest({ + type: "render-ssr", + id: "render-ssr-reserved", + pageModulePath: "/project/page.mjs", + layoutModulePaths: [], + pageProps: {}, + layoutProps: [], + delivery: "string", + dependencyPinningCacheKey: "on:unknown", + dependencyPinningDependencies: { react: "19.0.0" }, + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + }), + TypeError, + "Invalid worker request dependencyPinningCacheKey", + ); + + const dependencies = { react: "19.0.0" }; + const pinned = snapshotWorkerRequest({ + type: "render-ssr", + id: "render-ssr-pinned", + pageModulePath: "/project/page.mjs", + layoutModulePaths: [], + pageProps: {}, + layoutProps: [], + delivery: "string", + dependencyPinningCacheKey: "on:1", + dependencyPinningDependencies: dependencies, + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + }); + dependencies.react = "18.2.0"; + assertEquals(pinned.type, "render-ssr"); + if (pinned.type !== "render-ssr") { + throw new Error("expected render-ssr snapshot"); + } + assertEquals(pinned.dependencyPinningDependencies, { + react: "19.0.0", + }); + assertEquals( + Object.getPrototypeOf(pinned.dependencyPinningDependencies), + null, + ); + assertEquals( + Object.isFrozen(pinned.dependencyPinningDependencies), + true, + ); + assertThrows( + () => assertIsolatedSsrDependencySnapshotSupported(pinned), + Error, + "Isolated SSR does not support enabled dependency snapshots", + ); + }); }); describe("worker-script loadModule", () => { const tempFiles: string[] = []; afterEach(async () => { - clearModuleCache(); for (const f of tempFiles.splice(0)) { try { await Deno.remove(f); @@ -193,3 +674,898 @@ describe("worker-script loadModule", () => { await assertRejects(() => loadModule(path)); }); }); + +describe("worker-script prepared modules", () => { + it("requires an explicit source integration policy", async () => { + const prepared = await prepareWorkerModule("export function GET() {}"); + + await assertRejects( + () => + loadPreparedModule(prepared, { + logicalModuleId: "/routes/missing-policy.ts", + } as never), + TypeError, + "source integration policy", + ); + }); + + it("rejects non-lowercase and mismatched SHA-256 identities", async () => { + const source = "export function GET() {}"; + const prepared = await prepareWorkerModule(source); + + await assertRejects( + () => + loadPreparedModule( + { source, sha256: prepared.sha256.toUpperCase() }, + { + logicalModuleId: "/routes/uppercase.ts", + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + }, + ), + TypeError, + "Invalid worker request module", + ); + await assertRejects( + () => + loadPreparedModule( + { source, sha256: "0".repeat(64) }, + { + logicalModuleId: "/routes/mismatch.ts", + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + }, + ), + TypeError, + "digest mismatch", + ); + }); + + it("enforces the prepared source limit in UTF-8 bytes", async () => { + const oversizedSource = "é".repeat( + Math.floor(MAX_WORKER_MODULE_SOURCE_BYTES / 2) + 1, + ); + + await assertRejects( + () => + loadPreparedModule( + { source: oversizedSource, sha256: "0".repeat(64) }, + { + logicalModuleId: "/routes/oversized.ts", + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + }, + ), + TypeError, + "Invalid worker request module", + ); + }); + + it("rejects modules without a canonical callable route export", async () => { + const prepared = await prepareWorkerModule( + "export const GET = 1; export function helper() {}", + ); + + await assertRejects( + () => + loadPreparedModule(prepared, { + logicalModuleId: "/routes/no-handler.ts", + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + }), + Error, + "Prepared API route module import failed", + ); + }); + + it("accepts callable default and uppercase custom route exports", async () => { + const prepared = await prepareWorkerModule(` + export function PROPFIND() {} + export default function route() {} + `); + const module = await loadPreparedModule(prepared, { + logicalModuleId: "/routes/custom.ts", + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + }); + + assertEquals(typeof module.PROPFIND, "function"); + assertEquals(typeof module.default, "function"); + }); + + it("caches by logical route and digest while keeping routes distinct", async () => { + const counterKey = `__vf_prepared_counter_${crypto.randomUUID().replaceAll("-", "_")}`; + const source = ` + globalThis[${JSON.stringify(counterKey)}] = + (globalThis[${JSON.stringify(counterKey)}] ?? 0) + 1; + export const evaluationCount = globalThis[${JSON.stringify(counterKey)}]; + export function GET() {} + `; + const prepared = await prepareWorkerModule(source); + + try { + const first = await loadPreparedModule(prepared, { + logicalModuleId: "/routes/one.ts", + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + }); + const cached = await loadPreparedModule(prepared, { + logicalModuleId: "/routes/one.ts", + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + }); + const distinctRoute = await loadPreparedModule(prepared, { + logicalModuleId: "/routes/two.ts", + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + }); + + assert(first === cached); + assert(first !== distinctRoute); + assertEquals(first.evaluationCount, 1); + assertEquals(distinctRoute.evaluationCount, 2); + } finally { + delete (globalThis as Record)[counterKey]; + } + }); + + it("includes env and source-policy semantics in module identity", async () => { + const counterKey = `__vf_semantic_counter_${crypto.randomUUID().replaceAll("-", "_")}`; + const source = ` + globalThis[${JSON.stringify(counterKey)}] = + (globalThis[${JSON.stringify(counterKey)}] ?? 0) + 1; + export const evaluationCount = globalThis[${JSON.stringify(counterKey)}]; + export function GET() {} + `; + const prepared = await prepareWorkerModule(source); + + try { + const tenantA = await loadPreparedModule(prepared, { + logicalModuleId: "/routes/semantic.ts", + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + projectEnv: { TENANT: "a" }, + }); + const tenantB = await loadPreparedModule(prepared, { + logicalModuleId: "/routes/semantic.ts", + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + projectEnv: { TENANT: "b" }, + }); + + assert(tenantA !== tenantB); + assertEquals(tenantA.evaluationCount, 1); + assertEquals(tenantB.evaluationCount, 2); + } finally { + delete (globalThis as Record)[counterKey]; + } + }); + + it("redacts encoded source from data-module stacks", async () => { + const sentinel = "VF_SENTINEL_SOURCE_MUST_NOT_LEAK_7f3b"; + const prepared = await prepareWorkerModule(` + export function GET( { /* ${sentinel} */ + `); + const error = await assertRejects( + () => + loadPreparedModule(prepared, { + logicalModuleId: "/routes/private-source.ts", + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + }), + Error, + "Prepared API route module import failed", + ); + const serialized = serializeError(error, prepared.sha256); + const serializedText = JSON.stringify(serialized); + + assertExists(serialized.stack); + assert(!serializedText.includes("data:text/javascript")); + assert(!serializedText.includes(sentinel)); + assert( + serializedText.includes(`vf-api:${prepared.sha256}:`), + serializedText, + ); + }); + + it("redacts a complete percent-encoded data URL containing raw parentheses", () => { + const digest = "a".repeat(64); + const sentinel = "VF_SECRET_AFTER_PAREN"; + const stack = + `SyntaxError: data:text/javascript;charset=utf-8,var%20f%3D(x)%3D%3Ex%3B%0Aconst%20${sentinel}%20%3D%20%3B#sha256=${digest}:2:31`; + + const sanitized = sanitizeWorkerDataModuleStack(stack, digest); + + assert(!sanitized.includes("data:text/javascript")); + assert(!sanitized.includes(sentinel)); + assertEquals(sanitized, `SyntaxError: vf-api:${digest}:2:31`); + }); + + it("keeps requests and responses on the private control port after project poisoning", async () => { + const projectDir = await Deno.makeTempDir(); + const envKey = `VF_WORKER_PRIVATE_${crypto.randomUUID().replaceAll("-", "_")}`; + const observedKey = `__vf_observed_${crypto.randomUUID().replaceAll("-", "_")}`; + const workerOptions = { + type: "module", + deno: { + permissions: { + read: true, + write: false, + net: false, + env: false, + run: false, + ffi: false, + sys: false, + }, + }, + } as WorkerOptions & { + deno: { + permissions: { + read: boolean; + write: boolean; + net: boolean; + env: boolean; + run: boolean; + ffi: boolean; + sys: boolean; + }; + }; + }; + const worker = new Worker( + import.meta.resolve("./worker-script.ts"), + workerOptions, + ); + const channel = new MessageChannel(); + channel.port1.start(); + + try { + worker.postMessage( + { + type: "initialize-egress", + options: { allowInternalEgress: false }, + controlPort: channel.port2, + }, + [channel.port2], + ); + + const pong = waitForPortMessage( + channel.port1, + (message) => hasMessageIdentity(message, "ready"), + ); + channel.port1.postMessage({ type: "ping", id: "ready" }); + assertEquals( + (await pong as { type: string }).type, + "pong", + ); + + const poisonSource = ` + globalThis[${JSON.stringify(observedKey)}] = 0; + self.addEventListener("message", () => { + globalThis[${JSON.stringify(observedKey)}]++; + }); + self.postMessage = () => { + throw new Error("global response bus was used"); + }; + Object.entries = () => { + throw new Error("poisoned Object.entries was used"); + }; + Map.prototype.get = () => { + throw new Error("poisoned Map.get was used"); + }; + Map.prototype.set = () => { + throw new Error("poisoned Map.set was used"); + }; + Deno.env.get = () => { + throw new Error("poisoned Deno.env.get was used"); + }; + export async function GET(_request, { env }) { + await new Promise((resolve) => setTimeout(resolve, 20)); + return Response.json({ + observed: globalThis[${JSON.stringify(observedKey)}], + env: env[${JSON.stringify(envKey)}], + envFrozen: Object.isFrozen(env), + }); + } + `; + const poisonModule = await prepareWorkerModule(poisonSource); + const firstResponse = waitForPortMessage( + channel.port1, + (message) => hasMessageIdentity(message, "poison"), + ); + channel.port1.postMessage({ + type: "execute-app-route", + id: "poison", + module: poisonModule, + modulePath: `${projectDir}/poison.ts`, + method: "GET", + request: { + url: "http://localhost/api/poison", + method: "GET", + headers: [], + body: null, + }, + params: {}, + projectDir, + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + projectEnv: { [envKey]: "first-tenant-secret" }, + }); + const first = await firstResponse as { + type: string; + response?: { body: Uint8Array | null }; + }; + assertEquals(first.type, "result"); + assertExists(first.response?.body); + assertEquals( + JSON.parse(new TextDecoder().decode(first.response.body)), + { observed: 0, env: "first-tenant-secret", envFrozen: true }, + ); + + const healthySource = ` + export function GET(_request, { env }) { + return Response.json({ + observed: globalThis[${JSON.stringify(observedKey)}], + envWasScrubbed: env[${JSON.stringify(envKey)}] === undefined, + }); + } + `; + const healthyModule = await prepareWorkerModule(healthySource); + const secondResponse = waitForPortMessage( + channel.port1, + (message) => hasMessageIdentity(message, "healthy"), + ); + channel.port1.postMessage({ + type: "execute-app-route", + id: "healthy", + module: healthyModule, + modulePath: `${projectDir}/healthy.ts`, + method: "GET", + request: { + url: "http://localhost/api/healthy", + method: "GET", + headers: [], + body: null, + }, + params: {}, + projectDir, + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + }); + const second = await secondResponse as { + type: string; + response?: { body: Uint8Array | null }; + }; + assertEquals(second.type, "result"); + assertExists(second.response?.body); + assertEquals( + JSON.parse(new TextDecoder().decode(second.response.body)), + { observed: 0, envWasScrubbed: true }, + ); + } finally { + worker.terminate(); + channel.port1.close(); + await Deno.remove(projectDir, { recursive: true }); + } + }); + + it("keeps a restrictive policy deeply frozen after prior primordial poisoning", async () => { + const projectDir = await Deno.makeTempDir(); + const worker = new Worker( + import.meta.resolve("./worker-script.ts"), + { + type: "module", + deno: { + permissions: { + read: true, + write: false, + net: false, + env: false, + run: false, + ffi: false, + sys: false, + }, + }, + } as WorkerOptions & { + deno: { + permissions: { + read: boolean; + write: boolean; + net: boolean; + env: boolean; + run: boolean; + ffi: boolean; + sys: boolean; + }; + }; + }, + ); + const channel = new MessageChannel(); + channel.port1.start(); + + try { + worker.postMessage( + { + type: "initialize-egress", + options: { allowInternalEgress: false }, + controlPort: channel.port2, + }, + [channel.port2], + ); + const pong = waitForPortMessage( + channel.port1, + (message) => hasMessageIdentity(message, "policy-ready"), + ); + channel.port1.postMessage({ type: "ping", id: "policy-ready" }); + await pong; + + const poisonModule = await prepareWorkerModule(` + export function GET() { + Object.freeze = () => { + throw new Error("poisoned Object.freeze was used"); + }; + Object.create = () => { + throw new Error("poisoned Object.create was used"); + }; + Array.prototype.sort = () => { + throw new Error("poisoned Array.prototype.sort was used"); + }; + return new Response("poisoned"); + } + `); + const poisonResponse = waitForPortMessage( + channel.port1, + (message) => hasMessageIdentity(message, "poison-policy-primordials"), + ); + channel.port1.postMessage({ + type: "execute-app-route", + id: "poison-policy-primordials", + module: poisonModule, + modulePath: `${projectDir}/poison-policy.ts`, + method: "GET", + request: { + url: "http://localhost/api/poison-policy", + method: "GET", + headers: [], + body: null, + }, + params: {}, + projectDir, + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + }); + assertEquals( + (await poisonResponse as { type: string }).type, + "result", + ); + + const sourcePolicyContextUrl = import.meta.resolve( + "../../integrations/source-policy-context.ts", + ); + const sourcePolicyUrl = import.meta.resolve( + "../../integrations/source-policy.ts", + ); + const mutationModule = await prepareWorkerModule(` + import { + getActiveSourceIntegrationPolicy, + } from ${JSON.stringify(sourcePolicyContextUrl)}; + import { + isIntegrationToolAllowedBySourcePolicy, + } from ${JSON.stringify(sourcePolicyUrl)}; + + export function GET() { + const policy = getActiveSourceIntegrationPolicy(); + const integrations = policy.integrations; + const restriction = integrations.github; + const toolIds = restriction.allowedToolIds; + + const mutations = { + root: Reflect.set(policy, "mode", "unrestricted"), + integrations: Reflect.set(integrations, "slack", { + allowedToolIds: null, + }), + restriction: Reflect.set(restriction, "allowedToolIds", null), + toolArray: Reflect.set(toolIds, "0", "delete_repo"), + }; + + return Response.json({ + mutations, + frozen: { + root: Object.isFrozen(policy), + integrations: Object.isFrozen(integrations), + restriction: Object.isFrozen(restriction), + toolArray: Object.isFrozen(toolIds), + }, + mode: policy.mode, + deleteAllowed: isIntegrationToolAllowedBySourcePolicy( + "github__delete_repo", + policy, + ), + listAllowed: isIntegrationToolAllowedBySourcePolicy( + "github__list_repos", + policy, + ), + }); + } + `); + const mutationResponse = waitForPortMessage( + channel.port1, + (message) => hasMessageIdentity(message, "mutate-policy"), + ); + channel.port1.postMessage({ + type: "execute-app-route", + id: "mutate-policy", + module: mutationModule, + modulePath: `${projectDir}/mutate-policy.ts`, + method: "GET", + request: { + url: "http://localhost/api/mutate-policy", + method: "GET", + headers: [], + body: null, + }, + params: {}, + projectDir, + sourceIntegrationPolicy: { + schemaVersion: 1, + mode: "allowlist", + integrations: { + github: { + allowedToolIds: ["list_repos"], + }, + }, + }, + }); + + const response = await mutationResponse as { + type: string; + response?: { body: Uint8Array | null }; + }; + assertEquals(response.type, "result"); + assertExists(response.response?.body); + assertEquals( + JSON.parse(new TextDecoder().decode(response.response.body)), + { + mutations: { + root: false, + integrations: false, + restriction: false, + toolArray: false, + }, + frozen: { + root: true, + integrations: true, + restriction: true, + toolArray: true, + }, + mode: "allowlist", + deleteAllowed: false, + listAllowed: true, + }, + ); + } finally { + worker.terminate(); + channel.port1.close(); + await Deno.remove(projectDir, { recursive: true }); + } + }); + + it("keeps Pages filesystem guards fail-closed after project primordial poisoning", async () => { + const projectDir = await Deno.makeTempDir(); + const worker = new Worker( + import.meta.resolve("./worker-script.ts"), + { + type: "module", + deno: { + permissions: { + read: true, + write: false, + net: false, + env: false, + run: false, + ffi: false, + sys: false, + }, + }, + } as WorkerOptions & { + deno: { + permissions: { + read: boolean; + write: boolean; + net: boolean; + env: boolean; + run: boolean; + ffi: boolean; + sys: boolean; + }; + }; + }, + ); + const channel = new MessageChannel(); + channel.port1.start(); + + try { + worker.postMessage( + { + type: "initialize-egress", + options: { allowInternalEgress: false }, + controlPort: channel.port2, + }, + [channel.port2], + ); + const pong = waitForPortMessage( + channel.port1, + (message) => hasMessageIdentity(message, "ready-fs-poison"), + ); + channel.port1.postMessage({ type: "ping", id: "ready-fs-poison" }); + await pong; + + const prepared = await prepareWorkerModule(` + Object.defineProperty(Deno.errors.NotFound, Symbol.hasInstance, { + configurable: true, + value() { + throw new Error("poisoned NotFound Symbol.hasInstance was used"); + }, + }); + String.prototype.startsWith = () => { + throw new Error("poisoned String.prototype.startsWith was used"); + }; + Promise.prototype.catch = () => { + throw new Error("poisoned Promise.prototype.catch was used"); + }; + + export async function GET(ctx) { + const missing = await ctx.fs.exists("missing.txt"); + + let traversalError = ""; + try { + await ctx.fs.exists("../outside.txt"); + } catch (error) { + traversalError = error instanceof Error ? error.message : "non-error"; + } + + let invalidPathError = ""; + try { + await ctx.fs.exists(String.fromCharCode(0)); + } catch (error) { + invalidPathError = error instanceof Error ? error.message : "non-error"; + } + + return Response.json({ + missing, + traversalError, + invalidPathError, + }); + } + `); + const responseMessage = waitForPortMessage( + channel.port1, + (message) => hasMessageIdentity(message, "fs-poison"), + ); + channel.port1.postMessage({ + type: "execute-pages-route", + id: "fs-poison", + module: prepared, + modulePath: `${projectDir}/fs-poison.ts`, + method: "GET", + context: { + url: "http://localhost/api/fs-poison", + method: "GET", + headers: [], + body: null, + params: {}, + cookies: {}, + }, + projectDir, + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + }); + + const response = await responseMessage as { + type: string; + response?: { body: Uint8Array | null }; + }; + assertEquals(response.type, "result"); + assertExists(response.response?.body); + const body = JSON.parse( + new TextDecoder().decode(response.response.body), + ) as { + missing: boolean; + traversalError: string; + invalidPathError: string; + }; + assertEquals(body.missing, false); + assert(body.traversalError.includes("Path escapes project directory")); + assert(body.invalidPathError.length > 0); + assert(!body.invalidPathError.includes("poisoned")); + } finally { + worker.terminate(); + channel.port1.close(); + await Deno.remove(projectDir, { recursive: true }); + } + }); + + it("reports prepared import failures and then retires the worker", async () => { + const projectDir = await Deno.makeTempDir(); + const worker = new Worker( + import.meta.resolve("./worker-script.ts"), + { + type: "module", + deno: { + permissions: { + read: true, + write: false, + net: false, + env: false, + run: false, + ffi: false, + sys: false, + }, + }, + } as WorkerOptions & { + deno: { + permissions: { + read: boolean; + write: boolean; + net: boolean; + env: boolean; + run: boolean; + ffi: boolean; + sys: boolean; + }; + }; + }, + ); + const channel = new MessageChannel(); + channel.port1.start(); + + try { + worker.postMessage( + { + type: "initialize-egress", + options: { allowInternalEgress: false }, + controlPort: channel.port2, + }, + [channel.port2], + ); + const pong = waitForPortMessage( + channel.port1, + (message) => hasMessageIdentity(message, "ready-failure"), + ); + channel.port1.postMessage({ + type: "ping", + id: "ready-failure", + }); + await pong; + + const sentinel = "VF_FATAL_IMPORT_SENTINEL_92ac"; + const prepared = await prepareWorkerModule( + `export function GET( { /* ${sentinel} */`, + ); + const errorMessage = waitForPortMessage( + channel.port1, + (message) => hasMessageIdentity(message, "fatal-import"), + ); + const exitMessage = waitForPortMessage( + channel.port1, + (message) => + typeof message === "object" && + message !== null && + (message as { type?: unknown }).type === "worker-exit", + ); + channel.port1.postMessage({ + type: "inspect-api-route-methods", + id: "fatal-import", + module: prepared, + modulePath: `${projectDir}/fatal.ts`, + projectDir, + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + }); + + const response = await errorMessage as { + type: string; + error?: unknown; + }; + assertEquals(response.type, "error"); + const serialized = JSON.stringify(response.error); + assert(!serialized.includes("data:text/javascript")); + assert(!serialized.includes(sentinel)); + assert(serialized.includes(`vf-api:${prepared.sha256}:`)); + assertEquals( + (await exitMessage as { type: string }).type, + "worker-exit", + ); + } finally { + worker.terminate(); + channel.port1.close(); + await Deno.remove(projectDir, { recursive: true }); + } + }); + + it("rejects before import when aggregate retained source reaches its cap", async () => { + const before = getPreparedModuleRetentionStats(); + let remaining = MAX_WORKER_RETAINED_MODULE_SOURCE_BYTES - + before.sourceBytes; + const prefix = "export function GET() {}\n/*"; + const suffix = "*/"; + const minimumSourceBytes = prefix.length + suffix.length; + let moduleIndex = 0; + + while (remaining >= minimumSourceBytes) { + const sourceBytes = Math.min( + MAX_WORKER_MODULE_SOURCE_BYTES, + remaining, + ); + const source = prefix + + "x".repeat(sourceBytes - minimumSourceBytes) + + suffix; + await loadPreparedModule(await prepareWorkerModule(source), { + logicalModuleId: `/routes/capacity-${moduleIndex++}.ts`, + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + }); + remaining -= sourceBytes; + } + + const extra = await prepareWorkerModule("export function GET() {}"); + await assertRejects( + () => + loadPreparedModule(extra, { + logicalModuleId: "/routes/over-capacity.ts", + sourceIntegrationPolicy: TEST_SOURCE_INTEGRATION_POLICY, + }), + Error, + "retention capacity exceeded", + ); + assertEquals( + getPreparedModuleRetentionStats().sourceBytes, + MAX_WORKER_RETAINED_MODULE_SOURCE_BYTES - remaining, + ); + }); +}); + +describe("worker-script bootstrap", () => { + it("rejects bootstrap without an explicit internal-egress decision", async () => { + const worker = new Worker( + import.meta.resolve("./worker-script.ts"), + { + type: "module", + deno: { + permissions: { + read: true, + write: false, + net: false, + env: false, + run: false, + ffi: false, + sys: false, + }, + }, + } as WorkerOptions & { + deno: { + permissions: { + read: boolean; + write: boolean; + net: boolean; + env: boolean; + run: boolean; + ffi: boolean; + sys: boolean; + }; + }; + }, + ); + const channel = new MessageChannel(); + channel.port1.start(); + + try { + const exitMessage = waitForPortMessage( + channel.port1, + (message) => + typeof message === "object" && + message !== null && + (message as { type?: unknown }).type === "worker-exit", + ); + + worker.postMessage( + { + type: "initialize-egress", + options: {}, + controlPort: channel.port2, + }, + [channel.port2], + ); + + assertEquals( + (await exitMessage as { type: string }).type, + "worker-exit", + ); + } finally { + worker.terminate(); + channel.port1.close(); + } + }); +}); diff --git a/src/security/sandbox/worker-script.ts b/src/security/sandbox/worker-script.ts index 088f51bbbe..a1fa08d76d 100644 --- a/src/security/sandbox/worker-script.ts +++ b/src/security/sandbox/worker-script.ts @@ -14,6 +14,9 @@ import type { ExecuteAppRouteRequest, ExecutePagesRouteRequest, FetchDataRequest, + InspectApiRouteMethodsRequest, + PreparedWorkerModule, + RenderSSRRequest, SerializedDataContext, SerializedDataResult, SerializedError, @@ -22,41 +25,344 @@ import type { SerializedResponse, WorkerDataResultResponse, WorkerErrorResponse, + WorkerPreparedModuleCapacityResponse, WorkerRequest, WorkerResultResponse, + WorkerRouteMethodsResponse, + WorkerSSRExecutionOpen, + WorkerSSROutputLimit, + WorkerSSRWireError, + WorkerSSRWireResult, + WorkerStreamCredit, + WorkerStreamEnd, + WorkerStreamFrame, } from "./worker-types.ts"; -import { installWorkerEgressGuard, type WorkerEgressGuardOptions } from "./worker-egress-guard.ts"; -import { isAbsolute, relative, resolve as resolvePath, sep as PATH_SEP } from "node:path"; +import { + MAX_WORKER_BODY_BYTES, + MAX_WORKER_MODULE_SOURCE_BYTES, + MAX_WORKER_REQUEST_ID_CHARS, + MAX_WORKER_RETAINED_MODULE_SOURCE_BYTES, + MAX_WORKER_RETAINED_MODULES, + MAX_WORKER_SSR_CHUNK_BYTES, + MAX_WORKER_SSR_OUTPUT_BYTES, + MAX_WORKER_SSR_OUTPUT_CHUNKS, +} from "./worker-types.ts"; +import { + type InstalledWorkerEgressGuardOptions, + installWorkerEgressGuard, + type WorkerEgressHttpBrokerConfig, + type WorkerEgressSocksProxyConfig, +} from "./worker-egress-guard.ts"; +import { + basename, + dirname, + isAbsolute, + relative, + resolve as resolvePath, + sep as PATH_SEP, +} from "node:path"; +import { types as nodeUtilTypes } from "node:util"; import { runWithExactSourceIntegrationPolicy } from "#veryfront/integrations/source-policy-context.ts"; import { isDataControlResult, toDataControlResult } from "#veryfront/data/helpers.ts"; -import { parseSourceIntegrationPolicyManifest } from "#veryfront/integrations/source-policy.ts"; +import type { SourceIntegrationPolicyManifest } from "#veryfront/integrations/source-policy.ts"; +import { createBodyReader } from "#veryfront/routing/api/context-builder.ts"; +import { + resolveExecutableRouteMethods, + resolveRouteHandlerExport, +} from "#veryfront/routing/api/route-methods.ts"; +import { + createAppRouteMethodNotAllowed, + createPagesRouteMethodNotAllowed, +} from "#veryfront/routing/api/method-validator.ts"; +import { + detachThrowableForBoundary, + isNativeErrorWithoutHooks, + sanitizeDiagnosticText, + snapshotErrorForBoundary, + snapshotThrowableDiagnostic, +} from "#veryfront/errors/safe-diagnostics.ts"; +import { IMPORT_RESOLUTION_ERROR, INITIALIZATION_ERROR } from "#veryfront/errors/index.ts"; +import { + type IsolatedSsrRenderer, + validateIsolatedSsrRendererModuleUrl, +} from "#veryfront/extensions/rendering/index.ts"; import { - createBodyReader, - createJsonHelper, - createTextHelper, -} from "#veryfront/routing/api/context-builder.ts"; + isTrustedRouteResponsePromise, + serializeRouteResponse, +} from "#veryfront/routing/api/response-normalization.ts"; import { createWorkerExitControls } from "./worker-exit-controls.ts"; -// Module-level singletons to avoid per-call allocation churn -const encoder = new TextEncoder(); type InitializeEgressMessage = { type: "initialize-egress"; - options: WorkerEgressGuardOptions; + rendererModuleUrl?: string; + options: InstalledWorkerEgressGuardOptions; + controlPort: MessagePort; }; + +const apply = Reflect.apply; +const cloneStructuredValue = globalThis.structuredClone.bind(globalThis); +const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const eventTargetAddEventListener = EventTarget.prototype.addEventListener; +const eventTargetRemoveEventListener = EventTarget.prototype.removeEventListener; +const eventCurrentTargetGetter = getOwnPropertyDescriptor(Event.prototype, "currentTarget")?.get; +const eventIsTrustedGetter = getOwnPropertyDescriptor(Event.prototype, "isTrusted")?.get; +const eventPreventDefault = Event.prototype.preventDefault; +const messageEventDataGetter = getOwnPropertyDescriptor(MessageEvent.prototype, "data")?.get; +const getPrototypeOf = Object.getPrototypeOf; +const objectEntries = Object.entries; +const objectKeys = Object.keys; +const ownKeys = Reflect.ownKeys; +const isArray = Array.isArray; +const isProxy = nodeUtilTypes.isProxy; +const NativeArray = Array; +const NativeError = Error; +const NativeMap = Map; +const NativeMessagePort = MessagePort; +const NativeNotFound = Deno.errors.NotFound; +const NativePromise = Promise; +const NativeRequest = Request; +const NativeResponse = Response; +const NativeSet = Set; +const NativeString = String; +const NativeTypeError = TypeError; +const NativeUint8Array = Uint8Array; +const NativeURL = URL; +const NativeURLSearchParams = URLSearchParams; +const nativeTypeErrorPrototype = NativeTypeError.prototype; +const nativeErrorStackGetter = getOwnPropertyDescriptor(new NativeError(), "stack")?.get; +const objectPrototype = Object.prototype; +const arrayPrototype = Array.prototype; +const uint8ArrayPrototype = NativeUint8Array.prototype; +const typedArrayPrototype = getPrototypeOf(uint8ArrayPrototype); +const typedArrayBufferGetter = typedArrayPrototype + ? getOwnPropertyDescriptor(typedArrayPrototype, "buffer")?.get + : undefined; +const typedArrayByteLengthGetter = typedArrayPrototype + ? getOwnPropertyDescriptor(typedArrayPrototype, "byteLength")?.get + : undefined; +const typedArrayByteOffsetGetter = typedArrayPrototype + ? getOwnPropertyDescriptor(typedArrayPrototype, "byteOffset")?.get + : undefined; +const arrayBufferPrototype = ArrayBuffer.prototype; +const arrayBufferByteLengthGetter = getOwnPropertyDescriptor( + arrayBufferPrototype, + "byteLength", +)?.get; +const arrayBufferResizableGetter = getOwnPropertyDescriptor( + arrayBufferPrototype, + "resizable", +)?.get; +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder("utf-8", { fatal: true }); +const encodeText = TextEncoder.prototype.encode; +const decodeText = TextDecoder.prototype.decode; +const bytesToBase64 = NativeUint8Array.prototype.toBase64; +const bytesToHex = NativeUint8Array.prototype.toHex; +const setBytes = NativeUint8Array.prototype.set; +const digestBytes = crypto.subtle.digest.bind(crypto.subtle); +const messagePortPostMessage = MessagePort.prototype.postMessage; +const messagePortStart = MessagePort.prototype.start; +const promiseThen = Promise.prototype.then; +const readableStreamGetReader = ReadableStream.prototype.getReader; +const readableStreamReaderCancel = ReadableStreamDefaultReader.prototype.cancel; +const readableStreamReaderRead = ReadableStreamDefaultReader.prototype.read; +const readableStreamReaderReleaseLock = ReadableStreamDefaultReader.prototype.releaseLock; +const arrayPush = Array.prototype.push; +const mapDelete = Map.prototype.delete; +const mapGet = Map.prototype.get; +const mapSet = Map.prototype.set; +const weakMapGet = WeakMap.prototype.get; +const weakMapSet = WeakMap.prototype.set; +const setAdd = Set.prototype.add; +const setHas = Set.prototype.has; +const setSizeGetter = getOwnPropertyDescriptor(Set.prototype, "size")?.get; +const NULL_BODY_STATUSES = new NativeSet([101, 103, 204, 205, 304]); +const jsonStringify = JSON.stringify; +const functionHasInstance = Function.prototype[Symbol.hasInstance]; +const arraySort = Array.prototype.sort; +const stringIndexOf = String.prototype.indexOf; +const stringSlice = String.prototype.slice; +const stringStartsWith = String.prototype.startsWith; +const regexpExec = RegExp.prototype.exec; +const regexpReplace = RegExp.prototype[Symbol.replace]; +const regexpTest = RegExp.prototype.test; +const objectCreate = Object.create; +const defineProperty = Object.defineProperty; +const objectFreeze = Object.freeze; +const numberIsFinite = Number.isFinite; +const numberIsSafeInteger = Number.isSafeInteger; +const MAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER; +const denoReadDir = Deno.readDir.bind(Deno); +const denoReadFile = Deno.readFile.bind(Deno); +const denoReadTextFile = Deno.readTextFile.bind(Deno); +const denoRealPath = Deno.realPath.bind(Deno); +const denoStat = Deno.stat.bind(Deno); +const nativeWorkerClose = typeof globalThis.close === "function" ? globalThis.close : undefined; +const WORKER_EXIT_MESSAGE = objectFreeze({ type: "worker-exit" as const }); +const LOWERCASE_SHA256_PATTERN = /^[0-9a-f]{64}$/; +const CANONICAL_POLICY_SEGMENT_PATTERN = /^[a-z0-9][a-z0-9_-]*$/; +const CANONICAL_ROUTE_METHOD_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Z]{1,64}$/; +const CANONICAL_DEPENDENCY_PINNING_CACHE_KEY_PATTERN = /^on:(0|[1-9a-z][0-9a-z]{0,12})$/; +const MAX_DEPENDENCY_PINNING_HASH = "3w5e11264sgsf"; +const PROJECT_ENV_KEY_PATTERN = /^[^=\0]+$/; +const PROJECT_ENV_VALUE_PATTERN = /^[^\0]*$/; +const DATA_JAVASCRIPT_URL_PATTERN = + /data:(?:text|application)\/javascript(?:;[a-zA-Z0-9=+._-]+)*,[^ \t\r\n]*/g; +const DATA_JAVASCRIPT_URL_PRESENCE_PATTERN = + /data:(?:text|application)\/javascript(?:;[a-zA-Z0-9=+._-]+)*,/; +const STACK_LOCATION_PATTERN = /:([0-9]+):([0-9]+)\)?$/; +const SANITIZED_DATA_MODULE_LABEL_PATTERN = /vf-api:(?:[0-9a-f]{64}|unknown)(?::[0-9]+:[0-9]+)?/; +const MAX_WORKER_PATH_CHARS = 32 * 1024; +const MAX_WORKER_URL_CHARS = 64 * 1024; +const MAX_WORKER_HEADER_COUNT = 1_024; +const MAX_WORKER_HEADER_FIELD_CHARS = 64 * 1024; +const MAX_WORKER_HEADER_UTF8_BYTES = 1024 * 1024; +const MAX_WORKER_RECORD_ENTRIES = 4_096; +const MAX_WORKER_VALUE_CHARS = 1024 * 1024; +const MAX_WORKER_STRING_COLLECTION_VALUES = 16_384; +const MAX_WORKER_STRING_COLLECTION_UTF8_BYTES = 4 * 1024 * 1024; +const MAX_WORKER_PROJECT_ENV_UTF8_BYTES = 1024 * 1024; +const MAX_WORKER_POLICY_SEGMENT_CHARS = 256; +const MAX_WORKER_POLICY_UTF8_BYTES = 1024 * 1024; +const MAX_WORKER_ROUTE_METHOD_COUNT = 128; +const MAX_WORKER_DATA_DEPTH = 64; +const MAX_WORKER_DATA_NODES = 100_000; +const MAX_WORKER_DATA_UTF8_BYTES = 16 * 1024 * 1024; + let egressInitialized = false; let exitNotifierInstalled = false; +let workerControlPort: MessagePort | null = null; +let postControlPortMessage: + | ((message: unknown, transfer?: readonly Transferable[]) => void) + | null = null; +let closeWorkerProcess: (() => void) | null = null; +let workerWireGeneration: string | null = null; +let unhandledWorkerFaultClosed = false; + +interface SSRExecutionContext { + readonly id: string; + readonly generation: string; + readonly token: string; + readonly delivery: "string" | "stream"; + sequence: number; +} + +interface StreamCreditWaiter { + readonly id: string; + readonly generation: string; + readonly token: string; + readonly sequence: number; + readonly resolve: () => void; +} + +let pendingSSRExecutionOpen: SSRExecutionContext | null = null; +const activeSSRExecutions = new NativeMap(); +const streamCreditWaiters = new NativeMap(); +const MAX_SSR_WIRE_TOKEN_CHARS = 256; + +function containUnhandledWorkerFault(event: Event): void { + apply(eventPreventDefault, event, []); + if (unhandledWorkerFaultClosed) return; + unhandledWorkerFaultClosed = true; + + const closeWorker = closeWorkerProcess; + try { + if (closeWorker) { + closeWorker(); + } else if (nativeWorkerClose) { + apply(nativeWorkerClose, globalThis, []); + } + } catch { + // The protected close wrapper invokes the native close in a finally block. + } +} + +function installUnhandledWorkerFaultBoundary(): void { + if (!nativeWorkerClose) return; + apply(eventTargetAddEventListener, self, [ + "error", + containUnhandledWorkerFault as EventListener, + ]); + apply(eventTargetAddEventListener, self, [ + "unhandledrejection", + containUnhandledWorkerFault as EventListener, + ]); +} + +installUnhandledWorkerFaultBoundary(); + +function createWorkerResponse( + body: BodyInit | null | undefined, + contentType: string, + init?: ResponseInit, +): Response { + const status = init?.status; + const responseBody = status !== undefined && + apply(setHas, NULL_BODY_STATUSES, [status]) + ? null + : body; + + return new NativeResponse(responseBody, { + ...init, + headers: { + "Content-Type": contentType, + ...init?.headers, + }, + }); +} + +function createWorkerJsonResponse(data: unknown, init?: ResponseInit): Response { + return createWorkerResponse(jsonStringify(data), "application/json", init); +} + +function createWorkerTextResponse(data: string, init?: ResponseInit): Response { + return createWorkerResponse(data, "text/plain", init); +} + +function sendControlMessage( + message: unknown, + transfer?: readonly Transferable[], +): void { + const postMessage = postControlPortMessage; + if (!postMessage) { + throw new NativeError("Worker control channel is not initialized"); + } + postMessage(message, transfer); +} + +function isTrustedMessageEventFrom( + event: MessageEvent, + target: EventTarget, +): boolean { + if (!eventCurrentTargetGetter || !eventIsTrustedGetter) return false; + return apply(eventIsTrustedGetter, event, []) === true && + apply(eventCurrentTargetGetter, event, []) === target; +} + +function readMessageEventData(event: MessageEvent): unknown { + const ownData = getOwnPropertyDescriptor(event, "data"); + if (ownData) { + if ("value" in ownData) return ownData.value; + throw new NativeError("MessageEvent data is not a native data field"); + } + if (!messageEventDataGetter) { + throw new NativeError("MessageEvent data getter is unavailable"); + } + return apply(messageEventDataGetter, event, []); +} function installWorkerExitNotifier(): void { - if (exitNotifierInstalled || typeof globalThis.close !== "function") return; + if (exitNotifierInstalled || !nativeWorkerClose) return; - const postMessage = self.postMessage.bind(self); - const notifyExit = () => postMessage({ type: "worker-exit" }); - const closeWorker = globalThis.close.bind(globalThis); + const notifyExit = () => sendControlMessage(WORKER_EXIT_MESSAGE); + const closeWorker = () => apply(nativeWorkerClose, globalThis, []); const exitWorker = typeof Deno.exit === "function" ? Deno.exit.bind(Deno) : undefined; const controls = createWorkerExitControls({ notifyExit, closeWorker, exitWorker }); + const workerPostMessage = self.postMessage.bind(self); + closeWorkerProcess = controls.close; Object.defineProperty(self, "postMessage", { configurable: false, - get: () => postMessage, + get: () => workerPostMessage, set: () => { // Project code must not be able to silence worker lifecycle messages. }, @@ -87,14 +393,49 @@ function installWorkerExitNotifier(): void { function isContained(root: string, child: string): boolean { if (child === root) return true; const rel = relative(root, child); - return rel !== "" && rel !== ".." && !rel.startsWith(`..${PATH_SEP}`) && !isAbsolute(rel); + return rel !== "" && + rel !== ".." && + !apply(stringStartsWith, rel, [`..${PATH_SEP}`]) && + !isAbsolute(rel); +} + +function isNativeNotFound(error: unknown): boolean { + return apply(functionHasInstance, NativeNotFound, [error]) as boolean; } -async function tryRealPath(path: string): Promise { +async function realPathIfExisting(path: string): Promise { try { - return await Deno.realPath(path); - } catch { - return null; + return await denoRealPath(path); + } catch (error) { + if (isNativeNotFound(error)) return null; + throw new NativeError("Unable to canonicalize project path"); + } +} + +async function realPathThroughExistingAncestor(path: string): Promise { + const unresolvedSegments: string[] = []; + let candidate = path; + + while (true) { + const realCandidate = await realPathIfExisting(candidate); + if (realCandidate !== null) { + let resolved = realCandidate; + for (let index = unresolvedSegments.length - 1; index >= 0; index--) { + resolved = resolvePath(resolved, unresolvedSegments[index]!); + } + return resolved; + } + + const parent = dirname(candidate); + if (parent === candidate) { + throw new NativeError("Unable to canonicalize project path"); + } + const segment = basename(candidate); + if (!segment || segment === "." || segment === "..") { + throw new NativeError("Unable to canonicalize project path"); + } + apply(arrayPush, unresolvedSegments, [segment]); + candidate = parent; } } @@ -118,29 +459,1505 @@ export function makeProjectPathGuard(projectDir: string): (path: string) => Prom // Lexical containment first — cheap, and catches plain `../` traversal // even when the target doesn't exist yet. if (!isContained(root, resolved)) { - throw new Error(`Path escapes project directory: ${path}`); + throw new NativeError(`Path escapes project directory: ${path}`); } - // Canonicalize to defeat symlinks that escape the project. realPath fails - // for a not-yet-existing target (e.g. a fresh path); the lexical check - // above already covers that case, so fall back to the resolved path. - realRootPromise ??= tryRealPath(root).then((r) => r ?? root); + // Canonicalize through the nearest existing ancestor so a missing target + // beneath an existing symlink cannot escape through a lexical fallback. + realRootPromise ??= (async () => { + try { + return await denoRealPath(root); + } catch { + throw new NativeError("Unable to canonicalize project root"); + } + })(); const realRoot = await realRootPromise; - const realResolved = await tryRealPath(resolved); - if (realResolved !== null && !isContained(realRoot, realResolved)) { - throw new Error(`Path escapes project directory: ${path}`); + const realResolved = await realPathThroughExistingAncestor(resolved); + if (!isContained(realRoot, realResolved)) { + throw new NativeError(`Path escapes project directory: ${path}`); } - return realResolved ?? resolved; + return realResolved; }; } +// The host admits a trusted local extension module only for SSR workers. API +// workers never receive or resolve a renderer, and there is deliberately no +// implicit framework fallback. +let isolatedSsrRendererModuleUrl: string | null = null; +let isolatedSsrRendererPromise: Promise> | null = null; + +function rendererBoundaryError(stage: "import" | "initialization", cause: unknown): Error { + const diagnostic = snapshotThrowableDiagnostic(cause); + const message = diagnostic + ? `Isolated SSR renderer extension ${stage} failed: ${diagnostic}` + : `Isolated SSR renderer extension ${stage} failed`; + return (stage === "import" ? IMPORT_RESOLUTION_ERROR : INITIALIZATION_ERROR).create({ + message, + cause: diagnostic || "Unknown error", + }); +} + +function snapshotIsolatedSsrRenderer(value: unknown): Readonly { + if ( + value === null || + typeof value !== "object" || + isProxy(value) || + isArray(value) + ) { + throw new NativeTypeError("Isolated SSR renderer factory must return a plain object"); + } + const prototype = getPrototypeOf(value); + if (prototype !== objectPrototype && prototype !== null) { + throw new NativeTypeError("Isolated SSR renderer factory must return a plain object"); + } + + const renderer = value as Record; + const keys = objectKeys(renderer); + const reflectedKeys = ownKeys(renderer); + if ( + keys.length !== 2 || + reflectedKeys.length !== 2 || + !includesExpectedKey(keys, "createElement") || + !includesExpectedKey(keys, "renderToReadableStream") + ) { + throw new NativeTypeError( + 'Isolated SSR renderer must contain only "createElement" and "renderToReadableStream"', + ); + } + + const createElementDescriptor = getOwnPropertyDescriptor(renderer, "createElement"); + const renderDescriptor = getOwnPropertyDescriptor(renderer, "renderToReadableStream"); + if ( + !createElementDescriptor?.enumerable || + !("value" in createElementDescriptor) || + typeof createElementDescriptor.value !== "function" || + isProxy(createElementDescriptor.value) + ) { + throw new NativeTypeError( + "Isolated SSR renderer createElement must be a non-proxy function data property", + ); + } + if ( + !renderDescriptor?.enumerable || + !("value" in renderDescriptor) || + typeof renderDescriptor.value !== "function" || + isProxy(renderDescriptor.value) + ) { + throw new NativeTypeError( + "Isolated SSR renderer renderToReadableStream must be a non-proxy function data property", + ); + } + + return objectFreeze({ + createElement: createElementDescriptor.value as IsolatedSsrRenderer["createElement"], + renderToReadableStream: renderDescriptor.value as IsolatedSsrRenderer["renderToReadableStream"], + }); +} + +async function initializeIsolatedSsrRenderer(): Promise> { + const moduleUrl = isolatedSsrRendererModuleUrl; + if (moduleUrl === null) { + throw new NativeError( + "Missing isolated SSR renderer extension. Install and register @veryfront/ext-react-ssr", + ); + } + + let rendererModule: unknown; + try { + rendererModule = await import(moduleUrl); + } catch (cause) { + throw rendererBoundaryError("import", cause); + } + + if ( + rendererModule === null || + typeof rendererModule !== "object" || + isProxy(rendererModule) + ) { + throw new NativeTypeError("Isolated SSR renderer extension must export a module object"); + } + const moduleRecord = rendererModule as Record; + const moduleKeys = objectKeys(moduleRecord); + const moduleReflectedKeys = ownKeys(moduleRecord); + if ( + moduleKeys.length !== 1 || + moduleKeys[0] !== "createIsolatedSsrRenderer" || + moduleReflectedKeys.length !== 2 || + moduleReflectedKeys[0] !== "createIsolatedSsrRenderer" || + moduleReflectedKeys[1] !== Symbol.toStringTag + ) { + throw new NativeTypeError( + 'Isolated SSR renderer extension must export only "createIsolatedSsrRenderer"', + ); + } + const factoryDescriptor = getOwnPropertyDescriptor( + moduleRecord, + "createIsolatedSsrRenderer", + ); + if ( + !factoryDescriptor?.enumerable || + !("value" in factoryDescriptor) || + typeof factoryDescriptor.value !== "function" || + isProxy(factoryDescriptor.value) + ) { + throw new NativeTypeError( + "Isolated SSR renderer extension factory must be a non-proxy function data property", + ); + } + + let renderer: unknown; + try { + renderer = apply(factoryDescriptor.value, undefined, []); + } catch (cause) { + throw rendererBoundaryError("initialization", cause); + } + return snapshotIsolatedSsrRenderer(renderer); +} + +function getIsolatedSsrRenderer(): Promise> { + isolatedSsrRendererPromise ??= initializeIsolatedSsrRenderer(); + return isolatedSsrRendererPromise; +} + +// --------------------------------------------------------------------------- +// Trusted Control-Channel Request Snapshots +// --------------------------------------------------------------------------- + +type DataRecord = Record; + +interface DataSnapshotBudget { + nodes: number; + utf8Bytes: number; +} + +interface StringSnapshotBudget { + values: number; + utf8Bytes: number; + maxValues: number; + maxUtf8Bytes: number; +} + +function invalidWorkerRequest(field: string): never { + const isSourceIntegrationPolicy = field === "sourceIntegrationPolicy" || + apply(stringStartsWith, field, ["sourceIntegrationPolicy."]); + throw new NativeTypeError( + isSourceIntegrationPolicy + ? "Invalid source integration policy manifest" + : `Invalid worker request ${field}`, + ); +} + +function encodeUtf8(value: string): Uint8Array { + return apply(encodeText, textEncoder, [value]) as Uint8Array; +} + +function byteLengthOf(bytes: Uint8Array): number { + if (!typedArrayByteLengthGetter) { + throw new NativeError("Uint8Array byte length getter is unavailable"); + } + return apply(typedArrayByteLengthGetter, bytes, []) as number; +} + +function matches(pattern: RegExp, value: string): boolean { + return apply(regexpTest, pattern, [value]) as boolean; +} + +function requireString( + value: unknown, + field: string, + maxChars = MAX_WORKER_VALUE_CHARS, + allowEmpty = true, +): string { + if ( + typeof value !== "string" || + value.length > maxChars || + (!allowEmpty && value.length === 0) + ) { + return invalidWorkerRequest(field); + } + return value; +} + +function requirePlainDataRecord( + value: unknown, + field: string, + maxEntries = MAX_WORKER_RECORD_ENTRIES, +): { record: DataRecord; keys: string[] } { + if ( + value === null || + typeof value !== "object" || + isProxy(value) || + isArray(value) + ) { + return invalidWorkerRequest(field); + } + + const prototype = getPrototypeOf(value); + if (prototype !== objectPrototype && prototype !== null) { + return invalidWorkerRequest(field); + } + + const record = value as DataRecord; + const keys = objectKeys(record); + const reflectedKeys = ownKeys(record); + if (keys.length > maxEntries || reflectedKeys.length !== keys.length) { + return invalidWorkerRequest(field); + } + + for (let index = 0; index < keys.length; index++) { + const key = keys[index]!; + const descriptor = getOwnPropertyDescriptor(record, key); + if (!descriptor || !descriptor.enumerable || !("value" in descriptor)) { + return invalidWorkerRequest(field); + } + } + + return { record, keys }; +} + +function includesExpectedKey( + expected: readonly string[], + key: string, +): boolean { + for (let index = 0; index < expected.length; index++) { + if (expected[index] === key) return true; + } + return false; +} + +function requireRecordShape( + value: unknown, + required: readonly string[], + optional: readonly string[], + field: string, +): DataRecord { + const { record, keys } = requirePlainDataRecord( + value, + field, + required.length + optional.length, + ); + + if (keys.length < required.length) return invalidWorkerRequest(field); + + for (let index = 0; index < keys.length; index++) { + const key = keys[index]!; + if ( + !includesExpectedKey(required, key) && + !includesExpectedKey(optional, key) + ) { + return invalidWorkerRequest(field); + } + } + for (let index = 0; index < required.length; index++) { + const key = required[index]!; + if (!getOwnPropertyDescriptor(record, key)) { + return invalidWorkerRequest(field); + } + } + + return record; +} + +function readDataProperty(record: DataRecord, key: string): unknown { + const descriptor = getOwnPropertyDescriptor(record, key); + if (!descriptor || !("value" in descriptor)) { + return invalidWorkerRequest(key); + } + return descriptor.value; +} + +function readOptionalDataProperty( + record: DataRecord, + key: string, +): { present: false } | { present: true; value: unknown } { + const descriptor = getOwnPropertyDescriptor(record, key); + if (!descriptor) return { present: false }; + if (!("value" in descriptor)) return invalidWorkerRequest(key); + return { present: true, value: descriptor.value }; +} + +function requireDenseArray( + value: unknown, + field: string, + maxLength = MAX_WORKER_RECORD_ENTRIES, +): unknown[] { + if ( + !isArray(value) || + isProxy(value) || + getPrototypeOf(value) !== arrayPrototype + ) { + return invalidWorkerRequest(field); + } + + const lengthDescriptor = getOwnPropertyDescriptor(value, "length"); + const length = lengthDescriptor && "value" in lengthDescriptor + ? lengthDescriptor.value + : undefined; + if ( + typeof length !== "number" || + !numberIsSafeInteger(length) || + length < 0 || + length > maxLength + ) { + return invalidWorkerRequest(field); + } + + const keys = objectKeys(value); + const reflectedKeys = ownKeys(value); + if (keys.length !== length || reflectedKeys.length !== length + 1) { + return invalidWorkerRequest(field); + } + for (let index = 0; index < length; index++) { + if (keys[index] !== NativeString(index)) { + return invalidWorkerRequest(field); + } + const descriptor = getOwnPropertyDescriptor(value, keys[index]!); + if (!descriptor || !descriptor.enumerable || !("value" in descriptor)) { + return invalidWorkerRequest(field); + } + } + + return value as unknown[]; +} + +function arrayElement(values: unknown[], index: number, field: string): unknown { + const descriptor = getOwnPropertyDescriptor(values, NativeString(index)); + if (!descriptor || !("value" in descriptor)) { + return invalidWorkerRequest(field); + } + return descriptor.value; +} + +function defineDataProperty( + target: object, + key: PropertyKey, + value: unknown, +): void { + defineProperty(target, key, { + configurable: true, + enumerable: true, + value, + writable: true, + }); +} + +function createNullPrototypeRecord(): Record { + return apply(objectCreate, null, [null]) as Record; +} + +function freezeObject(value: T): T { + return apply(objectFreeze, null, [value]) as T; +} + +function createFrozenPolicyRestriction( + allowedToolIds: readonly string[] | null, +): Readonly<{ allowedToolIds: readonly string[] | null }> { + const restriction = createNullPrototypeRecord(); + defineDataProperty(restriction, "allowedToolIds", allowedToolIds); + return freezeObject(restriction) as Readonly<{ + allowedToolIds: readonly string[] | null; + }>; +} + +function createFrozenPolicyRoot( + mode: "unrestricted", +): SourceIntegrationPolicyManifest; +function createFrozenPolicyRoot( + mode: "allowlist", + integrations: Readonly< + Record< + string, + Readonly<{ allowedToolIds: readonly string[] | null }> + > + >, +): SourceIntegrationPolicyManifest; +function createFrozenPolicyRoot( + mode: "unrestricted" | "allowlist", + integrations?: Readonly< + Record< + string, + Readonly<{ allowedToolIds: readonly string[] | null }> + > + >, +): SourceIntegrationPolicyManifest { + const policy = createNullPrototypeRecord(); + defineDataProperty(policy, "schemaVersion", 1); + defineDataProperty(policy, "mode", mode); + if (mode === "allowlist") { + defineDataProperty(policy, "integrations", integrations); + } + return freezeObject(policy) as SourceIntegrationPolicyManifest; +} + +function copyUint8Array( + value: unknown, + field: string, + maxBytes: number, +): Uint8Array { + if ( + value === null || + typeof value !== "object" || + isProxy(value) || + getPrototypeOf(value) !== uint8ArrayPrototype || + !typedArrayByteLengthGetter + ) { + return invalidWorkerRequest(field); + } + + let byteLength: number; + let copy: Uint8Array; + try { + byteLength = apply(typedArrayByteLengthGetter, value, []) as number; + if (byteLength > maxBytes) return invalidWorkerRequest(field); + copy = new NativeUint8Array(byteLength); + apply(setBytes, copy, [value]); + } catch { + return invalidWorkerRequest(field); + } + if (byteLength !== apply(typedArrayByteLengthGetter, copy, [])) { + return invalidWorkerRequest(field); + } + return copy; +} + +function snapshotPreparedWorkerModule(value: unknown): PreparedWorkerModule { + const record = requireRecordShape( + value, + ["source", "sha256"], + [], + "module", + ); + const source = requireString( + readDataProperty(record, "source"), + "module.source", + MAX_SAFE_INTEGER, + ); + const sha256 = requireString( + readDataProperty(record, "sha256"), + "module.sha256", + 64, + false, + ); + if ( + !matches(LOWERCASE_SHA256_PATTERN, sha256) || + byteLengthOf(encodeUtf8(source)) > MAX_WORKER_MODULE_SOURCE_BYTES + ) { + return invalidWorkerRequest("module"); + } + return { source, sha256 }; +} + +function snapshotStringArray( + value: unknown, + field: string, + maxLength = MAX_WORKER_RECORD_ENTRIES, + maxStringChars = MAX_WORKER_VALUE_CHARS, + budget?: StringSnapshotBudget, +): string[] { + const input = requireDenseArray(value, field, maxLength); + const output = new NativeArray(input.length); + for (let index = 0; index < input.length; index++) { + const stringValue = requireString( + arrayElement(input, index, field), + field, + maxStringChars, + ); + if (budget) consumeStringBudget(budget, stringValue, field); + defineDataProperty( + output, + NativeString(index), + stringValue, + ); + } + return output; +} + +function consumeStringBudget( + budget: StringSnapshotBudget, + value: string, + field: string, +): void { + budget.values++; + budget.utf8Bytes += byteLengthOf(encodeUtf8(value)); + if ( + budget.values > budget.maxValues || + budget.utf8Bytes > budget.maxUtf8Bytes + ) { + invalidWorkerRequest(field); + } +} + +function snapshotStringRecord( + value: unknown, + field: string, + valueMayBeArray: false, + maxUtf8Bytes?: number, + maxValues?: number, +): Record; +function snapshotStringRecord( + value: unknown, + field: string, + valueMayBeArray: true, + maxUtf8Bytes?: number, + maxValues?: number, +): Record; +function snapshotStringRecord( + value: unknown, + field: string, + valueMayBeArray: boolean, + maxUtf8Bytes = MAX_WORKER_STRING_COLLECTION_UTF8_BYTES, + maxValues = MAX_WORKER_STRING_COLLECTION_VALUES, +): Record { + const { record, keys } = requirePlainDataRecord(value, field); + const output: Record = {}; + const budget: StringSnapshotBudget = { + values: 0, + utf8Bytes: 0, + maxValues, + maxUtf8Bytes, + }; + + for (let index = 0; index < keys.length; index++) { + const key = keys[index]!; + requireString(key, field, MAX_WORKER_VALUE_CHARS); + consumeStringBudget(budget, key, field); + const raw = readDataProperty(record, key); + const copied = valueMayBeArray && isArray(raw) + ? snapshotStringArray( + raw, + field, + MAX_WORKER_RECORD_ENTRIES, + MAX_WORKER_VALUE_CHARS, + budget, + ) + : requireString(raw, field); + if (typeof copied === "string") { + consumeStringBudget(budget, copied, field); + } + defineDataProperty(output, key, copied); + } + return output; +} + +function snapshotProjectEnv( + value: unknown, +): Record | undefined { + if (value === undefined) return undefined; + const env = snapshotStringRecord( + value, + "projectEnv", + false, + MAX_WORKER_PROJECT_ENV_UTF8_BYTES, + ); + const keys = objectKeys(env); + for (let index = 0; index < keys.length; index++) { + const key = keys[index]!; + const envValue = env[key]!; + if ( + key.length > 1024 || + !matches(PROJECT_ENV_KEY_PATTERN, key) || + !matches(PROJECT_ENV_VALUE_PATTERN, envValue) + ) { + return invalidWorkerRequest("projectEnv"); + } + } + return env; +} + +function snapshotSourceIntegrationPolicy( + value: unknown, +): SourceIntegrationPolicyManifest { + const common = requireRecordShape( + value, + ["schemaVersion", "mode"], + ["integrations"], + "sourceIntegrationPolicy", + ); + if ( + readDataProperty(common, "schemaVersion") !== 1 + ) { + return invalidWorkerRequest("sourceIntegrationPolicy"); + } + + const mode = readDataProperty(common, "mode"); + const integrationsField = readOptionalDataProperty(common, "integrations"); + if (mode === "unrestricted") { + if (integrationsField.present) { + return invalidWorkerRequest("sourceIntegrationPolicy"); + } + return createFrozenPolicyRoot("unrestricted"); + } + if (mode !== "allowlist" || !integrationsField.present) { + return invalidWorkerRequest("sourceIntegrationPolicy"); + } + + const { record: rawIntegrations, keys: integrationNames } = requirePlainDataRecord( + integrationsField.value, + "sourceIntegrationPolicy.integrations", + ); + apply(arraySort, integrationNames, [compareStrings]); + const integrations: Record< + string, + Readonly<{ readonly allowedToolIds: readonly string[] | null }> + > = createNullPrototypeRecord(); + const policyBudget: StringSnapshotBudget = { + values: 0, + utf8Bytes: 0, + maxValues: MAX_WORKER_STRING_COLLECTION_VALUES, + maxUtf8Bytes: MAX_WORKER_POLICY_UTF8_BYTES, + }; + + for (let integrationIndex = 0; integrationIndex < integrationNames.length; integrationIndex++) { + const integrationName = requireString( + integrationNames[integrationIndex], + "sourceIntegrationPolicy.integrations", + MAX_WORKER_POLICY_SEGMENT_CHARS, + false, + ); + consumeStringBudget( + policyBudget, + integrationName, + "sourceIntegrationPolicy", + ); + if (!matches(CANONICAL_POLICY_SEGMENT_PATTERN, integrationName)) { + return invalidWorkerRequest("sourceIntegrationPolicy.integrations"); + } + const restriction = requireRecordShape( + readDataProperty(rawIntegrations, integrationName), + ["allowedToolIds"], + [], + "sourceIntegrationPolicy.integrations", + ); + const rawToolIds = readDataProperty(restriction, "allowedToolIds"); + let allowedToolIds: string[] | null = null; + if (rawToolIds !== null) { + allowedToolIds = snapshotStringArray( + rawToolIds, + "sourceIntegrationPolicy.allowedToolIds", + MAX_WORKER_RECORD_ENTRIES, + MAX_WORKER_POLICY_SEGMENT_CHARS, + policyBudget, + ); + const seenToolIds = new NativeSet(); + for (let index = 0; index < allowedToolIds.length; index++) { + const toolId = allowedToolIds[index]!; + if (!matches(CANONICAL_POLICY_SEGMENT_PATTERN, toolId)) { + return invalidWorkerRequest("sourceIntegrationPolicy.allowedToolIds"); + } + if (apply(setHas, seenToolIds, [toolId])) { + return invalidWorkerRequest( + "sourceIntegrationPolicy.allowedToolIds", + ); + } + apply(setAdd, seenToolIds, [toolId]); + } + apply(arraySort, allowedToolIds, [compareStrings]); + freezeObject(allowedToolIds); + } + defineDataProperty( + integrations, + integrationName, + createFrozenPolicyRestriction(allowedToolIds), + ); + } + + return createFrozenPolicyRoot( + "allowlist", + freezeObject(integrations), + ); +} + +function snapshotRequiredSourceIntegrationPolicy( + request: DataRecord, +): SourceIntegrationPolicyManifest { + const field = readOptionalDataProperty( + request, + "sourceIntegrationPolicy", + ); + if (!field.present) return invalidWorkerRequest("sourceIntegrationPolicy"); + return snapshotSourceIntegrationPolicy(field.value); +} + +function snapshotHeaders(value: unknown): [string, string][] { + const rawHeaders = requireDenseArray( + value, + "headers", + MAX_WORKER_HEADER_COUNT, + ); + const headers = new NativeArray<[string, string]>(rawHeaders.length); + const budget: StringSnapshotBudget = { + values: 0, + utf8Bytes: 0, + maxValues: MAX_WORKER_HEADER_COUNT * 2, + maxUtf8Bytes: MAX_WORKER_HEADER_UTF8_BYTES, + }; + + for (let index = 0; index < rawHeaders.length; index++) { + const rawPair = requireDenseArray( + arrayElement(rawHeaders, index, "headers"), + "header", + 2, + ); + if (rawPair.length !== 2) return invalidWorkerRequest("header"); + const name = requireString( + arrayElement(rawPair, 0, "header"), + "header.name", + MAX_WORKER_HEADER_FIELD_CHARS, + ); + const headerValue = requireString( + arrayElement(rawPair, 1, "header"), + "header.value", + MAX_WORKER_HEADER_FIELD_CHARS, + ); + consumeStringBudget(budget, name, "headers"); + consumeStringBudget(budget, headerValue, "headers"); + const pair: [string, string] = [ + name, + headerValue, + ]; + defineDataProperty(headers, NativeString(index), pair); + } + return headers; +} + +function snapshotSerializedRequest(value: unknown): SerializedRequest { + const record = requireRecordShape( + value, + ["url", "method", "headers", "body"], + [], + "request", + ); + const rawBody = readDataProperty(record, "body"); + return { + url: requireString( + readDataProperty(record, "url"), + "request.url", + MAX_WORKER_URL_CHARS, + false, + ), + method: requireString( + readDataProperty(record, "method"), + "request.method", + 64, + false, + ), + headers: snapshotHeaders(readDataProperty(record, "headers")), + body: rawBody === null ? null : copyUint8Array(rawBody, "request.body", MAX_WORKER_BODY_BYTES), + }; +} + +function snapshotPagesContext(value: unknown): SerializedPagesContext { + const record = requireRecordShape( + value, + ["url", "method", "headers", "body", "params", "cookies"], + [], + "context", + ); + const request = snapshotSerializedRequest({ + url: readDataProperty(record, "url"), + method: readDataProperty(record, "method"), + headers: readDataProperty(record, "headers"), + body: readDataProperty(record, "body"), + }); + return { + ...request, + params: snapshotStringRecord( + readDataProperty(record, "params"), + "context.params", + true, + ), + cookies: snapshotStringRecord( + readDataProperty(record, "cookies"), + "context.cookies", + false, + ) as Record, + }; +} + +function snapshotDataContext(value: unknown): SerializedDataContext { + const record = requireRecordShape( + value, + ["params", "query", "request", "url"], + [], + "context", + ); + return { + params: snapshotStringRecord( + readDataProperty(record, "params"), + "context.params", + true, + ), + query: requireString( + readDataProperty(record, "query"), + "context.query", + MAX_WORKER_URL_CHARS, + ), + request: snapshotSerializedRequest(readDataProperty(record, "request")), + url: requireString( + readDataProperty(record, "url"), + "context.url", + MAX_WORKER_URL_CHARS, + false, + ), + }; +} + +function consumeDataBudget( + budget: DataSnapshotBudget, + value: string, +): void { + budget.nodes++; + budget.utf8Bytes += byteLengthOf(encodeUtf8(value)); + if ( + budget.nodes > MAX_WORKER_DATA_NODES || + budget.utf8Bytes > MAX_WORKER_DATA_UTF8_BYTES + ) { + invalidWorkerRequest("render data"); + } +} + +function snapshotStructuredData( + value: unknown, + budget: DataSnapshotBudget, + depth = 0, +): unknown { + if (depth > MAX_WORKER_DATA_DEPTH) { + return invalidWorkerRequest("render data"); + } + if (value === null || typeof value === "boolean") { + budget.nodes++; + if (budget.nodes > MAX_WORKER_DATA_NODES) { + return invalidWorkerRequest("render data"); + } + return value; + } + if (typeof value === "string") { + consumeDataBudget(budget, value); + return value; + } + if (typeof value === "number") { + budget.nodes++; + if ( + budget.nodes > MAX_WORKER_DATA_NODES || + !numberIsFinite(value) + ) { + return invalidWorkerRequest("render data"); + } + return value; + } + if (isArray(value)) { + const input = requireDenseArray( + value, + "render data", + MAX_WORKER_DATA_NODES, + ); + budget.nodes++; + if (budget.nodes > MAX_WORKER_DATA_NODES) { + return invalidWorkerRequest("render data"); + } + const output = new NativeArray(input.length); + for (let index = 0; index < input.length; index++) { + defineDataProperty( + output, + NativeString(index), + snapshotStructuredData( + arrayElement(input, index, "render data"), + budget, + depth + 1, + ), + ); + } + return output; + } + if (typeof value !== "object" || value === null) { + return invalidWorkerRequest("render data"); + } + + const { record, keys } = requirePlainDataRecord( + value, + "render data", + MAX_WORKER_DATA_NODES, + ); + budget.nodes++; + if (budget.nodes > MAX_WORKER_DATA_NODES) { + return invalidWorkerRequest("render data"); + } + const output: Record = {}; + for (let index = 0; index < keys.length; index++) { + const key = keys[index]!; + consumeDataBudget(budget, key); + defineDataProperty( + output, + key, + snapshotStructuredData( + readDataProperty(record, key), + budget, + depth + 1, + ), + ); + } + return output; +} + +function snapshotStructuredDataRecord( + value: unknown, + budget: DataSnapshotBudget, +): Record { + const snapshot = snapshotStructuredData(value, budget); + if ( + snapshot === null || + typeof snapshot !== "object" || + isArray(snapshot) + ) { + return invalidWorkerRequest("render data"); + } + return snapshot as Record; +} + +function invalidIsolatedDataResult(): never { + throw new NativeTypeError("Invalid isolated data result"); +} + +function snapshotDataResultForBoundary(value: unknown): SerializedDataResult { + try { + const { record: result } = requirePlainDataRecord( + value, + "data result", + ); + const rawProps = readOptionalDataProperty(result, "props"); + const rawRedirect = readOptionalDataProperty(result, "redirect"); + const rawNotFound = readOptionalDataProperty(result, "notFound"); + const rawRevalidate = readOptionalDataProperty(result, "revalidate"); + const hasProps = rawProps.present && rawProps.value !== undefined; + const hasRedirect = rawRedirect.present && rawRedirect.value !== undefined; + const hasNotFound = rawNotFound.present && rawNotFound.value !== undefined; + const hasRevalidate = rawRevalidate.present && rawRevalidate.value !== undefined; + + let normalizedRedirect: + | { destination: string; permanent?: boolean } + | undefined; + if (hasRedirect) { + const { record: redirectRecord } = requirePlainDataRecord( + rawRedirect.value, + "data result redirect", + ); + const destinationField = readOptionalDataProperty( + redirectRecord, + "destination", + ); + if (!destinationField.present) return invalidIsolatedDataResult(); + const destination = requireString( + destinationField.value, + "data result redirect destination", + MAX_WORKER_URL_CHARS, + true, + ); + const permanent = readOptionalDataProperty(redirectRecord, "permanent"); + const hasPermanent = permanent.present && permanent.value !== undefined; + if (hasPermanent && typeof permanent.value !== "boolean") { + return invalidIsolatedDataResult(); + } + normalizedRedirect = { + destination, + ...(hasPermanent ? { permanent: permanent.value as boolean } : {}), + }; + } + + if (hasNotFound && typeof rawNotFound.value !== "boolean") { + return invalidIsolatedDataResult(); + } + const normalizedNotFound = hasNotFound ? rawNotFound.value as boolean : undefined; + const activeOutcomes = (hasProps ? 1 : 0) + + (hasRedirect ? 1 : 0) + + (normalizedNotFound === true ? 1 : 0); + if (activeOutcomes > 1) { + return invalidIsolatedDataResult(); + } + + let normalizedRevalidate: number | false | undefined; + if (hasRevalidate) { + if ( + rawRevalidate.value !== false && + (typeof rawRevalidate.value !== "number" || + !numberIsFinite(rawRevalidate.value) || + rawRevalidate.value < 0) + ) { + return invalidIsolatedDataResult(); + } + normalizedRevalidate = rawRevalidate.value as number | false; + } + + const normalized: Record = {}; + if (hasProps) defineDataProperty(normalized, "props", rawProps.value); + if (normalizedRedirect) { + defineDataProperty(normalized, "redirect", normalizedRedirect); + } + if (normalizedNotFound !== undefined) { + defineDataProperty(normalized, "notFound", normalizedNotFound); + } + if (normalizedRevalidate !== undefined) { + defineDataProperty(normalized, "revalidate", normalizedRevalidate); + } + const budget: DataSnapshotBudget = { nodes: 0, utf8Bytes: 0 }; + return snapshotStructuredDataRecord(normalized, budget) as SerializedDataResult; + } catch { + return invalidIsolatedDataResult(); + } +} + +function snapshotOptionalString( + record: DataRecord, + key: string, + maxChars: number, +): string | undefined { + const field = readOptionalDataProperty(record, key); + if (!field.present || field.value === undefined) return undefined; + return requireString(field.value, key, maxChars); +} + +function snapshotSSRDependencyPinning( + request: DataRecord, +): Pick< + RenderSSRRequest, + "dependencyPinningCacheKey" | "dependencyPinningDependencies" +> { + const cacheKeyField = readOptionalDataProperty( + request, + "dependencyPinningCacheKey", + ); + const dependenciesField = readOptionalDataProperty( + request, + "dependencyPinningDependencies", + ); + const rawCacheKey = cacheKeyField.present ? cacheKeyField.value : undefined; + const rawDependencies = dependenciesField.present ? dependenciesField.value : undefined; + + if (rawCacheKey === undefined && rawDependencies === undefined) return {}; + const cacheKey = requireString( + rawCacheKey, + "dependencyPinningCacheKey", + 16, + false, + ); + if (cacheKey === "off") { + if (rawDependencies !== undefined) { + return invalidWorkerRequest("dependencyPinningDependencies"); + } + return { dependencyPinningCacheKey: cacheKey }; + } + + const match = apply( + regexpExec, + CANONICAL_DEPENDENCY_PINNING_CACHE_KEY_PATTERN, + [cacheKey], + ) as RegExpExecArray | null; + const hash = match?.[1]; + if ( + cacheKey === "on:unknown" || + cacheKey === "on:no-project" || + !hash || + (hash.length === MAX_DEPENDENCY_PINNING_HASH.length && + hash > MAX_DEPENDENCY_PINNING_HASH) || + rawDependencies === undefined + ) { + return invalidWorkerRequest("dependencyPinningCacheKey"); + } + + const { record, keys } = requirePlainDataRecord( + rawDependencies, + "dependencyPinningDependencies", + ); + apply(arraySort, keys, [compareStrings]); + const dependencies = createNullPrototypeRecord(); + const budget: StringSnapshotBudget = { + values: 0, + utf8Bytes: 0, + maxValues: MAX_WORKER_RECORD_ENTRIES * 2, + maxUtf8Bytes: MAX_WORKER_PROJECT_ENV_UTF8_BYTES, + }; + for (let index = 0; index < keys.length; index++) { + const name = requireString( + keys[index], + "dependencyPinningDependencies", + MAX_WORKER_VALUE_CHARS, + false, + ); + const declaration = requireString( + readDataProperty(record, name), + "dependencyPinningDependencies", + ); + consumeStringBudget(budget, name, "dependencyPinningDependencies"); + consumeStringBudget( + budget, + declaration, + "dependencyPinningDependencies", + ); + defineDataProperty(dependencies, name, declaration); + } + + return { + dependencyPinningCacheKey: cacheKey, + dependencyPinningDependencies: freezeObject(dependencies), + }; +} + +/** + * The bundled worker renderer has one fixed React implementation. Until the + * extension can select a renderer by canonical dependency snapshot, accepting + * an enabled host snapshot would silently render with the wrong React graph. + */ +export function assertIsolatedSsrDependencySnapshotSupported( + request: RenderSSRRequest, +): void { + if ( + request.dependencyPinningCacheKey === undefined || + request.dependencyPinningCacheKey === "off" + ) { + return; + } + throw new NativeError( + "Isolated SSR does not support enabled dependency snapshots", + ); +} + +/** + * Synchronously detach and validate one control-port request before it can be + * observed or mutated by any later project task. + * + * @internal Exported for deterministic boundary regression tests. + */ +export function snapshotWorkerRequest(value: unknown): WorkerRequest { + let cloned: unknown; + try { + cloned = cloneStructuredValue(value); + } catch { + return invalidWorkerRequest("payload"); + } + + const envelope = requirePlainDataRecord(cloned, "payload", 16).record; + const type = requireString( + readDataProperty(envelope, "type"), + "type", + 64, + false, + ); + + if (type === "execute-app-route") { + const sourceIntegrationPolicy = snapshotRequiredSourceIntegrationPolicy( + envelope, + ); + const request = requireRecordShape( + cloned, + [ + "type", + "id", + "module", + "modulePath", + "method", + "request", + "params", + "projectDir", + "sourceIntegrationPolicy", + ], + ["projectEnv"], + "payload", + ); + return { + type, + id: requireString( + readDataProperty(request, "id"), + "id", + MAX_WORKER_REQUEST_ID_CHARS, + false, + ), + module: snapshotPreparedWorkerModule( + readDataProperty(request, "module"), + ), + modulePath: requireString( + readDataProperty(request, "modulePath"), + "modulePath", + MAX_WORKER_PATH_CHARS, + false, + ), + method: requireString( + readDataProperty(request, "method"), + "method", + 64, + false, + ), + request: snapshotSerializedRequest( + readDataProperty(request, "request"), + ), + params: snapshotStringRecord( + readDataProperty(request, "params"), + "params", + false, + ), + projectDir: requireString( + readDataProperty(request, "projectDir"), + "projectDir", + MAX_WORKER_PATH_CHARS, + false, + ), + sourceIntegrationPolicy, + projectEnv: snapshotProjectEnv( + readOptionalDataProperty(request, "projectEnv").present + ? readDataProperty(request, "projectEnv") + : undefined, + ), + }; + } + + if (type === "execute-pages-route") { + const sourceIntegrationPolicy = snapshotRequiredSourceIntegrationPolicy( + envelope, + ); + const request = requireRecordShape( + cloned, + [ + "type", + "id", + "module", + "modulePath", + "method", + "context", + "projectDir", + "sourceIntegrationPolicy", + ], + ["projectEnv"], + "payload", + ); + return { + type, + id: requireString( + readDataProperty(request, "id"), + "id", + MAX_WORKER_REQUEST_ID_CHARS, + false, + ), + module: snapshotPreparedWorkerModule( + readDataProperty(request, "module"), + ), + modulePath: requireString( + readDataProperty(request, "modulePath"), + "modulePath", + MAX_WORKER_PATH_CHARS, + false, + ), + method: requireString( + readDataProperty(request, "method"), + "method", + 64, + false, + ), + context: snapshotPagesContext(readDataProperty(request, "context")), + projectDir: requireString( + readDataProperty(request, "projectDir"), + "projectDir", + MAX_WORKER_PATH_CHARS, + false, + ), + sourceIntegrationPolicy, + projectEnv: snapshotProjectEnv( + readOptionalDataProperty(request, "projectEnv").present + ? readDataProperty(request, "projectEnv") + : undefined, + ), + }; + } + + if (type === "inspect-api-route-methods") { + const sourceIntegrationPolicy = snapshotRequiredSourceIntegrationPolicy( + envelope, + ); + const request = requireRecordShape( + cloned, + [ + "type", + "id", + "module", + "modulePath", + "projectDir", + "sourceIntegrationPolicy", + ], + ["requestedMethod", "projectEnv"], + "payload", + ); + return { + type, + id: requireString( + readDataProperty(request, "id"), + "id", + MAX_WORKER_REQUEST_ID_CHARS, + false, + ), + module: snapshotPreparedWorkerModule( + readDataProperty(request, "module"), + ), + modulePath: requireString( + readDataProperty(request, "modulePath"), + "modulePath", + MAX_WORKER_PATH_CHARS, + false, + ), + requestedMethod: snapshotOptionalString( + request, + "requestedMethod", + 64, + ), + projectDir: requireString( + readDataProperty(request, "projectDir"), + "projectDir", + MAX_WORKER_PATH_CHARS, + false, + ), + sourceIntegrationPolicy, + projectEnv: snapshotProjectEnv( + readOptionalDataProperty(request, "projectEnv").present + ? readDataProperty(request, "projectEnv") + : undefined, + ), + }; + } + + if (type === "fetch-data") { + const sourceIntegrationPolicy = snapshotRequiredSourceIntegrationPolicy( + envelope, + ); + const request = requireRecordShape( + cloned, + [ + "type", + "id", + "modulePath", + "context", + "sourceIntegrationPolicy", + ], + ["projectEnv"], + "payload", + ); + return { + type, + id: requireString( + readDataProperty(request, "id"), + "id", + MAX_WORKER_REQUEST_ID_CHARS, + false, + ), + modulePath: requireString( + readDataProperty(request, "modulePath"), + "modulePath", + MAX_WORKER_PATH_CHARS, + false, + ), + context: snapshotDataContext(readDataProperty(request, "context")), + sourceIntegrationPolicy, + projectEnv: snapshotProjectEnv( + readOptionalDataProperty(request, "projectEnv").present + ? readDataProperty(request, "projectEnv") + : undefined, + ), + }; + } + + if (type === "render-ssr") { + const sourceIntegrationPolicy = snapshotRequiredSourceIntegrationPolicy( + envelope, + ); + const request = requireRecordShape( + cloned, + [ + "type", + "id", + "pageModulePath", + "layoutModulePaths", + "pageProps", + "layoutProps", + "delivery", + "sourceIntegrationPolicy", + ], + [ + "dependencyPinningCacheKey", + "dependencyPinningDependencies", + ], + "payload", + ); + const budget: DataSnapshotBudget = { nodes: 0, utf8Bytes: 0 }; + const layoutPathBudget: StringSnapshotBudget = { + values: 0, + utf8Bytes: 0, + maxValues: MAX_WORKER_RECORD_ENTRIES, + maxUtf8Bytes: MAX_WORKER_STRING_COLLECTION_UTF8_BYTES, + }; + const layoutModulePaths = snapshotStringArray( + readDataProperty(request, "layoutModulePaths"), + "layoutModulePaths", + MAX_WORKER_RECORD_ENTRIES, + MAX_WORKER_PATH_CHARS, + layoutPathBudget, + ); + const rawLayoutProps = requireDenseArray( + readDataProperty(request, "layoutProps"), + "layoutProps", + MAX_WORKER_RECORD_ENTRIES, + ); + if (rawLayoutProps.length !== layoutModulePaths.length) { + return invalidWorkerRequest("layoutProps"); + } + const layoutProps = new NativeArray>( + rawLayoutProps.length, + ); + for (let index = 0; index < rawLayoutProps.length; index++) { + defineDataProperty( + layoutProps, + NativeString(index), + snapshotStructuredDataRecord( + arrayElement(rawLayoutProps, index, "layoutProps"), + budget, + ), + ); + } + const delivery = readDataProperty(request, "delivery"); + if (delivery !== "string" && delivery !== "stream") { + return invalidWorkerRequest("delivery"); + } + const dependencyPinning = snapshotSSRDependencyPinning(request); + return { + type, + id: requireString( + readDataProperty(request, "id"), + "id", + MAX_WORKER_REQUEST_ID_CHARS, + false, + ), + pageModulePath: requireString( + readDataProperty(request, "pageModulePath"), + "pageModulePath", + MAX_WORKER_PATH_CHARS, + false, + ), + layoutModulePaths, + pageProps: snapshotStructuredDataRecord( + readDataProperty(request, "pageProps"), + budget, + ), + layoutProps, + delivery, + ...dependencyPinning, + sourceIntegrationPolicy, + }; + } + + return invalidWorkerRequest("type"); +} + // --------------------------------------------------------------------------- // Serialization Helpers // --------------------------------------------------------------------------- function deserializeRequest(s: SerializedRequest): Request { - return new Request(s.url, { + return new NativeRequest(s.url, { method: s.method, headers: s.headers, body: s.body as BodyInit | null, @@ -154,7 +1971,7 @@ function deserializePagesRequest( params: Record; cookies: Record; } { - const request = new Request(s.url, { + const request = new NativeRequest(s.url, { method: s.method, headers: s.headers, body: s.body as BodyInit | null, @@ -162,31 +1979,144 @@ function deserializePagesRequest( return { request, params: s.params, cookies: s.cookies }; } -async function serializeResponse(response: Response): Promise { - const body = response.body ? new Uint8Array(await response.arrayBuffer()) : null; - return { - status: response.status, - statusText: response.statusText, - headers: [...response.headers.entries()], - body, - }; +async function serializeResponse( + response: unknown, + requestMethod?: string, +): Promise { + return await serializeRouteResponse(response, requestMethod); } -export function serializeError(error: unknown): SerializedError { - if (error instanceof Error) { - const serialized: SerializedError = { - message: error.message, - name: error.name, - stack: error.stack, - }; - // Preserve RFC 9457 fields if present (VFError instances) - const e = error as unknown as Record; - if (typeof e.type === "string") serialized.type = e.type; - if (typeof e.status === "number") serialized.status = e.status; - if (typeof e.detail === "string") serialized.detail = e.detail; - return serialized; +function dataModuleStackLabel( + match: string, + fallbackDigest: string | undefined, +): string { + const digestMarker = "sha256="; + const markerIndex = apply(stringIndexOf, match, [digestMarker]) as number; + const digestStart = markerIndex < 0 ? -1 : markerIndex + digestMarker.length; + const digest = digestStart < 0 + ? "unknown" + : apply(stringSlice, match, [digestStart, digestStart + 64]) as string; + const safeDigest = matches(LOWERCASE_SHA256_PATTERN, digest) + ? digest + : fallbackDigest !== undefined && + matches(LOWERCASE_SHA256_PATTERN, fallbackDigest) + ? fallbackDigest + : "unknown"; + + const location = apply(regexpExec, STACK_LOCATION_PATTERN, [ + match, + ]) as RegExpExecArray | null; + return location ? `vf-api:${safeDigest}:${location[1]}:${location[2]}` : `vf-api:${safeDigest}`; +} + +/** + * Remove encoded project source from data-module stack frames before any + * boundary logger or response can observe it. + */ +export function sanitizeWorkerDataModuleStack( + stack: string, + fallbackDigest?: string, +): string { + if (!matches(DATA_JAVASCRIPT_URL_PRESENCE_PATTERN, stack)) return stack; + + const replaced = apply(regexpReplace, DATA_JAVASCRIPT_URL_PATTERN, [ + stack, + (match: string) => dataModuleStackLabel(match, fallbackDigest), + ]) as string; + const firstNewline = apply(stringIndexOf, replaced, ["\n"]) as number; + const firstLine = firstNewline < 0 + ? replaced + : apply(stringSlice, replaced, [0, firstNewline]) as string; + const label = apply(regexpExec, SANITIZED_DATA_MODULE_LABEL_PATTERN, [ + replaced, + ]) as RegExpExecArray | null; + if ( + !label || + (apply(stringIndexOf, firstLine, [label[0]]) as number) >= 0 + ) { + return firstLine; + } + return `${firstLine}\n at ${label[0]}`; +} + +function readNativeErrorStack(error: unknown): string | undefined { + if (!isNativeErrorWithoutHooks(error)) return undefined; + const descriptor = getOwnPropertyDescriptor(error, "stack"); + if ( + descriptor && + "value" in descriptor && + typeof descriptor.value === "string" + ) { + return descriptor.value; + } + if ( + descriptor?.get === nativeErrorStackGetter && + nativeErrorStackGetter + ) { + const stack = apply(nativeErrorStackGetter, error, []); + return typeof stack === "string" ? stack : undefined; } - return { message: String(error), name: "Error" }; + return undefined; +} + +export function serializeError( + error: unknown, + dataModuleDigest?: string, +): SerializedError { + const sourceWasError = isNativeErrorWithoutHooks(error); + const sourceWasNativeTypeError = sourceWasError && + error !== null && + typeof error === "object" && + !isProxy(error) && + getPrototypeOf(error) === nativeTypeErrorPrototype; + const detached = detachThrowableForBoundary(error); + if (sourceWasError) { + try { + const stack = readNativeErrorStack(error); + if (typeof stack === "string") { + defineProperty(detached, "stack", { + configurable: true, + value: sanitizeWorkerDataModuleStack(stack, dataModuleDigest), + writable: true, + }); + } + } catch { + // The detached boundary snapshot remains safe when a stack is unreadable. + } + } + const snapshot = snapshotErrorForBoundary(detached); + const message = snapshot.slug === "unknown-error" + ? snapshot.detail ?? snapshot.message + : snapshot.message; + const sanitizeDataDiagnostic = (value: string): string => + sanitizeWorkerDataModuleStack(value, dataModuleDigest); + const sanitizeOptionalDataDiagnostic = ( + value: string | undefined, + ): string | undefined => value === undefined ? undefined : sanitizeDataDiagnostic(value); + + return { + message: sanitizeDataDiagnostic(sanitizeDiagnosticText(message)), + name: sanitizeDataDiagnostic( + sanitizeDiagnosticText( + sourceWasNativeTypeError ? "TypeError" : detached.name, + ), + ), + stack: sourceWasError && snapshot.stack !== undefined + ? sanitizeWorkerDataModuleStack(snapshot.stack, dataModuleDigest) + : undefined, + problem: { + slug: snapshot.slug, + category: snapshot.category, + status: snapshot.status, + title: sanitizeDataDiagnostic(snapshot.title), + suggestion: sanitizeOptionalDataDiagnostic(snapshot.suggestion), + detail: sanitizeOptionalDataDiagnostic(snapshot.detail), + cause: typeof snapshot.cause === "string" + ? sanitizeDataDiagnostic(snapshot.cause) + : undefined, + instance: sanitizeOptionalDataDiagnostic(snapshot.instance), + }, + }; } // --------------------------------------------------------------------------- @@ -194,100 +2124,343 @@ export function serializeError(error: unknown): SerializedError { // --------------------------------------------------------------------------- const moduleCache = new Map>(); +const preparedModuleCache = new Map< + string, + Promise> +>(); +const retainedPreparedModuleIdentities = new Set(); +const preparedModuleFailureCauses = new WeakMap(); +let retainedPreparedModuleSourceBytes = 0; +const WORKER_MODULE_CAPACITY_ERROR = new NativeError( + "Worker prepared-module retention capacity exceeded", +); +const WORKER_SSR_OUTPUT_BYTE_LIMIT_ERROR = new NativeError( + "Isolated SSR output exceeded its byte boundary", +); +const WORKER_SSR_OUTPUT_CHUNK_LIMIT_ERROR = new NativeError( + "Isolated SSR output exceeded its chunk boundary", +); + +function closeForSSRProtocolViolation(): void { + try { + closeWorkerProcess?.(); + } catch { + // Closing the worker is the only safe recovery from a framework-wire fault. + } +} + +function waitForStreamCredit( + execution: SSRExecutionContext, + sequence: number, +): Promise { + if (apply(mapGet, streamCreditWaiters, [execution.token]) !== undefined) { + throw new NativeError("Duplicate isolated SSR stream credit waiter"); + } + return new NativePromise((resolve) => { + apply(mapSet, streamCreditWaiters, [ + execution.token, + { + id: execution.id, + generation: execution.generation, + token: execution.token, + sequence, + resolve, + } satisfies StreamCreditWaiter, + ]); + }); +} + +function discardStreamCredit(token: string): void { + apply(mapDelete, streamCreditWaiters, [token]); +} + +function acceptWorkerStreamCredit(credit: WorkerStreamCredit): void { + const waiter = apply(mapGet, streamCreditWaiters, [credit.token]) as + | StreamCreditWaiter + | undefined; + if ( + !waiter || + credit.id !== waiter.id || + credit.generation !== waiter.generation || + credit.token !== waiter.token || + credit.sequence !== waiter.sequence + ) { + closeForSSRProtocolViolation(); + return; + } + apply(mapDelete, streamCreditWaiters, [credit.token]); + waiter.resolve(); +} +function wrapPreparedModuleFailure( + cause: unknown, + digest: string, +): Error { + const diagnostic = sanitizeWorkerDataModuleStack( + snapshotThrowableDiagnostic(cause), + digest, + ); + const error = new NativeError( + diagnostic + ? `Prepared API route module import failed: ${diagnostic}` + : "Prepared API route module import failed", + ); + const causeStack = readNativeErrorStack(cause); + if (causeStack !== undefined) { + defineProperty(error, "stack", { + configurable: true, + value: sanitizeWorkerDataModuleStack(causeStack, digest), + writable: true, + }); + } + apply(weakMapSet, preparedModuleFailureCauses, [error, { cause }]); + return error; +} + +function preparedModuleFailureCause(error: unknown): { + failed: boolean; + cause: unknown; +} { + if (!isNativeErrorWithoutHooks(error)) { + return { failed: false, cause: undefined }; + } + const record = apply(weakMapGet, preparedModuleFailureCauses, [error]) as + | { cause: unknown } + | undefined; + return record === undefined + ? { failed: false, cause: undefined } + : { failed: true, cause: record.cause }; +} + +async function sha256Hex(bytes: Uint8Array): Promise { + const digest = await digestBytes("SHA-256", bytes as BufferSource); + return apply(bytesToHex, new NativeUint8Array(digest), []) as string; +} export async function loadModule(modulePath: string): Promise> { - const cached = moduleCache.get(modulePath); + const cached = apply(mapGet, moduleCache, [modulePath]) as + | Record + | undefined; if (cached) return cached; const mod = await import(`file://${modulePath}`) as Record; - moduleCache.set(modulePath, mod); + apply(mapSet, moduleCache, [modulePath, mod]); return mod; } -export function clearModuleCache(): void { - moduleCache.clear(); +function compareStrings(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; } -function getMethodExport(mod: Record, method: string): unknown { - switch (method.toUpperCase()) { - case "DELETE": - return mod.DELETE; - case "GET": - return mod.GET; - case "HEAD": - return mod.HEAD; - case "OPTIONS": - return mod.OPTIONS; - case "PATCH": - return mod.PATCH; - case "POST": - return mod.POST; - case "PUT": - return mod.PUT; - default: - return undefined; - } +function sortedOwnKeys(record: Record): string[] { + const keys = objectKeys(record); + apply(arraySort, keys, [compareStrings]); + return keys; } -// --------------------------------------------------------------------------- -// Project Env Overlay -// --------------------------------------------------------------------------- +function appendIdentityField(identity: string, value: string): string { + return `${identity}${value.length}:${value};`; +} -async function withProjectEnv( +function buildModuleSemanticIdentity( + policy: SourceIntegrationPolicyManifest, env: Record | undefined, - operation: () => Promise, -): Promise { - if (!env) return await operation(); +): string { + let identity = "policy;"; + identity = appendIdentityField(identity, policy.mode); + + if (policy.mode === "allowlist") { + const integrationNames = sortedOwnKeys(policy.integrations); + for (let index = 0; index < integrationNames.length; index++) { + const integrationName = integrationNames[index]!; + identity = appendIdentityField(identity, integrationName); + const allowedToolIds = policy.integrations[integrationName]!.allowedToolIds; + if (allowedToolIds === null) { + identity += "all;"; + continue; + } + const sortedToolIds = new NativeArray(allowedToolIds.length); + for (let toolIndex = 0; toolIndex < allowedToolIds.length; toolIndex++) { + defineDataProperty( + sortedToolIds, + NativeString(toolIndex), + allowedToolIds[toolIndex], + ); + } + apply(arraySort, sortedToolIds, [compareStrings]); + identity += "tools;"; + for (let toolIndex = 0; toolIndex < sortedToolIds.length; toolIndex++) { + identity = appendIdentityField(identity, sortedToolIds[toolIndex]!); + } + } + } + + identity += "env;"; + if (env) { + const envKeys = sortedOwnKeys(env); + for (let index = 0; index < envKeys.length; index++) { + const key = envKeys[index]!; + identity = appendIdentityField(identity, key); + identity = appendIdentityField(identity, env[key]!); + } + } + return identity; +} + +function reservePreparedModuleIdentity( + cacheKey: string, + sourceBytes: number, +): void { + if (apply(setHas, retainedPreparedModuleIdentities, [cacheKey])) return; + if (!setSizeGetter) throw WORKER_MODULE_CAPACITY_ERROR; + + const entryCount = apply( + setSizeGetter, + retainedPreparedModuleIdentities, + [], + ) as number; + if ( + entryCount >= MAX_WORKER_RETAINED_MODULES || + sourceBytes > + MAX_WORKER_RETAINED_MODULE_SOURCE_BYTES - + retainedPreparedModuleSourceBytes + ) { + throw WORKER_MODULE_CAPACITY_ERROR; + } + + apply(setAdd, retainedPreparedModuleIdentities, [cacheKey]); + retainedPreparedModuleSourceBytes += sourceBytes; +} - const previousValues = new Map(); - for (const [key, value] of Object.entries(env)) { - previousValues.set(key, Deno.env.get(key)); - Deno.env.set(key, value); +function snapshotResolvedRouteMethods( + methods: unknown, + allowEmpty: boolean, +): string[] { + const input = requireDenseArray( + methods, + "route methods", + MAX_WORKER_ROUTE_METHOD_COUNT, + ); + if (!allowEmpty && input.length === 0) { + throw new NativeTypeError( + "Prepared API route module has no callable route export", + ); + } + + const output = new NativeArray(input.length); + for (let index = 0; index < input.length; index++) { + const method = requireString( + arrayElement(input, index, "route methods"), + "route method", + 64, + false, + ); + if (!matches(CANONICAL_ROUTE_METHOD_PATTERN, method)) { + return invalidWorkerRequest("route method"); + } + defineDataProperty(output, NativeString(index), method); + } + return output; +} + +function validatePreparedRouteModule( + module: Record, +): Record { + snapshotResolvedRouteMethods( + resolveExecutableRouteMethods( + module, + undefined, + { includeFrameworkOptions: false }, + ), + false, + ); + return module; +} + +interface PreparedModuleLoadOptions { + logicalModuleId: string; + sourceIntegrationPolicy: SourceIntegrationPolicyManifest; + projectEnv?: Record; +} + +/** + * Rehash, content-address, import, and validate one host-prepared API module. + * + * The ESM identity includes logical route, source, and top-level semantic + * context. No raw path, source, policy, or env value appears in the URL. + */ +export async function loadPreparedModule( + value: PreparedWorkerModule, + options: PreparedModuleLoadOptions, +): Promise> { + const prepared = snapshotPreparedWorkerModule(value); + const logicalModuleId = requireString( + options.logicalModuleId, + "modulePath", + MAX_WORKER_PATH_CHARS, + false, + ); + const policy = snapshotSourceIntegrationPolicy(options.sourceIntegrationPolicy); + const env = snapshotProjectEnv(options.projectEnv); + const semanticIdentity = buildModuleSemanticIdentity(policy, env); + const sourceBytes = encodeUtf8(prepared.source); + const sourceByteLength = byteLengthOf(sourceBytes); + + const actualDigest = await sha256Hex(sourceBytes); + if (actualDigest !== prepared.sha256) { + throw new NativeTypeError("Prepared API route module digest mismatch"); } - try { - return await operation(); - } finally { - for (const [key, value] of previousValues) { - if (value === undefined) { - Deno.env.delete(key); - } else { - Deno.env.set(key, value); - } + const logicalModuleHash = await sha256Hex(encodeUtf8(logicalModuleId)); + const semanticContextHash = await sha256Hex(encodeUtf8(semanticIdentity)); + const cacheKey = `${logicalModuleHash}:${semanticContextHash}:${prepared.sha256}`; + const cached = apply(mapGet, preparedModuleCache, [cacheKey]) as + | Promise> + | undefined; + if (cached) return await cached; + + reservePreparedModuleIdentity(cacheKey, sourceByteLength); + const encodedSource = apply(bytesToBase64, sourceBytes, []) as string; + const moduleUrl = `data:text/javascript;base64,${encodedSource}#vf-route=${logicalModuleHash}` + + `&vf-context=${semanticContextHash}&sha256=${prepared.sha256}`; + const pending = (async () => { + try { + const module = await import(moduleUrl) as Record; + return validatePreparedRouteModule(module); + } catch (error) { + throw wrapPreparedModuleFailure(error, prepared.sha256); } - } + })(); + apply(mapSet, preparedModuleCache, [cacheKey, pending]); + return await pending; +} + +/** @internal Read-only retention counters for deterministic capacity tests. */ +export function getPreparedModuleRetentionStats(): { + entries: number; + sourceBytes: number; +} { + const entries = setSizeGetter + ? apply(setSizeGetter, retainedPreparedModuleIdentities, []) as number + : 0; + return { entries, sourceBytes: retainedPreparedModuleSourceBytes }; } // --------------------------------------------------------------------------- -// Agent Discovery (per-project, cached per worker lifetime) +// Request-owned Project Env // --------------------------------------------------------------------------- -let discoveredProjectDir: string | null = null; - -async function ensureAgentDiscovery(projectDir: string): Promise { - if (discoveredProjectDir === projectDir) return; - - try { - const { discoverAll } = await import( - "#veryfront/discovery/discovery-engine.ts" - ); - const { agentRegistry } = await import( - "#veryfront/agent/composition/composition.ts" - ); - - agentRegistry.clear(); - - await discoverAll({ - baseDir: projectDir, - verbose: false, - }); - - discoveredProjectDir = projectDir; - } catch { - // Discovery may fail in some environments — route handler will - // return its own error (e.g. "Agent not found") which the main - // process fallback handles. +function createRequestProjectEnv( + env: Record | undefined, +): Readonly> { + const output = createNullPrototypeRecord(); + if (env) { + const entries = apply(objectEntries, Object, [env]) as [string, string][]; + for (let index = 0; index < entries.length; index++) { + const [key, value] = entries[index]!; + defineDataProperty(output, key, value); + } } + return freezeObject(output); } // --------------------------------------------------------------------------- @@ -295,44 +2468,49 @@ async function ensureAgentDiscovery(projectDir: string): Promise { // --------------------------------------------------------------------------- function runWithWorkerSourceIntegrationPolicy( - policy: unknown, + policy: SourceIntegrationPolicyManifest, fn: () => T, ): T { - return runWithExactSourceIntegrationPolicy( - parseSourceIntegrationPolicyManifest(policy), - fn, - ); + return runWithExactSourceIntegrationPolicy(policy, fn); } async function handleAppRoute(req: ExecuteAppRouteRequest): Promise { return await runWithWorkerSourceIntegrationPolicy( req.sourceIntegrationPolicy, - () => - withProjectEnv(req.projectEnv, async () => { - await ensureAgentDiscovery(req.projectDir); - const mod = await loadModule(req.modulePath); - - const handlerFn = (getMethodExport(mod, req.method) ?? mod.default) as - | (( - request: Request, - context: { params: Record }, - ) => Promise | Response) - | undefined; - - if (!handlerFn) { - return { - status: 405, - statusText: "Method Not Allowed", - headers: [["content-type", "application/json"]], - body: encoder.encode(JSON.stringify({ error: "Method not allowed" })), - }; - } + async () => { + const env = createRequestProjectEnv(req.projectEnv); + const mod = await loadPreparedModule(req.module, { + logicalModuleId: req.modulePath, + sourceIntegrationPolicy: req.sourceIntegrationPolicy, + projectEnv: req.projectEnv, + }); + + const handlerFn = resolveRouteHandlerExport(mod, req.method) as + | (( + request: Request, + context: { + params: Record; + env: Readonly>; + }, + ) => Promise | unknown) + | undefined; - const response = await handlerFn(deserializeRequest(req.request), { - params: req.params ?? {}, - }); - return serializeResponse(response); - }), + if (!handlerFn) { + return serializeResponse( + createAppRouteMethodNotAllowed(mod), + req.method, + ); + } + + const pendingResponse = handlerFn(deserializeRequest(req.request), { + params: req.params ?? {}, + env, + }); + const response = isTrustedRouteResponsePromise(pendingResponse) + ? await pendingResponse + : pendingResponse; + return serializeResponse(response, req.method); + }, ); } @@ -344,16 +2522,16 @@ function deserializeDataContext( request: Request; url: URL; } { - const request = new Request(s.request.url, { + const request = new NativeRequest(s.request.url, { method: s.request.method, headers: s.request.headers, body: s.request.body as BodyInit | null, }); return { params: s.params, - query: new URLSearchParams(s.query), + query: new NativeURLSearchParams(s.query), request, - url: new URL(s.url), + url: new NativeURL(s.url), }; } @@ -393,12 +2571,9 @@ async function handleFetchData(req: FetchDataRequest): Promise { return await runWithWorkerSourceIntegrationPolicy( req.sourceIntegrationPolicy, - () => - withProjectEnv(req.projectEnv, async () => { - await ensureAgentDiscovery(req.projectDir); - const mod = await loadModule(req.modulePath); + async () => { + const env = createRequestProjectEnv(req.projectEnv); + const mod = await loadPreparedModule(req.module, { + logicalModuleId: req.modulePath, + sourceIntegrationPolicy: req.sourceIntegrationPolicy, + projectEnv: req.projectEnv, + }); + + const handlerFn = resolveRouteHandlerExport(mod, req.method) as + | ((ctx: unknown) => Promise | unknown) + | undefined; - const handlerFn = (getMethodExport(mod, req.method) ?? mod.default) as - | ((ctx: unknown) => Promise | Response) - | undefined; + if (!handlerFn) { + return serializeResponse( + createPagesRouteMethodNotAllowed(mod), + req.method, + ); + } - if (!handlerFn) { + const { request, params, cookies } = deserializePagesRequest(req.context); + const url = new NativeURL(request.url); + + // Build a minimal read-only fs adapter scoped to the project directory. + // Every path is validated against the project root before it reaches a + // Deno API so user route handlers cannot read arbitrary host files. + const assertContained = makeProjectPathGuard(req.projectDir); + const workerFs = { + readTextFile: async (path: string) => denoReadTextFile(await assertContained(path)), + readFile: async (path: string) => denoReadFile(await assertContained(path)), + exists: async (path: string) => { + try { + await denoStat(await assertContained(path)); + return true; + } catch (error) { + if (isNativeNotFound(error)) return false; + throw error; + } + }, + stat: async (path: string) => { + const info = await denoStat(await assertContained(path)); return { - status: 405, - statusText: "Method Not Allowed", - headers: [["content-type", "application/json"]], - body: encoder.encode(JSON.stringify({ error: "Method not allowed" })), + isFile: info.isFile, + isDirectory: info.isDirectory, + isSymlink: info.isSymlink, + size: info.size, + mtime: info.mtime, }; - } + }, + readDir: async function* (path: string) { + const safePath = await assertContained(path); + for await (const entry of denoReadDir(safePath)) { + yield { name: entry.name, isFile: entry.isFile, isDirectory: entry.isDirectory }; + } + }, + }; - const { request, params, cookies } = deserializePagesRequest(req.context); - const url = new URL(request.url); - - // Build a minimal read-only fs adapter scoped to the project directory. - // Every path is validated against the project root before it reaches a - // Deno API so user route handlers cannot read arbitrary host files. - const assertContained = makeProjectPathGuard(req.projectDir); - const workerFs = { - readTextFile: async (path: string) => Deno.readTextFile(await assertContained(path)), - readFile: async (path: string) => Deno.readFile(await assertContained(path)), - exists: async (path: string) => { - try { - await Deno.stat(await assertContained(path)); - return true; - } catch { - return false; - } - }, - stat: async (path: string) => { - const info = await Deno.stat(await assertContained(path)); - return { - isFile: info.isFile, - isDirectory: info.isDirectory, - isSymlink: info.isSymlink, - size: info.size, - mtime: info.mtime, - }; - }, - readDir: async function* (path: string) { - const safePath = await assertContained(path); - for await (const entry of Deno.readDir(safePath)) { - yield { name: entry.name, isFile: entry.isFile, isDirectory: entry.isDirectory }; - } - }, - }; - - // Build a minimal APIContext (subset of the full context) - const ctx = { - request, - req: request, - params, - query: url.searchParams, - cookies, - headers: request.headers, - url, - // The same helpers the in-process context uses, so a handler behaves - // the same whether or not isolation is enabled. - json: createJsonHelper(request), - body: createBodyReader(request), - text: createTextHelper(), - fs: workerFs, - }; - - const response = await handlerFn(ctx); - return serializeResponse(response); - }), + // Build a minimal APIContext (subset of the full context) + const ctx = { + request, + req: request, + params, + query: url.searchParams, + cookies, + headers: request.headers, + url, + // The same helpers the in-process context uses, so a handler behaves + // the same whether or not isolation is enabled. + json: createWorkerJsonResponse, + body: createBodyReader(request), + text: createWorkerTextResponse, + fs: workerFs, + env, + }; + + const pendingResponse = handlerFn(ctx); + const response = isTrustedRouteResponsePromise(pendingResponse) + ? await pendingResponse + : pendingResponse; + return serializeResponse(response, req.method); + }, + ); +} + +async function handleInspectApiRouteMethods( + req: InspectApiRouteMethodsRequest, +): Promise { + return await runWithWorkerSourceIntegrationPolicy( + req.sourceIntegrationPolicy, + async () => { + const mod = await loadPreparedModule(req.module, { + logicalModuleId: req.modulePath, + sourceIntegrationPolicy: req.sourceIntegrationPolicy, + projectEnv: req.projectEnv, + }); + return snapshotResolvedRouteMethods( + resolveExecutableRouteMethods(mod, req.requestedMethod), + false, + ); + }, + ); +} + +// --------------------------------------------------------------------------- +// SSR Rendering Handler +// --------------------------------------------------------------------------- + +/** + * Handle SSR rendering in the isolated Worker. + * + * Imports the page + layout components from their temp file paths, + * constructs an extension-owned element tree (layouts wrapping page), and + * renders bounded HTML output. For streaming, sends chunks via postMessage. + * + * The Worker gets its own renderer instance; framework core never imports or + * shares a renderer implementation across the host boundary. + */ +async function handleRenderSSR( + req: RenderSSRRequest, + execution: SSRExecutionContext, +): Promise { + return await runWithWorkerSourceIntegrationPolicy( + req.sourceIntegrationPolicy, + async () => await renderSSR(req, execution), ); } +interface FixedUint8View { + readonly buffer: ArrayBuffer; + readonly byteOffset: number; + readonly byteLength: number; +} + +function inspectFixedUint8View(value: unknown): FixedUint8View { + if ( + value === null || + typeof value !== "object" || + isProxy(value) || + getPrototypeOf(value) !== uint8ArrayPrototype || + !typedArrayBufferGetter || + !typedArrayByteLengthGetter || + !typedArrayByteOffsetGetter || + !arrayBufferByteLengthGetter + ) { + throw new NativeTypeError("SSR renderer emitted a non-native byte chunk"); + } + + const buffer = apply(typedArrayBufferGetter, value, []) as unknown; + if ( + buffer === null || + typeof buffer !== "object" || + getPrototypeOf(buffer) !== arrayBufferPrototype + ) { + throw new NativeTypeError("SSR renderer emitted a shared byte chunk"); + } + if ( + arrayBufferResizableGetter && + apply(arrayBufferResizableGetter, buffer, []) === true + ) { + throw new NativeTypeError("SSR renderer emitted a resizable byte chunk"); + } + + const byteOffset = apply(typedArrayByteOffsetGetter, value, []) as number; + const byteLength = apply(typedArrayByteLengthGetter, value, []) as number; + const bufferByteLength = apply(arrayBufferByteLengthGetter, buffer, []) as number; + if ( + !numberIsSafeInteger(byteOffset) || + !numberIsSafeInteger(byteLength) || + byteOffset < 0 || + byteLength < 0 || + byteOffset > bufferByteLength || + byteLength > bufferByteLength - byteOffset + ) { + throw new NativeTypeError("SSR renderer emitted an invalid byte view"); + } + return { + buffer: buffer as ArrayBuffer, + byteOffset, + byteLength, + }; +} + +function copyTightSSRFrame( + source: FixedUint8View, + relativeOffset: number, + byteLength: number, +): Uint8Array { + if ( + byteLength <= 0 || + byteLength > MAX_WORKER_SSR_CHUNK_BYTES || + relativeOffset < 0 || + relativeOffset > source.byteLength || + byteLength > source.byteLength - relativeOffset + ) { + throw new NativeTypeError("Invalid isolated SSR frame slice"); + } + const sourceSlice = new NativeUint8Array( + source.buffer, + source.byteOffset + relativeOffset, + byteLength, + ); + const frame = new NativeUint8Array(byteLength); + apply(setBytes, frame, [sourceSlice]); + + const frameBuffer = typedArrayBufferGetter + ? apply(typedArrayBufferGetter, frame, []) as ArrayBuffer + : undefined; + const frameOffset = typedArrayByteOffsetGetter + ? apply(typedArrayByteOffsetGetter, frame, []) as number + : -1; + const frameBufferBytes = frameBuffer && arrayBufferByteLengthGetter + ? apply(arrayBufferByteLengthGetter, frameBuffer, []) as number + : -1; + if ( + !frameBuffer || + getPrototypeOf(frameBuffer) !== arrayBufferPrototype || + frameOffset !== 0 || + frameBufferBytes !== byteLength || + (arrayBufferResizableGetter && + apply(arrayBufferResizableGetter, frameBuffer, []) === true) + ) { + throw new NativeError("Unable to allocate a fixed isolated SSR frame"); + } + return frame; +} + +async function sendStreamFrame( + execution: SSRExecutionContext, + frame: Uint8Array, +): Promise { + const sequence = execution.sequence; + const nextSequence = sequence + 1; + const credit = waitForStreamCredit(execution, nextSequence); + try { + const buffer = apply(typedArrayBufferGetter!, frame, []) as ArrayBuffer; + const message: WorkerStreamFrame = { + type: "stream-frame", + id: execution.id, + generation: execution.generation, + token: execution.token, + sequence, + chunk: frame, + }; + sendControlMessage(message, [buffer]); + execution.sequence = nextSequence; + } catch (error) { + discardStreamCredit(execution.token); + throw error; + } + await credit; +} + +/** + * Bound framework-owned SSR retention after the renderer yields each source chunk. + * + * This worker shares a process with the renderer and project code. Either may + * therefore allocate a large value before the framework can observe, + * split, account, cancel, or release it. A hard pre-allocation memory boundary + * requires process/container isolation rather than a same-process Worker. + */ +async function consumeSSRByteStream( + stream: ReadableStream, + onFrame: (frame: Uint8Array) => Promise | void, +): Promise { + const reader = apply( + readableStreamGetReader, + stream, + [], + ) as ReadableStreamDefaultReader; + let completed = false; + let outputBytes = 0; + let outputFrames = 0; + let sourceChunks = 0; + + try { + while (true) { + const { done, value } = await apply( + readableStreamReaderRead, + reader, + [], + ) as ReadableStreamReadResult; + if (done) { + completed = true; + return outputBytes; + } + + sourceChunks += 1; + if (sourceChunks > MAX_WORKER_SSR_OUTPUT_CHUNKS) { + throw WORKER_SSR_OUTPUT_CHUNK_LIMIT_ERROR; + } + const source = inspectFixedUint8View(value); + if (source.byteLength > MAX_WORKER_SSR_OUTPUT_BYTES - outputBytes) { + throw WORKER_SSR_OUTPUT_BYTE_LIMIT_ERROR; + } + outputBytes += source.byteLength; + + let offset = 0; + while (offset < source.byteLength) { + outputFrames += 1; + if (outputFrames > MAX_WORKER_SSR_OUTPUT_CHUNKS) { + throw WORKER_SSR_OUTPUT_CHUNK_LIMIT_ERROR; + } + const frameBytes = Math.min( + MAX_WORKER_SSR_CHUNK_BYTES, + source.byteLength - offset, + ); + const frame = copyTightSSRFrame(source, offset, frameBytes); + await onFrame(frame); + offset += frameBytes; + } + } + } finally { + if (!completed) { + try { + await (apply( + readableStreamReaderCancel, + reader, + ["Isolated SSR rendering stopped"], + ) as Promise); + } catch { + // The worker request failure remains authoritative. + } + } + try { + apply(readableStreamReaderReleaseLock, reader, []); + } catch { + // The worker request failure remains authoritative. + } + } +} + +async function renderSSR( + req: RenderSSRRequest, + execution: SSRExecutionContext, +): Promise { + assertIsolatedSsrDependencySnapshotSupported(req); + const renderer = await getIsolatedSsrRenderer(); + const createElement = renderer.createElement; + const renderToReadableStream = renderer.renderToReadableStream; + + // Import the page component + const pageMod = await loadModule(req.pageModulePath); + const PageComponent = pageMod.default ?? pageMod; + + // Import layout components (innermost → outermost order) + const layoutComponents = new NativeArray(req.layoutModulePaths.length); + for (let index = 0; index < req.layoutModulePaths.length; index++) { + const layoutPath = req.layoutModulePaths[index]!; + const layoutMod = await loadModule(layoutPath); + defineDataProperty( + layoutComponents, + NativeString(index), + layoutMod.default ?? layoutMod, + ); + } + + // Build element tree: page is innermost, layouts wrap outward + let element: unknown = createElement(PageComponent, req.pageProps); + + for (let i = 0; i < layoutComponents.length; i++) { + const Layout = layoutComponents[i]; + const layoutProps = req.layoutProps[i] ?? {}; + element = createElement(Layout, layoutProps, element); + } + + const stream = await renderToReadableStream(element); + if (execution.delivery === "stream") { + await consumeSSRByteStream( + stream, + async (frame) => await sendStreamFrame(execution, frame), + ); + return null; + } + + const frames = new NativeArray(); + const outputBytes = await consumeSSRByteStream(stream, (frame) => { + apply(arrayPush, frames, [frame]); + }); + const collected = new NativeUint8Array(outputBytes); + let offset = 0; + for (let index = 0; index < frames.length; index++) { + const frame = frames[index]!; + apply(setBytes, collected, [frame, offset]); + offset += byteLengthOf(frame); + } + return apply(decodeText, textDecoder, [collected]) as string; +} + // --------------------------------------------------------------------------- // Message Handler // --------------------------------------------------------------------------- -async function processWorkerRequest(request: WorkerRequest): Promise { +function claimQueuedSSRExecution( + request: RenderSSRRequest, +): SSRExecutionContext | null { + const execution = pendingSSRExecutionOpen; + if ( + !execution || + execution.id !== request.id || + execution.delivery !== request.delivery || + execution.generation !== workerWireGeneration + ) { + closeForSSRProtocolViolation(); + return null; + } + pendingSSRExecutionOpen = null; + return execution; +} + +async function processSSRWorkerRequest( + request: RenderSSRRequest, + execution: SSRExecutionContext, +): Promise { + if (apply(mapGet, activeSSRExecutions, [request.id]) !== undefined) { + closeForSSRProtocolViolation(); + return; + } + apply(mapSet, activeSSRExecutions, [request.id, execution]); + + try { + if (!egressInitialized) { + throw new NativeError("Worker egress guard is not initialized"); + } + const html = await handleRenderSSR(request, execution); + if (execution.delivery === "stream") { + const end: WorkerStreamEnd = { + type: "stream-end", + id: execution.id, + generation: execution.generation, + token: execution.token, + sequence: execution.sequence, + }; + sendControlMessage(end); + } else { + if (html === null || execution.sequence !== 0) { + throw new NativeError("Invalid isolated SSR string rendering state"); + } + const result: WorkerSSRWireResult = { + type: "ssr-wire-result", + id: execution.id, + generation: execution.generation, + token: execution.token, + sequence: 0, + html, + }; + sendControlMessage(result); + } + } catch (error) { + if ( + error === WORKER_SSR_OUTPUT_BYTE_LIMIT_ERROR || + error === WORKER_SSR_OUTPUT_CHUNK_LIMIT_ERROR + ) { + const outputLimit: WorkerSSROutputLimit = { + type: "ssr-output-limit", + id: execution.id, + generation: execution.generation, + token: execution.token, + sequence: execution.sequence, + limit: error === WORKER_SSR_OUTPUT_BYTE_LIMIT_ERROR ? "bytes" : "chunks", + }; + sendControlMessage(outputLimit); + } else { + const failure: WorkerSSRWireError = { + type: "ssr-wire-error", + id: execution.id, + generation: execution.generation, + token: execution.token, + sequence: execution.sequence, + error: serializeError(error), + }; + sendControlMessage(failure); + } + } finally { + discardStreamCredit(execution.token); + const active = apply(mapGet, activeSSRExecutions, [ + execution.id, + ]) as SSRExecutionContext | undefined; + if (active === execution) { + apply(mapDelete, activeSSRExecutions, [execution.id]); + } + } +} + +async function processWorkerRequest( + request: WorkerRequest, + ssrExecution?: SSRExecutionContext, +): Promise { + if (request.type === "render-ssr") { + if (!ssrExecution) { + closeForSSRProtocolViolation(); + return; + } + await processSSRWorkerRequest(request, ssrExecution); + return; + } + try { if (!egressInitialized) { - throw new Error("Worker egress guard is not initialized"); + throw new NativeError("Worker egress guard is not initialized"); } // Data fetcher returns a different response shape than HTTP handlers @@ -501,7 +3089,17 @@ async function processWorkerRequest(request: WorkerRequest): Promise { id: request.id, result: dataResult, }; - self.postMessage(response); + sendControlMessage(response); + return; + } + + if (request.type === "inspect-api-route-methods") { + const response: WorkerRouteMethodsResponse = { + type: "api-route-methods", + id: request.id, + methods: await handleInspectApiRouteMethods(request), + }; + sendControlMessage(response); return; } @@ -515,7 +3113,7 @@ async function processWorkerRequest(request: WorkerRequest): Promise { serializedResponse = await handlePagesRoute(request); break; default: - throw new Error(`Unknown request type: ${(request as { type: string }).type}`); + throw new NativeError("Unknown worker request type"); } const result: WorkerResultResponse = { @@ -523,67 +3121,454 @@ async function processWorkerRequest(request: WorkerRequest): Promise { id: request.id, response: serializedResponse, }; - self.postMessage(result); + sendControlMessage(result); } catch (error) { + if (error === WORKER_MODULE_CAPACITY_ERROR) { + const capacityResponse: WorkerPreparedModuleCapacityResponse = { + type: "prepared-module-capacity", + id: request.id, + }; + sendControlMessage(capacityResponse); + return; + } + + const dataModuleDigest = request.type === "execute-app-route" || + request.type === "execute-pages-route" || + request.type === "inspect-api-route-methods" + ? request.module.sha256 + : undefined; + const preparedFailure = preparedModuleFailureCause(error); const errorResponse: WorkerErrorResponse = { type: "error", id: request.id, - error: serializeError(error), + error: serializeError( + preparedFailure.failed ? preparedFailure.cause : error, + dataModuleDigest, + ), }; - self.postMessage(errorResponse); + sendControlMessage(errorResponse); + if (preparedFailure.failed) { + closeWorkerProcess?.(); + } } } let requestQueue: Promise = Promise.resolve(); -function handleWorkerMessage( - event: MessageEvent< - | WorkerRequest - | InitializeEgressMessage - | { type: "ping"; id: string } - | { type: "clear-cache" } - >, +function snapshotControlMessageId(value: unknown): string { + if ( + value === null || + typeof value !== "object" || + isProxy(value) || + isArray(value) + ) { + return ""; + } + const descriptor = getOwnPropertyDescriptor(value, "id"); + return descriptor && "value" in descriptor && + typeof descriptor.value === "string" && + descriptor.value.length > 0 && + descriptor.value.length <= MAX_WORKER_REQUEST_ID_CHARS + ? descriptor.value + : ""; +} + +function enqueueWorkerRequest( + request: WorkerRequest, + ssrExecution?: SSRExecutionContext, +): void { + // Project code may mutate Promise.prototype after its first import. Invoke + // the captured intrinsic directly so the serialized env overlay queue + // remains framework-owned. + requestQueue = apply(promiseThen, requestQueue, [ + () => processWorkerRequest(request, ssrExecution), + () => processWorkerRequest(request, ssrExecution), + ]) as Promise; +} + +function snapshotSSRExecutionOpen(message: unknown): WorkerSSRExecutionOpen { + const cloned = cloneStructuredValue(message); + const open = requireRecordShape( + cloned, + ["type", "id", "generation", "token", "delivery"], + [], + "SSR execution open", + ); + if (readDataProperty(open, "type") !== "ssr-execution-open") { + return invalidWorkerRequest("type"); + } + const delivery = readDataProperty(open, "delivery"); + if (delivery !== "string" && delivery !== "stream") { + return invalidWorkerRequest("delivery"); + } + return { + type: "ssr-execution-open", + id: requireString( + readDataProperty(open, "id"), + "id", + MAX_WORKER_REQUEST_ID_CHARS, + false, + ), + generation: requireString( + readDataProperty(open, "generation"), + "generation", + MAX_SSR_WIRE_TOKEN_CHARS, + false, + ), + token: requireString( + readDataProperty(open, "token"), + "token", + MAX_SSR_WIRE_TOKEN_CHARS, + false, + ), + delivery, + }; +} + +function openSSRExecution(message: unknown): void { + const open = snapshotSSRExecutionOpen(message); + if ( + pendingSSRExecutionOpen !== null || + (workerWireGeneration !== null && + workerWireGeneration !== open.generation) || + apply(mapGet, activeSSRExecutions, [open.id]) !== undefined + ) { + closeForSSRProtocolViolation(); + return; + } + workerWireGeneration ??= open.generation; + pendingSSRExecutionOpen = { + id: open.id, + generation: open.generation, + token: open.token, + delivery: open.delivery, + sequence: 0, + }; +} + +function snapshotStreamCredit(message: unknown): WorkerStreamCredit { + const cloned = cloneStructuredValue(message); + const credit = requireRecordShape( + cloned, + ["type", "id", "generation", "token", "sequence"], + [], + "stream credit", + ); + if (readDataProperty(credit, "type") !== "stream-credit") { + return invalidWorkerRequest("type"); + } + const sequence = readDataProperty(credit, "sequence"); + if ( + typeof sequence !== "number" || + !numberIsSafeInteger(sequence) || + sequence < 1 || + sequence > MAX_WORKER_SSR_OUTPUT_CHUNKS + ) { + return invalidWorkerRequest("sequence"); + } + return { + type: "stream-credit", + id: requireString( + readDataProperty(credit, "id"), + "id", + MAX_WORKER_REQUEST_ID_CHARS, + false, + ), + generation: requireString( + readDataProperty(credit, "generation"), + "generation", + MAX_SSR_WIRE_TOKEN_CHARS, + false, + ), + token: requireString( + readDataProperty(credit, "token"), + "token", + MAX_SSR_WIRE_TOKEN_CHARS, + false, + ), + sequence, + }; +} + +function sendInvalidSSRRequestFailure( + message: unknown, + error: unknown, ): void { - // Dedicated workers receive host messages on a private channel. Deno marks - // those events as trusted, with an empty origin and no source object. Keep - // the listener private and reject synthetic events from project code before - // reading privileged messages such as the egress broker configuration. + const id = snapshotControlMessageId(message); + const execution = pendingSSRExecutionOpen; + if (!execution || execution.id !== id) { + closeForSSRProtocolViolation(); + return; + } + pendingSSRExecutionOpen = null; + sendControlMessage( + { + type: "ssr-wire-error", + id, + generation: execution.generation, + token: execution.token, + sequence: 0, + error: serializeError(error), + } satisfies WorkerSSRWireError, + ); +} + +function handleControlPortMessage(event: MessageEvent): void { + const port = workerControlPort; + if (!port || !isTrustedMessageEventFrom(event, port)) return; + + const message = readMessageEventData(event); + let messageType: unknown; + if ( + message !== null && + typeof message === "object" && + !isProxy(message) && + !isArray(message) + ) { + const descriptor = getOwnPropertyDescriptor(message, "type"); + messageType = descriptor && "value" in descriptor ? descriptor.value : undefined; + } + + // The host posts an SSR open and its request synchronously on one ordered + // channel. Pair them at admission so queued valid work does not consume a + // separate hard-coded "pending open" capacity. if ( - !event.isTrusted || event.origin !== "" || event.source !== null || - event.currentTarget !== self - ) return; + pendingSSRExecutionOpen !== null && + messageType !== "render-ssr" + ) { + closeForSSRProtocolViolation(); + return; + } - const msg = event.data; + if (messageType === "ping") { + try { + const cloned = cloneStructuredValue(message); + const ping = requireRecordShape(cloned, ["type", "id"], [], "ping"); + const id = requireString( + readDataProperty(ping, "id"), + "id", + MAX_WORKER_REQUEST_ID_CHARS, + false, + ); + sendControlMessage({ type: "pong", id }); + } catch (error) { + sendControlMessage( + { + type: "error", + id: snapshotControlMessageId(message), + error: serializeError(error), + } satisfies WorkerErrorResponse, + ); + } + return; + } - if (msg.type === "initialize-egress") { - if (!egressInitialized) { - installWorkerExitNotifier(); - installWorkerEgressGuard(msg.options); - egressInitialized = true; + if (messageType === "ssr-execution-open") { + try { + openSSRExecution(message); + } catch { + closeForSSRProtocolViolation(); } return; } - // Health check - if (msg.type === "ping") { - self.postMessage({ type: "pong", id: (msg as { id: string }).id }); + if (messageType === "stream-credit") { + try { + acceptWorkerStreamCredit(snapshotStreamCredit(message)); + } catch { + closeForSSRProtocolViolation(); + } return; } - // Module cache invalidation (for dev mode hot reload) - if (msg.type === "clear-cache") { - clearModuleCache(); + try { + const request = snapshotWorkerRequest(message); + if (request.type === "render-ssr") { + const execution = claimQueuedSSRExecution(request); + if (!execution) return; + enqueueWorkerRequest(request, execution); + } else { + if (pendingSSRExecutionOpen !== null) { + closeForSSRProtocolViolation(); + return; + } + enqueueWorkerRequest(request); + } + } catch (error) { + if (messageType === "render-ssr") { + sendInvalidSSRRequestFailure(message, error); + return; + } + sendControlMessage( + { + type: "error", + id: snapshotControlMessageId(message), + error: serializeError(error), + } satisfies WorkerErrorResponse, + ); + } +} + +function snapshotWorkerEgressSocksProxy( + value: unknown, +): WorkerEgressSocksProxyConfig { + const record = requireRecordShape( + value, + ["hostname", "port", "username", "password"], + [], + "bootstrap options socksProxy", + ); + const port = readDataProperty(record, "port"); + if ( + typeof port !== "number" || + !numberIsSafeInteger(port) || + port < 1 || + port > 65_535 + ) { + return invalidWorkerRequest("bootstrap options socksProxy"); + } + return { + hostname: requireString( + readDataProperty(record, "hostname"), + "bootstrap options socksProxy hostname", + MAX_WORKER_URL_CHARS, + false, + ), + port, + username: requireString( + readDataProperty(record, "username"), + "bootstrap options socksProxy username", + MAX_WORKER_VALUE_CHARS, + false, + ), + password: requireString( + readDataProperty(record, "password"), + "bootstrap options socksProxy password", + MAX_WORKER_VALUE_CHARS, + false, + ), + }; +} + +function snapshotWorkerEgressHttpBroker( + value: unknown, +): WorkerEgressHttpBrokerConfig { + const record = requireRecordShape( + value, + ["url", "token"], + [], + "bootstrap options httpBroker", + ); + return { + url: requireString( + readDataProperty(record, "url"), + "bootstrap options httpBroker url", + MAX_WORKER_URL_CHARS, + false, + ), + token: requireString( + readDataProperty(record, "token"), + "bootstrap options httpBroker token", + MAX_WORKER_VALUE_CHARS, + false, + ), + }; +} + +function snapshotWorkerEgressBootstrapOptions( + value: unknown, +): InstalledWorkerEgressGuardOptions { + const cloned = cloneStructuredValue(value); + const record = requireRecordShape( + cloned, + ["allowInternalEgress"], + ["socksProxy", "httpBroker"], + "bootstrap options", + ); + const allowInternalEgress = readDataProperty( + record, + "allowInternalEgress", + ); + if (typeof allowInternalEgress !== "boolean") { + return invalidWorkerRequest("bootstrap options allowInternalEgress"); + } + + const socksProxy = readOptionalDataProperty(record, "socksProxy"); + const httpBroker = readOptionalDataProperty(record, "httpBroker"); + return { + allowInternalEgress, + ...(socksProxy.present && socksProxy.value !== undefined + ? { socksProxy: snapshotWorkerEgressSocksProxy(socksProxy.value) } + : {}), + ...(httpBroker.present && httpBroker.value !== undefined + ? { httpBroker: snapshotWorkerEgressHttpBroker(httpBroker.value) } + : {}), + }; +} + +function handleWorkerBootstrapMessage( + event: MessageEvent< + InitializeEgressMessage + >, +): void { + if ( + egressInitialized || + !isTrustedMessageEventFrom(event, self) + ) { return; } - const request = msg as WorkerRequest; - // User code runs in the worker process and may read process-global state such - // as Deno.env. Keep requests non-overlapping so per-request env overlays - // cannot bleed across async handlers in the same pooled worker. - requestQueue = requestQueue.then( - () => processWorkerRequest(request), - () => processWorkerRequest(request), + const message = readMessageEventData(event); + const bootstrap = requireRecordShape( + message, + ["type", "options", "controlPort"], + ["rendererModuleUrl"], + "bootstrap", + ); + if (readDataProperty(bootstrap, "type") !== "initialize-egress") return; + + const port = readDataProperty(bootstrap, "controlPort"); + if (!(port instanceof NativeMessagePort)) { + throw new NativeTypeError("Invalid worker control port"); + } + const rendererModuleUrlProperty = readOptionalDataProperty( + bootstrap, + "rendererModuleUrl", + ); + isolatedSsrRendererModuleUrl = rendererModuleUrlProperty.present + ? validateIsolatedSsrRendererModuleUrl(rendererModuleUrlProperty.value) + : null; + + workerControlPort = port; + postControlPortMessage = ( + payload: unknown, + transfer?: readonly Transferable[], + ): void => { + apply( + messagePortPostMessage, + port, + transfer === undefined ? [payload] : [payload, transfer], + ); + }; + installWorkerExitNotifier(); + const options = snapshotWorkerEgressBootstrapOptions( + readDataProperty(bootstrap, "options"), ); + apply(eventTargetAddEventListener, port, [ + "message", + handleControlPortMessage as EventListener, + ]); + apply(messagePortStart, port, []); + apply(eventTargetRemoveEventListener, self, [ + "message", + handleWorkerBootstrapMessage as EventListener, + ]); + + installWorkerEgressGuard(options); + egressInitialized = true; } -self.addEventListener("message", handleWorkerMessage); +apply(eventTargetAddEventListener, self, [ + "message", + handleWorkerBootstrapMessage as EventListener, +]); diff --git a/src/security/sandbox/worker-types.ts b/src/security/sandbox/worker-types.ts index 6e4ff0297a..7d81aaebda 100644 --- a/src/security/sandbox/worker-types.ts +++ b/src/security/sandbox/worker-types.ts @@ -8,6 +8,7 @@ */ import type { SourceIntegrationPolicyManifest } from "#veryfront/integrations/source-policy.ts"; +import type { ErrorCategory } from "#veryfront/errors"; /** * Serialized request data that can cross the Worker boundary via postMessage. @@ -50,10 +51,17 @@ export interface SerializedError { message: string; name: string; stack?: string; - /** RFC 9457 fields if the error originated from VFError */ - type?: string; - status?: number; - detail?: string; + /** Detached, sanitized registered-error identity for the host boundary. */ + problem?: { + slug: string; + category: ErrorCategory; + status: number; + title: string; + suggestion?: string; + detail?: string; + cause?: string; + instance?: string; + }; } /** @@ -83,35 +91,69 @@ export interface SerializedDataResult { // Worker Request / Response Protocol // --------------------------------------------------------------------------- +/** + * Immutable, host-prepared JavaScript sent across the worker boundary. + * + * `sha256` is the exact lowercase hexadecimal SHA-256 digest of the UTF-8 + * encoded `source`. Workers rehash before importing and key their module cache + * by this content identity. + */ +export interface PreparedWorkerModule { + source: string; + sha256: string; +} + export type WorkerRequest = | ExecuteAppRouteRequest | ExecutePagesRouteRequest - | FetchDataRequest; + | InspectApiRouteMethodsRequest + | FetchDataRequest + | RenderSSRRequest; export interface ExecuteAppRouteRequest { type: "execute-app-route"; id: string; + module: PreparedWorkerModule; + /** Required logical route identity and bounded diagnostic; never imported by the worker. */ modulePath: string; method: string; request: SerializedRequest; - params: Record; + /** App Router's public handler contract uses slash-flattened catch-all values. */ + params: Record; projectDir: string; /** Exact source-owned integration policy for this project execution. */ sourceIntegrationPolicy: SourceIntegrationPolicyManifest; - /** Per-project env var overlay for multi-tenant proxy mode */ + /** Immutable per-request project env snapshot exposed through the handler context. */ projectEnv?: Record; } export interface ExecutePagesRouteRequest { type: "execute-pages-route"; id: string; + module: PreparedWorkerModule; + /** Required logical route identity and bounded diagnostic; never imported by the worker. */ modulePath: string; method: string; context: SerializedPagesContext; projectDir: string; /** Exact source-owned integration policy for this project execution. */ sourceIntegrationPolicy: SourceIntegrationPolicyManifest; - /** Per-project env var overlay for multi-tenant proxy mode */ + /** Immutable per-request project env snapshot exposed through the handler context. */ + projectEnv?: Record; +} + +export interface InspectApiRouteMethodsRequest { + type: "inspect-api-route-methods"; + id: string; + module: PreparedWorkerModule; + /** Required logical route identity and bounded diagnostic; never imported by the worker. */ + modulePath: string; + /** Optional custom-method probe used for default-export capability parity. */ + requestedMethod?: string; + projectDir: string; + /** Exact source-owned integration policy for this project execution. */ + sourceIntegrationPolicy: SourceIntegrationPolicyManifest; + /** Immutable per-request project env snapshot used in module semantics. */ projectEnv?: Record; } @@ -122,19 +164,132 @@ export interface FetchDataRequest { context: SerializedDataContext; /** Exact source-owned integration policy for this project execution. */ sourceIntegrationPolicy: SourceIntegrationPolicyManifest; + /** Immutable project env snapshot included in worker generation semantics. */ + projectEnv?: Record; +} + +export interface RenderSSRRequest { + type: "render-ssr"; + id: string; + /** Temp file path for the page component module */ + pageModulePath: string; + /** Ordered layout module temp paths (innermost → outermost) */ + layoutModulePaths: string[]; + /** Page component props (JSON-serializable) */ + pageProps: Record; + /** Layout props keyed by layout index (matching layoutModulePaths order) */ + layoutProps: Record[]; + /** Rendering delivery mode */ + delivery: "string" | "stream"; + /** + * Exact dependency snapshot selected by the host renderer. + * + * Omitted is the legacy flag-off wire shape. "off" is accepted explicitly; + * enabled snapshots require the paired immutable dependency map. + */ + dependencyPinningCacheKey?: string; + dependencyPinningDependencies?: Readonly>; + /** Exact source-owned integration policy for this project execution. */ + sourceIntegrationPolicy: SourceIntegrationPolicyManifest; +} + +// --------------------------------------------------------------------------- +// Internal SSR Wire Protocol +// --------------------------------------------------------------------------- + +/** @internal Opens one host-owned SSR execution on this worker generation. */ +export interface WorkerSSRExecutionOpen { + type: "ssr-execution-open"; + id: string; + generation: string; + token: string; + delivery: "string" | "stream"; +} + +/** @internal Grants exactly one additional framework-owned SSR frame. */ +export interface WorkerStreamCredit { + type: "stream-credit"; + id: string; + generation: string; + token: string; + sequence: number; +} + +/** @internal One fixed, tightly owned framework SSR frame. */ +export interface WorkerStreamFrame { + type: "stream-frame"; + id: string; + generation: string; + token: string; + sequence: number; + chunk: Uint8Array; +} + +/** @internal Successful terminal message for streaming delivery. */ +export interface WorkerStreamEnd { + type: "stream-end"; + id: string; + generation: string; + token: string; + sequence: number; +} + +/** @internal Successful terminal message for string delivery. */ +export interface WorkerSSRWireResult { + type: "ssr-wire-result"; + id: string; + generation: string; + token: string; + sequence: number; + html: string; +} + +/** @internal Bounded worker-side SSR output failure. */ +export interface WorkerSSROutputLimit { + type: "ssr-output-limit"; + id: string; + generation: string; + token: string; + sequence: number; + limit: "bytes" | "chunks"; +} + +/** @internal Token-bound SSR failure serialized by the worker. */ +export interface WorkerSSRWireError { + type: "ssr-wire-error"; + id: string; + generation: string; + token: string; + sequence: number; + error: SerializedError; } export type WorkerResponse = | WorkerResultResponse + | WorkerRouteMethodsResponse | WorkerDataResultResponse + | WorkerSSRResultResponse + | WorkerPreparedModuleCapacityResponse | WorkerErrorResponse; +export interface WorkerSSRResultResponse { + type: "ssr-result"; + id: string; + html: string; +} + export interface WorkerResultResponse { type: "result"; id: string; response: SerializedResponse; } +export interface WorkerRouteMethodsResponse { + type: "api-route-methods"; + id: string; + methods: string[]; +} + export interface WorkerDataResultResponse { type: "data-result"; id: string; @@ -147,6 +302,19 @@ export interface WorkerErrorResponse { error: SerializedError; } +/** + * Internal pre-execution rollover signal. + * + * The worker emits this only when a prepared API module cannot be reserved + * within the current worker generation's retained-module limits. No project + * module has been imported or executed for this request. The pool retires the + * generation and may retry the request once in a fresh generation. + */ +export interface WorkerPreparedModuleCapacityResponse { + type: "prepared-module-capacity"; + id: string; +} + // --------------------------------------------------------------------------- // Worker Pool Configuration // --------------------------------------------------------------------------- @@ -156,7 +324,10 @@ export interface WorkerPoolConfig { maxPoolSize: number; /** Idle timeout before evicting a worker (default: 300_000 = 5 minutes) */ idleTimeoutMs: number; - /** Per-request timeout inside the worker (default: 30_000) */ + /** + * Absolute wall-clock request deadline, including stream backpressure + * (default: 30_000). + */ requestTimeoutMs: number; /** Health check interval (default: 30_000) */ healthCheckIntervalMs: number; @@ -164,19 +335,40 @@ export interface WorkerPoolConfig { maxRequestsPerWorker: number; /** Maximum age of a worker in ms before recycling (default: 600_000 = 10 minutes) */ maxWorkerAgeMs: number; - /** Per-worker memory budget in MB (default: 64). Workers exceeding this are evicted. */ - memoryBudgetMb: number; + /** Host-owned snapshot allowing internal network egress (default: false). */ + allowInternalEgress?: boolean; } /** Maximum request body size for worker isolation (10 MB) */ export const MAX_WORKER_BODY_BYTES = 10 * 1024 * 1024; -export const DEFAULT_WORKER_POOL_CONFIG: WorkerPoolConfig = { +/** Compatibility boundary: isolated SSR rejects HTML above 16 MiB. */ +export const MAX_WORKER_SSR_OUTPUT_BYTES = 16 * 1024 * 1024; + +/** Maximum chunks one isolated streaming SSR request may emit. */ +export const MAX_WORKER_SSR_OUTPUT_CHUNKS = 16_384; + +/** Maximum bytes accepted in one isolated streaming SSR chunk (1 MiB). */ +export const MAX_WORKER_SSR_CHUNK_BYTES = 1024 * 1024; + +/** Maximum number of UTF-16 code units in one worker protocol request ID. */ +export const MAX_WORKER_REQUEST_ID_CHARS = 256; + +/** Maximum UTF-8 size of one prepared API route module (4 MiB). */ +export const MAX_WORKER_MODULE_SOURCE_BYTES = 4 * 1024 * 1024; + +/** Maximum aggregate source retained by content-addressed API modules (16 MiB). */ +export const MAX_WORKER_RETAINED_MODULE_SOURCE_BYTES = 16 * 1024 * 1024; + +/** Maximum number of distinct logical-route/source module identities per worker. */ +export const MAX_WORKER_RETAINED_MODULES = 128; + +export const DEFAULT_WORKER_POOL_CONFIG: Readonly> = Object.freeze({ maxPoolSize: 20, idleTimeoutMs: 300_000, requestTimeoutMs: 30_000, healthCheckIntervalMs: 30_000, maxRequestsPerWorker: 1_000, maxWorkerAgeMs: 600_000, - memoryBudgetMb: 64, -}; + allowInternalEgress: false, +}); diff --git a/src/server/context/enriched-context-types.ts b/src/server/context/enriched-context-types.ts index adbabeda09..c2d911dcd5 100644 --- a/src/server/context/enriched-context-types.ts +++ b/src/server/context/enriched-context-types.ts @@ -22,6 +22,8 @@ export interface EnrichedContext { environment: Environment; branch: string | null; isLocalProject: boolean; + /** Narrow host-owned capability for project-code execution. */ + allowHostProjectCodeExecution?: boolean; mode: RenderMode; /** Content source identifier for cache isolation (e.g., "release-abc123", "preview-main", "local-main") */ @@ -50,6 +52,8 @@ export interface BuildEnrichedContextOptions { environment: Environment; branch: string | null; isLocalProject: boolean; + /** Narrow host-owned capability for project-code execution. */ + allowHostProjectCodeExecution?: boolean; /** Content source identifier for cache isolation - computed by proxy */ contentSourceId: string; parsedDomain: ParsedDomain; diff --git a/src/server/context/enriched-context.ts b/src/server/context/enriched-context.ts index bdc8140583..b1ffc004f7 100644 --- a/src/server/context/enriched-context.ts +++ b/src/server/context/enriched-context.ts @@ -37,6 +37,8 @@ export function buildEnrichedContext(options: BuildEnrichedContextOptions): Enri environment: options.environment, branch: options.branch, isLocalProject: options.isLocalProject, + allowHostProjectCodeExecution: options.isLocalProject || + options.allowHostProjectCodeExecution === true, mode: options.isLocalProject ? "development" : "production", contentSourceId: options.contentSourceId, diff --git a/src/server/dev-server/middleware.test.ts b/src/server/dev-server/middleware.test.ts index d46cded271..e85df395fa 100644 --- a/src/server/dev-server/middleware.test.ts +++ b/src/server/dev-server/middleware.test.ts @@ -9,14 +9,23 @@ import { afterAll, describe, it } from "#veryfront/testing/bdd.ts"; import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; import { loadMiddlewareFile } from "./middleware.ts"; -function createVirtualAdapter(source: string): RuntimeAdapter { +function createVirtualAdapter( + source: string | undefined, + onFileAccess?: (operation: "exists" | "read") => void, +): RuntimeAdapter { const fs = { getUnderlyingAdapter: () => fs, getAdapterType: () => "MultiProjectFSAdapter", isVeryfrontAdapter: () => true, isMultiProjectMode: () => true, - exists: (path: string) => Promise.resolve(path.endsWith("/middleware.ts")), - readFile: () => Promise.resolve(source), + exists: (path: string) => { + onFileAccess?.("exists"); + return Promise.resolve(source !== undefined && path.endsWith("/middleware.ts")); + }, + readFile: () => { + onFileAccess?.("read"); + return Promise.resolve(source ?? ""); + }, } as unknown as RuntimeAdapter["fs"]; return { @@ -42,11 +51,49 @@ describe("loadMiddlewareFile", () => { await stop(); }); + it("rejects remote middleware before reading or evaluating project source", async () => { + const marker = `__vf_middleware_isolation_${crypto.randomUUID().replaceAll("-", "")}`; + const host = globalThis as unknown as Record; + let sourceReads = 0; + const adapter = createVirtualAdapter( + `globalThis.${marker} = Deno.env.get("HOST_SECRET"); export default [];`, + (operation) => { + if (operation === "read") sourceReads++; + }, + ); + + try { + await assertRejects( + () => loadMiddlewareFile("/app", adapter, { throwOnError: true }), + TypeError, + "requires explicit trusted-local execution", + ); + assertEquals(sourceReads, 0); + assertEquals(host[marker], undefined); + } finally { + delete host[marker]; + } + }); + + it("allows shared runtimes to establish that no middleware exists", async () => { + let sourceReads = 0; + const adapter = createVirtualAdapter(undefined, (operation) => { + if (operation === "read") sourceReads++; + }); + + assertEquals(await loadMiddlewareFile("/app", adapter), []); + assertEquals(sourceReads, 0); + }); + it("fails closed for invalid production middleware", async () => { const adapter = createVirtualAdapter("export default function broken( {"); await assertRejects( - () => loadMiddlewareFile("/app", adapter, { throwOnError: true }), + () => + loadMiddlewareFile("/app", adapter, { + throwOnError: true, + allowHostProjectCodeExecution: true, + }), Error, ); }); @@ -55,7 +102,11 @@ describe("loadMiddlewareFile", () => { const adapter = createVirtualAdapter("export const middleware = () => new Response('ok');"); await assertRejects( - () => loadMiddlewareFile("/app", adapter, { throwOnError: true }), + () => + loadMiddlewareFile("/app", adapter, { + throwOnError: true, + allowHostProjectCodeExecution: true, + }), TypeError, "Invalid middleware export", ); @@ -67,7 +118,11 @@ describe("loadMiddlewareFile", () => { ); await assertRejects( - () => loadMiddlewareFile("/app", adapter, { throwOnError: true }), + () => + loadMiddlewareFile("/app", adapter, { + throwOnError: true, + allowHostProjectCodeExecution: true, + }), TypeError, "Invalid middleware export", ); @@ -76,7 +131,10 @@ describe("loadMiddlewareFile", () => { it("preserves nonfatal development loading for invalid middleware", async () => { const adapter = createVirtualAdapter("export default function broken( {"); - assertEquals(await loadMiddlewareFile("/app", adapter), []); + assertEquals( + await loadMiddlewareFile("/app", adapter, { allowHostProjectCodeExecution: true }), + [], + ); }); }); @@ -94,7 +152,11 @@ describe("dev-server/middleware: actionable rejection", () => { ); const error = await assertRejects( - () => loadMiddlewareFile("/app", adapter, { throwOnError: true }), + () => + loadMiddlewareFile("/app", adapter, { + throwOnError: true, + allowHostProjectCodeExecution: true, + }), TypeError, ); @@ -111,7 +173,11 @@ describe("dev-server/middleware: actionable rejection", () => { const adapter = createVirtualAdapter("export const handler = 1; export const other = 2;"); const error = await assertRejects( - () => loadMiddlewareFile("/app", adapter, { throwOnError: true }), + () => + loadMiddlewareFile("/app", adapter, { + throwOnError: true, + allowHostProjectCodeExecution: true, + }), TypeError, ); @@ -130,7 +196,11 @@ describe("dev-server/middleware: actionable rejection", () => { ); const error = await assertRejects( - () => loadMiddlewareFile("/app", adapter, { throwOnError: true }), + () => + loadMiddlewareFile("/app", adapter, { + throwOnError: true, + allowHostProjectCodeExecution: true, + }), TypeError, ); @@ -143,7 +213,11 @@ describe("dev-server/middleware: actionable rejection", () => { const adapter = createVirtualAdapter("export default [];"); const error = await assertRejects( - () => loadMiddlewareFile("/app", adapter, { throwOnError: true }), + () => + loadMiddlewareFile("/app", adapter, { + throwOnError: true, + allowHostProjectCodeExecution: true, + }), TypeError, ); @@ -157,7 +231,11 @@ describe("dev-server/middleware: actionable rejection", () => { ); const error = await assertRejects( - () => loadMiddlewareFile("/app", adapter, { throwOnError: true }), + () => + loadMiddlewareFile("/app", adapter, { + throwOnError: true, + allowHostProjectCodeExecution: true, + }), TypeError, ); @@ -171,7 +249,10 @@ describe("dev-server/middleware: actionable rejection", () => { "export default async function (c, next) { return await next(); }", ); - const middleware = await loadMiddlewareFile("/app", adapter, { throwOnError: true }); + const middleware = await loadMiddlewareFile("/app", adapter, { + throwOnError: true, + allowHostProjectCodeExecution: true, + }); assertEquals(middleware.length, 1); }); @@ -180,7 +261,10 @@ describe("dev-server/middleware: actionable rejection", () => { "export default [async (c, next) => await next(), async (c, next) => await next()];", ); - const middleware = await loadMiddlewareFile("/app", adapter, { throwOnError: true }); + const middleware = await loadMiddlewareFile("/app", adapter, { + throwOnError: true, + allowHostProjectCodeExecution: true, + }); assertEquals(middleware.length, 2); }); }); diff --git a/src/server/dev-server/middleware.ts b/src/server/dev-server/middleware.ts index 102538d4e0..8603752267 100644 --- a/src/server/dev-server/middleware.ts +++ b/src/server/dev-server/middleware.ts @@ -10,11 +10,25 @@ import { cors } from "#veryfront/security"; import { getBaseLogger, type RequestContext, runWithRequestContextAsync } from "#veryfront/utils"; import { getEsbuildLoader } from "#veryfront/utils/path-utils.ts"; import { generateRequestId } from "#veryfront/utils/request-id.ts"; +import { isExplicitHostProjectCodeExecutionAllowed } from "#veryfront/security/project-locality.ts"; export type MiddlewareFunction = MiddlewareHandler; interface MiddlewareLoadOptions { throwOnError?: boolean; + /** Explicit host-owned capability for a trusted local or dedicated runtime. */ + allowHostProjectCodeExecution?: boolean; +} + +/** + * Internal control signal used when project middleware exists but the current + * runtime is not permitted to evaluate it in the host process. + */ +export class ProjectMiddlewareHostExecutionDeniedError extends TypeError { + constructor() { + super("Project middleware host loading requires explicit trusted-local execution"); + this.name = "ProjectMiddlewareHostExecutionDeniedError"; + } } const baseLogger = getBaseLogger("SERVER"); @@ -107,6 +121,12 @@ export async function loadMiddlewareFile( for (const middlewareFile of middlewareFiles) { const middlewarePath = join(projectDir, middlewareFile); if (!(await adapter.fs.exists(middlewarePath))) continue; + // Shared runtimes may inspect project-scoped metadata to determine that no + // middleware exists, but they must never read or evaluate a discovered + // middleware module in the host process. + if (!isExplicitHostProjectCodeExecutionAllowed(options)) { + throw new ProjectMiddlewareHostExecutionDeniedError(); + } try { logger.debug(`Loading ${middlewareFile}`); diff --git a/src/server/dev-server/server.ts b/src/server/dev-server/server.ts index 8f6179ae7f..552a0c4a71 100644 --- a/src/server/dev-server/server.ts +++ b/src/server/dev-server/server.ts @@ -306,6 +306,7 @@ export class DevServer { workflowDirs: ["workflows"], fsAdapter: this.adapter.fs, verbose: this.isDebug(), + allowHostProjectCodeExecution: true, }; } diff --git a/src/server/handlers/preview/markdown-preview.handler.test.ts b/src/server/handlers/preview/markdown-preview.handler.test.ts index 4b91f1fa8c..39a9e55775 100644 --- a/src/server/handlers/preview/markdown-preview.handler.test.ts +++ b/src/server/handlers/preview/markdown-preview.handler.test.ts @@ -70,6 +70,35 @@ Deno.test("MarkdownPreviewHandler admits the resolver result before reading", as assertEquals(reads, 0); }); +Deno.test("MarkdownPreviewHandler fails closed before shared source reads", async () => { + let reads = 0; + const ctx = { + projectDir: "/project", + isLocalProject: false, + requestContext: { mode: "preview" }, + adapter: { + fs: { + isMultiProjectMode: () => true, + readFile: () => { + reads++; + throw new Error("shared markdown preview read project source"); + }, + }, + }, + securityConfig: null, + cspUserHeader: null, + } as unknown as HandlerContext; + + const result = await new MarkdownPreviewHandler().handle( + new Request("https://tenant.example/README.md"), + ctx, + ); + + assertEquals(result.response?.status, 503); + assertEquals(result.response?.headers.get("content-type"), "application/problem+json"); + assertEquals(reads, 0); +}); + Deno.test("MarkdownPreviewHandler admits and reads through a real wrapped GitHub adapter", async () => { const originalFetch = globalThis.fetch; let contentReads = 0; diff --git a/src/server/handlers/preview/markdown-preview.handler.ts b/src/server/handlers/preview/markdown-preview.handler.ts index 2e8af387fc..2336b9a7d7 100644 --- a/src/server/handlers/preview/markdown-preview.handler.ts +++ b/src/server/handlers/preview/markdown-preview.handler.ts @@ -16,6 +16,11 @@ import { extract } from "#std/front-matter/yaml.ts"; import { tryNotFoundFallback } from "../request/ssr/not-found-fallback.ts"; import { generateMarkdownHtml } from "./markdown-html-generator.ts"; import { validateLexicalPath, validatePath, ValidationPresets } from "#veryfront/security"; +import { isSharedProjectRuntime } from "#veryfront/security/project-locality.ts"; +import { + createErrorResponseFromDefinition, + PROJECT_EXECUTION_UNAVAILABLE, +} from "#veryfront/errors"; const logger = serverLogger.component("markdown-preview-handler"); @@ -43,6 +48,23 @@ export class MarkdownPreviewHandler extends BaseHandler { return this.continue(); } + if (isSharedProjectRuntime(ctx)) { + const problem = createErrorResponseFromDefinition( + PROJECT_EXECUTION_UNAVAILABLE, + { + detail: + "Shared runtimes require a dedicated isolated project runtime for markdown rendering", + instance: pathname, + }, + ); + const response = this.createResponseBuilder(ctx) + .withSecurity(ctx.securityConfig ?? undefined, req) + .withCache("no-store") + .withHeaders(problem.headers) + .build(problem.body, problem.status); + return Promise.resolve(this.respond(response)); + } + const filePath = pathname.replace(/^\//, ""); const pathResult = validateLexicalPath(filePath, { diff --git a/src/server/handlers/request/agent-stream.handler.test.ts b/src/server/handlers/request/agent-stream.handler.test.ts index 6a7b159b12..d76eea2866 100644 --- a/src/server/handlers/request/agent-stream.handler.test.ts +++ b/src/server/handlers/request/agent-stream.handler.test.ts @@ -34,6 +34,11 @@ import { runWithRequestContext, } from "#veryfront/platform/adapters/fs/veryfront/request-context.ts"; +// Literal public addresses exercise guarded egress deterministically without +// depending on external DNS answers for production or reserved test hosts. +const TEST_PUBLIC_API_ORIGIN = "https://93.184.216.34"; +const TEST_PUBLIC_STUDIO_MCP_URL = "https://93.184.216.35/studio-mcp"; + function createRuntimeAgentRunInvocationBody() { return JSON.stringify({ run: { @@ -642,10 +647,10 @@ describe("server/handlers/request/agent-stream.handler", () => { const originalApiUrl = Deno.env.get("VERYFRONT_API_URL"); const originalApiBaseUrl = Deno.env.get("VERYFRONT_API_BASE_URL"); - Deno.env.set("VERYFRONT_API_URL", "https://api.veryfront.org"); + Deno.env.set("VERYFRONT_API_URL", TEST_PUBLIC_API_ORIGIN); Deno.env.delete("VERYFRONT_API_BASE_URL"); globalThis.fetch = ((url, init) => { - if (String(url) === "https://api.veryfront.org/mcp") { + if (String(url) === `${TEST_PUBLIC_API_ORIGIN}/mcp`) { platformMcpFetchCalls += 1; assertEquals( new Headers(init?.headers).get("authorization"), @@ -676,7 +681,7 @@ describe("server/handlers/request/agent-stream.handler", () => { ); } - if (String(url) === "https://api.veryfront.org/projects/demo-project/environments") { + if (String(url) === `${TEST_PUBLIC_API_ORIGIN}/projects/demo-project/environments`) { return Promise.resolve( new Response(JSON.stringify({ data: [] }), { headers: { "content-type": "application/json" }, @@ -1113,9 +1118,9 @@ describe("server/handlers/request/agent-stream.handler", () => { const originalFetch = globalThis.fetch; const originalStudioMcpUrl = Deno.env.get("VERYFRONT_STUDIO_MCP_URL"); - Deno.env.set("VERYFRONT_STUDIO_MCP_URL", "https://studio.veryfront.org/mcp"); + Deno.env.set("VERYFRONT_STUDIO_MCP_URL", TEST_PUBLIC_STUDIO_MCP_URL); globalThis.fetch = ((url, init) => { - assertEquals(String(url), "https://studio.veryfront.org/mcp"); + assertEquals(String(url), TEST_PUBLIC_STUDIO_MCP_URL); const headers = new Headers(init?.headers); assertEquals(headers.get("authorization"), "Bearer request-scoped-user-token"); assertEquals(headers.get("x-project-id"), "proj-1"); @@ -1230,9 +1235,9 @@ describe("server/handlers/request/agent-stream.handler", () => { const originalFetch = globalThis.fetch; const originalStudioMcpUrl = Deno.env.get("VERYFRONT_STUDIO_MCP_URL"); - Deno.env.set("VERYFRONT_STUDIO_MCP_URL", "https://studio.veryfront.org/mcp"); + Deno.env.set("VERYFRONT_STUDIO_MCP_URL", TEST_PUBLIC_STUDIO_MCP_URL); globalThis.fetch = ((url) => { - if (String(url) === "https://studio.veryfront.org/mcp") { + if (String(url) === TEST_PUBLIC_STUDIO_MCP_URL) { studioMcpFetchCalls += 1; } return Promise.resolve(new Response(null, { status: 503 })); @@ -1571,10 +1576,10 @@ describe("server/handlers/request/agent-stream.handler", () => { const originalApiUrl = Deno.env.get("VERYFRONT_API_URL"); const originalApiBaseUrl = Deno.env.get("VERYFRONT_API_BASE_URL"); - Deno.env.set("VERYFRONT_API_URL", "https://api.veryfront.org"); + Deno.env.set("VERYFRONT_API_URL", TEST_PUBLIC_API_ORIGIN); Deno.env.delete("VERYFRONT_API_BASE_URL"); globalThis.fetch = ((url, init) => { - assertEquals(String(url), "https://api.veryfront.org/mcp"); + assertEquals(String(url), `${TEST_PUBLIC_API_ORIGIN}/mcp`); assertEquals( new Headers(init?.headers).get("authorization"), "Bearer request-scoped-user-token", @@ -1793,7 +1798,7 @@ describe("server/handlers/request/agent-stream.handler", () => { const originalApiUrl = Deno.env.get("VERYFRONT_API_URL"); const originalApiBaseUrl = Deno.env.get("VERYFRONT_API_BASE_URL"); const fetchUrls: string[] = []; - Deno.env.set("VERYFRONT_API_URL", "https://api.veryfront.org"); + Deno.env.set("VERYFRONT_API_URL", TEST_PUBLIC_API_ORIGIN); Deno.env.delete("VERYFRONT_API_BASE_URL"); globalThis.fetch = ((url, init) => { fetchUrls.push(String(url)); @@ -1838,7 +1843,7 @@ describe("server/handlers/request/agent-stream.handler", () => { ); } - if (String(url) === "https://api.veryfront.org/mcp") { + if (String(url) === `${TEST_PUBLIC_API_ORIGIN}/mcp`) { capturedMcpRequest = { url: String(url), authorization: new Headers(init?.headers).get("authorization"), @@ -1896,7 +1901,7 @@ describe("server/handlers/request/agent-stream.handler", () => { assertEquals(result.response.status, 200); assertEquals(capturedEnv, { VERYFRONT_API_TOKEN: "request-scoped-user-token", - VERYFRONT_API_URL: "https://api.veryfront.org", + VERYFRONT_API_URL: TEST_PUBLIC_API_ORIGIN, VERYFRONT_PROJECT_SLUG: "support-agent-fork", CUSTOM_PROJECT_ENV: "project-value", OTEL_EXPORTER_OTLP_ENDPOINT: undefined, @@ -1905,7 +1910,7 @@ describe("server/handlers/request/agent-stream.handler", () => { assertStringIncludes(capturedSystem ?? "", "project_reference=support-agent-fork"); assertStringIncludes(capturedSystem ?? "", '\nproject_reference: "proj-1"'); assertEquals(capturedMcpRequest, { - url: "https://api.veryfront.org/mcp", + url: `${TEST_PUBLIC_API_ORIGIN}/mcp`, authorization: "Bearer request-scoped-user-token", }); assertEquals(capturedAllowedRemoteTools, ["list_projects", "search_knowledge"]); @@ -1913,14 +1918,14 @@ describe("server/handlers/request/agent-stream.handler", () => { // The environment is resolved before the source config is evaluated, so // both the config and the MCP tool headers see the same variables. assertEquals(fetchUrls, [ - "https://api.veryfront.org/projects/support-agent-fork/environments", - "https://api.veryfront.org/projects/support-agent-fork/environment-variables?environment_id=env-production&limit=100", - "https://api.veryfront.org/mcp", + `${TEST_PUBLIC_API_ORIGIN}/projects/support-agent-fork/environments`, + `${TEST_PUBLIC_API_ORIGIN}/projects/support-agent-fork/environment-variables?environment_id=env-production&limit=100`, + `${TEST_PUBLIC_API_ORIGIN}/mcp`, ]); }); it("prefers VERYFRONT_API_BASE_URL over VERYFRONT_API_URL", async () => { - const apiBaseUrl = "http://veryfront-api.veryfront-staging.svc.cluster.local:80"; + const apiBaseUrl = "http://93.184.216.34:8080"; let capturedEnv: Record | null = null; let capturedSystem: string | null = null; @@ -1982,7 +1987,7 @@ describe("server/handlers/request/agent-stream.handler", () => { const originalApiUrl = Deno.env.get("VERYFRONT_API_URL"); const originalApiBaseUrl = Deno.env.get("VERYFRONT_API_BASE_URL"); const fetchUrls: string[] = []; - Deno.env.set("VERYFRONT_API_URL", "https://wrong-api.example.test"); + Deno.env.set("VERYFRONT_API_URL", "https://1.1.1.1/unused-fallback"); Deno.env.set("VERYFRONT_API_BASE_URL", apiBaseUrl); globalThis.fetch = ((url, init) => { fetchUrls.push(String(url)); diff --git a/src/server/handlers/request/api/api-handler-wrapper.test.ts b/src/server/handlers/request/api/api-handler-wrapper.test.ts index 79ce03a25b..fd673d3278 100644 --- a/src/server/handlers/request/api/api-handler-wrapper.test.ts +++ b/src/server/handlers/request/api/api-handler-wrapper.test.ts @@ -202,7 +202,8 @@ describe("ApiHandlerWrapper", () => { const result = await handler.handle(new Request("http://localhost/new-webhook"), ctx); - assertEquals(result, { continue: true }); + assertEquals(result.response?.status, 503); + assertEquals(result.response?.headers.get("content-type"), "application/problem+json"); assertEquals(sourceSnapshotRefreshes, 1); }); @@ -236,8 +237,50 @@ describe("ApiHandlerWrapper", () => { const result = await handler.handle(new Request("http://localhost/api"), ctx); - assertEquals(result.response?.status, 404); - assertEquals(sourceSnapshotRefreshes, 1); + assertEquals(result.response?.status, 503); + assertEquals(sourceSnapshotRefreshes, 0); + }); + + it("never starts shared-runtime API discovery or a same-process Worker", async () => { + let projectContextEntries = 0; + let filesystemReads = 0; + const ctx = createCtx({}); + const fs = ctx.adapter.fs as unknown as { + runWithContext: ( + slug: string, + token: string, + fn: () => Promise, + ) => Promise; + exists: (path: string) => Promise; + readDir: (path: string) => AsyncIterable; + }; + fs.runWithContext = async (_slug, _token, fn) => { + projectContextEntries++; + return await fn(); + }; + fs.exists = () => { + filesystemReads++; + return Promise.resolve(true); + }; + fs.readDir = async function* () { + filesystemReads++; + yield* []; + }; + + const handler = new ApiHandlerWrapper("/tmp/project", ctx.adapter); + const result = await handler.handle( + new Request("http://localhost/api/private"), + ctx, + ); + + assertEquals(result.response?.status, 503); + assertEquals(projectContextEntries, 1); + assertEquals(filesystemReads, 0); + const problem = await result.response!.json(); + assertEquals( + problem.type, + "https://veryfront.com/docs/errors/project-execution-unavailable", + ); }); it("forwards environmentName into multi-project request context", async () => { diff --git a/src/server/handlers/request/api/api-handler-wrapper.ts b/src/server/handlers/request/api/api-handler-wrapper.ts index 42264b240d..6e319d1971 100644 --- a/src/server/handlers/request/api/api-handler-wrapper.ts +++ b/src/server/handlers/request/api/api-handler-wrapper.ts @@ -14,6 +14,11 @@ import { PRIORITY_MEDIUM_API } from "#veryfront/utils/constants/index.ts"; import { withSpan } from "#veryfront/observability/tracing/otlp-setup.ts"; import { ensureProjectDiscovery } from "./project-discovery.ts"; import { PageResolver } from "#veryfront/rendering/page-resolution/page-resolver.ts"; +import { + createErrorResponseFromDefinition, + PROJECT_EXECUTION_UNAVAILABLE, +} from "#veryfront/errors"; +import { isSharedProjectRuntime } from "#veryfront/security/project-locality.ts"; type FsWrapper = { isMultiProjectMode?: () => boolean; @@ -80,8 +85,10 @@ export class ApiHandlerWrapper extends BaseHandler { typeof fsWrapper.isMultiProjectMode === "function" && fsWrapper.isMultiProjectMode(); + const isSharedRuntime = isSharedProjectRuntime(ctx); + if (!isMultiProject) { - return this.handleWithContext(req, ctx, pathname); + return this.handleWithContext(req, ctx, pathname, isSharedRuntime); } const isProduction = ctx.requestContext?.mode === "production"; @@ -100,7 +107,7 @@ export class ApiHandlerWrapper extends BaseHandler { return fsWrapper.runWithContext!( ctx.projectSlug!, ctx.proxyToken ?? "", - () => this.handleWithContext(req, ctx, pathname), + () => this.handleWithContext(req, ctx, pathname, true), ctx.projectId, { productionMode: isProduction, @@ -116,11 +123,19 @@ export class ApiHandlerWrapper extends BaseHandler { req: Request, ctx: HandlerContext, pathname: string, + isSharedRuntime: boolean, ): Promise { return withSpan( "api.handleWithContext", async () => { try { + if ( + isSharedRuntime && + (pathname === "/api" || pathname.startsWith("/api/")) + ) { + return this.sharedRuntimeExecutionUnavailable(req, ctx, pathname); + } + // WebSocket pokes update mutable previews immediately. This bounded, // coalesced check is the fallback for missed pokes and establishes // one source snapshot for route and primitive discovery. @@ -139,6 +154,10 @@ export class ApiHandlerWrapper extends BaseHandler { return this.continue(); } + if (isSharedRuntime) { + return this.sharedRuntimeExecutionUnavailable(req, ctx, pathname); + } + // Lazy per-project primitive discovery (agents, tools) on first access. // Must run within runWithContext so VFS and registry scope are correct. await ensureProjectDiscovery(ctx); @@ -194,6 +213,28 @@ export class ApiHandlerWrapper extends BaseHandler { ); } + private sharedRuntimeExecutionUnavailable( + req: Request, + ctx: HandlerContext, + pathname: string, + ): HandlerResult { + const problem = createErrorResponseFromDefinition( + PROJECT_EXECUTION_UNAVAILABLE, + { + detail: + "Shared runtimes do not execute tenant API modules in the host process or same-process Workers", + instance: pathname, + }, + ); + const response = this.createResponseBuilder(ctx) + .withCORS(req, ctx.securityConfig?.cors) + .withSecurity(ctx.securityConfig ?? undefined, req) + .withCache("no-store") + .withHeaders(problem.headers) + .build(problem.body, problem.status); + return this.respond(response, { executionTopology: "dedicated-runtime-required" }); + } + private async isPageRequest(pathname: string, ctx: HandlerContext): Promise { const slug = pathname === "/" ? "" : pathname.replace(/^\/+|\/+$/g, ""); const pageResolver = new PageResolver({ diff --git a/src/server/handlers/request/api/app-router-handler.test.ts b/src/server/handlers/request/api/app-router-handler.test.ts new file mode 100644 index 0000000000..c3854d3a36 --- /dev/null +++ b/src/server/handlers/request/api/app-router-handler.test.ts @@ -0,0 +1,36 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import type { HandlerContext } from "../../types.ts"; +import { handleAppRouter } from "./app-router-handler.ts"; + +describe("server API app-router compatibility handler", () => { + it("fails closed before shared route discovery or module import", async () => { + let filesystemCalls = 0; + const ctx = { + projectDir: "/remote/project", + adapter: { + fs: { + isMultiProjectMode: () => true, + stat: () => { + filesystemCalls++; + throw new Error("shared app route reached filesystem discovery"); + }, + }, + }, + securityConfig: null, + cspUserHeader: null, + } as unknown as HandlerContext; + + const response = await handleAppRouter( + new Request("https://tenant.example/api/private"), + "/api/private", + ctx, + ); + + assertEquals(response?.status, 503); + assertEquals(response?.headers.get("cache-control"), "no-store"); + assertEquals(response?.headers.get("content-type"), "application/problem+json"); + assertEquals(filesystemCalls, 0); + }); +}); diff --git a/src/server/handlers/request/api/app-router-handler.ts b/src/server/handlers/request/api/app-router-handler.ts index 68f1818fbf..8ca8309621 100644 --- a/src/server/handlers/request/api/app-router-handler.ts +++ b/src/server/handlers/request/api/app-router-handler.ts @@ -11,6 +11,12 @@ import { applySecurityHeaders } from "./security-headers.ts"; import { applyCORSHeaders } from "#veryfront/security"; import { serverLogger } from "#veryfront/utils"; import { methodNotAllowed } from "#veryfront/http/responses"; +import { createApplicationRequest } from "#veryfront/security/http/application-request.ts"; +import { isSharedProjectRuntime } from "#veryfront/security/project-locality.ts"; +import { + createErrorResponseFromDefinition, + PROJECT_EXECUTION_UNAVAILABLE, +} from "#veryfront/errors"; const logger = serverLogger.component("app-router-api-handler"); @@ -38,6 +44,29 @@ export async function handleAppRouter( ctx: HandlerContext, ): Promise { try { + if (isSharedProjectRuntime(ctx)) { + const unavailable = createErrorResponseFromDefinition( + PROJECT_EXECUTION_UNAVAILABLE, + { + detail: "Shared runtimes require a dedicated isolated project runtime for API execution", + instance: pathname, + }, + ); + const headers = new Headers(unavailable.headers); + headers.set("cache-control", "no-store"); + await applyCORSHeaders({ + request: req, + headers, + config: ctx.securityConfig?.cors, + }); + applySecurityHeaders(headers, ctx, req); + return new Response(req.method === "HEAD" ? null : unavailable.body, { + status: unavailable.status, + statusText: unavailable.statusText, + headers, + }); + } + const match = await resolveAppRouteFile(pathname, ctx); if (!match) return null; @@ -47,7 +76,7 @@ export async function handleAppRouter( const [fn, headShim] = resolveHandlerFunction(mod, method); if (!fn) return methodNotAllowed(getAllowedMethods(mod)); - const res = await fn(req, { params: match.params }); + const res = await fn(createApplicationRequest(req), { params: match.params }); const headers = new Headers(res.headers); await applyCORSHeaders({ diff --git a/src/server/handlers/request/api/pages-api-handler.test.ts b/src/server/handlers/request/api/pages-api-handler.test.ts index 9d24729165..b3f89274fe 100644 --- a/src/server/handlers/request/api/pages-api-handler.test.ts +++ b/src/server/handlers/request/api/pages-api-handler.test.ts @@ -82,6 +82,9 @@ function createHandlerContext( mode: input.mode ?? "preview", branch: "main", }, + // These cache-lifecycle tests deliberately exercise the dedicated-runtime + // host loader; worker isolation has its own contract tests. + allowHostProjectCodeExecution: true, }; } diff --git a/src/server/handlers/request/api/project-discovery.test.ts b/src/server/handlers/request/api/project-discovery.test.ts index 5992ff81cc..24ff25b62c 100644 --- a/src/server/handlers/request/api/project-discovery.test.ts +++ b/src/server/handlers/request/api/project-discovery.test.ts @@ -42,7 +42,7 @@ function createHandlerContext( adapter: createMockAdapter(), securityConfig: null, cspUserHeader: null, - isLocalProject: false, + isLocalProject: true, } as HandlerContext; } @@ -99,6 +99,32 @@ describe( "server/handlers/request/api/project-discovery", { sanitizeOps: false, sanitizeResources: false }, () => { + it("fails closed for remote discovery before reading or evaluating project modules", async () => { + const ctx = createHandlerContext("/project", "remote", "preview"); + ctx.isLocalProject = false; + ctx.prepareHostedConfigContext = () => Promise.reject(new Error("must not be called")); + const marker = "__vf_remote_discovery_host_marker__"; + delete (globalThis as Record)[marker]; + await ctx.adapter.fs.writeFile( + "/project/tools/untrusted.ts", + `globalThis.${marker} = Deno.env.get("VERYFRONT_API_TOKEN"); export default {};`, + ); + let reads = 0; + const readFile = ctx.adapter.fs.readFile.bind(ctx.adapter.fs); + ctx.adapter.fs.readFile = (path) => { + reads++; + return readFile(path); + }; + + await assertRejects( + () => ensureProjectDiscovery(ctx), + Error, + "isolated project runtime", + ); + assertEquals(reads, 0); + assertEquals((globalThis as Record)[marker], undefined); + }); + afterAll(async () => { await stopEsbuild(); }); diff --git a/src/server/handlers/request/api/project-discovery.ts b/src/server/handlers/request/api/project-discovery.ts index 1a70a7e18c..c4b7a967c0 100644 --- a/src/server/handlers/request/api/project-discovery.ts +++ b/src/server/handlers/request/api/project-discovery.ts @@ -6,6 +6,10 @@ import { clearTrackedAgents, createProjectDiscoveryConfig } from "#veryfront/dis import { tryGetRegistryScopeContext } from "#veryfront/cache/cache-key-builder.ts"; import { runWithRegistryTransaction } from "#veryfront/registry/project-scoped-registry-manager.ts"; import { sanitizeUrlCredentials } from "#veryfront/utils/logger/redact.ts"; +import { + isExplicitlyLocalProject, + isSharedProjectRuntime, +} from "#veryfront/security/project-locality.ts"; import type { HandlerContext } from "../../types.ts"; const logger = serverLogger.component("api-wrapper"); @@ -137,6 +141,13 @@ function shouldCacheCompletedDiscovery(ctx: HandlerContext): boolean { * correct project scope. */ export async function ensureProjectDiscovery(ctx: HandlerContext): Promise { + if (!isExplicitlyLocalProject(ctx) && isSharedProjectRuntime(ctx)) { + throw INITIALIZATION_ERROR.create({ + detail: + "Remote executable discovery requires an isolated project runtime and cannot run in the shared host", + }); + } + await ctx.adapter.fs.ensureSourceSnapshotFresh?.("primitive-discovery"); const key = discoveryKey(ctx); const sourceSnapshotVersion = await ctx.adapter.fs.getSourceSnapshotVersion?.(); @@ -179,6 +190,7 @@ export async function ensureProjectDiscovery(ctx: HandlerContext): Promise 0 || diff --git a/src/server/handlers/request/module/module.handler.test.ts b/src/server/handlers/request/module/module.handler.test.ts index 5ba8c3ca7e..1406d94b0e 100644 --- a/src/server/handlers/request/module/module.handler.test.ts +++ b/src/server/handlers/request/module/module.handler.test.ts @@ -51,6 +51,7 @@ function makeCtx(overrides: Partial = {}): HandlerContext { adapter: createMockAdapter(), securityConfig: null, cspUserHeader: null, + isLocalProject: true, ...overrides, }; } @@ -210,6 +211,70 @@ describe("server/handlers/request/module/module.handler", () => { }); }); + describe("remote execution isolation", () => { + it("fails closed before resolving the host renderer", async () => { + let rendererCalls = 0; + setRendererInitializer({ + initialize: () => { + rendererCalls++; + throw new Error("remote module endpoint reached the host renderer"); + }, + isInitialized: () => false, + get: () => { + throw new Error("remote module endpoint reached the host renderer"); + }, + destroy: () => Promise.resolve(), + }); + + const handler = new ModuleHandler(); + for ( + const pathname of [ + "/_veryfront/modules/runtime.js", + "/_veryfront/pages/page.js", + "/_veryfront/data/page.json", + "/_veryfront/page-data/page.json", + ] + ) { + const result = await handler.handle( + new Request(`https://tenant.example${pathname}`), + makeCtx({ + isLocalProject: false, + prepareHostedConfigContext: (() => { + throw new Error("shared module endpoint prepared host rendering context"); + }) as HandlerContext["prepareHostedConfigContext"], + }), + ); + assertEquals(result.continue, false); + assertEquals(result.response?.status, 503); + assertEquals(result.response?.headers.get("cache-control"), "no-store"); + assertEquals(result.response?.headers.get("content-type"), "application/problem+json"); + assertEquals( + (await result.response?.json() as { type?: string }).type, + "https://veryfront.com/docs/errors/project-execution-unavailable", + ); + } + + assertEquals(rendererCalls, 0); + }); + + it("returns an empty fail-closed response for HEAD", async () => { + const result = await new ModuleHandler().handle( + new Request("https://tenant.example/_veryfront/page-data/page.json", { + method: "HEAD", + }), + makeCtx({ + isLocalProject: false, + prepareHostedConfigContext: (() => { + throw new Error("shared module endpoint prepared host rendering context"); + }) as HandlerContext["prepareHostedConfigContext"], + }), + ); + + assertEquals(result.response?.status, 503); + assertEquals(await result.response?.text(), ""); + }); + }); + describe("handle - page modules", () => { it("returns 404 when a missing page module falls through from static handling", async () => { setRendererInitializer(createInitializer({ diff --git a/src/server/handlers/request/module/module.handler.ts b/src/server/handlers/request/module/module.handler.ts index 8ccd749df2..8ffbacc1af 100644 --- a/src/server/handlers/request/module/module.handler.ts +++ b/src/server/handlers/request/module/module.handler.ts @@ -12,6 +12,11 @@ import { handlePageDataEndpoint } from "./page-data-endpoint-handler.ts"; import { handleVirtualModule } from "./virtual-module-handler.ts"; import { handleBatchModuleEndpoint } from "./batch-module-handler.ts"; import { HTTP_METHOD_NOT_ALLOWED, PRIORITY_MEDIUM } from "#veryfront/utils/constants/index.ts"; +import { + createErrorResponseFromDefinition, + PROJECT_EXECUTION_UNAVAILABLE, +} from "#veryfront/errors"; +import { isSharedProjectRuntime } from "#veryfront/security/project-locality.ts"; const MODULE_ENDPOINT_PREFIXES = [ "/_vf_modules/", @@ -21,6 +26,13 @@ const MODULE_ENDPOINT_PREFIXES = [ "/_veryfront/page-data/", ] as const; +const HOST_RENDERER_ENDPOINT_PREFIXES = [ + "/_veryfront/modules/", + "/_veryfront/pages/", + "/_veryfront/data/", + "/_veryfront/page-data/", +] as const; + export class ModuleHandler extends BaseHandler { metadata: HandlerMetadata = { name: "ModuleHandler", @@ -56,6 +68,36 @@ export class ModuleHandler extends BaseHandler { ); } + // These endpoints delegate to the legacy renderer, whose module loader + // imports page and layout code in the host process. Remote source must not + // reach that path until rendering has a generation-owned prepared module + // graph equivalent to isolated API routes. + if ( + isSharedProjectRuntime(ctx) && + HOST_RENDERER_ENDPOINT_PREFIXES.some((prefix) => pathname.startsWith(prefix)) + ) { + const problem = createErrorResponseFromDefinition( + PROJECT_EXECUTION_UNAVAILABLE, + { + detail: + "Shared runtimes require a dedicated isolated project runtime for module rendering", + instance: pathname, + }, + ); + problem.headers.set("Cache-Control", "no-store"); + return Promise.resolve( + respond( + method === "HEAD" + ? new Response(null, { + status: problem.status, + statusText: problem.statusText, + headers: problem.headers, + }) + : problem, + ), + ); + } + if (pathname === "/_vf_modules/_batch") { return this.withProxyContext( ctx, diff --git a/src/server/handlers/request/openapi.handler.test.ts b/src/server/handlers/request/openapi.handler.test.ts index 41c139abc9..1696b116be 100644 --- a/src/server/handlers/request/openapi.handler.test.ts +++ b/src/server/handlers/request/openapi.handler.test.ts @@ -51,14 +51,14 @@ function createCtx(overrides: Partial = {}): HandlerContext { projectDir: "/project", adapter: { fs: createMockFs().fs } as never, config: { openapi: { enabled: true } }, - isLocalProject: false, + isLocalProject: true, ...overrides, } as unknown as HandlerContext; } describe("server/handlers/request/openapi.handler", () => { - describe("proxy mode uses runWithContext", () => { - it("should call runWithContext when in proxy mode with extended FS", async () => { + describe("remote execution isolation", () => { + it("fails closed before route discovery or proxy context setup", async () => { const { fs, calls } = createMockFs({ needsContext: true }); const handler = new OpenAPIHandler(); const ctx = createCtx({ @@ -74,12 +74,15 @@ describe("server/handlers/request/openapi.handler", () => { const req = new Request("https://example.com/_openapi.json"); const result = await handler.handle(req, ctx); - assertEquals(result.response?.status, 200); + assertEquals(result.response?.status, 503); const body = JSON.parse(await result.response!.text()); - assertEquals(typeof body.paths, "object"); - assertEquals(calls.includes("runWithContext"), true); + assertEquals(body.error, "Isolated OpenAPI generation is unavailable"); + assertEquals(calls.includes("runWithContext"), false); + assertEquals(calls.some((call) => call.startsWith("exists:")), false); }); + }); + describe("local generation", () => { it("should NOT call runWithContext for local projects", async () => { const { fs, calls } = createMockFs({ needsContext: true }); const handler = new OpenAPIHandler(); @@ -96,102 +99,5 @@ describe("server/handlers/request/openapi.handler", () => { assertEquals(result.response?.status, 200); assertEquals(calls.includes("runWithContext"), false); }); - - it("should NOT call runWithContext when no proxyToken", async () => { - const { fs, calls } = createMockFs({ needsContext: true }); - const handler = new OpenAPIHandler(); - const ctx = createCtx({ - adapter: { fs } as never, - isLocalProject: false, - projectSlug: "test-project", - proxyToken: undefined, - }); - - const req = new Request("https://example.com/_openapi.json"); - const result = await handler.handle(req, ctx); - - assertEquals(result.response?.status, 200); - assertEquals(calls.includes("runWithContext"), false); - }); - - it("should NOT call runWithContext when extended FS lacks multi-project mode", async () => { - const { fs, calls } = createMockFs({ needsContext: true, multiProject: false }); - const handler = new OpenAPIHandler(); - const ctx = createCtx({ - adapter: { fs } as never, - isLocalProject: false, - projectSlug: "test-project", - proxyToken: "test-token", - projectId: "proj-123", - resolvedEnvironment: "production", - parsedDomain: { branch: null } as never, - }); - - const req = new Request("https://example.com/_openapi.json"); - const result = await handler.handle(req, ctx); - - assertEquals(result.response?.status, 200); - assertEquals(calls.includes("runWithContext"), false); - }); - }); - - describe("spec caching", () => { - it("should use different cache keys for different branches", async () => { - const { fs } = createMockFs({ needsContext: true }); - const handler = new OpenAPIHandler(); - - // First request on branch "main" - const ctx1 = createCtx({ - adapter: { fs } as never, - isLocalProject: false, - projectSlug: "test-project", - proxyToken: "test-token", - parsedDomain: { branch: "main" } as never, - releaseId: "rel-1", - }); - const req1 = new Request("https://example.com/_openapi.json"); - const result1 = await handler.handle(req1, ctx1); - assertEquals(result1.response?.status, 200); - - // Second request on branch "feature" — should NOT serve stale spec - const ctx2 = createCtx({ - adapter: { fs } as never, - isLocalProject: false, - projectSlug: "test-project", - proxyToken: "test-token", - parsedDomain: { branch: "feature" } as never, - releaseId: "rel-2", - }); - const req2 = new Request("https://example.com/_openapi.json"); - const result2 = await handler.handle(req2, ctx2); - assertEquals(result2.response?.status, 200); - - // Both should succeed without serving stale cached spec from first branch - // (The handler's internal cacheKey should differ for different branches/releases) - }); - }); - - describe("spec generation with route discovery in proxy mode", () => { - it("should attempt directory existence checks within runWithContext", async () => { - const { fs, calls } = createMockFs({ needsContext: true, existsReturn: true }); - const handler = new OpenAPIHandler(); - const ctx = createCtx({ - adapter: { fs } as never, - isLocalProject: false, - projectSlug: "test-project", - proxyToken: "test-token", - projectId: "proj-123", - resolvedEnvironment: "production", - parsedDomain: { branch: null } as never, - }); - - const req = new Request("https://example.com/_openapi.json"); - await handler.handle(req, ctx); - - // Verify runWithContext was used and discovery directories were checked - assertEquals(calls.includes("runWithContext"), true); - const existsCalls = calls.filter((c) => c.startsWith("exists:")); - assertEquals(existsCalls.length > 0, true); - }); }); }); diff --git a/src/server/handlers/request/openapi.handler.ts b/src/server/handlers/request/openapi.handler.ts index 390c723495..9ff9065b98 100644 --- a/src/server/handlers/request/openapi.handler.ts +++ b/src/server/handlers/request/openapi.handler.ts @@ -1,29 +1,24 @@ import { BaseHandler } from "../response/base.ts"; import type { HandlerContext, HandlerMetadata, HandlerPriority, HandlerResult } from "../types.ts"; -import { HTTP_OK, HTTP_SERVER_ERROR, PRIORITY_HIGH_DEV } from "#veryfront/utils/constants/index.ts"; +import { + HTTP_OK, + HTTP_SERVER_ERROR, + HTTP_UNAVAILABLE, + PRIORITY_HIGH_DEV, +} from "#veryfront/utils/constants/index.ts"; import { ApiRouteMatcher } from "#veryfront/routing/api/api-route-matcher.ts"; import { discoverAppRoutes, discoverPagesRoutes } from "#veryfront/routing/api/route-discovery.ts"; import { generateOpenAPISpec, specToYaml } from "#veryfront/routing/api/openapi/spec-generator.ts"; import type { OpenAPISpec } from "#veryfront/routing/api/openapi/types.ts"; import { join } from "#veryfront/compat/path/index.ts"; import { logger as baseLogger } from "#veryfront/utils"; -import { - type ExtendedFileSystemAdapter, - isExtendedFSAdapter, -} from "#veryfront/platform/adapters/fs/wrapper.ts"; const logger = baseLogger.component("open-api"); const DEFAULT_JSON_PATH = "/_openapi.json"; const DEFAULT_YAML_PATH = "/_openapi.yaml"; -/** Cache duration for production OpenAPI spec (1 hour) */ -const SPEC_CACHE_MAX_AGE_SECONDS = 3_600; - export class OpenAPIHandler extends BaseHandler { - private cachedSpec: OpenAPISpec | null = null; - private cacheKey: string | null = null; - metadata: HandlerMetadata = { name: "OpenAPIHandler", priority: PRIORITY_HIGH_DEV as HandlerPriority, @@ -44,6 +39,20 @@ export class OpenAPIHandler extends BaseHandler { async handle(req: Request, ctx: HandlerContext): Promise { if (!this.shouldHandle(req, ctx)) return this.continue(); + // OpenAPI metadata currently lives on exported handler functions, so + // generating it imports and evaluates every project route. Remote project + // code must never cross that host-realm boundary. A future isolated/static + // metadata format can replace this fail-closed response. + if (ctx.isLocalProject !== true) { + const response = this.createResponseBuilder(ctx) + .withCache("no-cache") + .json( + { error: "Isolated OpenAPI generation is unavailable" }, + HTTP_UNAVAILABLE, + ); + return this.respond(response); + } + const url = new URL(req.url); const { yamlPath } = this.getPaths(ctx); const isYaml = url.pathname === yamlPath; @@ -51,10 +60,9 @@ export class OpenAPIHandler extends BaseHandler { try { const spec = await this.getOrGenerateSpec(ctx, url); const content = isYaml ? specToYaml(spec) : JSON.stringify(spec, null, 2); - const isDev = !!ctx.isLocalProject; const response = this.createResponseBuilder(ctx) - .withCache(isDev ? "no-cache" : { maxAge: SPEC_CACHE_MAX_AGE_SECONDS, public: true }) + .withCache("no-cache") .withCORS(req, ctx.securityConfig?.cors) .withContentType( isYaml ? "text/yaml; charset=utf-8" : "application/json; charset=utf-8", @@ -88,14 +96,6 @@ export class OpenAPIHandler extends BaseHandler { } private async getOrGenerateSpec(ctx: HandlerContext, url: URL): Promise { - const isDev = !!ctx.isLocalProject; - const branch = ctx.parsedDomain?.branch ?? ""; - const currentKey = `${ctx.projectDir}:${ctx.projectSlug || "default"}:${branch}:${ - ctx.releaseId ?? "" - }`; - - if (!isDev && this.cachedSpec && this.cacheKey === currentKey) return this.cachedSpec; - const discover = async (): Promise => { const router = new ApiRouteMatcher(); const pagesDir = ctx.config?.directories?.pages ?? "pages"; @@ -122,38 +122,15 @@ export class OpenAPIHandler extends BaseHandler { const serverUrl = `${url.protocol}//${url.host}`; return await generateOpenAPISpec(router, ctx.projectDir, ctx.adapter, ctx.config, { servers: [{ url: serverUrl, description: "Current server" }], + allowHostProjectCodeExecution: true, }); }; - // In proxy mode, wrap discovery in runWithContext so VFS can resolve files. - // Requires both extended FS adapter AND multi-project mode support. - const extFs = isExtendedFSAdapter(ctx.adapter.fs) ? ctx.adapter.fs : null; - const needsContext = !isDev && ctx.projectSlug && ctx.proxyToken && - extFs?.isMultiProjectMode(); - - const spec = needsContext - ? await (extFs as ExtendedFileSystemAdapter).runWithContext( - ctx.projectSlug!, - ctx.proxyToken!, - discover, - ctx.projectId, - { - productionMode: ctx.resolvedEnvironment === "production", - releaseId: ctx.releaseId, - branch: ctx.parsedDomain?.branch ?? null, - environmentName: ctx.environmentName, - }, - ) - : await discover(); - - if (!isDev) { - this.cachedSpec = spec; - this.cacheKey = currentKey; - } + const spec = await discover(); logger.debug("Generated spec", { pathCount: Object.keys(spec.paths).length, - isDev, + isLocalProject: true, }); return spec; diff --git a/src/server/handlers/request/project-run-execute.handler.test.ts b/src/server/handlers/request/project-run-execute.handler.test.ts index f94440d5e2..358d6b516f 100644 --- a/src/server/handlers/request/project-run-execute.handler.test.ts +++ b/src/server/handlers/request/project-run-execute.handler.test.ts @@ -13,6 +13,8 @@ import { runEval as runEvalDefinition } from "#veryfront/eval/runner.ts"; import { datasets, evalAgent, type EvalReport, metrics } from "veryfront/eval"; import { createMockAdapter } from "#veryfront/platform/adapters/mock.ts"; import { runWithRequestContext } from "#veryfront/platform/adapters/fs/veryfront/request-context.ts"; +import { runWithExactSourceIntegrationPolicy } from "#veryfront/integrations/source-policy-context.ts"; +import { normalizeSourceIntegrationPolicy } from "#veryfront/integrations/source-policy.ts"; import { withMockFetch } from "#veryfront/testing/mock-fetch.ts"; import { ProjectRunExecuteHandler, @@ -1335,23 +1337,28 @@ describe("server/handlers/request/project-run-execute.handler", () => { mode: "preview" as const, }, resolvedEnvironment: "preview", + allowHostProjectCodeExecution: true, } as HandlerContext; - const result = await runWithRequestContext( - { - projectSlug: "demo-project", - projectId: "proj-1", - token: "runtime-token", - productionMode: false, - branch: "main", - }, - () => handler.handle(request, ctx), + const result = await runWithExactSourceIntegrationPolicy( + normalizeSourceIntegrationPolicy(undefined), + () => + runWithRequestContext( + { + projectSlug: "demo-project", + projectId: "proj-1", + token: "runtime-token", + productionMode: false, + branch: "main", + }, + () => handler.handle(request, ctx), + ), ); assertExists(result.response); assertEquals(result.response.status, 200); const response = await result.response.json(); - assertEquals(response.success, true); + assertEquals(response.success, true, response.error ?? undefined); assertEquals(response.result, { lookup: { message: "hello from control plane", diff --git a/src/server/handlers/request/project-run-execute.handler.ts b/src/server/handlers/request/project-run-execute.handler.ts index d90e5a3add..2e05c2ccbf 100644 --- a/src/server/handlers/request/project-run-execute.handler.ts +++ b/src/server/handlers/request/project-run-execute.handler.ts @@ -123,6 +123,7 @@ export interface ProjectRunExecuteHandlerDeps { adapter: RuntimeAdapter; config?: VeryfrontConfig; debug?: boolean; + allowHostProjectCodeExecution?: boolean; }, ): Promise; findEvalById( @@ -132,6 +133,7 @@ export interface ProjectRunExecuteHandlerDeps { adapter: RuntimeAdapter; config?: VeryfrontConfig; debug?: boolean; + allowHostProjectCodeExecution?: boolean; }, ): Promise; createWorkflowClient( @@ -421,6 +423,7 @@ async function executeWorkflowRun( adapter: ctx.adapter, config: ctx.config, debug: ctx.debug, + allowHostProjectCodeExecution: ctx.allowHostProjectCodeExecution, }); if (!workflow) { @@ -1020,6 +1023,7 @@ async function executeEvalRun( adapter: ctx.adapter, config: ctx.config, debug: ctx.debug, + allowHostProjectCodeExecution: ctx.allowHostProjectCodeExecution, }); if (!evalItem) { diff --git a/src/server/handlers/request/rsc/index.ts b/src/server/handlers/request/rsc/index.ts index 813f539901..e380cfd736 100644 --- a/src/server/handlers/request/rsc/index.ts +++ b/src/server/handlers/request/rsc/index.ts @@ -24,6 +24,7 @@ import { createHandlerDependencyPinningSource, getHandlerDependencyPinningIdentity, } from "#veryfront/server/handlers/utils/dependency-pinning-source.ts"; +import { isHostProjectCodeExecutionAllowed } from "#veryfront/security/project-locality.ts"; export class RSCHandler extends BaseHandler { metadata: HandlerMetadata = { @@ -71,6 +72,7 @@ export class RSCHandler extends BaseHandler { adapter: ctx.adapter, config: ctx.config, isLocalProject, + allowHostProjectCodeExecution: isHostProjectCodeExecutionAllowed(ctx), mode: isRSCProductionMode(ctx) ? "production" : "development", nonce, }); diff --git a/src/server/handlers/request/snippet.handler.test.ts b/src/server/handlers/request/snippet.handler.test.ts index 9a251f9a66..7b48344a47 100644 --- a/src/server/handlers/request/snippet.handler.test.ts +++ b/src/server/handlers/request/snippet.handler.test.ts @@ -61,8 +61,8 @@ describe("snippet handler path validation", () => { }); }); -Deno.test("SnippetHandler validates and reads inside the same proxy context", async () => { - let inContext = false; +Deno.test("SnippetHandler rejects shared rendering before proxy context or source reads", async () => { + let contextCalls = 0; let readPath: string | undefined; const fs = { symlinkSemantics: "none" as const, @@ -73,12 +73,8 @@ Deno.test("SnippetHandler validates and reads inside the same proxy context", as _token: string, fn: () => Promise, ) => { - inContext = true; - try { - return await fn(); - } finally { - inContext = false; - } + contextCalls++; + return await fn(); }, exists: () => Promise.resolve(true), stat: () => @@ -90,7 +86,6 @@ Deno.test("SnippetHandler validates and reads inside the same proxy context", as mtime: new Date(), }), readFile: (path: string) => { - assertEquals(inContext, true); readPath = path; return Promise.resolve(""); }, @@ -103,6 +98,42 @@ Deno.test("SnippetHandler validates and reads inside the same proxy context", as adapter: { fs }, } as unknown as HandlerContext; + const result = await new SnippetHandler().handle( + new Request("http://localhost/@components/button"), + ctx, + ); + assertEquals(result.response?.status, 503); + assertEquals(result.response?.headers.get("content-type"), "application/problem+json"); + assertEquals(contextCalls, 0); + assertEquals(readPath, undefined); +}); + +Deno.test("SnippetHandler preserves dedicated local rendering", async () => { + let readPath: string | undefined; + const fs = { + symlinkSemantics: "none" as const, + isMultiProjectMode: () => false, + isContextualMode: () => false, + exists: () => Promise.resolve(true), + stat: () => + Promise.resolve({ + isFile: true, + isDirectory: false, + isSymlink: false, + size: 0, + mtime: new Date(), + }), + readFile: (path: string) => { + readPath = path; + return Promise.resolve(""); + }, + }; + const ctx = { + projectDir: "/project", + isLocalProject: true, + adapter: { fs }, + } as unknown as HandlerContext; + const result = await new SnippetHandler().handle( new Request("http://localhost/@components/button"), ctx, diff --git a/src/server/handlers/request/snippet.handler.ts b/src/server/handlers/request/snippet.handler.ts index 05b924c145..946576aff5 100644 --- a/src/server/handlers/request/snippet.handler.ts +++ b/src/server/handlers/request/snippet.handler.ts @@ -4,12 +4,15 @@ import { serverLogger } from "#veryfront/utils"; import { renderSnippet } from "#veryfront/rendering/snippet-renderer.ts"; import { createErrorResponse, + createErrorResponseFromDefinition, FILE_NOT_FOUND, getErrorMessage, + PROJECT_EXECUTION_UNAVAILABLE, SECURITY_VIOLATION, VeryfrontError, } from "#veryfront/errors"; import { validatePath, ValidationPresets } from "#veryfront/security"; +import { isSharedProjectRuntime } from "#veryfront/security/project-locality.ts"; import { createHandlerDependencyPinningSource, getHandlerDependencyPinningIdentity, @@ -34,6 +37,24 @@ export class SnippetHandler extends BaseHandler { return this.continue(); } + if (isSharedProjectRuntime(ctx)) { + const problem = createErrorResponseFromDefinition( + PROJECT_EXECUTION_UNAVAILABLE, + { + detail: + "Shared runtimes require a dedicated isolated project runtime for snippet rendering", + instance: pathname, + }, + ); + const response = this.createResponseBuilder(ctx) + .withCORS(req, ctx.securityConfig?.cors) + .withSecurity(ctx.securityConfig ?? undefined, req) + .withCache("no-store") + .withHeaders(problem.headers) + .build(problem.body, problem.status); + return Promise.resolve(this.respond(response)); + } + logger.debug("Handling snippet request", { pathname, projectSlug: ctx.projectSlug, diff --git a/src/server/handlers/request/ssr/ssr.handler.test.ts b/src/server/handlers/request/ssr/ssr.handler.test.ts index 4aa6a16ceb..162e970fac 100644 --- a/src/server/handlers/request/ssr/ssr.handler.test.ts +++ b/src/server/handlers/request/ssr/ssr.handler.test.ts @@ -96,6 +96,73 @@ describe("server/handlers/request/ssr/ssr.handler", () => { }); describe("handle - with mock SSRService", () => { + it("passes only application headers into project rendering", async () => { + let renderedRequest: Request | undefined; + const mockService = createMockSSRService({ + renderPage: (_ctx, options) => { + renderedRequest = options.request; + return Promise.resolve({ + status: 200, + html: "rendered page", + isStreaming: false, + cacheStrategy: "short" as const, + slug: "headers", + }); + }, + }); + const handler = new SSRHandler(mockService); + await handler.handle( + new Request("http://localhost/headers", { + headers: { + authorization: "Bearer application-token", + cookie: "session=application-cookie", + "proxy-authorization": "Basic infrastructure-proxy-token", + "x-forwarded-host": "internal-proxy.example", + "x-project-id": "infrastructure-project", + "x-token": "platform-service-token", + "x-veryfront-control-plane-jws": "signed-control-plane-request", + }, + }), + makeCtx({ isLocalProject: true }), + ); + + assertEquals(renderedRequest?.headers.get("authorization"), "Bearer application-token"); + assertEquals(renderedRequest?.headers.get("cookie"), "session=application-cookie"); + assertEquals(renderedRequest?.headers.get("proxy-authorization"), null); + assertEquals(renderedRequest?.headers.get("x-forwarded-host"), null); + assertEquals(renderedRequest?.headers.get("x-project-id"), null); + assertEquals(renderedRequest?.headers.get("x-token"), null); + assertEquals(renderedRequest?.headers.get("x-veryfront-control-plane-jws"), null); + }); + + it("returns a typed 503 before shared-runtime rendering", async () => { + let renderCalls = 0; + const handler = new SSRHandler(createMockSSRService({ + renderPage: () => { + renderCalls++; + throw new Error("shared runtime reached the host renderer"); + }, + })); + const result = await handler.handle( + new Request("https://tenant.example/private-page"), + makeCtx({ + isLocalProject: false, + prepareHostedConfigContext: (() => { + throw new Error("shared runtime prepared host rendering context"); + }) as HandlerContext["prepareHostedConfigContext"], + }), + ); + + assertEquals(result.response?.status, 503); + assertEquals(result.response?.headers.get("cache-control"), "no-store"); + assertEquals(result.response?.headers.get("content-type"), "application/problem+json"); + assertEquals( + (await result.response?.json() as { type?: string }).type, + "https://veryfront.com/docs/errors/project-execution-unavailable", + ); + assertEquals(renderCalls, 0); + }); + it("returns response from renderPage result", async () => { const mockService = createMockSSRService({ renderPage: () => @@ -342,7 +409,7 @@ describe("server/handlers/request/ssr/ssr.handler", () => { }; } - it("calls runWithContext with correct args in multi-project mode", async () => { + it("fails closed before entering a multi-project rendering context", async () => { const mockService = createMockSSRService(); const handler = new SSRHandler(mockService); const { ctx, calls } = makeExtendedCtx({}, { @@ -362,15 +429,11 @@ describe("server/handlers/request/ssr/ssr.handler", () => { }); const req = new Request("http://localhost/page"); - await handler.handle(req, ctx); + const result = await handler.handle(req, ctx); - assertEquals(calls.runWithContext![0], "my-slug"); - assertEquals(calls.runWithContext![1], "tok-abc"); - assertEquals(calls.runWithContext![2], "proj-42"); - const opts = calls.runWithContext![3] as Record; - assertEquals(opts.releaseId, "rel-1"); - assertEquals(opts.branch, "feature-x"); - assertEquals(opts.environmentName, "staging"); + assertEquals(calls.runWithContext, undefined); + assertEquals(result.response?.status, 503); + assertEquals(result.response?.headers.get("content-type"), "application/problem+json"); }); it("skips runWithContext when projectSlug is missing", async () => { @@ -601,8 +664,8 @@ describe("server/handlers/request/ssr/ssr.handler", () => { }); }); - describe("handle - context setup error", () => { - it("falls through to 404 when context setup throws", async () => { + describe("handle - hostile shared context", () => { + it("returns 503 without invoking a throwing shared context", async () => { const throwingFs = { exists: () => Promise.resolve(false), readFile: () => Promise.resolve(""), @@ -624,7 +687,8 @@ describe("server/handlers/request/ssr/ssr.handler", () => { const ctx = makeCtx({ adapter, projectSlug: "test" }); const result = await handler.handle(new Request("http://localhost/page"), ctx); - assertEquals(result.continue, true); + assertEquals(result.continue, false); + assertEquals(result.response?.status, 503); }); }); diff --git a/src/server/handlers/request/ssr/ssr.handler.ts b/src/server/handlers/request/ssr/ssr.handler.ts index 4a084ec69b..c67683cbff 100644 --- a/src/server/handlers/request/ssr/ssr.handler.ts +++ b/src/server/handlers/request/ssr/ssr.handler.ts @@ -33,6 +33,7 @@ import { ErrorPages } from "../../../utils/error-html.ts"; import { isSSRBuildFailure } from "#veryfront/rendering/ssr-outcome.ts"; import { buildSSRResponse } from "./ssr-response-builder.ts"; import { type DependencyPinningSnapshot } from "#veryfront/transforms/esm/package-registry.ts"; +import { createApplicationRequestHeaders } from "#veryfront/security/http/application-request.ts"; import { createHandlerDependencyPinningSource } from "#veryfront/server/handlers/utils/dependency-pinning-source.ts"; import { applySnapshotResponseHeaders, @@ -42,6 +43,14 @@ import { stripSnapshotHeader, } from "#veryfront/server/handlers/utils/dependency-snapshot-protocol.ts"; import { isProductionMode, shouldHideRouteInProduction } from "../route-visibility-policy.ts"; +import { + createErrorResponseFromDefinition, + PROJECT_EXECUTION_UNAVAILABLE, +} from "#veryfront/errors"; +import { + isHostProjectCodeExecutionAllowed, + isSharedProjectRuntime, +} from "#veryfront/security/project-locality.ts"; const logger = serverLogger.component("ssr"); @@ -95,6 +104,24 @@ export class SSRHandler extends BaseHandler { return Promise.resolve(this.continue()); } + if (isSharedProjectRuntime(ctx) && !isHostProjectCodeExecutionAllowed(ctx)) { + const problem = createErrorResponseFromDefinition( + PROJECT_EXECUTION_UNAVAILABLE, + { + detail: + "Shared runtimes require a dedicated isolated project runtime for server rendering", + instance: pathname, + }, + ); + const body = req.method === "HEAD" ? null : problem.body; + const response = this.createResponseBuilder(ctx, generateNonce()) + .withSecurity(ctx.securityConfig ?? undefined, req) + .withCache("no-store") + .withHeaders(problem.headers) + .build(body, problem.status); + return Promise.resolve(this.respond(response)); + } + this.logDebug("SSR attempt", { pathname, slug }, ctx); return this.setupContextAndRender(req, ctx, slug, requestId, url); @@ -208,7 +235,9 @@ export class SSRHandler extends BaseHandler { const dependencySnapshot = resolution.snapshot; const applicationUrl = new URL(url); - const applicationHeaders = stripSnapshotHeader(req.headers); + const applicationHeaders = createApplicationRequestHeaders( + stripSnapshotHeader(req.headers), + ); const applicationRequest = new Request(applicationUrl, { method: req.method, headers: applicationHeaders, diff --git a/src/server/handlers/response/cors.test.ts b/src/server/handlers/response/cors.test.ts index c766fefafe..7d99069095 100644 --- a/src/server/handlers/response/cors.test.ts +++ b/src/server/handlers/response/cors.test.ts @@ -53,7 +53,7 @@ describe("server/handlers/response/cors", () => { const handler = new CorsHandler(); assertEquals(handler.metadata.name, "CorsHandler"); assertEquals(handler.metadata.patterns?.length, 1); - assertEquals(handler.metadata.patterns?.[0].method, "OPTIONS"); + assertEquals(handler.metadata.patterns?.[0]?.method, "OPTIONS"); }); it("continues for non-OPTIONS requests", async () => { @@ -120,5 +120,54 @@ describe("server/handlers/response/cors", () => { const result = await handler.handle(req, ctx); assertEquals(result.response instanceof Response, true); }); + + it("does not resolve or import project routes in a shared runtime", async () => { + let routeResolutionCalls = 0; + const handler = new CorsHandler({ + resolveAppRouteFile: () => { + routeResolutionCalls++; + throw new Error("shared preflight reached project route discovery"); + }, + }); + const result = await handler.handle( + new Request("https://tenant.example/api/private", { + method: "OPTIONS", + headers: { + Origin: "https://app.example", + "access-control-request-method": "POST", + }, + }), + makeCtx({ + prepareHostedConfigContext: (() => { + throw new Error("shared preflight prepared project config"); + }) as HandlerContext["prepareHostedConfigContext"], + }), + ); + + assertEquals(result.response instanceof Response, true); + assertEquals(routeResolutionCalls, 0); + }); + + it("does not advertise infrastructure-only request headers", async () => { + const result = await new CorsHandler().handle( + new Request("http://localhost/api/test", { + method: "OPTIONS", + headers: { + Origin: "https://app.example", + "access-control-request-method": "POST", + "access-control-request-headers": + "Authorization, X-Token, X-Project-Id, X-Veryfront-Dispatch-JWS, X-App-Trace", + }, + }), + makeCtx({ + securityConfig: { cors: { origin: ["https://app.example"] } } as never, + }), + ); + + assertEquals( + result.response?.headers.get("access-control-allow-headers"), + "Authorization, X-App-Trace", + ); + }); }); }); diff --git a/src/server/handlers/response/cors.ts b/src/server/handlers/response/cors.ts index 0a54b7417d..13259d1802 100644 --- a/src/server/handlers/response/cors.ts +++ b/src/server/handlers/response/cors.ts @@ -10,6 +10,25 @@ import { ResponseBuilder } from "#veryfront/security/index.ts"; import { getConfig } from "#veryfront/config"; import { PRIORITY_VERY_HIGH } from "#veryfront/utils/constants/index.ts"; import { resolveAppRouteFile } from "../request/api/app-router-resolver.ts"; +import { isSharedProjectRuntime } from "#veryfront/security/project-locality.ts"; +import { isInfrastructureOnlyRequestHeader } from "#veryfront/security/http/application-request.ts"; + +type AppRouteResolver = typeof resolveAppRouteFile; +const DEFAULT_ALLOWED_HEADERS = "Content-Type,Authorization"; + +function getApplicationPreflightHeaders(request: Request): string { + const requested = request.headers.get("access-control-request-headers"); + if (!requested) return DEFAULT_ALLOWED_HEADERS; + + const allowed = requested.split(",") + .map((name) => name.trim()) + .filter((name) => name.length > 0 && !isInfrastructureOnlyRequestHeader(name)); + return allowed.length > 0 ? allowed.join(",") : DEFAULT_ALLOWED_HEADERS; +} + +export interface CorsHandlerDependencies { + resolveAppRouteFile?: AppRouteResolver; +} export class CorsHandler extends BaseHandler { metadata: HandlerMetadata = { @@ -20,31 +39,41 @@ export class CorsHandler extends BaseHandler { private static readonly DEFAULT_METHODS = "GET,POST,PUT,PATCH,DELETE,OPTIONS"; private static readonly HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const; + private readonly resolveAppRouteFile: AppRouteResolver; + + constructor(dependencies: CorsHandlerDependencies = {}) { + super(); + this.resolveAppRouteFile = dependencies.resolveAppRouteFile ?? resolveAppRouteFile; + } async handle(req: Request, ctx: HandlerContext): Promise { if (req.method.toUpperCase() !== "OPTIONS") return this.continue(); const pathname = new URL(req.url).pathname; - const allowMethods = await this.resolveAllowedMethods(pathname, ctx); + const isSharedRuntime = isSharedProjectRuntime(ctx); + const allowMethods = isSharedRuntime + ? CorsHandler.DEFAULT_METHODS + : await this.resolveAllowedMethods(pathname, ctx); let corsConfig = ctx.securityConfig?.cors; - try { - const cfg = await getConfig(ctx.projectDir, ctx.adapter); - corsConfig = cfg?.security?.cors ?? corsConfig; - } catch (error) { - // Falling back to ctx.securityConfig?.cors (set at request time). If that is - // also absent, ResponseBuilder.preflight will use its own restrictive defaults. - // Verify the fallback is not more permissive than the config-file value intended. - this.logWarn( - "Failed to load CORS config — falling back to security-context defaults", - { error }, - ); + if (!isSharedRuntime) { + try { + const cfg = await getConfig(ctx.projectDir, ctx.adapter); + corsConfig = cfg?.security?.cors ?? corsConfig; + } catch (error) { + // Falling back to ctx.securityConfig?.cors (set at request time). If that is + // also absent, ResponseBuilder.preflight will use its own restrictive defaults. + // Verify the fallback is not more permissive than the config-file value intended. + this.logWarn( + "Failed to load CORS config — falling back to security-context defaults", + { error }, + ); + } } const response = ResponseBuilder.preflight(req, { allowMethods, - allowHeaders: req.headers.get("access-control-request-headers") ?? - "Content-Type,Authorization", + allowHeaders: getApplicationPreflightHeaders(req), securityConfig: ctx.securityConfig ?? undefined, corsConfig, }); @@ -54,7 +83,7 @@ export class CorsHandler extends BaseHandler { private async resolveAllowedMethods(pathname: string, ctx: HandlerContext): Promise { try { - const match = await resolveAppRouteFile(pathname, ctx); + const match = await this.resolveAppRouteFile(pathname, ctx); if (!match) return CorsHandler.DEFAULT_METHODS; const mod = (await import(`file://${match.file}`)) as RouteHandlerModule; diff --git a/src/server/production-server.ts b/src/server/production-server.ts index 1ee5a15c77..c4390c08bb 100644 --- a/src/server/production-server.ts +++ b/src/server/production-server.ts @@ -32,6 +32,7 @@ import { } from "#veryfront/rendering/ssr-globals.ts"; import type { FileSystemAdapter } from "#veryfront/platform/adapters/base.ts"; import { snapshotNodeWebSocketServerProvider } from "#veryfront/extensions/websocket"; +import { isSharedProjectRuntime } from "#veryfront/security/project-locality.ts"; const serverLog = logger.component("server"); const globalLog = logger.component("global"); @@ -249,6 +250,7 @@ export function startProductionServer( baseDir: discoveryConfig.baseDir, fsAdapter: discoveryConfig.fsAdapter, verbose: discoveryConfig.verbose ?? false, + allowHostProjectCodeExecution: true, }); } } catch (error) { @@ -269,6 +271,8 @@ export function startProductionServer( defaultReleaseId, defaultEnvironment, localProjects, + allowHostProjectCodeExecution: bootstrap.config.fs?.veryfront?.proxyMode !== true && + !isSharedProjectRuntime({ adapter }), }); const coreHandler = baseHandler; diff --git a/src/server/runtime-handler/handler-context-builder.test.ts b/src/server/runtime-handler/handler-context-builder.test.ts index 6893aceec0..b83a2b2a82 100644 --- a/src/server/runtime-handler/handler-context-builder.test.ts +++ b/src/server/runtime-handler/handler-context-builder.test.ts @@ -68,6 +68,16 @@ describe("buildHandlerContext", () => { assertEquals(ctx.proxyToken, undefined); }); + it("preserves the narrow host project-code execution capability", () => { + const ctx = buildHandlerContext( + makeOpts({ allowHostProjectCodeExecution: true }), + ); + + assertEquals(ctx.allowHostProjectCodeExecution, true); + assertEquals(ctx.isLocalProject, false); + assertEquals(ctx.enriched?.allowHostProjectCodeExecution, true); + }); + it("builds enriched context when both config and projectSlug present", () => { const opts = makeOpts({ config: { name: "test" } as any, diff --git a/src/server/runtime-handler/handler-context-builder.ts b/src/server/runtime-handler/handler-context-builder.ts index e05db73c24..9d5b50096b 100644 --- a/src/server/runtime-handler/handler-context-builder.ts +++ b/src/server/runtime-handler/handler-context-builder.ts @@ -52,6 +52,8 @@ export interface HandlerContextOptions { routeRegistry: RouteRegistry; /** Whether this is a local project */ isLocalProject: boolean; + /** Narrow host-owned capability for project-code execution. */ + allowHostProjectCodeExecution?: boolean; /** Module server URL */ moduleServerUrl: string | undefined; /** Environment ID for env var resolution (from proxy x-environment-id header) */ @@ -87,6 +89,7 @@ export function buildHandlerContext(opts: HandlerContextOptions): HandlerContext environment: opts.resolvedEnvironment, branch: opts.requestContext.branch, isLocalProject: opts.isLocalProject, + allowHostProjectCodeExecution: opts.allowHostProjectCodeExecution, contentSourceId, parsedDomain: opts.parsedDomain, adapter: opts.adapter, @@ -116,6 +119,7 @@ export function buildHandlerContext(opts: HandlerContextOptions): HandlerContext requestContext: { ...opts.requestContext, mode: opts.resolvedEnvironment }, routeRegistry: opts.routeRegistry, isLocalProject: opts.isLocalProject, + allowHostProjectCodeExecution: opts.allowHostProjectCodeExecution, environmentId: opts.environmentId, prepareHostedConfigContext: opts.prepareHostedConfigContext, enriched: enrichedContext, diff --git a/src/server/runtime-handler/index.ts b/src/server/runtime-handler/index.ts index 434a029af6..b1f1cf95bc 100644 --- a/src/server/runtime-handler/index.ts +++ b/src/server/runtime-handler/index.ts @@ -284,6 +284,8 @@ export interface RuntimeHandlerOptions { defaultReleaseId?: string; /** Default environment for standalone mode (preview or production). Defaults to preview for safety. */ defaultEnvironment?: "preview" | "production"; + /** Host-owned capability for dedicated single-project runtime execution. */ + allowHostProjectCodeExecution?: boolean; } export function createVeryfrontHandler( @@ -550,6 +552,7 @@ export function createVeryfrontHandler( headers, requestContext: reqCtx, isProxyMode, + allowHostProjectCodeExecution: opts.allowHostProjectCodeExecution, proxyTrust: { proxyTrusted }, securityConfig: securityLoader.getSecurityConfig(), cspUserHeader: securityLoader.getCspUserHeader(), diff --git a/src/server/runtime-handler/project-middleware.test.ts b/src/server/runtime-handler/project-middleware.test.ts index d6b75e0bde..c169306349 100644 --- a/src/server/runtime-handler/project-middleware.test.ts +++ b/src/server/runtime-handler/project-middleware.test.ts @@ -25,9 +25,14 @@ interface ActiveFsContext { function createAdapter( storage = new AsyncLocalStorage(), middlewareSource?: string, - options: { requireContextForFileAccess?: boolean } = {}, + options: { + requireContextForFileAccess?: boolean; + onFileAccess?: () => void; + onFileRead?: () => void; + } = {}, ): RuntimeAdapter { const assertContext = () => { + options.onFileAccess?.(); if (options.requireContextForFileAccess && !storage.getStore()) { throw new Error("[test] No request context available"); } @@ -44,6 +49,7 @@ function createAdapter( }, readFile: () => { assertContext(); + options.onFileRead?.(); return Promise.resolve(middlewareSource ?? ""); }, runWithContext: ( @@ -294,7 +300,51 @@ describe("ProjectMiddlewareRuntime", () => { ]); }); - it("preserves request and response identity for non-HMR WebSocket upgrade handling", async () => { + it("exposes application auth while withholding infrastructure headers", async () => { + const adapter = createAdapter(); + const runtime = new ProjectMiddlewareRuntime({ + loadMiddleware: () => + Promise.resolve([ + (c) => + Response.json({ + authorization: c.req.headers.get("authorization"), + cookie: c.req.headers.get("cookie"), + proxyAuthorization: c.req.headers.get("proxy-authorization"), + forwardedHost: c.req.headers.get("x-forwarded-host"), + projectId: c.req.headers.get("x-project-id"), + platformToken: c.req.headers.get("x-token"), + dispatchSignature: c.req.headers.get("x-veryfront-dispatch-jws"), + }), + ]), + }); + const response = await execute( + runtime, + createContext(adapter), + new Request("https://example.com/resource", { + headers: { + authorization: "Bearer application-token", + cookie: "session=application-cookie", + "proxy-authorization": "Basic infrastructure-proxy-token", + "x-forwarded-host": "internal-proxy.example", + "x-project-id": "infrastructure-project", + "x-token": "platform-service-token", + "x-veryfront-dispatch-jws": "signed-dispatch-request", + }, + }), + ); + + assertEquals(await response?.json(), { + authorization: "Bearer application-token", + cookie: "session=application-cookie", + proxyAuthorization: null, + forwardedHost: null, + projectId: null, + platformToken: null, + dispatchSignature: null, + }); + }); + + it("detaches the project request while preserving WebSocket and response behavior", async () => { const adapter = createAdapter(); const request = new Request("https://example.com/socket", { headers: { upgrade: "websocket" }, @@ -305,7 +355,8 @@ describe("ProjectMiddlewareRuntime", () => { loadMiddleware: () => Promise.resolve([ async (c, next) => { - assertEquals(c.req === request, true); + assertEquals(c.req === request, false); + assertEquals(c.req.headers.get("upgrade"), "websocket"); middlewareSawRequest = true; return await next(); }, @@ -384,25 +435,102 @@ describe("ProjectMiddlewareRuntime", () => { assertEquals(await response?.text(), "recovered"); }); - it("rejects malformed shared production middleware before routing", async () => { + it("returns an unavailable response before reading or evaluating shared middleware", async () => { + const marker = `__vf_project_middleware_${crypto.randomUUID().replaceAll("-", "")}`; + const host = globalThis as unknown as Record; + let sourceReads = 0; const adapter = createAdapter( undefined, - "export const middleware = () => new Response('untrusted');", + `globalThis.${marker} = Deno.env.get("HOST_SECRET"); export default [];`, + { onFileRead: () => sourceReads++ }, ); const runtime = new ProjectMiddlewareRuntime(); let routeCalls = 0; - await assertRejects( - () => - execute(runtime, createContext(adapter), undefined, () => { + try { + const response = await execute( + runtime, + createContext(adapter), + undefined, + () => { routeCalls++; return Promise.resolve(new Response("route")); + }, + ); + + assertEquals(response?.status, 503); + assertEquals(response?.headers.get("cache-control"), "no-store"); + assertEquals(response?.headers.get("content-type"), "application/problem+json"); + const problem = await response?.json(); + assertEquals(problem?.title, "Project execution unavailable"); + + const headResponse = await execute( + runtime, + createContext(adapter), + new Request("https://example.com/resource", { method: "HEAD" }), + ); + assertEquals(headResponse?.status, 503); + assertEquals(headResponse?.headers.get("cache-control"), "no-store"); + assertEquals(await headResponse?.text(), ""); + assertEquals(sourceReads, 0); + assertEquals(host[marker], undefined); + assertEquals(routeCalls, 0); + } finally { + delete host[marker]; + } + }); + + it("honors an explicit host-execution denial outside proxy mode", async () => { + const marker = `__vf_denied_project_middleware_${crypto.randomUUID().replaceAll("-", "")}`; + const host = globalThis as unknown as Record; + let sourceReads = 0; + const adapter = createAdapter( + undefined, + `globalThis.${marker} = Deno.env.get("HOST_SECRET"); export default [];`, + { onFileRead: () => sourceReads++ }, + ); + const runtime = new ProjectMiddlewareRuntime(); + let routeCalls = 0; + + try { + const response = await runtime.execute({ + request: new Request("https://example.com/resource"), + handlerContext: createContext(adapter, { + allowHostProjectCodeExecution: false, }), - TypeError, - "Invalid middleware export", + isSharedProxy: false, + next: () => { + routeCalls++; + return Promise.resolve(new Response("route")); + }, + }); + + assertEquals(response?.status, 503); + assertEquals(response?.headers.get("cache-control"), "no-store"); + assertEquals(sourceReads, 0); + assertEquals(host[marker], undefined); + assertEquals(routeCalls, 0); + } finally { + delete host[marker]; + } + }); + + it("passes through shared requests when no root middleware exists", async () => { + const runtime = new ProjectMiddlewareRuntime(); + let routeCalls = 0; + + const response = await execute( + runtime, + createContext(createAdapter()), + undefined, + () => { + routeCalls++; + return Promise.resolve(new Response("route")); + }, ); - assertEquals(routeCalls, 0); + assertEquals(routeCalls, 1); + assertEquals(await response?.text(), "route"); }); it("keeps the compiled middleware cache bounded", async () => { @@ -554,7 +682,7 @@ describe("ProjectMiddlewareRuntime", () => { assertEquals(routeCalls, 0); }); - it("uses the same middleware runtime for local, standalone, and unauthenticated contexts", async () => { + it("separates shared middleware cache entries from host-execution entries", async () => { const adapter = createAdapter(); let loads = 0; const runtime = new ProjectMiddlewareRuntime({ @@ -578,7 +706,7 @@ describe("ProjectMiddlewareRuntime", () => { await execute(runtime, createContext(adapter, { isLocalProject: true }), undefined, next); await execute(runtime, createContext(adapter, { proxyToken: undefined }), undefined, next); - assertEquals(loads, 1); + assertEquals(loads, 2); assertEquals(routeCalls, 3); }); diff --git a/src/server/runtime-handler/project-middleware.ts b/src/server/runtime-handler/project-middleware.ts index 370650f2d6..13fcb922b9 100644 --- a/src/server/runtime-handler/project-middleware.ts +++ b/src/server/runtime-handler/project-middleware.ts @@ -8,11 +8,18 @@ import { getProjectEnvSnapshot } from "#veryfront/server/project-env"; import { loadMiddlewareFile, type MiddlewareFunction, + ProjectMiddlewareHostExecutionDeniedError, } from "#veryfront/server/dev-server/middleware.ts"; +import { + createErrorResponseFromDefinition, + PROJECT_EXECUTION_UNAVAILABLE, +} from "#veryfront/errors"; import type { HandlerContext } from "#veryfront/types"; import { LRUCache } from "#veryfront/utils/lru-wrapper.ts"; import { serverLogger } from "#veryfront/utils"; import { isWebSocketPath } from "#veryfront/server/runtime-handler/request-utils.ts"; +import { isHostProjectCodeExecutionAllowed } from "#veryfront/security/project-locality.ts"; +import { createApplicationRequest } from "#veryfront/security/http/application-request.ts"; const DEFAULT_MAX_ENTRIES = 100; const logger = serverLogger.component("project-middleware"); @@ -20,6 +27,7 @@ const logger = serverLogger.component("project-middleware"); type MiddlewareLoader = ( projectDir: string, adapter: RuntimeAdapter, + allowHostProjectCodeExecution: boolean, ) => Promise; interface ProjectMiddlewareRuntimeOptions { @@ -57,7 +65,11 @@ export class ProjectMiddlewareRuntime { maxEntries: options.maxEntries ?? DEFAULT_MAX_ENTRIES, }); this.#loadMiddleware = options.loadMiddleware ?? - ((projectDir, adapter) => loadMiddlewareFile(projectDir, adapter, { throwOnError: true })); + ((projectDir, adapter, allowHostProjectCodeExecution) => + loadMiddlewareFile(projectDir, adapter, { + throwOnError: true, + allowHostProjectCodeExecution, + })); if (options.registryName) { registerLRUCache(options.registryName, this.#cache); @@ -103,8 +115,36 @@ export class ProjectMiddlewareRuntime { const environment = resolvedEnvironment(ctx); const branch = resolvedBranch(ctx); + const allowHostProjectCodeExecution = isHostProjectCodeExecutionAllowed(ctx); const executeMiddleware = async (): Promise => { - const middleware = await this.#getMiddleware(ctx, environment, branch); + let middleware: readonly MiddlewareFunction[]; + try { + middleware = await this.#getMiddleware( + ctx, + environment, + branch, + allowHostProjectCodeExecution, + ); + } catch (error) { + if (!(error instanceof ProjectMiddlewareHostExecutionDeniedError)) throw error; + + const unavailable = createErrorResponseFromDefinition( + PROJECT_EXECUTION_UNAVAILABLE, + { + detail: + "Shared runtimes require a dedicated isolated project runtime for project middleware", + instance: pathname, + }, + ); + unavailable.headers.set("cache-control", "no-store"); + if (request.method !== "HEAD") return unavailable; + + return new Response(null, { + status: unavailable.status, + statusText: unavailable.statusText, + headers: unavailable.headers, + }); + } if (middleware.length === 0) return next(); const pipeline = new MiddlewarePipeline(); @@ -112,7 +152,7 @@ export class ProjectMiddlewareRuntime { const composed = pipeline.compose(); const middlewareContext = new MiddlewareContext( - request, + createApplicationRequest(request), getProjectEnvSnapshot() ?? {}, ); return await composed(middlewareContext, next); @@ -144,13 +184,19 @@ export class ProjectMiddlewareRuntime { ctx: HandlerContext, environment: "production" | "preview", branch: string | null, + allowHostProjectCodeExecution: boolean, ): Promise { - const key = this.#buildCacheKey(ctx, environment, branch); - if (!key) return this.#load(ctx); + const key = this.#buildCacheKey( + ctx, + environment, + branch, + allowHostProjectCodeExecution, + ); + if (!key) return this.#load(ctx, allowHostProjectCodeExecution); let pending = this.#cache.get(key); if (!pending) { - pending = Promise.resolve().then(() => this.#load(ctx)); + pending = Promise.resolve().then(() => this.#load(ctx, allowHostProjectCodeExecution)); this.#cache.set(key, pending); } @@ -166,6 +212,7 @@ export class ProjectMiddlewareRuntime { ctx: HandlerContext, environment: "production" | "preview", branch: string | null, + allowHostProjectCodeExecution: boolean, ): string | null { const projectIdentity = ctx.projectId ?? ctx.projectSlug; if (!projectIdentity) return null; @@ -176,24 +223,34 @@ export class ProjectMiddlewareRuntime { const environmentIdentity = ctx.environmentId ?? ctx.environmentName ?? "default"; return [ cacheSegment(projectIdentity), + allowHostProjectCodeExecution ? "host" : "isolated", environment, cacheSegment(sourceIdentity), cacheSegment(environmentIdentity), ].join(":"); } - async #load(ctx: HandlerContext): Promise { + async #load( + ctx: HandlerContext, + allowHostProjectCodeExecution: boolean, + ): Promise { try { - const fileMiddleware = await this.#loadMiddleware(ctx.projectDir, ctx.adapter); + const fileMiddleware = await this.#loadMiddleware( + ctx.projectDir, + ctx.adapter, + allowHostProjectCodeExecution, + ); return [...fileMiddleware, ...(ctx.config?.middleware?.custom ?? [])]; } catch (error) { - logger.error("Failed to load project middleware", { - projectSlug: ctx.projectSlug, - projectId: ctx.projectId, - releaseId: ctx.releaseId, - branch: resolvedBranch(ctx), - error: error instanceof Error ? error.message : String(error), - }); + if (!(error instanceof ProjectMiddlewareHostExecutionDeniedError)) { + logger.error("Failed to load project middleware", { + projectSlug: ctx.projectSlug, + projectId: ctx.projectId, + releaseId: ctx.releaseId, + branch: resolvedBranch(ctx), + error: error instanceof Error ? error.message : String(error), + }); + } throw error; } } diff --git a/src/server/runtime-handler/project-runtime-context.test.ts b/src/server/runtime-handler/project-runtime-context.test.ts index d3026886f8..4bdf288cf2 100644 --- a/src/server/runtime-handler/project-runtime-context.test.ts +++ b/src/server/runtime-handler/project-runtime-context.test.ts @@ -731,6 +731,7 @@ describe("resolveProjectRuntimeContext", () => { const standaloneProduction = await resolveProjectRuntimeContext(makeRuntimeContextInput({ isProxyMode: false, + allowHostProjectCodeExecution: true, defaultEnvironment: "production", projectIdentity: { projectSlug: "remote-project", @@ -747,6 +748,10 @@ describe("resolveProjectRuntimeContext", () => { assertEquals(standaloneProduction.environment.releaseId, "standalone-dev"); assertExists(standaloneProduction.handlerContext); assertEquals(standaloneProduction.handlerContext.releaseId, "standalone-dev"); + assertEquals( + standaloneProduction.handlerContext.allowHostProjectCodeExecution, + true, + ); }); it("returns production environment errors before reading source policy config", async () => { diff --git a/src/server/runtime-handler/project-runtime-context.ts b/src/server/runtime-handler/project-runtime-context.ts index bdfbcfee7f..78a887f2dd 100644 --- a/src/server/runtime-handler/project-runtime-context.ts +++ b/src/server/runtime-handler/project-runtime-context.ts @@ -84,6 +84,8 @@ export interface ResolveProjectRuntimeContextInput { headers: ProjectRequestHeaders; requestContext: ProjectRequestContext; isProxyMode: boolean; + /** Host-owned capability for dedicated single-project runtime execution. */ + allowHostProjectCodeExecution?: boolean; proxyTrust: { proxyTrusted: boolean | undefined; }; @@ -311,6 +313,7 @@ export async function resolveProjectRuntimeContext( requestContext: reqCtx, routeRegistry: input.routeRegistry, isLocalProject: adapterRes.isLocalProject, + allowHostProjectCodeExecution: input.allowHostProjectCodeExecution, moduleServerUrl: input.moduleServerUrl, environmentId: input.environmentId ?? input.headers.environmentId, skipEnrichedContext: input.skipEnrichedContext ?? shouldSkipEnrichedContext(input.url.pathname), diff --git a/src/server/services/rendering/ssr.service.test.ts b/src/server/services/rendering/ssr.service.test.ts index afabe4c2dc..87aa967549 100644 --- a/src/server/services/rendering/ssr.service.test.ts +++ b/src/server/services/rendering/ssr.service.test.ts @@ -1,6 +1,6 @@ import "#veryfront/schemas/_test-setup.ts"; import "../../../transforms/mdx/compiler/__tests__/content-processor-setup.ts"; -import { assertEquals } from "#veryfront/testing/assert.ts"; +import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { SSRService } from "./ssr.service.ts"; import type { RendererProvider, SSRRenderOptions, SSRRenderResult } from "./ssr.service.ts"; @@ -52,10 +52,20 @@ function makeCtx(overrides: Partial = {}): HandlerContext { adapter: createMockAdapter(), securityConfig: null, cspUserHeader: null, + isLocalProject: true, ...overrides, }; } +function makeSharedCtx(overrides: Partial = {}): HandlerContext { + return makeCtx({ + isLocalProject: false, + prepareHostedConfigContext: () => + Promise.reject(new Error("Shared context preparation is not used by this unit test")), + ...overrides, + }); +} + function makeRenderOptions(overrides: Partial = {}): SSRRenderOptions { const url = new URL("http://localhost/test-page"); return { @@ -232,9 +242,68 @@ describe("server/services/rendering/ssr.service", () => { await service.getRenderer(ctx); assertEquals(receivedProjectSlug, "my-project"); }); + + it("rejects direct shared renderer access before invoking the provider", async () => { + let called = false; + const service = new SSRService({ + rendererProvider: { + getRenderer: () => { + called = true; + return Promise.resolve(createMockRendererAdapter()); + }, + }, + }); + + await assertRejects( + () => service.getRenderer(makeSharedCtx()), + Error, + "generation-owned isolated renderer admission", + ); + assertEquals(called, false); + }); + + it("allows a dedicated non-local runtime to use its renderer", async () => { + let called = false; + const service = new SSRService({ + rendererProvider: { + getRenderer: () => { + called = true; + return Promise.resolve(createMockRendererAdapter()); + }, + }, + }); + + await service.getRenderer(makeCtx({ + isLocalProject: false, + allowHostProjectCodeExecution: true, + })); + assertEquals(called, true); + }); }); describe("renderPage (with mock renderer)", () => { + it("fails closed before resolving a renderer in a shared runtime", async () => { + let rendererRequests = 0; + const service = new SSRService({ + rendererProvider: { + getRenderer: () => { + rendererRequests++; + return Promise.resolve(createMockRendererAdapter()); + }, + }, + }); + + const result = await service.renderPage( + makeSharedCtx(), + makeRenderOptions(), + ); + + assertEquals(result.status, 503); + assertEquals(result.cacheStrategy, "no-cache"); + assertEquals(result.htmlProvenance, "framework"); + assertEquals(rendererRequests, 0); + }); + it("returns 200 with HTML from renderer", async () => { const adapter = createMockRendererAdapter({ renderPage: () => @@ -593,7 +662,7 @@ describe("server/services/rendering/ssr.service", () => { assertEquals(redirectLocationOf(result), "/login"); }); - it("treats an unbranded notFound-shaped throw as a server error", async () => { + it("treats an unbranded notFound-shaped throw as a local runtime error", async () => { // A loader doing `throw await response.json()` against an upstream // answering `{ notFound: true }` is reporting a failure, not requesting a // 404. Only the brand, never the shape, routes to not-found. @@ -608,10 +677,10 @@ describe("server/services/rendering/ssr.service", () => { const result = await service.renderPage(makeCtx(), makeRenderOptions()); assertEquals(result.status, 500); - assertEquals(result.failure?.kind, "server-error"); + assertEquals(result.failure?.kind, "runtime"); }); - it("returns server-error for generic errors in production", async () => { + it("captures generic local runtime errors", async () => { const captured: Array<{ error: unknown; context: ApplicationErrorContext }> = []; setApplicationErrorReporter({ capture(error, context) { @@ -632,7 +701,7 @@ describe("server/services/rendering/ssr.service", () => { try { const result = await service.renderPage(makeCtx(), makeRenderOptions()); assertEquals(result.status, 500); - assertEquals(result.failure?.kind, "server-error"); + assertEquals(result.failure?.kind, "runtime"); assertEquals(typeof result.html, "string"); assertEquals(captured.length, 1); assertEquals((captured[0]?.error as Error).message, "Something broke"); diff --git a/src/server/services/rendering/ssr.service.ts b/src/server/services/rendering/ssr.service.ts index c526f7dfb8..de5e4658ce 100644 --- a/src/server/services/rendering/ssr.service.ts +++ b/src/server/services/rendering/ssr.service.ts @@ -29,6 +29,7 @@ import { } from "#veryfront/utils/constants/index.ts"; import type { CacheRepository } from "#veryfront/repositories/types.ts"; import type { DependencyPinningSourceInput } from "#veryfront/transforms/esm/package-registry.ts"; +import { isHostProjectCodeExecutionAllowed } from "#veryfront/security/project-locality.ts"; const logger = serverLogger.component("ssr-service"); @@ -185,6 +186,11 @@ export class SSRService implements SSRServiceLike { } async getRenderer(ctx: HandlerContext): Promise { + if (!isHostProjectCodeExecutionAllowed(ctx)) { + throw new Error( + "Project renderers without host execution capability require generation-owned isolated renderer admission", + ); + } return this.rendererProvider.getRenderer(ctx); } @@ -192,6 +198,20 @@ export class SSRService implements SSRServiceLike { const { request, url, slug, nonce, studioEmbed, projectId, pageId, noHmr, useNoCache } = options; + // Project source without an explicit host capability is not trusted to + // execute in the server process. Dedicated single-project runtimes may + // grant the capability; all other projects require isolated admission. + if (!isHostProjectCodeExecutionAllowed(ctx)) { + return { + status: HTTP_UNAVAILABLE, + html: ErrorPages.serverError("Isolated rendering is temporarily unavailable."), + htmlProvenance: "framework", + isStreaming: false, + cacheStrategy: "no-cache", + slug, + }; + } + const renderSessionId = `${ctx.projectSlug || "default"}-${slug || "index"}-${Date.now()}`; const preRenderHeap = getHeapStats(); diff --git a/src/server/services/rsc/endpoints/action-authorization.test.ts b/src/server/services/rsc/endpoints/action-authorization.test.ts index 9d5c0d6c45..951aa6cc53 100644 --- a/src/server/services/rsc/endpoints/action-authorization.test.ts +++ b/src/server/services/rsc/endpoints/action-authorization.test.ts @@ -89,7 +89,13 @@ function request(args: unknown[] = []): Request { method: "POST", headers: { authorization: "Bearer request-token", + cookie: "session=application-cookie", "content-type": "application/json", + "proxy-authorization": "Basic infrastructure-proxy-token", + "x-forwarded-host": "internal-proxy.example", + "x-project-id": "infrastructure-project", + "x-token": "platform-service-token", + "x-veryfront-control-plane-jws": "signed-control-plane-request", }, body: JSON.stringify({ id: "save", args }), }); @@ -194,6 +200,7 @@ describe("RSC action authorization provider", () => { const original = { role: "user" }; let observedHasBody = true; let observedAuthorization: string | undefined; + let observedHeaders: Readonly> | undefined; let observedProject: unknown; let observedSameSignal = true; let providerMutationSucceeded = true; @@ -203,6 +210,7 @@ describe("RSC action authorization provider", () => { authorize(providerRequest, context) { observedHasBody = Object.hasOwn(providerRequest, "body"); observedAuthorization = providerRequest.headers.authorization; + observedHeaders = providerRequest.headers; observedSameSignal = providerRequest.signal === originalSignal; observedProject = { projectId: context.projectId, @@ -233,6 +241,11 @@ describe("RSC action authorization provider", () => { assertEquals(await response.json(), { ok: true, result: original }); assertEquals(observedHasBody, false); assertEquals(observedAuthorization, "Bearer request-token"); + assertEquals(observedHeaders, { + authorization: "Bearer request-token", + "content-type": "application/json", + cookie: "session=application-cookie", + }); assertEquals(observedSameSignal, false); assertEquals(providerMutationSucceeded, false); assertEquals(observedProject, { diff --git a/src/server/services/rsc/endpoints/action-handler.ts b/src/server/services/rsc/endpoints/action-handler.ts index 8e1b1d513d..d849763fef 100644 --- a/src/server/services/rsc/endpoints/action-handler.ts +++ b/src/server/services/rsc/endpoints/action-handler.ts @@ -54,6 +54,7 @@ import { snapshotRscActionAuthorizationArgs, snapshotRscActionInvocationArgs, } from "./action-authorization-snapshot.ts"; +import { isInfrastructureOnlyRequestHeader } from "#veryfront/security/http/application-request.ts"; const logger = serverLogger.component("rsc"); const apply = Reflect.apply; @@ -513,7 +514,11 @@ function createAuthorizationRequest( const headers = createObject(null) as Record; const sourceHeaders = apply(requestHeadersGetter, request, []) as Headers; apply(headersForEach, sourceHeaders, [ - (value: string, name: string) => defineImmutableData(headers, name, value), + (value: string, name: string) => { + if (!isInfrastructureOnlyRequestHeader(name)) { + defineImmutableData(headers, name, value); + } + }, ]); freeze(headers); diff --git a/src/server/services/rsc/endpoints/endpoint-router.test-helpers.ts b/src/server/services/rsc/endpoints/endpoint-router.test-helpers.ts index 4b759f1221..e8abbc0b65 100644 --- a/src/server/services/rsc/endpoints/endpoint-router.test-helpers.ts +++ b/src/server/services/rsc/endpoints/endpoint-router.test-helpers.ts @@ -112,6 +112,8 @@ export function makeParams( adapter: overrides.adapter ?? createMockAdapter(), config: overrides.config, ...overrides, + isLocalProject: overrides.isLocalProject ?? true, + allowHostProjectCodeExecution: overrides.allowHostProjectCodeExecution ?? true, req: overrides.req ?? new Request("http://localhost" + overrides.pathname), }; } diff --git a/src/server/services/rsc/endpoints/endpoint-router.test.ts b/src/server/services/rsc/endpoints/endpoint-router.test.ts index 85f6d7eff7..575c314e6d 100644 --- a/src/server/services/rsc/endpoints/endpoint-router.test.ts +++ b/src/server/services/rsc/endpoints/endpoint-router.test.ts @@ -213,6 +213,63 @@ describe("server/services/rsc/endpoints/endpoint-router", () => { }); }); + describe("shared runtime execution isolation", () => { + for (const endpoint of ["render", "render/page", "stream", "stream/page", "payload"]) { + it(`fails closed for shared ${endpoint} execution`, async () => { + const result = await handleRSCEndpoint( + makeParams({ + pathname: `/_veryfront/rsc/${endpoint}`, + config: rscEnabledConfig, + isLocalProject: false, + allowHostProjectCodeExecution: false, + }), + ); + + assertEquals(result?.status, 503); + assertEquals(result?.headers.get("cache-control"), "no-store"); + assertEquals(result?.headers.get("content-type"), "application/problem+json"); + assertEquals( + (await result?.json() as { type?: string }).type, + "https://veryfront.com/docs/errors/project-execution-unavailable", + ); + }); + } + + it("fails closed for shared server actions before authorization or import", async () => { + const result = await handleRSCEndpoint( + makeParams({ + pathname: "/_veryfront/rsc/action", + config: rscEnabledConfig, + isLocalProject: false, + allowHostProjectCodeExecution: false, + req: new Request("http://localhost/_veryfront/rsc/action", { + method: "POST", + body: "{}", + }), + }), + ); + + assertEquals(result?.status, 503); + assertEquals(result?.headers.get("cache-control"), "no-store"); + }); + + it("does not conflate a dedicated production runtime with a shared runtime", async () => { + const result = await handleRSCEndpoint( + makeParams({ + pathname: "/_veryfront/rsc/action", + config: rscEnabledConfig, + isLocalProject: false, + allowHostProjectCodeExecution: true, + req: new Request("http://localhost/_veryfront/rsc/action", { + method: "GET", + }), + }), + ); + + assertEquals(result?.status, 405); + }); + }); + describe("render endpoint", () => { it("renders components from the request filesystem adapter", async () => { const pagePath = "/virtual/project/app/page.tsx"; @@ -241,7 +298,7 @@ describe("server/services/rsc/endpoints/endpoint-router", () => { contentSourceId: "preview-main", adapter, config: rscEnabledConfig, - isLocalProject: false, + isLocalProject: true, mode: "development", }), ); @@ -1809,6 +1866,7 @@ describe("server/services/rsc/endpoints/endpoint-router", () => { adapter: branchAAdapter, config: rscEnabledConfig, isLocalProject: false, + allowHostProjectCodeExecution: false, mode: "development", }), ); @@ -1822,6 +1880,7 @@ describe("server/services/rsc/endpoints/endpoint-router", () => { adapter: branchBAdapter, config: rscEnabledConfig, isLocalProject: false, + allowHostProjectCodeExecution: false, mode: "development", }), ); @@ -1855,11 +1914,13 @@ describe("server/services/rsc/endpoints/endpoint-router", () => { adapter: branchBAdapter, config: rscEnabledConfig, isLocalProject: false, + allowHostProjectCodeExecution: false, mode: "development", }), ); - assertEquals(renderB?.status, 200); + assertEquals(renderB?.status, 503); + assertEquals(renderB?.headers.get("cache-control"), "no-store"); } finally { __resetRSCHandlerForTests(); setEnv(DEPENDENCY_PINNING_ENV_FLAG, originalFlag ?? ""); diff --git a/src/server/services/rsc/endpoints/endpoint-router.ts b/src/server/services/rsc/endpoints/endpoint-router.ts index cddb350df5..a355fcef27 100644 --- a/src/server/services/rsc/endpoints/endpoint-router.ts +++ b/src/server/services/rsc/endpoints/endpoint-router.ts @@ -35,6 +35,10 @@ import { handleClientScript, handleDomScript } from "./script-handlers.ts"; import type { RSCEndpointParams } from "./types.ts"; import { analyzeComponent } from "#veryfront/rendering/rsc/component-analyzer.ts"; import { computeHash } from "#veryfront/utils/hash-utils.ts"; +import { + createErrorResponseFromDefinition, + PROJECT_EXECUTION_UNAVAILABLE, +} from "#veryfront/errors"; const rscEndpointRouterLog = serverLogger.component("rsc-endpoint-router"); const rscLog = serverLogger.component("rsc"); @@ -84,6 +88,7 @@ export async function handleRSCEndpoint( adapter, config, isLocalProject, + allowHostProjectCodeExecution, mode, nonce, }: RSCEndpointParams, @@ -113,6 +118,29 @@ export async function handleRSCEndpoint( return new Response("Flight endpoint removed. Use custom RSC endpoints.", { status: 410 }); } + // These transports import or evaluate project-owned server modules. Until + // the generation-owned isolated RSC graph is connected to the worker + // renderer, requests without an explicit host capability must not fall back + // to the host realm. + if (!allowHostProjectCodeExecution && isRscServerExecutionEndpoint(sub)) { + const unavailable = createErrorResponseFromDefinition( + PROJECT_EXECUTION_UNAVAILABLE, + { + detail: "RSC server execution requires a dedicated isolated project runtime", + instance: pathname, + }, + ); + unavailable.headers.set("cache-control", "no-store"); + unavailable.headers.set("retry-after", "1"); + return req.method === "HEAD" + ? new Response(null, { + status: unavailable.status, + statusText: unavailable.statusText, + headers: unavailable.headers, + }) + : unavailable; + } + const url = new URL(req.url); const dependencyPinningSource = providedDependencyPinningSource ?? createDependencyPinningSource({ @@ -289,6 +317,15 @@ export async function handleRSCEndpoint( } } +function isRscServerExecutionEndpoint(sub: string): boolean { + return sub === "action" || + sub === "payload" || + sub === "render" || + sub.startsWith("render/") || + sub === "stream" || + sub.startsWith("stream/"); +} + function isDependencySnapshotBoundEndpoint(sub: string): boolean { return sub === "render" || sub.startsWith("render/") || diff --git a/src/server/services/rsc/endpoints/rsc-bundles.generated.ts b/src/server/services/rsc/endpoints/rsc-bundles.generated.ts index 37bb38122b..2d25b377a5 100644 --- a/src/server/services/rsc/endpoints/rsc-bundles.generated.ts +++ b/src/server/services/rsc/endpoints/rsc-bundles.generated.ts @@ -7,7 +7,7 @@ */ export const CLIENT_BOOT_BUNDLE: string = - 'var at=Object.defineProperty;var ct=(e,t,r)=>t in e?at(e,t,{enumerable:!0,configurable:!0,writable:!0,value:r}):e[t]=r;var m=(e,t,r)=>ct(e,typeof t!="symbol"?t+"":t,r);var ut="3.2.3";function lt(e,t,r,n){let o=[];if(n?.external?.length&&o.push(`external=${n.external.join(",")}`),o.push(`target=${n?.target??"es2022"}`),n?.deps){let d=Object.entries(n.deps).map(([c,g])=>`${c}@${g}`).join(",");o.push(`deps=${d}`)}let s=t?`@${t}`:"",a=r??"",u=o.length?`?${o.join("&")}`:"";return`https://esm.sh/${e}${s}${a}${u}`}function _(e,t,r,n=!1){return lt(e,t,r,{external:n?["react"]:void 0,deps:{csstype:ut}})}var gt="19.2.4",O=gt;function Ee(e=O){return{react:_("react",e),"react-dom":_("react-dom",e,void 0,!0),"react-dom/client":_("react-dom",e,"/client",!0),"react-dom/server":_("react-dom",e,"/server",!0),"react/jsx-runtime":_("react",e,"/jsx-runtime",!0),"react/jsx-dev-runtime":_("react",e,"/jsx-dev-runtime",!0)}}function Re(e=O){return Ee(e).react}function he(e=O){return Ee(e)["react-dom/client"]}function ft(e){return e.replaceAll("+","-").replaceAll("/","_").replaceAll("=","")}function pt(e){if(typeof globalThis.btoa=="function")try{return globalThis.btoa(e)}catch{return yt(new TextEncoder().encode(e))}let t=globalThis.Buffer;if(t)return t.from(e,"utf8").toString("base64");throw new Error("Base64 encoding is not supported in this runtime")}function yt(e){let t=globalThis.Buffer;if(t)return t.from(e).toString("base64");if(typeof globalThis.btoa=="function"){let r="";for(let n of e)r+=String.fromCharCode(n);return globalThis.btoa(r)}throw new Error("Base64 encoding is not supported in this runtime")}function te(e){return ft(pt(e))}var Wo=Object.freeze({react:"","react-dom":"","react-dom/client":"","react-dom/server":"","react/jsx-runtime":"","react/jsx-dev-runtime":""});function v(e,t){if(!t)return!1;if(Object.prototype.hasOwnProperty.call(t,e))return!0;for(let r of Object.keys(t))if(r.endsWith("/")&&e.startsWith(r))return!0;return!1}function mt(e){try{return JSON.parse(e)?.imports??{}}catch(t){return console.warn("Failed to parse import map JSON; treating as empty",{errorName:t instanceof Error?t.name:typeof t,inputLength:e.length}),{}}}function re(e=document){let t=e.querySelector(\'script[type="importmap"]\');return t?.textContent?mt(t.textContent):{}}var Xo=Object.freeze({IPV4:"127.0.0.1",IPV6:"::1",HOSTNAME:"localhost"});var Et=5e3,Rt=1e4,Qo=16*1024*1024,ht=5e3;var _t=100;var xt=Object.freeze([5,10,25,50,75,100,250,500,750,1e3,2500,5e3,7500,1e4]),ei=Object.freeze([1,5,10,25,50,100,250,500,1e3,2500,5e3,1e4]),ti=Object.freeze({server:Object.freeze({port:3e3,hostname:"0.0.0.0"}),timeouts:Object.freeze({default:Et,api:3e4,ssr:Rt,hmr:3e4,sandbox:ht}),cache:Object.freeze({jit:Object.freeze({maxSize:_t,tempDirPrefix:"vf-bundle-"})}),metrics:Object.freeze({ssrBoundaries:xt})});var p="/_veryfront",ne={RSC:`${p}/rsc/`,FS:`${p}/fs/`,MODULES:`${p}/modules/`,PAGES:`${p}/pages/`,DATA:`${p}/data/`,LIB:`${p}/lib/`,CHUNKS:`${p}/chunks/`,CLIENT:`${p}/client/`},xe={HMR_RUNTIME:`${p}/hmr-runtime.js`,HMR:`${p}/hmr.js`,ERROR_OVERLAY:`${p}/error-overlay.js`,DEV_LOADER:`${p}/dev-loader.js`,CLIENT_LOG:`${p}/log`,CLIENT_JS:`${p}/client.js`,ROUTER_JS:`${p}/router.js`,PREFETCH_JS:`${p}/prefetch.js`,MANIFEST_JSON:`${p}/manifest.json`,APP_JS:`${p}/app.js`,RSC_CLIENT:`${p}/rsc/client.js`,RSC_MANIFEST:`${p}/rsc/manifest`,RSC_STREAM:`${p}/rsc/stream`,RSC_PAYLOAD:`${p}/rsc/payload`,RSC_RENDER:`${p}/rsc/render`,RSC_PAGE:`${p}/rsc/page`,RSC_MODULE:`${p}/rsc/module`,RSC_DOM:`${p}/rsc/dom.js`,LIB_CHAT_REACT:`${p}/lib/chat/react.js`,LIB_CHAT_COMPONENTS:`${p}/lib/chat/components.js`,LIB_CHAT_PRIMITIVES:`${p}/lib/chat/primitives.js`};var Tt={ROOT:".veryfront",CACHE:".veryfront/cache",KV:".veryfront/kv",LOGS:".veryfront/logs",TMP:".veryfront/tmp"},ni=Tt.CACHE;var oi={HMR_RUNTIME:xe.HMR_RUNTIME,ERROR_OVERLAY:xe.ERROR_OVERLAY};var I=ne.RSC,Te=ne.FS;var N="rsc-root",k="x-veryfront-dependency-pins";var T=class{constructor(t,r){m(this,"prefix",t);m(this,"level",r)}log(t,r,n,...o){this.level>t||r?.(n,...o)}debug(t,...r){this.log(0,console.debug,`[${this.prefix}] DEBUG: ${t}`,...r)}info(t,...r){this.log(1,console.log,`[${this.prefix}] ${t}`,...r)}warn(t,...r){this.log(2,console.warn,`[${this.prefix}] WARN: ${t}`,...r)}error(t,...r){this.log(3,console.error,`[${this.prefix}] ERROR: ${t}`,...r)}};function St(){if(typeof window>"u")return 2;let e=globalThis;return e.__VERYFRONT_DEV__||e.__RSC_DEV__?e.__VERYFRONT_DEBUG__||e.__RSC_DEBUG__?0:1:2}var $=St(),l=new T("RSC",$),ui=new T("PREFETCH",$),li=new T("HYDRATE",$),di=new T("VERYFRONT",$);var Ct="veryfront-hydration-data";function oe(e){try{let t=[...e.querySelectorAll(`[id="${Ct}"]`)];if(t.length!==1)return null;let r=e.body;if(!r)return null;let n=t[0];return r.firstElementChild!==n&&n.parentElement!==r||n.tagName?.toLowerCase()!=="script"||n.getAttribute("type")?.trim().toLowerCase()!=="application/json"?null:n}catch{return null}}function S(e=document){try{let t=oe(e);return t?JSON.parse(t.textContent||"{}"):null}catch(t){return l.debug("hydration data parse failed",t),null}}function F(e,t){if(!t?.startsWith("on:"))return!1;try{let r=oe(e);if(!r)return!1;let n=JSON.parse(r.textContent||"{}");return n.dependencyPinningCacheKey=t,r.textContent=JSON.stringify(n),!0}catch(r){return l.debug("hydration dependency snapshot seed failed",r),!1}}function V(e){return e?.clientModuleStrategy?e.clientModuleStrategy:e?.dev?"fs":"rsc-module"}function At(e,t){if(!t)return e;let r=e.includes("?")?"&":"?";return`${e}${r}v=${encodeURIComponent(t)}`}function B(e,t){if(!t?.startsWith("on:"))return e;let r=e.indexOf("#"),n=r===-1?"":e.slice(r),o=r===-1?e:e.slice(0,r),s=o.indexOf("?"),a=s===-1?o:o.slice(0,s),u=new URLSearchParams(s===-1?"":o.slice(s+1));u.set("pins",t);let d=u.toString();return`${a}${d?`?${d}`:""}${n}`}function bt(e,t){return At(`${Te}${te(e)}.js`,t)}function Ot(e,t,r){let n=t?`&v=${encodeURIComponent(t)}`:"";return B(`${I}module?rel=${encodeURIComponent(e)}${n}`,r)}function D(e){let t=e?.dependencyPinningCacheKey;return t?.startsWith("on:")?{[k]:t}:{}}function It(e){return e.replace(/^\\/+_vf_modules\\//,"").replace(/^\\/+/,"").replace(/\\.js$/,"")}var Nt=/\\.(tsx|ts|jsx|mdx|js)$/;function Dt(e){let t=It(e),r=[e,t];return Nt.test(t)||r.push(`${t}.tsx`,`${t}.ts`,`${t}.jsx`,`${t}.mdx`,`${t}.js`),Array.from(new Set(r))}function wt(e,t){if(!e)return null;for(let r of Dt(t)){let n=e[r];if(n)return n}return null}function G(e){if(e.strategy==="fs"){let r=e.absPath??e.rel;return r?B(bt(r,e.version),e.dependencyPinningCacheKey):null}let t=wt(e.releaseAssetModules,e.rel);return t||Ot(e.rel,e.version,e.dependencyPinningCacheKey)}function j(e=document,t=O){let r=re(e);return{react:v("react",r)?"react":Re(t),reactDomClient:v("react-dom/client",r)?"react-dom/client":he(t)}}function Se(e=document){let t=re(e);return v("veryfront/router",t)?"veryfront/router":null}var Mt=new Set(["CONFIG","BUILD","RUNTIME","ROUTE","MODULE","SERVER","BOUNDARY","DEV","DEPLOY","AGENT","GENERAL"]);function Lt(e,...t){let r=Object.create(null),n=e.charAt(0).toUpperCase()+e.slice(1);for(let o of t)for(let[s,a]of Object.entries(o)){if(typeof a!="object"||a===null||!Object.hasOwn(a,"slug"))throw new Error(`${n} entry "${s}" must define a slug`);let u=a.slug;if(typeof u!="string")throw new Error(`${n} entry "${s}" must define a string slug`);if(u!==s)throw new Error(`${n} key "${s}" does not match entry slug "${u}"`);if(Object.hasOwn(r,s))throw new Error(`Duplicate ${e} slug "${s}"`);r[s]=a}return Object.freeze(r)}function Ce(...e){for(let t of e)for(let r of Object.values(t)){if(typeof r.slug!="string"||r.slug.length<3||r.slug.length>40||!/^[a-z][a-z0-9-]*[a-z0-9]$/.test(r.slug))throw new TypeError(`Registered error slug must be 3-40 characters of lowercase kebab-case, got "${r.slug}"`);if(typeof r.category!="string"||!Mt.has(r.category))throw new TypeError(`Registered error has unknown category "${r.category}"`);if(!Number.isInteger(r.status)||r.status<400||r.status>=600)throw new RangeError(`Registered error status must be an integer from 400 through 599, got ${r.status}`);if(typeof r.title!="string"||r.title.trim().length===0)throw new TypeError("Registered error title must be a non-empty string");if(r.suggestion!==void 0&&(typeof r.suggestion!="string"||r.suggestion.trim().length===0))throw new TypeError("Registered error suggestion must be non-empty when provided")}return Lt("error registry",...e)}var z={reset:"\\x1B[0m",dim:"\\x1B[2m",gray:"\\x1B[90m",red:"\\x1B[31m",green:"\\x1B[32m",yellow:"\\x1B[33m",blue:"\\x1B[34m",magenta:"\\x1B[35m",cyan:"\\x1B[36m"},Si={debug:z.gray,info:z.green,warn:z.yellow,error:z.red};var y="[REDACTED]",E=Reflect.apply;var Ae=RegExp.prototype.exec,x=RegExp.prototype[Symbol.replace],Ai=String.prototype.charCodeAt,be=String.prototype.slice,Pt=String.prototype.toLowerCase,Ht=/[^a-z0-9]/g;function ie(e){let t=E(Pt,e,[]);return E(x,Ht,[t,""])}function Y(e,t,r){return r===void 0?E(be,e,[t]):E(be,e,[t,r])}var Ut=["password","passwd","pwd","passphrase","secret","clientsecret","token","apikey","accesskey","privatekey","credential","authorization","cookie","bearer","jwt","connectionstring","signature","sessionid","sid","otp","mfa","pin","salt","xsrf","csrf"],vt=512,kt=128,w=new Map;function Ne(e){let t=e.length<=kt;if(t){let o=w.get(e);if(o!==void 0)return o}let r=ie(e),n=Ut.some(o=>r.includes(o));if(t){if(w.size>=vt){let o=w.keys().next().value;o!==void 0&&w.delete(o)}w.set(e,n)}return n}var $t=["access_token","accesstoken","refresh_token","api_key","apikey","code","token","secret","client_secret","password","passwd","pwd","state","sig","signature","auth","x-amz-credential","x-amz-signature","x-amz-security-token","x-goog-credential","x-goog-signature"],Ft=new Set($t.map(ie)),Vt=/(\\b[a-z][a-z0-9+.-]*:\\/\\/|\\/\\/)([^/?#\\s]+)@/gi,Bt=/(\\b[a-z][a-z0-9+.-]*:\\/\\/|\\/\\/)([a-z0-9._~!$&\'()*+,;=%-]+):([^/?#@\\r\\n \\t]+[ \\t][^/?#@\\r\\n]*)@/gi,Gt=3;function jt(e){return e===" "||e==="\t"||e===","||e===";"||e==="&"||e==="?"||e==="#"}function zt(e){if(!e)return!1;let t=e.charCodeAt(0);return t>=65&&t<=90||t>=97&&t<=122}function De(e){return zt(e)||e==="_"||e==="$"}function Yt(e){if(!e)return!1;let t=e.charCodeAt(0);return De(e)||t>=48&&t<=57||e==="."||e==="-"}function we(e,t){let r=t,n=e[r]===\'"\'||e[r]==="\'"?e[r++]:"";if(!De(e[r]))return!1;for(r++;Yt(e[r]);)r++;if(n){if(e[r]!==n)return!1;r++}for(;e[r]===" "||e[r]==="\t";)r++;return e[r]===":"||e[r]==="="}function Me(e){return e==="\\r"||e===`\n`||e==="}"||e==="]"||jt(e)}function Le(e,t){let r=t;for(;r=e.length||we(e,r)}function Kt(e,t){let r=t,n=!0;if(e.startsWith(y,t)){let g=t+y.length;if(Oe(e,g))return{end:g,replacement:y};r=g,n=!1}let o=n&&(e[r]===\'"\'||e[r]==="\'"||e[r]==="`")?e[r]:"",s=!1,a=()=>o?`${o}${y}${s?o:""}`:y,u=[],d="",c=-1;for(let g=r;g0&&(f==="}"||f==="]")){if(u.at(-1)!==f)return{end:e.length,replacement:a()};if(u.pop(),g++,u.length===0&&Oe(e,g))return{end:g,replacement:a()};continue}if(u.length>0||!Me(f)){g++;continue}let R=g;if(g=Le(e,g),g>=e.length||we(e,g))return{end:R,replacement:a()}}return{end:e.length,replacement:a()}}function Ie(e,t,r,n){let o=0,s="";for(let a=E(Ae,t,[e]);a;a=E(Ae,t,[e])){let u=a[r];if(!Ne(u))continue;let d=t.lastIndex,c=n===void 0?void 0:a[n],g=d+y.length;if((c==="?"||c==="&"||c===";")&&e.startsWith(y,d)&&e[g]==="#")continue;let f=Kt(e,d);s+=Y(e,o,a.index),s+=a[0],s+=f.replacement,o=f.end,t.lastIndex=f.end}return o===0?e:s+Y(e,o)}function Wt(e,t,r){let n=r.search(/[ \\t]/);if(n<0)return!1;let o=`${t}:${Y(r,0,n)}`,s=e==="//"?`https://${o}`:`${e}${o}`;try{let a=new URL(s);return a.username.length===0&&a.password.length===0}catch{return!1}}function qt(e){let t=e;for(let r=0;r{let s=o.indexOf(":");if(s===-1)return`${n}${y}@`;let a=Y(o,0,s);return`${n}${a}:${y}@`}]);return t=E(x,Bt,[t,(r,n,o,s)=>Wt(n,o,s)?r:`${n}${o}:${y}@`]),t=E(x,/([?#&;])([-a-z0-9_.%\\[\\]]+)=([^&#;\\s]*)/gi,[t,(r,n,o,s)=>{let a=qt(o);return Ft.has(ie(a))||Ne(a)?`${n}${o}=${y}`:r}]),t=E(x,/(^|[^a-z0-9_-])((?:set-cookie|cookie)\\s*:\\s*)[^\\r\\n]*/gi,[t,(r,n,o)=>`${n}${o}${y}`]),t=E(x,/\\b(authorization\\s*[:=]\\s*)[^\\r\\n]*/gi,[t,(r,n)=>`${n}${y}`]),t=E(x,/\\b(bearer|basic)(\\s+)(?:"[^"\\r\\n]*"|\'[^\'\\r\\n]*\'|[a-z0-9._~+/=-]+)/gi,[t,(r,n,o)=>`${n}${o}${y}`]),t=Ie(t,/(["\'])([_$a-z][a-z0-9_.$-]*)\\1(\\s*[:=]\\s*)/gi,2),t=Ie(t,/(^|[^a-z0-9_.$-])([_$a-z][a-z0-9_.$-]*)(\\s*[:=]\\s*)/gi,2,1),t}var Xt=2048;var Di=64*1024,Jt=256,Zt="https://veryfront.com/docs/errors/",Pe="...[truncated]",ae="unknown-error";function He(e,t){if(e.length<=t)return e;let r=Math.max(0,t-Pe.length);return`${Qt(e,r)}${Pe}`}function Qt(e,t){let r=e.slice(0,t),n=r.charCodeAt(r.length-1);return n>=55296&&n<=56319&&(r=r.slice(0,-1)),r}function er(e){let t="";for(let r=0;r=55296&&n<=56319){let o=e.charCodeAt(r+1);o>=56320&&o<=57343?(t+=e.slice(r,r+2),r++):t+="\\uFFFD";continue}t+=n>=56320&&n<=57343?"\\uFFFD":e.charAt(r)}return t}function C(e){return typeof e!="string"?y:He(se(e),Xt)}function tr(e){let t=typeof e=="string"?se(e):ae,r=He(t||ae,Jt),n=er(r);return n==="."||n===".."?ae:n}function K(e){let t=encodeURIComponent(tr(e));return`${Zt}${t}`}var rr=Object.freeze,nr=Object.getOwnPropertyDescriptors,Ue=Number.isFinite,ke=new WeakSet,or=new Set(["CONFIG","BUILD","RUNTIME","ROUTE","MODULE","SERVER","BOUNDARY","DEV","DEPLOY","AGENT","GENERAL"]);function i(e){let t={...e},r={...t,create(n){let o=n?.message,s=n?.detail,a=n?.cause,u=n?.instance,d=n?.context,c=n?.status??t.status;return new ce(o||s||t.title,{slug:t.slug,category:t.category,status:c,title:t.title,suggestion:t.suggestion,exitCode:t.exitCode,detail:s,cause:a,instance:u,context:d})}};return rr(r)}var ce=class extends Error{constructor(r,n){super(r);m(this,"slug");m(this,"category");m(this,"status");m(this,"title");m(this,"suggestion");m(this,"exitCode");m(this,"detail");m(this,"cause");m(this,"instance");m(this,"context");ke.add(this),this.name="VeryfrontError",this.slug=n.slug,this.category=n.category,this.status=n.status,this.title=n.title,this.suggestion=n.suggestion,this.exitCode=n.exitCode,this.detail=n.detail,this.cause=n.cause,this.instance=n.instance,this.context=n.context}toRFC9457(){let r=ve(this);return r?{type:K(r.slug),title:C(r.title),status:r.status,detail:r.detail===void 0?void 0:C(r.detail),instance:r.instance===void 0?void 0:C(r.instance),category:r.category,suggestion:r.suggestion===void 0?void 0:C(r.suggestion),cause:typeof r.cause=="string"?C(r.cause):void 0}:{type:K("unknown-error"),title:"Unknown/unclassified error",status:500,category:"GENERAL"}}getDocsUrl(){let r=ve(this);return K(r?.slug??"unknown-error")}};function $e(e){return typeof e=="object"&&e!==null&&ke.has(e)}function ve(e){return $e(e)?ir(e):null}function ir(e){try{if(!$e(e))return null;let t=nr(e),r=J=>{let b=t[J];return b&&"value"in b?b.value:void 0},n=r("slug"),o=r("category"),s=r("status"),a=r("title"),u=r("message"),d=r("suggestion"),c=r("exitCode"),g=r("detail"),f=r("cause"),R=r("instance"),P=r("context"),h=r("stack");return typeof n!="string"||!or.has(o)||typeof s!="number"||!Ue(s)||typeof a!="string"||typeof u!="string"||d!==void 0&&typeof d!="string"||c!==void 0&&(typeof c!="number"||!Ue(c))||g!==void 0&&typeof g!="string"||R!==void 0&&typeof R!="string"||h!==void 0&&typeof h!="string"?null:{slug:n,category:o,status:s,title:a,message:u,suggestion:d,exitCode:c,detail:g,cause:f,instance:R,context:P,stack:h}}catch{return null}}var sr=i({slug:"config-not-found",category:"CONFIG",status:404,title:"Configuration file not found",suggestion:"Create veryfront.config.js, veryfront.config.ts, or veryfront.config.mjs in the project root"}),ar=i({slug:"config-invalid",category:"CONFIG",status:400,title:"Invalid configuration format",suggestion:"Check the reported configuration path and validation details"}),cr=i({slug:"config-parse-error",category:"CONFIG",status:400,title:"Failed to parse configuration",suggestion:"Ensure your configuration file contains valid JavaScript or TypeScript"}),ur=i({slug:"config-validation-error",category:"CONFIG",status:422,title:"Configuration validation failed",suggestion:"Check the configuration against the schema requirements"}),lr=i({slug:"config-type-error",category:"CONFIG",status:400,title:"Configuration type mismatch",suggestion:"Ensure configuration values match expected types"}),dr=i({slug:"import-map-invalid",category:"CONFIG",status:400,title:"Invalid import map configuration",suggestion:"Check your import map syntax and paths"}),gr=i({slug:"cors-config-invalid",category:"CONFIG",status:400,title:"Invalid CORS configuration",suggestion:"Review CORS settings in your configuration"}),fr=i({slug:"config-validation-failed",category:"CONFIG",status:400,title:"Configuration validation failed",suggestion:"Check configuration values against requirements"}),pr=i({slug:"webhook-config-invalid",category:"CONFIG",status:400,title:"Invalid webhook configuration",suggestion:"Check webhook definition fields, target settings, and eventFilter conditions"}),yr=i({slug:"schedule-config-invalid",category:"CONFIG",status:400,title:"Invalid schedule configuration",suggestion:"Check schedule definition fields, cron expression, target settings, and positive-integer limits"}),mr=i({slug:"trigger-config-invalid",category:"CONFIG",status:400,title:"Invalid trigger configuration",suggestion:"Check trigger ID format (lowercase, alphanumeric, dots/slashes/hyphens) and ensure all input values are JSON-serializable"}),Fe={"config-not-found":sr,"config-invalid":ar,"config-parse-error":cr,"config-validation-error":ur,"config-type-error":lr,"import-map-invalid":dr,"cors-config-invalid":gr,"config-validation-failed":fr,"webhook-config-invalid":pr,"schedule-config-invalid":yr,"trigger-config-invalid":mr};var Er=i({slug:"build-failed",category:"BUILD",status:500,title:"Build process failed",suggestion:"Check the build output for specific errors"}),Rr=i({slug:"bundle-error",category:"BUILD",status:500,title:"Bundle generation failed",suggestion:"Review bundler output for details"}),hr=i({slug:"typescript-error",category:"BUILD",status:500,title:"TypeScript compilation error",suggestion:"Fix TypeScript errors shown in the output"}),_r=i({slug:"mdx-compile-error",category:"BUILD",status:500,title:"MDX compilation failed",suggestion:"Check your MDX file syntax"}),xr=i({slug:"asset-optimization-error",category:"BUILD",status:500,title:"Asset optimization failed",suggestion:"Check asset file formats and paths"}),Tr=i({slug:"ssg-generation-error",category:"BUILD",status:500,title:"Static site generation failed",suggestion:"Review SSG configuration and data fetching"}),Sr=i({slug:"sourcemap-error",category:"BUILD",status:500,title:"Source map generation failed",suggestion:"Check source map configuration"}),Cr=i({slug:"compilation-error",category:"BUILD",status:500,title:"Compilation failed",suggestion:"Review compiler output for specific errors"}),Ve={"build-failed":Er,"bundle-error":Rr,"typescript-error":hr,"mdx-compile-error":_r,"asset-optimization-error":xr,"ssg-generation-error":Tr,"sourcemap-error":Sr,"compilation-error":Cr};var Ar=i({slug:"hydration-mismatch",category:"RUNTIME",status:500,title:"Client/server hydration mismatch",suggestion:"Ensure server and client render the same content"}),br=i({slug:"render-error",category:"RUNTIME",status:500,title:"Component render failed",suggestion:"Check component for runtime errors"}),Or=i({slug:"component-error",category:"RUNTIME",status:500,title:"Component execution error",suggestion:"Review component logic and props"}),Ir=i({slug:"layout-not-found",category:"RUNTIME",status:404,title:"Layout component not found",suggestion:"Ensure layout file exists at the expected path"}),Nr=i({slug:"page-not-found",category:"RUNTIME",status:404,title:"Page component not found",suggestion:"Check that the page file exists in the routes directory"}),Dr=i({slug:"api-error",category:"RUNTIME",status:500,title:"API route handler error",suggestion:"Review API route handler for errors"}),wr=i({slug:"middleware-error",category:"RUNTIME",status:500,title:"Middleware execution error",suggestion:"Check middleware function for errors"}),Mr=i({slug:"trigger-target-not-found",category:"RUNTIME",status:404,title:"Trigger target not found",suggestion:"Ensure the referenced task or workflow ID is registered in the project"}),Lr=i({slug:"trigger-execution-failed",category:"RUNTIME",status:500,title:"Trigger target execution failed",suggestion:"Check the task or workflow for errors and review the trigger input"}),Pr=i({slug:"trigger-not-supported",category:"RUNTIME",status:501,title:"Trigger target type not supported in local runtime",suggestion:"Use a workflow or task target for local trigger runs; agent targets require the Cloud runtime"}),Be={"hydration-mismatch":Ar,"render-error":br,"component-error":Or,"layout-not-found":Ir,"page-not-found":Nr,"api-error":Dr,"middleware-error":wr,"trigger-target-not-found":Mr,"trigger-execution-failed":Lr,"trigger-not-supported":Pr};var Hr=i({slug:"route-conflict",category:"ROUTE",status:409,title:"Conflicting route definitions",suggestion:"Rename or reorganize conflicting route files"}),Ur=i({slug:"invalid-route-file",category:"ROUTE",status:400,title:"Invalid route file structure",suggestion:"Ensure route file exports required functions"}),vr=i({slug:"route-handler-invalid",category:"ROUTE",status:400,title:"Invalid route handler export",suggestion:"Export a valid handler function from the route file"}),kr=i({slug:"dynamic-route-error",category:"ROUTE",status:500,title:"Dynamic route parsing failed",suggestion:"Check dynamic route segment syntax"}),$r=i({slug:"route-params-error",category:"ROUTE",status:400,title:"Route parameters invalid",suggestion:"Validate route parameter values"}),Fr=i({slug:"api-route-error",category:"ROUTE",status:500,title:"API route definition error",suggestion:"Review API route configuration"}),Ge={"route-conflict":Hr,"invalid-route-file":Ur,"route-handler-invalid":vr,"dynamic-route-error":kr,"route-params-error":$r,"api-route-error":Fr};var Vr=i({slug:"module-not-found",category:"MODULE",status:404,title:"Module could not be resolved",suggestion:"Check the import path and ensure the module is installed"}),Br=i({slug:"import-resolution-error",category:"MODULE",status:500,title:"Import path resolution failed",suggestion:"Verify import paths and module configuration"}),Gr=i({slug:"circular-dependency",category:"MODULE",status:500,title:"Circular dependency detected",suggestion:"Refactor imports to break the circular dependency"}),jr=i({slug:"invalid-import",category:"MODULE",status:400,title:"Invalid import statement",suggestion:"Fix import syntax or path"}),zr=i({slug:"dependency-missing",category:"MODULE",status:404,title:"Required dependency not installed",suggestion:"Install the missing dependency with your package manager"}),Yr=i({slug:"version-mismatch",category:"MODULE",status:409,title:"Dependency version mismatch",suggestion:"Update dependencies to compatible versions"}),je={"module-not-found":Vr,"import-resolution-error":Br,"circular-dependency":Gr,"invalid-import":jr,"dependency-missing":zr,"version-mismatch":Yr};var Kr=i({slug:"port-in-use",category:"SERVER",status:409,title:"Server port already in use",suggestion:"Use a different port or stop the process using this port"}),Wr=i({slug:"server-start-error",category:"SERVER",status:500,title:"Server failed to start",suggestion:"Check server configuration and port availability"}),qr=i({slug:"cache-error",category:"SERVER",status:500,title:"Cache operation failed",suggestion:"Clear the cache and try again"}),Xr=i({slug:"file-watch-error",category:"SERVER",status:500,title:"File watcher error",suggestion:"Restart the development server"}),Jr=i({slug:"request-error",category:"SERVER",status:500,title:"HTTP request handling error",suggestion:"Check request handler and middleware"}),Zr=i({slug:"service-overloaded",category:"SERVER",status:503,title:"Service overloaded",suggestion:"Reduce load or scale up resources"}),Qr=i({slug:"semaphore-timeout",category:"SERVER",status:503,title:"Semaphore acquire timeout",suggestion:"Reduce concurrency or increase the semaphore acquire timeout"}),en=i({slug:"circuit-breaker-open",category:"SERVER",status:503,title:"Circuit breaker is open",suggestion:"Wait for the breaker reset timeout before retrying"}),tn=i({slug:"cache-path-mismatch",category:"SERVER",status:500,title:"Cache path mismatch",suggestion:"Clear the cache directory and rebuild"}),rn=i({slug:"network-error",category:"SERVER",status:502,title:"Network operation failed",suggestion:"Check network connectivity and retry"}),nn=i({slug:"api-client-error",category:"SERVER",status:500,title:"API client request failed",suggestion:"Check API connectivity and authentication"}),on=i({slug:"token-storage-error",category:"SERVER",status:500,title:"Token storage operation failed",suggestion:"Check token storage backend and credentials"}),sn=i({slug:"cache-invariant-violation",category:"SERVER",status:500,title:"Cache path invariant violated",suggestion:"Clear the cache and rebuild"}),an=i({slug:"release-not-found",category:"SERVER",status:404,title:"No active release found",suggestion:"Deploy the project to create a release for this environment"}),cn=i({slug:"fallback-exhausted",category:"SERVER",status:500,title:"Primary and fallback operations both failed",suggestion:"Check service availability and connectivity"}),ze={"port-in-use":Kr,"server-start-error":Wr,"cache-error":qr,"file-watch-error":Xr,"request-error":Jr,"service-overloaded":Zr,"semaphore-timeout":Qr,"circuit-breaker-open":en,"cache-path-mismatch":tn,"network-error":rn,"api-client-error":nn,"token-storage-error":on,"cache-invariant-violation":sn,"release-not-found":an,"fallback-exhausted":cn};var un=i({slug:"client-boundary-violation",category:"BOUNDARY",status:400,title:"Client boundary rule violation",suggestion:"Add \'use client\' directive or move code to a client component"}),ln=i({slug:"server-only-in-client",category:"BOUNDARY",status:400,title:"Server-only code in client component",suggestion:"Move server-only code to a server component"}),dn=i({slug:"client-only-in-server",category:"BOUNDARY",status:400,title:"Client-only code in server component",suggestion:"Move client-only code to a client component"}),gn=i({slug:"invalid-use-client",category:"BOUNDARY",status:400,title:"Invalid \'use client\' directive",suggestion:"Place \'use client\' at the top of the file"}),fn=i({slug:"invalid-use-server",category:"BOUNDARY",status:400,title:"Invalid \'use server\' directive",suggestion:"Place \'use server\' at the top of the file or function"}),pn=i({slug:"rsc-payload-error",category:"BOUNDARY",status:500,title:"RSC payload serialization error",suggestion:"Ensure props are serializable (no functions, symbols, etc.)"}),Ye={"client-boundary-violation":un,"server-only-in-client":ln,"client-only-in-server":dn,"invalid-use-client":gn,"invalid-use-server":fn,"rsc-payload-error":pn};var yn=i({slug:"hmr-error",category:"DEV",status:500,title:"Hot module replacement error",suggestion:"Restart the development server"}),mn=i({slug:"dev-server-error",category:"DEV",status:500,title:"Development server error",suggestion:"Check the dev server logs and restart"}),En=i({slug:"fast-refresh-error",category:"DEV",status:500,title:"Fast refresh failed",suggestion:"Save the file again or restart the dev server"}),Rn=i({slug:"error-overlay-error",category:"DEV",status:500,title:"Error overlay failed",suggestion:"Check browser console for details"}),hn=i({slug:"source-map-error",category:"DEV",status:500,title:"Source map loading error",suggestion:"Rebuild or clear cache"}),Ke={"hmr-error":yn,"dev-server-error":mn,"fast-refresh-error":En,"error-overlay-error":Rn,"source-map-error":hn};var _n=i({slug:"deployment-error",category:"DEPLOY",status:500,title:"Deployment process failed",suggestion:"Check deployment logs for details"}),xn=i({slug:"platform-error",category:"DEPLOY",status:500,title:"Platform-specific error",suggestion:"Check platform documentation and requirements"}),Tn=i({slug:"env-var-missing",category:"DEPLOY",status:500,title:"Required environment variable missing",suggestion:"Set the required environment variable"}),Sn=i({slug:"production-build-required",category:"DEPLOY",status:400,title:"Production build required",suggestion:"Run \'veryfront build\' before deploying"}),Cn=i({slug:"environment-not-found",category:"DEPLOY",status:404,title:"Deployment environment not found",suggestion:"Check environment names with: veryfront config"}),An=i({slug:"release-missing-version",category:"DEPLOY",status:500,title:"Release has no version",suggestion:"Try again or check the build logs in Studio"}),bn=i({slug:"release-build-timeout",category:"DEPLOY",status:408,title:"Release build timed out",suggestion:"Try again or check the build logs in Studio"}),On=i({slug:"deployment-verification-timeout",category:"DEPLOY",status:408,title:"Deployment verification timed out",suggestion:"Try again or check the deployment status in Studio"}),In=i({slug:"push-receipt-missing",category:"DEPLOY",status:400,title:"Push receipt not found",suggestion:"Run: veryfront push --branch main first"}),Nn=i({slug:"source-digest-mismatch",category:"DEPLOY",status:409,title:"Release source digest mismatch",suggestion:"Run veryfront push again to re-upload source files"}),Dn=i({slug:"preview-hostname-too-long",category:"DEPLOY",status:400,title:"Preview hostname too long",suggestion:"Use a shorter project slug or branch name"}),wn=i({slug:"branch-not-found",category:"DEPLOY",status:404,title:"Branch not found",suggestion:"List branches in Studio or push a new one with: veryfront push --branch "}),We={"deployment-error":_n,"platform-error":xn,"env-var-missing":Tn,"production-build-required":Sn,"environment-not-found":Cn,"release-missing-version":An,"release-build-timeout":bn,"deployment-verification-timeout":On,"push-receipt-missing":In,"source-digest-mismatch":Nn,"preview-hostname-too-long":Dn,"branch-not-found":wn};var Mn=i({slug:"agent-error",category:"AGENT",status:500,title:"Agent operation error",suggestion:"Check agent configuration and logs"}),Ln=i({slug:"agent-not-found",category:"AGENT",status:404,title:"Agent not found",suggestion:"Verify the agent ID exists"}),Pn=i({slug:"agent-timeout",category:"AGENT",status:408,title:"Agent operation timed out",suggestion:"Increase timeout or simplify the request"}),Hn=i({slug:"agent-intent-error",category:"AGENT",status:400,title:"Agent intent parsing error",suggestion:"Rephrase the request more clearly"}),Un=i({slug:"orchestration-error",category:"AGENT",status:500,title:"Multi-agent orchestration error",suggestion:"Check agent coordination logic"}),vn=i({slug:"cost-limit-exceeded",category:"AGENT",status:429,title:"Cost limit exceeded",suggestion:"Wait for the budget period to reset or increase the limit"}),kn=i({slug:"tool-id-conflict",category:"AGENT",status:409,title:"Tool ID conflict",suggestion:"Use a unique tool ID or rename one of the conflicting tools"}),qe={"agent-error":Mn,"agent-not-found":Ln,"agent-timeout":Pn,"agent-intent-error":Hn,"orchestration-error":Un,"cost-limit-exceeded":vn,"tool-id-conflict":kn};var $n=i({slug:"unknown-error",category:"GENERAL",status:500,title:"Unknown/unclassified error",suggestion:"Check logs for more details"}),Fn=i({slug:"authentication-required",category:"GENERAL",status:401,title:"Authentication required",suggestion:"Set VERYFRONT_API_TOKEN or run \'veryfront login\'"}),Vn=i({slug:"permission-denied",category:"GENERAL",status:403,title:"File/resource permission denied",suggestion:"Check file permissions and access rights"}),Bn=i({slug:"file-not-found",category:"GENERAL",status:404,title:"File not found",suggestion:"Verify the file path exists"}),Gn=i({slug:"resource-not-found",category:"GENERAL",status:404,title:"Requested resource not found",suggestion:"Verify the referenced resource ID or name exists"}),jn=i({slug:"invalid-argument",category:"GENERAL",status:400,title:"Invalid function argument",suggestion:"Check argument types and values",exitCode:2}),zn=i({slug:"timeout-error",category:"GENERAL",status:408,title:"Operation timed out",suggestion:"Increase timeout or optimize the operation"}),Yn=i({slug:"initialization-error",category:"GENERAL",status:500,title:"Initialization failed",suggestion:"Check initialization requirements and dependencies"}),Kn=i({slug:"not-supported",category:"GENERAL",status:501,title:"Feature not supported",suggestion:"Check documentation for supported features"}),ue=i({slug:"security-violation",category:"GENERAL",status:403,title:"Security violation detected",suggestion:"Check for path traversal or unauthorized access attempts"}),Wn=i({slug:"input-validation-failed",category:"GENERAL",status:400,title:"Input validation failed",suggestion:"Check request input against validation rules"}),qn=i({slug:"project-source-empty",category:"GENERAL",status:400,title:"Project source is empty",suggestion:"Add project files or run \'veryfront init\'"}),Xe={"unknown-error":$n,"authentication-required":Fn,"permission-denied":Vn,"file-not-found":Bn,"resource-not-found":Gn,"invalid-argument":jn,"timeout-error":zn,"initialization-error":Yn,"not-supported":Kn,"security-violation":ue,"input-validation-failed":Wn,"project-source-empty":qn};var Rs=Ce(Fe,Ve,Be,Ge,je,ze,Ye,Ke,We,qe,Xe);var Xn=[{source:String.raw`]*>[\\s\\S]*?<\\/script>`,flags:"gi",name:"inline script"},{source:String.raw`javascript:`,flags:"gi",name:"javascript: URL"},{source:String.raw`\\bon\\w+\\s*=`,flags:"gi",name:"event handler attribute"},{source:String.raw`data:\\s*text\\/html`,flags:"gi",name:"data: HTML URL"}];function Jn(){return Xn.map(({source:e,flags:t,name:r})=>({pattern:new RegExp(e,t),name:r}))}function Zn(){let e=globalThis;return e.__VERYFRONT_DEV__===!0||e.Deno?.env?.get?.("VERYFRONT_ENV")==="development"}function M(e,t={}){let{allowInlineScripts:r=!1,strict:n=!1,warn:o=!0}=t;for(let{pattern:s,name:a}of Jn())if(!(r&&a==="inline script")&&(s.lastIndex=0,!!s.test(e)&&(o&&console.warn(`[Security] Suspicious ${a} detected in server HTML`),n||!Zn())))throw ue.create({detail:`Potentially unsafe HTML: ${a} detected`});return e}function L(e,t){let r=t==="root"?N:`rsc-slot-${t}`,n=e.getElementById(r);if(n)return n;let o=e.createElement("div");return o.id=r,e.body.appendChild(o),o}function Qn(e,t){if(t.type!=="slot")return;let r=L(e,t.id);r.innerHTML=M(String(t.html??""))}function Je(e,t){let r=t.split(`\n`),n=r.pop()??"";for(let o of r){let s=o.trim();if(!s)continue;let a;try{a=JSON.parse(s)}catch(d){l.debug("[client-dom] malformed NDJSON line",{line:s,error:d instanceof Error?d.message:String(d)});continue}if(!a||typeof a!="object")continue;let u=a;if(u.type==="slot"){Qn(e,u);try{ro(e,u.id||"root")}catch(d){l.debug("[client-dom] hydration optional failed",d)}}}return n}function eo(e){return new Promise((t,r)=>{let n=()=>r(new DOMException("aborted","AbortError"));if(e.aborted){n();return}e.addEventListener("abort",n,{once:!0})})}async function Ze(e,t=document,r){let n="body"in e?e:null,o=n?.body??e;if(!o)return;n&&F(t,n.headers.get(k));let s=o.getReader(),a=new TextDecoder,u="",d=!1;try{for(;;){if(r?.aborted)throw new DOMException("aborted","AbortError");let c=s.read(),{done:g,value:f}=r?await Promise.race([c,eo(r)]):await c;if(g){d=!0;break}u+=a.decode(f,{stream:!0}),u=Je(t,u)}u&&Je(t,`${u}\n`)}catch(c){throw c instanceof Error&&c.name==="AbortError"||l.debug("[client-dom] consumeNdjsonStream error",c),c}finally{try{await s.cancel()}catch(c){d||l.debug("[client-dom] reader.cancel failed",c)}try{s.releaseLock()}catch(c){l.debug("[client-dom] reader.releaseLock failed",c)}if(typeof o.cancel=="function")try{await o.cancel()}catch(c){l.debug("[client-dom] stream.cancel failed",c)}if(typeof n?.body?.cancel=="function")try{await n.body.cancel()}catch(c){l.debug("[client-dom] response.body.cancel failed",c)}}}function to(e,t){let r=L(e,t),n=[],o=s=>{let a=s;a.dataset?.clientRef&&n.push(a);for(let u of s.children)o(u)};return o(r),n}function ro(e,t){let r=to(e,t);for(let n of r){let o=n.dataset?.clientRef;o&&(n.dataset.hydrated="true",l.debug("[client-dom] marked for hydration",o))}}var no=new Set(["server","client","html","fragment"]);function Qe(e){if(!e)return[];try{let t=JSON.parse(e);return io(t)?t.nodes:[]}catch{return[]}}async function de(e,t,r){return await Promise.all(e.map(n=>oo(n,t,r)))}async function oo(e,t,r){if(e.type==="html")return e.text??e.html??"";let n=await de(e.children??[],t,r);if(e.type==="fragment"||e.type==="server"&&!e.component)return t.createElement(t.Fragment,{},...n);if(e.type==="server")return t.createElement(e.component,e.props??{},...n);let o=await r(e.component);return o?t.createElement(o,e.props??{},...n):null}function io(e){return!le(e)||e.version!==1||!Array.isArray(e.nodes)?!1:e.nodes.every(t=>et(t,0))}function et(e,t){return t>100||!le(e)||!no.has(e.type)||e.type==="html"&&typeof e.html!="string"&&typeof e.text!="string"||e.type==="client"&&typeof e.component!="string"||e.type==="server"&&e.component!==void 0&&typeof e.component!="string"||e.props!==void 0&&!le(e.props)?!1:e.children===void 0?!0:Array.isArray(e.children)&&e.children.every(r=>et(r,t+1))}function le(e){return typeof e=="object"&&e!==null&&!Array.isArray(e)}function so(e){if(!e)return{};let t={};for(let[r,n]of Object.entries(e))t[r]=Array.isArray(n)?n.join("/"):n;return t}async function W(e,t,r=document){try{let n=Se(r);if(!n)return e;let s=(await import(n)).wrapForHydration;return typeof s!="function"?e:s(e,{params:so(t?.params),frontmatter:t?.frontmatter??{},data:t?.props??{}})}catch(n){return l.debug("router provider wrap failed",n),e}}var ao="Unknown dependency snapshot",co="export default null; // Unknown dependency snapshot",ge="__VF_DEPENDENCY_SNAPSHOT_RECOVERY_STARTED__";function uo(){return globalThis}async function lo(e){if(e.status!==409)return!1;try{let t=(await e.clone().text()).trim();return t===ao||t===co}catch{return!1}}async function A(e,t=()=>globalThis.location.reload()){if(!await lo(e))return!1;let r=uo();if(r[ge])return!0;r[ge]=!0;try{t()}catch{return delete r[ge],!1}return!0}async function q(e,t=globalThis.fetch,r=()=>globalThis.location.reload()){try{let n=new URL(e,"http://veryfront.local").searchParams.getAll("pins");if(n.length!==1||!n[0]?.startsWith("on:"))return!1;let o=await t(e,{cache:"no-store"});return await A(o,r)}catch{return!1}}var go=100;function fo(e,t){if(globalThis.__VF_CLIENT_MOD_CACHE??(globalThis.__VF_CLIENT_MOD_CACHE=new Map),globalThis.__VF_CLIENT_MOD_CACHE.size>=go){let r=globalThis.__VF_CLIENT_MOD_CACHE.keys().next().value;r&&globalThis.__VF_CLIENT_MOD_CACHE.delete(r)}globalThis.__VF_CLIENT_MOD_CACHE.set(e,t)}function tt(e){let t=e.match(/^\\/app\\/(.+)#([\\w$.-]+)$/);if(t)return{rel:`/${t[1]||""}`,exportName:t[2]||"default"};let r=e.match(/^(\\/_veryfront\\/[^#]+)#([\\w$.-]+)$/);return r?{moduleUrl:r[1],exportName:r[2]||"default"}:(l.debug("hydrate: unrecognised client ref format, skipping",{ref:e}),null)}function po(e){let t=e.dataset?.rscProps;if(!t)return{};try{let r=JSON.parse(t);return r&&typeof r=="object"&&!Array.isArray(r)?r:{}}catch(r){return l.debug("hydrate: invalid client boundary props, using empty props",r),{}}}function yo(e){return Qe(e.dataset?.rscChildren)}function mo(e){return"/_veryfront/rsc/manifest"}function Eo(e){return D(e)}async function Ro(e=document){try{let t=S(e),r=await fetch(mo(t),{headers:Eo(t)});return r.ok?await r.json():(await A(r),null)}catch{return null}}async function rt(e,t,r,n={}){let o=ho(e,t,r,n.releaseAssetModules),s=t.moduleUrl??t.rel;if(!s)return null;let a=`${s}#${e.hash??""}`;try{let u=globalThis.__VF_CLIENT_MOD_CACHE?.get(a);if(u)return u}catch(u){l.debug("hydrate: cache get failed",u)}if(!o)return null;try{let u=await(n.importModule??(d=>import(d)))(o);try{fo(a,u)}catch(d){l.debug("hydrate: cache set failed",d)}return u}catch(u){return l.debug("hydrate: failed to import module",{moduleUrl:o,error:u}),await(n.recoverSnapshotFailure??q)(o),null}}function ho(e,t,r,n){if(t.moduleUrl)return B(t.moduleUrl,e.dependencyPinningCacheKey);if(!t.rel)return null;let o=e.graphIds?.client.find(s=>s.rel===t.rel)?.path;return G({strategy:r,rel:t.rel,absPath:o,version:e.hash,dependencyPinningCacheKey:e.dependencyPinningCacheKey,releaseAssetModules:n})}function _o(e){let t=Array.from(e.querySelectorAll("[data-client-ref]")),r=new Set(t);return t.filter(n=>{let o=n.parentElement;for(;o;){if(r.has(o))return!1;o=o.parentElement}return!0})}async function nt(e=document){let t=null;try{t=await Ro(e)}catch(c){l.debug("hydrate: fetch manifest failed",c)}if(!t){l.debug("hydrate: no manifest");return}let r=_o(e);try{let c=globalThis.__VF_MANIFEST_HASH;if(!r.some(f=>f.dataset?.hydrated!=="true")&&c&&t.hash&&c===t.hash)return}catch(c){l.debug("hydrate: hmr hash read failed",c)}if(r.length===0){try{globalThis.__VF_MANIFEST_HASH=t.hash??""}catch(c){l.debug("hydrate: set hash failed",c)}return}let n=S(e),o=V(n),s=n?.releaseAssetModules;try{if(globalThis.__VF_TEST_MODE__){globalThis.__VF_HYDRATE_CALLED=!0,globalThis.__VF_MANIFEST_HASH=t.hash??"";return}}catch(c){l.debug("hydrate: test mode flags failed",c)}let a=j(e,n?.reactVersion),[{default:u},{createRoot:d}]=await Promise.all([import(a.react),import(a.reactDomClient)]);for(let c of r){let g=c.dataset?.clientRef??"";if(!g||c.dataset?.hydrated==="true")continue;let f=tt(g);if(!f)continue;let R=await rt(t,f,o,{releaseAssetModules:s});if(!R)continue;let P=R[f.exportName]??R.default;if(typeof P=="function")try{let h=d(c),J=po(c),b=yo(c),ot=await de(b,{Fragment:u.Fragment,createElement(H,Z,...U){return u.createElement(H,Z,...U)}},async H=>{let Z=t.modules.find(st=>st.id===H),U=t.components?.[H],ye=Z?.clientRef??(U?`${U}#default`:void 0);if(!ye)return null;let Q=tt(ye);if(!Q)return null;let ee=await rt(t,Q,o,{releaseAssetModules:s});if(!ee)return null;let me=ee[Q.exportName]??ee.default;return typeof me=="function"?me:null}),it=await W(u.createElement(P,J,...ot),n,e);h.render(it),c.dataset.hydrated="true"}catch(h){l.warn("hydrate: render failed",h)}}try{globalThis.__VF_MANIFEST_HASH=t.hash??""}catch(c){l.debug("hydrate: set hash failed (post)",c)}}var fe="data-vf-react-head-owner";var xo=2*1024*1024,Xs=xo*2;var Js=64*1024,Zs=1024*1024,Qs=1024*1024;var ea=new TextEncoder;async function To(){let e=S(document),t=j(document,e?.reactVersion),[r,n]=await Promise.all([import(t.react),import(t.reactDomClient)]);return{React:r,ReactDOM:n}}var So=new Set(["SCRIPT","STYLE","NOSCRIPT","TEMPLATE"]);function pe(e){let t=e.getAttribute("style")??"";return e.hasAttribute("data-veryfront-head")||e.hasAttribute("hidden")||/(?:^|;)\\s*display\\s*:\\s*none(?:\\s*;|$)/i.test(t)||So.has(e.tagName.toUpperCase())}function Co(e,t){return e.find(r=>r.tagName.toUpperCase()==="DIV"&&!!r.getAttribute("class")?.trim()&&!pe(r))??t}function Ao(e,t){return e===t}function bo(e,t){let r=document.createElement("div");r.setAttribute("data-veryfront-hydration-root","page");let n=e.find(o=>!pe(o));n?.parentNode===t?t.insertBefore(r,n):t.appendChild(r);for(let o of e)!pe(o)&&o.parentNode===t&&r.appendChild(o);return r}function Oo(e,t){for(let r of e){let n=[...r.hasAttribute(fe)?[r]:[],...r.querySelectorAll(`[${fe}]`)];for(let o of n)t.contains(o)||o.remove()}}function Io(e,t,r=document){return!!t?.pagePath&&typeof e?.__veryfrontRenderPage=="function"&&!!r.getElementById("root")}function No(e,t){return t?.pagePath?!1:!!e.getElementById(N)}function Do(e=import.meta.url){try{return new URL(e,"http://veryfront.local").searchParams.get("hydrate")==="1"}catch{return!1}}function wo(e){return e==="rsc-module"}function Mo(e,t){return e?e.startsWith("?")?e:`?${e}`:""}function Lo(e,t,r){return G({strategy:t,rel:e,releaseAssetModules:r?.releaseAssetModules,dependencyPinningCacheKey:r?.dependencyPinningCacheKey})}async function Po(e,t){try{let r=await fetch(I+"stream"+e,{headers:D(t)});if(!r.ok)return await A(r)?"snapshot-conflict":"failure";if(!r.body)return"failure";let n=new AbortController;return addEventListener("pagehide",()=>n.abort(),{once:!0}),await Ze(r,document,n.signal),"success"}catch(r){return l.debug("tryStream failed",r),"failure"}}async function X(){try{await nt(document)}catch(e){l.debug("hydration failed",e)}}async function Ho(e,t,r){try{let{React:n,ReactDOM:o}=await To(),s=Lo(e,t,r);if(!s)return!1;l.debug("Loading component from:",s);let a;try{a=await import(s)}catch(R){throw await q(s),R}let u=a.default;if(typeof u!="function")return l.debug("Page component is not a function"),!1;let d=Array.from(document.body.children),c=Co(d,document.body),g=Ao(c,document.body)?bo(d,document.body):c;Oo(d,g);let f=await W(n.createElement(u,{}),r);return wo(t)?o.createRoot(g).render(f):o.hydrateRoot(g,f,{identifierPrefix:"vf",onRecoverableError:()=>{}}),l.debug("Page component hydrated successfully"),!0}catch(n){return l.error("Page hydration failed",n),!1}}async function Uo(e,t){try{let r=await fetch(I+"payload"+e,{headers:D(t)});if(!r.ok)return await A(r)?"snapshot-conflict":"failure";let n=await r.json();if(F(document,n?.dependencyPinningCacheKey),n?.slots){for(let[o,s]of Object.entries(n.slots))L(document,o).innerHTML=M(String(s||""));return"success"}return L(document,N).innerHTML=M(String(n?.html||"")),"success"}catch(r){return l.debug("payload fetch failed",r),"failure"}}async function vo(){try{let e=S(document),t=Mo(globalThis.window?.location.search??"",e?.dependencyPinningCacheKey);if(Do()){await X();return}let r=e?.pagePath,n=V(e);if(r){if(Io(globalThis.window,e,document)){l.debug("Page renderer owns hydration");return}l.debug("Found page component in hydration data:",r),await Ho(r,n,e)&&l.debug("Client component hydrated successfully");return}if(!No(document,e))return;let o=await Po(t,e);if(o==="snapshot-conflict")return;if(o==="success"){await X();return}let s=await Uo(t,e);if(s==="snapshot-conflict")return;if(s==="success"){await X();return}await X()}catch(e){l.error("boot failed",e)}}if(typeof document<"u"){let e=()=>{vo()};document.readyState==="loading"?document.addEventListener("DOMContentLoaded",e,{once:!0}):e()}export{vo as boot,Lo as buildPageHydrationModuleUrl,Mo as buildRSCTransportQuery,Oo as retireAbandonedHeadOwnerMarkers,Co as selectHydrationRoot,No as shouldAttemptRSCTransport,Do as shouldHydrateOnly,wo as shouldRenderPageComponent,Io as shouldUsePageRendererHydration,Ao as shouldWrapPageHydrationRoot};\n'; + 'var at=Object.defineProperty;var ct=(e,t,r)=>t in e?at(e,t,{enumerable:!0,configurable:!0,writable:!0,value:r}):e[t]=r;var m=(e,t,r)=>ct(e,typeof t!="symbol"?t+"":t,r);var ut="3.2.3";function lt(e,t,r,n){let o=[];if(n?.external?.length&&o.push(`external=${n.external.join(",")}`),o.push(`target=${n?.target??"es2022"}`),n?.deps){let d=Object.entries(n.deps).map(([c,g])=>`${c}@${g}`).join(",");o.push(`deps=${d}`)}let s=t?`@${t}`:"",a=r??"",u=o.length?`?${o.join("&")}`:"";return`https://esm.sh/${e}${s}${a}${u}`}function _(e,t,r,n=!1){return lt(e,t,r,{external:n?["react"]:void 0,deps:{csstype:ut}})}var gt="19.2.4",O=gt;function Ee(e=O){return{react:_("react",e),"react-dom":_("react-dom",e,void 0,!0),"react-dom/client":_("react-dom",e,"/client",!0),"react-dom/server":_("react-dom",e,"/server",!0),"react/jsx-runtime":_("react",e,"/jsx-runtime",!0),"react/jsx-dev-runtime":_("react",e,"/jsx-dev-runtime",!0)}}function Re(e=O){return Ee(e).react}function he(e=O){return Ee(e)["react-dom/client"]}function ft(e){return e.replaceAll("+","-").replaceAll("/","_").replaceAll("=","")}function pt(e){if(typeof globalThis.btoa=="function")try{return globalThis.btoa(e)}catch{return yt(new TextEncoder().encode(e))}let t=globalThis.Buffer;if(t)return t.from(e,"utf8").toString("base64");throw new Error("Base64 encoding is not supported in this runtime")}function yt(e){let t=globalThis.Buffer;if(t)return t.from(e).toString("base64");if(typeof globalThis.btoa=="function"){let r="";for(let n of e)r+=String.fromCharCode(n);return globalThis.btoa(r)}throw new Error("Base64 encoding is not supported in this runtime")}function te(e){return ft(pt(e))}var qo=Object.freeze({react:"","react-dom":"","react-dom/client":"","react-dom/server":"","react/jsx-runtime":"","react/jsx-dev-runtime":""});function v(e,t){if(!t)return!1;if(Object.prototype.hasOwnProperty.call(t,e))return!0;for(let r of Object.keys(t))if(r.endsWith("/")&&e.startsWith(r))return!0;return!1}function mt(e){try{return JSON.parse(e)?.imports??{}}catch(t){return console.warn("Failed to parse import map JSON; treating as empty",{errorName:t instanceof Error?t.name:typeof t,inputLength:e.length}),{}}}function re(e=document){let t=e.querySelector(\'script[type="importmap"]\');return t?.textContent?mt(t.textContent):{}}var Zo=Object.freeze({IPV4:"127.0.0.1",IPV6:"::1",HOSTNAME:"localhost"});var Et=5e3,Rt=1e4,ti=16*1024*1024,ht=5e3;var _t=100;var xt=Object.freeze([5,10,25,50,75,100,250,500,750,1e3,2500,5e3,7500,1e4]),ri=Object.freeze([1,5,10,25,50,100,250,500,1e3,2500,5e3,1e4]),ni=Object.freeze({server:Object.freeze({port:3e3,hostname:"0.0.0.0"}),timeouts:Object.freeze({default:Et,api:3e4,ssr:Rt,hmr:3e4,sandbox:ht}),cache:Object.freeze({jit:Object.freeze({maxSize:_t,tempDirPrefix:"vf-bundle-"})}),metrics:Object.freeze({ssrBoundaries:xt})});var p="/_veryfront",ne={RSC:`${p}/rsc/`,FS:`${p}/fs/`,MODULES:`${p}/modules/`,PAGES:`${p}/pages/`,DATA:`${p}/data/`,LIB:`${p}/lib/`,CHUNKS:`${p}/chunks/`,CLIENT:`${p}/client/`},xe={HMR_RUNTIME:`${p}/hmr-runtime.js`,HMR:`${p}/hmr.js`,ERROR_OVERLAY:`${p}/error-overlay.js`,DEV_LOADER:`${p}/dev-loader.js`,CLIENT_LOG:`${p}/log`,CLIENT_JS:`${p}/client.js`,ROUTER_JS:`${p}/router.js`,PREFETCH_JS:`${p}/prefetch.js`,MANIFEST_JSON:`${p}/manifest.json`,APP_JS:`${p}/app.js`,RSC_CLIENT:`${p}/rsc/client.js`,RSC_MANIFEST:`${p}/rsc/manifest`,RSC_STREAM:`${p}/rsc/stream`,RSC_PAYLOAD:`${p}/rsc/payload`,RSC_RENDER:`${p}/rsc/render`,RSC_PAGE:`${p}/rsc/page`,RSC_MODULE:`${p}/rsc/module`,RSC_DOM:`${p}/rsc/dom.js`,LIB_CHAT_REACT:`${p}/lib/chat/react.js`,LIB_CHAT_COMPONENTS:`${p}/lib/chat/components.js`,LIB_CHAT_PRIMITIVES:`${p}/lib/chat/primitives.js`};var Tt={ROOT:".veryfront",CACHE:".veryfront/cache",KV:".veryfront/kv",LOGS:".veryfront/logs",TMP:".veryfront/tmp"},ii=Tt.CACHE;var si={HMR_RUNTIME:xe.HMR_RUNTIME,ERROR_OVERLAY:xe.ERROR_OVERLAY};var I=ne.RSC,Te=ne.FS;var N="rsc-root",k="x-veryfront-dependency-pins";var T=class{constructor(t,r){m(this,"prefix",t);m(this,"level",r)}log(t,r,n,...o){this.level>t||r?.(n,...o)}debug(t,...r){this.log(0,console.debug,`[${this.prefix}] DEBUG: ${t}`,...r)}info(t,...r){this.log(1,console.log,`[${this.prefix}] ${t}`,...r)}warn(t,...r){this.log(2,console.warn,`[${this.prefix}] WARN: ${t}`,...r)}error(t,...r){this.log(3,console.error,`[${this.prefix}] ERROR: ${t}`,...r)}};function St(){if(typeof window>"u")return 2;let e=globalThis;return e.__VERYFRONT_DEV__||e.__RSC_DEV__?e.__VERYFRONT_DEBUG__||e.__RSC_DEBUG__?0:1:2}var $=St(),l=new T("RSC",$),di=new T("PREFETCH",$),gi=new T("HYDRATE",$),fi=new T("VERYFRONT",$);var Ct="veryfront-hydration-data";function oe(e){try{let t=[...e.querySelectorAll(`[id="${Ct}"]`)];if(t.length!==1)return null;let r=e.body;if(!r)return null;let n=t[0];return r.firstElementChild!==n&&n.parentElement!==r||n.tagName?.toLowerCase()!=="script"||n.getAttribute("type")?.trim().toLowerCase()!=="application/json"?null:n}catch{return null}}function S(e=document){try{let t=oe(e);return t?JSON.parse(t.textContent||"{}"):null}catch(t){return l.debug("hydration data parse failed",t),null}}function V(e,t){if(!t?.startsWith("on:"))return!1;try{let r=oe(e);if(!r)return!1;let n=JSON.parse(r.textContent||"{}");return n.dependencyPinningCacheKey=t,r.textContent=JSON.stringify(n),!0}catch(r){return l.debug("hydration dependency snapshot seed failed",r),!1}}function F(e){return e?.clientModuleStrategy?e.clientModuleStrategy:e?.dev?"fs":"rsc-module"}function At(e,t){if(!t)return e;let r=e.includes("?")?"&":"?";return`${e}${r}v=${encodeURIComponent(t)}`}function B(e,t){if(!t?.startsWith("on:"))return e;let r=e.indexOf("#"),n=r===-1?"":e.slice(r),o=r===-1?e:e.slice(0,r),s=o.indexOf("?"),a=s===-1?o:o.slice(0,s),u=new URLSearchParams(s===-1?"":o.slice(s+1));u.set("pins",t);let d=u.toString();return`${a}${d?`?${d}`:""}${n}`}function bt(e,t){return At(`${Te}${te(e)}.js`,t)}function Ot(e,t,r){let n=t?`&v=${encodeURIComponent(t)}`:"";return B(`${I}module?rel=${encodeURIComponent(e)}${n}`,r)}function D(e){let t=e?.dependencyPinningCacheKey;return t?.startsWith("on:")?{[k]:t}:{}}function It(e){return e.replace(/^\\/+_vf_modules\\//,"").replace(/^\\/+/,"").replace(/\\.js$/,"")}var Nt=/\\.(tsx|ts|jsx|mdx|js)$/;function Dt(e){let t=It(e),r=[e,t];return Nt.test(t)||r.push(`${t}.tsx`,`${t}.ts`,`${t}.jsx`,`${t}.mdx`,`${t}.js`),Array.from(new Set(r))}function wt(e,t){if(!e)return null;for(let r of Dt(t)){let n=e[r];if(n)return n}return null}function G(e){if(e.strategy==="fs"){let r=e.absPath??e.rel;return r?B(bt(r,e.version),e.dependencyPinningCacheKey):null}let t=wt(e.releaseAssetModules,e.rel);return t||Ot(e.rel,e.version,e.dependencyPinningCacheKey)}function j(e=document,t=O){let r=re(e);return{react:v("react",r)?"react":Re(t),reactDomClient:v("react-dom/client",r)?"react-dom/client":he(t)}}function Se(e=document){let t=re(e);return v("veryfront/router",t)?"veryfront/router":null}var Mt=new Set(["CONFIG","BUILD","RUNTIME","ROUTE","MODULE","SERVER","BOUNDARY","DEV","DEPLOY","AGENT","GENERAL"]);function Lt(e,...t){let r=Object.create(null),n=e.charAt(0).toUpperCase()+e.slice(1);for(let o of t)for(let[s,a]of Object.entries(o)){if(typeof a!="object"||a===null||!Object.hasOwn(a,"slug"))throw new Error(`${n} entry "${s}" must define a slug`);let u=a.slug;if(typeof u!="string")throw new Error(`${n} entry "${s}" must define a string slug`);if(u!==s)throw new Error(`${n} key "${s}" does not match entry slug "${u}"`);if(Object.hasOwn(r,s))throw new Error(`Duplicate ${e} slug "${s}"`);r[s]=a}return Object.freeze(r)}function Ce(...e){for(let t of e)for(let r of Object.values(t)){if(typeof r.slug!="string"||r.slug.length<3||r.slug.length>40||!/^[a-z][a-z0-9-]*[a-z0-9]$/.test(r.slug))throw new TypeError(`Registered error slug must be 3-40 characters of lowercase kebab-case, got "${r.slug}"`);if(typeof r.category!="string"||!Mt.has(r.category))throw new TypeError(`Registered error has unknown category "${r.category}"`);if(!Number.isInteger(r.status)||r.status<400||r.status>=600)throw new RangeError(`Registered error status must be an integer from 400 through 599, got ${r.status}`);if(typeof r.title!="string"||r.title.trim().length===0)throw new TypeError("Registered error title must be a non-empty string");if(r.suggestion!==void 0&&(typeof r.suggestion!="string"||r.suggestion.trim().length===0))throw new TypeError("Registered error suggestion must be non-empty when provided")}return Lt("error registry",...e)}var z={reset:"\\x1B[0m",dim:"\\x1B[2m",gray:"\\x1B[90m",red:"\\x1B[31m",green:"\\x1B[32m",yellow:"\\x1B[33m",blue:"\\x1B[34m",magenta:"\\x1B[35m",cyan:"\\x1B[36m"},Ai={debug:z.gray,info:z.green,warn:z.yellow,error:z.red};var y="[REDACTED]",E=Reflect.apply;var Ae=RegExp.prototype.exec,x=RegExp.prototype[Symbol.replace],Oi=String.prototype.charCodeAt,be=String.prototype.slice,Pt=String.prototype.toLowerCase,Ht=/[^a-z0-9]/g;function ie(e){let t=E(Pt,e,[]);return E(x,Ht,[t,""])}function Y(e,t,r){return r===void 0?E(be,e,[t]):E(be,e,[t,r])}var Ut=["password","passwd","pwd","passphrase","secret","clientsecret","token","apikey","accesskey","privatekey","credential","authorization","cookie","bearer","jwt","connectionstring","signature","sessionid","sid","otp","mfa","pin","salt","xsrf","csrf"],vt=512,kt=128,w=new Map;function Ne(e){let t=e.length<=kt;if(t){let o=w.get(e);if(o!==void 0)return o}let r=ie(e),n=Ut.some(o=>r.includes(o));if(t){if(w.size>=vt){let o=w.keys().next().value;o!==void 0&&w.delete(o)}w.set(e,n)}return n}var $t=["access_token","accesstoken","refresh_token","api_key","apikey","code","token","secret","client_secret","password","passwd","pwd","state","sig","signature","auth","x-amz-credential","x-amz-signature","x-amz-security-token","x-goog-credential","x-goog-signature"],Vt=new Set($t.map(ie)),Ft=/(\\b[a-z][a-z0-9+.-]*:\\/\\/|\\/\\/)([^/?#\\s]+)@/gi,Bt=/(\\b[a-z][a-z0-9+.-]*:\\/\\/|\\/\\/)([a-z0-9._~!$&\'()*+,;=%-]+):([^/?#@\\r\\n \\t]+[ \\t][^/?#@\\r\\n]*)@/gi,Gt=3;function jt(e){return e===" "||e==="\t"||e===","||e===";"||e==="&"||e==="?"||e==="#"}function zt(e){if(!e)return!1;let t=e.charCodeAt(0);return t>=65&&t<=90||t>=97&&t<=122}function De(e){return zt(e)||e==="_"||e==="$"}function Yt(e){if(!e)return!1;let t=e.charCodeAt(0);return De(e)||t>=48&&t<=57||e==="."||e==="-"}function we(e,t){let r=t,n=e[r]===\'"\'||e[r]==="\'"?e[r++]:"";if(!De(e[r]))return!1;for(r++;Yt(e[r]);)r++;if(n){if(e[r]!==n)return!1;r++}for(;e[r]===" "||e[r]==="\t";)r++;return e[r]===":"||e[r]==="="}function Me(e){return e==="\\r"||e===`\n`||e==="}"||e==="]"||jt(e)}function Le(e,t){let r=t;for(;r=e.length||we(e,r)}function Kt(e,t){let r=t,n=!0;if(e.startsWith(y,t)){let g=t+y.length;if(Oe(e,g))return{end:g,replacement:y};r=g,n=!1}let o=n&&(e[r]===\'"\'||e[r]==="\'"||e[r]==="`")?e[r]:"",s=!1,a=()=>o?`${o}${y}${s?o:""}`:y,u=[],d="",c=-1;for(let g=r;g0&&(f==="}"||f==="]")){if(u.at(-1)!==f)return{end:e.length,replacement:a()};if(u.pop(),g++,u.length===0&&Oe(e,g))return{end:g,replacement:a()};continue}if(u.length>0||!Me(f)){g++;continue}let R=g;if(g=Le(e,g),g>=e.length||we(e,g))return{end:R,replacement:a()}}return{end:e.length,replacement:a()}}function Ie(e,t,r,n){let o=0,s="";for(let a=E(Ae,t,[e]);a;a=E(Ae,t,[e])){let u=a[r];if(!Ne(u))continue;let d=t.lastIndex,c=n===void 0?void 0:a[n],g=d+y.length;if((c==="?"||c==="&"||c===";")&&e.startsWith(y,d)&&e[g]==="#")continue;let f=Kt(e,d);s+=Y(e,o,a.index),s+=a[0],s+=f.replacement,o=f.end,t.lastIndex=f.end}return o===0?e:s+Y(e,o)}function Wt(e,t,r){let n=r.search(/[ \\t]/);if(n<0)return!1;let o=`${t}:${Y(r,0,n)}`,s=e==="//"?`https://${o}`:`${e}${o}`;try{let a=new URL(s);return a.username.length===0&&a.password.length===0}catch{return!1}}function Xt(e){let t=e;for(let r=0;r{let s=o.indexOf(":");if(s===-1)return`${n}${y}@`;let a=Y(o,0,s);return`${n}${a}:${y}@`}]);return t=E(x,Bt,[t,(r,n,o,s)=>Wt(n,o,s)?r:`${n}${o}:${y}@`]),t=E(x,/([?#&;])([-a-z0-9_.%\\[\\]]+)=([^&#;\\s]*)/gi,[t,(r,n,o,s)=>{let a=Xt(o);return Vt.has(ie(a))||Ne(a)?`${n}${o}=${y}`:r}]),t=E(x,/(^|[^a-z0-9_-])((?:set-cookie|cookie)\\s*:\\s*)[^\\r\\n]*/gi,[t,(r,n,o)=>`${n}${o}${y}`]),t=E(x,/\\b(authorization\\s*[:=]\\s*)[^\\r\\n]*/gi,[t,(r,n)=>`${n}${y}`]),t=E(x,/\\b(bearer|basic)(\\s+)(?:"[^"\\r\\n]*"|\'[^\'\\r\\n]*\'|[a-z0-9._~+/=-]+)/gi,[t,(r,n,o)=>`${n}${o}${y}`]),t=Ie(t,/(["\'])([_$a-z][a-z0-9_.$-]*)\\1(\\s*[:=]\\s*)/gi,2),t=Ie(t,/(^|[^a-z0-9_.$-])([_$a-z][a-z0-9_.$-]*)(\\s*[:=]\\s*)/gi,2,1),t}var qt=2048;var Mi=64*1024,Jt=256,Zt="https://veryfront.com/docs/errors/",Pe="...[truncated]",ae="unknown-error";function He(e,t){if(e.length<=t)return e;let r=Math.max(0,t-Pe.length);return`${Qt(e,r)}${Pe}`}function Qt(e,t){let r=e.slice(0,t),n=r.charCodeAt(r.length-1);return n>=55296&&n<=56319&&(r=r.slice(0,-1)),r}function er(e){let t="";for(let r=0;r=55296&&n<=56319){let o=e.charCodeAt(r+1);o>=56320&&o<=57343?(t+=e.slice(r,r+2),r++):t+="\\uFFFD";continue}t+=n>=56320&&n<=57343?"\\uFFFD":e.charAt(r)}return t}function C(e){return typeof e!="string"?y:He(se(e),qt)}function tr(e){let t=typeof e=="string"?se(e):ae,r=He(t||ae,Jt),n=er(r);return n==="."||n===".."?ae:n}function K(e){let t=encodeURIComponent(tr(e));return`${Zt}${t}`}var rr=Object.freeze,nr=Object.getOwnPropertyDescriptors,Ue=Number.isFinite,ke=new WeakSet,or=new Set(["CONFIG","BUILD","RUNTIME","ROUTE","MODULE","SERVER","BOUNDARY","DEV","DEPLOY","AGENT","GENERAL"]);function i(e){let t={...e},r={...t,create(n){let o=n?.message,s=n?.detail,a=n?.cause,u=n?.instance,d=n?.context,c=n?.status??t.status;return new ce(o||s||t.title,{slug:t.slug,category:t.category,status:c,title:t.title,suggestion:t.suggestion,exitCode:t.exitCode,detail:s,cause:a,instance:u,context:d})}};return rr(r)}var ce=class extends Error{constructor(r,n){super(r);m(this,"slug");m(this,"category");m(this,"status");m(this,"title");m(this,"suggestion");m(this,"exitCode");m(this,"detail");m(this,"cause");m(this,"instance");m(this,"context");ke.add(this),this.name="VeryfrontError",this.slug=n.slug,this.category=n.category,this.status=n.status,this.title=n.title,this.suggestion=n.suggestion,this.exitCode=n.exitCode,this.detail=n.detail,this.cause=n.cause,this.instance=n.instance,this.context=n.context}toRFC9457(){let r=ve(this);return r?{type:K(r.slug),title:C(r.title),status:r.status,detail:r.detail===void 0?void 0:C(r.detail),instance:r.instance===void 0?void 0:C(r.instance),category:r.category,suggestion:r.suggestion===void 0?void 0:C(r.suggestion),cause:typeof r.cause=="string"?C(r.cause):void 0}:{type:K("unknown-error"),title:"Unknown/unclassified error",status:500,category:"GENERAL"}}getDocsUrl(){let r=ve(this);return K(r?.slug??"unknown-error")}};function $e(e){return typeof e=="object"&&e!==null&&ke.has(e)}function ve(e){return $e(e)?ir(e):null}function ir(e){try{if(!$e(e))return null;let t=nr(e),r=J=>{let b=t[J];return b&&"value"in b?b.value:void 0},n=r("slug"),o=r("category"),s=r("status"),a=r("title"),u=r("message"),d=r("suggestion"),c=r("exitCode"),g=r("detail"),f=r("cause"),R=r("instance"),P=r("context"),h=r("stack");return typeof n!="string"||!or.has(o)||typeof s!="number"||!Ue(s)||typeof a!="string"||typeof u!="string"||d!==void 0&&typeof d!="string"||c!==void 0&&(typeof c!="number"||!Ue(c))||g!==void 0&&typeof g!="string"||R!==void 0&&typeof R!="string"||h!==void 0&&typeof h!="string"?null:{slug:n,category:o,status:s,title:a,message:u,suggestion:d,exitCode:c,detail:g,cause:f,instance:R,context:P,stack:h}}catch{return null}}var sr=i({slug:"config-not-found",category:"CONFIG",status:404,title:"Configuration file not found",suggestion:"Create veryfront.config.js, veryfront.config.ts, or veryfront.config.mjs in the project root"}),ar=i({slug:"config-invalid",category:"CONFIG",status:400,title:"Invalid configuration format",suggestion:"Check the reported configuration path and validation details"}),cr=i({slug:"config-parse-error",category:"CONFIG",status:400,title:"Failed to parse configuration",suggestion:"Ensure your configuration file contains valid JavaScript or TypeScript"}),ur=i({slug:"config-validation-error",category:"CONFIG",status:422,title:"Configuration validation failed",suggestion:"Check the configuration against the schema requirements"}),lr=i({slug:"config-type-error",category:"CONFIG",status:400,title:"Configuration type mismatch",suggestion:"Ensure configuration values match expected types"}),dr=i({slug:"import-map-invalid",category:"CONFIG",status:400,title:"Invalid import map configuration",suggestion:"Check your import map syntax and paths"}),gr=i({slug:"cors-config-invalid",category:"CONFIG",status:400,title:"Invalid CORS configuration",suggestion:"Review CORS settings in your configuration"}),fr=i({slug:"config-validation-failed",category:"CONFIG",status:400,title:"Configuration validation failed",suggestion:"Check configuration values against requirements"}),pr=i({slug:"webhook-config-invalid",category:"CONFIG",status:400,title:"Invalid webhook configuration",suggestion:"Check webhook definition fields, target settings, and eventFilter conditions"}),yr=i({slug:"schedule-config-invalid",category:"CONFIG",status:400,title:"Invalid schedule configuration",suggestion:"Check schedule definition fields, cron expression, target settings, and positive-integer limits"}),mr=i({slug:"trigger-config-invalid",category:"CONFIG",status:400,title:"Invalid trigger configuration",suggestion:"Check trigger ID format (lowercase, alphanumeric, dots/slashes/hyphens) and ensure all input values are JSON-serializable"}),Ve={"config-not-found":sr,"config-invalid":ar,"config-parse-error":cr,"config-validation-error":ur,"config-type-error":lr,"import-map-invalid":dr,"cors-config-invalid":gr,"config-validation-failed":fr,"webhook-config-invalid":pr,"schedule-config-invalid":yr,"trigger-config-invalid":mr};var Er=i({slug:"build-failed",category:"BUILD",status:500,title:"Build process failed",suggestion:"Check the build output for specific errors"}),Rr=i({slug:"bundle-error",category:"BUILD",status:500,title:"Bundle generation failed",suggestion:"Review bundler output for details"}),hr=i({slug:"typescript-error",category:"BUILD",status:500,title:"TypeScript compilation error",suggestion:"Fix TypeScript errors shown in the output"}),_r=i({slug:"mdx-compile-error",category:"BUILD",status:500,title:"MDX compilation failed",suggestion:"Check your MDX file syntax"}),xr=i({slug:"asset-optimization-error",category:"BUILD",status:500,title:"Asset optimization failed",suggestion:"Check asset file formats and paths"}),Tr=i({slug:"ssg-generation-error",category:"BUILD",status:500,title:"Static site generation failed",suggestion:"Review SSG configuration and data fetching"}),Sr=i({slug:"sourcemap-error",category:"BUILD",status:500,title:"Source map generation failed",suggestion:"Check source map configuration"}),Cr=i({slug:"compilation-error",category:"BUILD",status:500,title:"Compilation failed",suggestion:"Review compiler output for specific errors"}),Fe={"build-failed":Er,"bundle-error":Rr,"typescript-error":hr,"mdx-compile-error":_r,"asset-optimization-error":xr,"ssg-generation-error":Tr,"sourcemap-error":Sr,"compilation-error":Cr};var Ar=i({slug:"hydration-mismatch",category:"RUNTIME",status:500,title:"Client/server hydration mismatch",suggestion:"Ensure server and client render the same content"}),br=i({slug:"render-error",category:"RUNTIME",status:500,title:"Component render failed",suggestion:"Check component for runtime errors"}),Or=i({slug:"component-error",category:"RUNTIME",status:500,title:"Component execution error",suggestion:"Review component logic and props"}),Ir=i({slug:"layout-not-found",category:"RUNTIME",status:404,title:"Layout component not found",suggestion:"Ensure layout file exists at the expected path"}),Nr=i({slug:"page-not-found",category:"RUNTIME",status:404,title:"Page component not found",suggestion:"Check that the page file exists in the routes directory"}),Dr=i({slug:"api-error",category:"RUNTIME",status:500,title:"API route handler error",suggestion:"Review API route handler for errors"}),wr=i({slug:"middleware-error",category:"RUNTIME",status:500,title:"Middleware execution error",suggestion:"Check middleware function for errors"}),Mr=i({slug:"trigger-target-not-found",category:"RUNTIME",status:404,title:"Trigger target not found",suggestion:"Ensure the referenced task or workflow ID is registered in the project"}),Lr=i({slug:"trigger-execution-failed",category:"RUNTIME",status:500,title:"Trigger target execution failed",suggestion:"Check the task or workflow for errors and review the trigger input"}),Pr=i({slug:"trigger-not-supported",category:"RUNTIME",status:501,title:"Trigger target type not supported in local runtime",suggestion:"Use a workflow or task target for local trigger runs; agent targets require the Cloud runtime"}),Be={"hydration-mismatch":Ar,"render-error":br,"component-error":Or,"layout-not-found":Ir,"page-not-found":Nr,"api-error":Dr,"middleware-error":wr,"trigger-target-not-found":Mr,"trigger-execution-failed":Lr,"trigger-not-supported":Pr};var Hr=i({slug:"route-conflict",category:"ROUTE",status:409,title:"Conflicting route definitions",suggestion:"Rename or reorganize conflicting route files"}),Ur=i({slug:"invalid-route-file",category:"ROUTE",status:400,title:"Invalid route file structure",suggestion:"Ensure route file exports required functions"}),vr=i({slug:"route-handler-invalid",category:"ROUTE",status:400,title:"Invalid route handler export",suggestion:"Export a valid handler function from the route file"}),kr=i({slug:"dynamic-route-error",category:"ROUTE",status:500,title:"Dynamic route parsing failed",suggestion:"Check dynamic route segment syntax"}),$r=i({slug:"route-params-error",category:"ROUTE",status:400,title:"Route parameters invalid",suggestion:"Validate route parameter values"}),Vr=i({slug:"api-route-error",category:"ROUTE",status:500,title:"API route definition error",suggestion:"Review API route configuration"}),Ge={"route-conflict":Hr,"invalid-route-file":Ur,"route-handler-invalid":vr,"dynamic-route-error":kr,"route-params-error":$r,"api-route-error":Vr};var Fr=i({slug:"module-not-found",category:"MODULE",status:404,title:"Module could not be resolved",suggestion:"Check the import path and ensure the module is installed"}),Br=i({slug:"import-resolution-error",category:"MODULE",status:500,title:"Import path resolution failed",suggestion:"Verify import paths and module configuration"}),Gr=i({slug:"circular-dependency",category:"MODULE",status:500,title:"Circular dependency detected",suggestion:"Refactor imports to break the circular dependency"}),jr=i({slug:"invalid-import",category:"MODULE",status:400,title:"Invalid import statement",suggestion:"Fix import syntax or path"}),zr=i({slug:"dependency-missing",category:"MODULE",status:404,title:"Required dependency not installed",suggestion:"Install the missing dependency with your package manager"}),Yr=i({slug:"version-mismatch",category:"MODULE",status:409,title:"Dependency version mismatch",suggestion:"Update dependencies to compatible versions"}),je={"module-not-found":Fr,"import-resolution-error":Br,"circular-dependency":Gr,"invalid-import":jr,"dependency-missing":zr,"version-mismatch":Yr};var Kr=i({slug:"port-in-use",category:"SERVER",status:409,title:"Server port already in use",suggestion:"Use a different port or stop the process using this port"}),Wr=i({slug:"server-start-error",category:"SERVER",status:500,title:"Server failed to start",suggestion:"Check server configuration and port availability"}),Xr=i({slug:"cache-error",category:"SERVER",status:500,title:"Cache operation failed",suggestion:"Clear the cache and try again"}),qr=i({slug:"file-watch-error",category:"SERVER",status:500,title:"File watcher error",suggestion:"Restart the development server"}),Jr=i({slug:"request-error",category:"SERVER",status:500,title:"HTTP request handling error",suggestion:"Check request handler and middleware"}),Zr=i({slug:"service-overloaded",category:"SERVER",status:503,title:"Service overloaded",suggestion:"Reduce load or scale up resources"}),Qr=i({slug:"project-execution-unavailable",category:"SERVER",status:503,title:"Project execution unavailable",suggestion:"Route the project to a dedicated isolated runtime"}),en=i({slug:"semaphore-timeout",category:"SERVER",status:503,title:"Semaphore acquire timeout",suggestion:"Reduce concurrency or increase the semaphore acquire timeout"}),tn=i({slug:"circuit-breaker-open",category:"SERVER",status:503,title:"Circuit breaker is open",suggestion:"Wait for the breaker reset timeout before retrying"}),rn=i({slug:"cache-path-mismatch",category:"SERVER",status:500,title:"Cache path mismatch",suggestion:"Clear the cache directory and rebuild"}),nn=i({slug:"network-error",category:"SERVER",status:502,title:"Network operation failed",suggestion:"Check network connectivity and retry"}),on=i({slug:"api-client-error",category:"SERVER",status:500,title:"API client request failed",suggestion:"Check API connectivity and authentication"}),sn=i({slug:"token-storage-error",category:"SERVER",status:500,title:"Token storage operation failed",suggestion:"Check token storage backend and credentials"}),an=i({slug:"cache-invariant-violation",category:"SERVER",status:500,title:"Cache path invariant violated",suggestion:"Clear the cache and rebuild"}),cn=i({slug:"release-not-found",category:"SERVER",status:404,title:"No active release found",suggestion:"Deploy the project to create a release for this environment"}),un=i({slug:"fallback-exhausted",category:"SERVER",status:500,title:"Primary and fallback operations both failed",suggestion:"Check service availability and connectivity"}),ze={"port-in-use":Kr,"server-start-error":Wr,"cache-error":Xr,"file-watch-error":qr,"request-error":Jr,"service-overloaded":Zr,"project-execution-unavailable":Qr,"semaphore-timeout":en,"circuit-breaker-open":tn,"cache-path-mismatch":rn,"network-error":nn,"api-client-error":on,"token-storage-error":sn,"cache-invariant-violation":an,"release-not-found":cn,"fallback-exhausted":un};var ln=i({slug:"client-boundary-violation",category:"BOUNDARY",status:400,title:"Client boundary rule violation",suggestion:"Add \'use client\' directive or move code to a client component"}),dn=i({slug:"server-only-in-client",category:"BOUNDARY",status:400,title:"Server-only code in client component",suggestion:"Move server-only code to a server component"}),gn=i({slug:"client-only-in-server",category:"BOUNDARY",status:400,title:"Client-only code in server component",suggestion:"Move client-only code to a client component"}),fn=i({slug:"invalid-use-client",category:"BOUNDARY",status:400,title:"Invalid \'use client\' directive",suggestion:"Place \'use client\' at the top of the file"}),pn=i({slug:"invalid-use-server",category:"BOUNDARY",status:400,title:"Invalid \'use server\' directive",suggestion:"Place \'use server\' at the top of the file or function"}),yn=i({slug:"rsc-payload-error",category:"BOUNDARY",status:500,title:"RSC payload serialization error",suggestion:"Ensure props are serializable (no functions, symbols, etc.)"}),mn=i({slug:"ssr-output-limit-exceeded",category:"BOUNDARY",status:500,title:"SSR output limit exceeded",suggestion:"Reduce the rendered HTML size or split the response into smaller pages"}),Ye={"client-boundary-violation":ln,"server-only-in-client":dn,"client-only-in-server":gn,"invalid-use-client":fn,"invalid-use-server":pn,"rsc-payload-error":yn,"ssr-output-limit-exceeded":mn};var En=i({slug:"hmr-error",category:"DEV",status:500,title:"Hot module replacement error",suggestion:"Restart the development server"}),Rn=i({slug:"dev-server-error",category:"DEV",status:500,title:"Development server error",suggestion:"Check the dev server logs and restart"}),hn=i({slug:"fast-refresh-error",category:"DEV",status:500,title:"Fast refresh failed",suggestion:"Save the file again or restart the dev server"}),_n=i({slug:"error-overlay-error",category:"DEV",status:500,title:"Error overlay failed",suggestion:"Check browser console for details"}),xn=i({slug:"source-map-error",category:"DEV",status:500,title:"Source map loading error",suggestion:"Rebuild or clear cache"}),Ke={"hmr-error":En,"dev-server-error":Rn,"fast-refresh-error":hn,"error-overlay-error":_n,"source-map-error":xn};var Tn=i({slug:"deployment-error",category:"DEPLOY",status:500,title:"Deployment process failed",suggestion:"Check deployment logs for details"}),Sn=i({slug:"platform-error",category:"DEPLOY",status:500,title:"Platform-specific error",suggestion:"Check platform documentation and requirements"}),Cn=i({slug:"env-var-missing",category:"DEPLOY",status:500,title:"Required environment variable missing",suggestion:"Set the required environment variable"}),An=i({slug:"production-build-required",category:"DEPLOY",status:400,title:"Production build required",suggestion:"Run \'veryfront build\' before deploying"}),bn=i({slug:"environment-not-found",category:"DEPLOY",status:404,title:"Deployment environment not found",suggestion:"Check environment names with: veryfront config"}),On=i({slug:"release-missing-version",category:"DEPLOY",status:500,title:"Release has no version",suggestion:"Try again or check the build logs in Studio"}),In=i({slug:"release-build-timeout",category:"DEPLOY",status:408,title:"Release build timed out",suggestion:"Try again or check the build logs in Studio"}),Nn=i({slug:"deployment-verification-timeout",category:"DEPLOY",status:408,title:"Deployment verification timed out",suggestion:"Try again or check the deployment status in Studio"}),Dn=i({slug:"push-receipt-missing",category:"DEPLOY",status:400,title:"Push receipt not found",suggestion:"Run: veryfront push --branch main first"}),wn=i({slug:"source-digest-mismatch",category:"DEPLOY",status:409,title:"Release source digest mismatch",suggestion:"Run veryfront push again to re-upload source files"}),Mn=i({slug:"preview-hostname-too-long",category:"DEPLOY",status:400,title:"Preview hostname too long",suggestion:"Use a shorter project slug or branch name"}),Ln=i({slug:"branch-not-found",category:"DEPLOY",status:404,title:"Branch not found",suggestion:"List branches in Studio or push a new one with: veryfront push --branch "}),We={"deployment-error":Tn,"platform-error":Sn,"env-var-missing":Cn,"production-build-required":An,"environment-not-found":bn,"release-missing-version":On,"release-build-timeout":In,"deployment-verification-timeout":Nn,"push-receipt-missing":Dn,"source-digest-mismatch":wn,"preview-hostname-too-long":Mn,"branch-not-found":Ln};var Pn=i({slug:"agent-error",category:"AGENT",status:500,title:"Agent operation error",suggestion:"Check agent configuration and logs"}),Hn=i({slug:"agent-not-found",category:"AGENT",status:404,title:"Agent not found",suggestion:"Verify the agent ID exists"}),Un=i({slug:"agent-timeout",category:"AGENT",status:408,title:"Agent operation timed out",suggestion:"Increase timeout or simplify the request"}),vn=i({slug:"agent-intent-error",category:"AGENT",status:400,title:"Agent intent parsing error",suggestion:"Rephrase the request more clearly"}),kn=i({slug:"orchestration-error",category:"AGENT",status:500,title:"Multi-agent orchestration error",suggestion:"Check agent coordination logic"}),$n=i({slug:"cost-limit-exceeded",category:"AGENT",status:429,title:"Cost limit exceeded",suggestion:"Wait for the budget period to reset or increase the limit"}),Vn=i({slug:"tool-id-conflict",category:"AGENT",status:409,title:"Tool ID conflict",suggestion:"Use a unique tool ID or rename one of the conflicting tools"}),Xe={"agent-error":Pn,"agent-not-found":Hn,"agent-timeout":Un,"agent-intent-error":vn,"orchestration-error":kn,"cost-limit-exceeded":$n,"tool-id-conflict":Vn};var Fn=i({slug:"unknown-error",category:"GENERAL",status:500,title:"Unknown/unclassified error",suggestion:"Check logs for more details"}),Bn=i({slug:"authentication-required",category:"GENERAL",status:401,title:"Authentication required",suggestion:"Set VERYFRONT_API_TOKEN or run \'veryfront login\'"}),Gn=i({slug:"permission-denied",category:"GENERAL",status:403,title:"File/resource permission denied",suggestion:"Check file permissions and access rights"}),jn=i({slug:"file-not-found",category:"GENERAL",status:404,title:"File not found",suggestion:"Verify the file path exists"}),zn=i({slug:"resource-not-found",category:"GENERAL",status:404,title:"Requested resource not found",suggestion:"Verify the referenced resource ID or name exists"}),Yn=i({slug:"invalid-argument",category:"GENERAL",status:400,title:"Invalid function argument",suggestion:"Check argument types and values",exitCode:2}),Kn=i({slug:"timeout-error",category:"GENERAL",status:408,title:"Operation timed out",suggestion:"Increase timeout or optimize the operation"}),Wn=i({slug:"initialization-error",category:"GENERAL",status:500,title:"Initialization failed",suggestion:"Check initialization requirements and dependencies"}),Xn=i({slug:"not-supported",category:"GENERAL",status:501,title:"Feature not supported",suggestion:"Check documentation for supported features"}),ue=i({slug:"security-violation",category:"GENERAL",status:403,title:"Security violation detected",suggestion:"Check for path traversal or unauthorized access attempts"}),qn=i({slug:"input-validation-failed",category:"GENERAL",status:400,title:"Input validation failed",suggestion:"Check request input against validation rules"}),Jn=i({slug:"project-source-empty",category:"GENERAL",status:400,title:"Project source is empty",suggestion:"Add project files or run \'veryfront init\'"}),qe={"unknown-error":Fn,"authentication-required":Bn,"permission-denied":Gn,"file-not-found":jn,"resource-not-found":zn,"invalid-argument":Yn,"timeout-error":Kn,"initialization-error":Wn,"not-supported":Xn,"security-violation":ue,"input-validation-failed":qn,"project-source-empty":Jn};var _s=Ce(Ve,Fe,Be,Ge,je,ze,Ye,Ke,We,Xe,qe);var Zn=[{source:String.raw`]*>[\\s\\S]*?<\\/script>`,flags:"gi",name:"inline script"},{source:String.raw`javascript:`,flags:"gi",name:"javascript: URL"},{source:String.raw`\\bon\\w+\\s*=`,flags:"gi",name:"event handler attribute"},{source:String.raw`data:\\s*text\\/html`,flags:"gi",name:"data: HTML URL"}];function Qn(){return Zn.map(({source:e,flags:t,name:r})=>({pattern:new RegExp(e,t),name:r}))}function eo(){let e=globalThis;return e.__VERYFRONT_DEV__===!0||e.Deno?.env?.get?.("VERYFRONT_ENV")==="development"}function M(e,t={}){let{allowInlineScripts:r=!1,strict:n=!1,warn:o=!0}=t;for(let{pattern:s,name:a}of Qn())if(!(r&&a==="inline script")&&(s.lastIndex=0,!!s.test(e)&&(o&&console.warn(`[Security] Suspicious ${a} detected in server HTML`),n||!eo())))throw ue.create({detail:`Potentially unsafe HTML: ${a} detected`});return e}function L(e,t){let r=t==="root"?N:`rsc-slot-${t}`,n=e.getElementById(r);if(n)return n;let o=e.createElement("div");return o.id=r,e.body.appendChild(o),o}function to(e,t){if(t.type!=="slot")return;let r=L(e,t.id);r.innerHTML=M(String(t.html??""))}function Je(e,t){let r=t.split(`\n`),n=r.pop()??"";for(let o of r){let s=o.trim();if(!s)continue;let a;try{a=JSON.parse(s)}catch(d){l.debug("[client-dom] malformed NDJSON line",{line:s,error:d instanceof Error?d.message:String(d)});continue}if(!a||typeof a!="object")continue;let u=a;if(u.type==="slot"){to(e,u);try{oo(e,u.id||"root")}catch(d){l.debug("[client-dom] hydration optional failed",d)}}}return n}function ro(e){return new Promise((t,r)=>{let n=()=>r(new DOMException("aborted","AbortError"));if(e.aborted){n();return}e.addEventListener("abort",n,{once:!0})})}async function Ze(e,t=document,r){let n="body"in e?e:null,o=n?.body??e;if(!o)return;n&&V(t,n.headers.get(k));let s=o.getReader(),a=new TextDecoder,u="",d=!1;try{for(;;){if(r?.aborted)throw new DOMException("aborted","AbortError");let c=s.read(),{done:g,value:f}=r?await Promise.race([c,ro(r)]):await c;if(g){d=!0;break}u+=a.decode(f,{stream:!0}),u=Je(t,u)}u&&Je(t,`${u}\n`)}catch(c){throw c instanceof Error&&c.name==="AbortError"||l.debug("[client-dom] consumeNdjsonStream error",c),c}finally{try{await s.cancel()}catch(c){d||l.debug("[client-dom] reader.cancel failed",c)}try{s.releaseLock()}catch(c){l.debug("[client-dom] reader.releaseLock failed",c)}if(typeof o.cancel=="function")try{await o.cancel()}catch(c){l.debug("[client-dom] stream.cancel failed",c)}if(typeof n?.body?.cancel=="function")try{await n.body.cancel()}catch(c){l.debug("[client-dom] response.body.cancel failed",c)}}}function no(e,t){let r=L(e,t),n=[],o=s=>{let a=s;a.dataset?.clientRef&&n.push(a);for(let u of s.children)o(u)};return o(r),n}function oo(e,t){let r=no(e,t);for(let n of r){let o=n.dataset?.clientRef;o&&(n.dataset.hydrated="true",l.debug("[client-dom] marked for hydration",o))}}var io=new Set(["server","client","html","fragment"]);function Qe(e){if(!e)return[];try{let t=JSON.parse(e);return ao(t)?t.nodes:[]}catch{return[]}}async function de(e,t,r){return await Promise.all(e.map(n=>so(n,t,r)))}async function so(e,t,r){if(e.type==="html")return e.text??e.html??"";let n=await de(e.children??[],t,r);if(e.type==="fragment"||e.type==="server"&&!e.component)return t.createElement(t.Fragment,{},...n);if(e.type==="server")return t.createElement(e.component,e.props??{},...n);let o=await r(e.component);return o?t.createElement(o,e.props??{},...n):null}function ao(e){return!le(e)||e.version!==1||!Array.isArray(e.nodes)?!1:e.nodes.every(t=>et(t,0))}function et(e,t){return t>100||!le(e)||!io.has(e.type)||e.type==="html"&&typeof e.html!="string"&&typeof e.text!="string"||e.type==="client"&&typeof e.component!="string"||e.type==="server"&&e.component!==void 0&&typeof e.component!="string"||e.props!==void 0&&!le(e.props)?!1:e.children===void 0?!0:Array.isArray(e.children)&&e.children.every(r=>et(r,t+1))}function le(e){return typeof e=="object"&&e!==null&&!Array.isArray(e)}function co(e){if(!e)return{};let t={};for(let[r,n]of Object.entries(e))t[r]=Array.isArray(n)?n.join("/"):n;return t}async function W(e,t,r=document){try{let n=Se(r);if(!n)return e;let s=(await import(n)).wrapForHydration;return typeof s!="function"?e:s(e,{params:co(t?.params),frontmatter:t?.frontmatter??{},data:t?.props??{}})}catch(n){return l.debug("router provider wrap failed",n),e}}var uo="Unknown dependency snapshot",lo="export default null; // Unknown dependency snapshot",ge="__VF_DEPENDENCY_SNAPSHOT_RECOVERY_STARTED__";function go(){return globalThis}async function fo(e){if(e.status!==409)return!1;try{let t=(await e.clone().text()).trim();return t===uo||t===lo}catch{return!1}}async function A(e,t=()=>globalThis.location.reload()){if(!await fo(e))return!1;let r=go();if(r[ge])return!0;r[ge]=!0;try{t()}catch{return delete r[ge],!1}return!0}async function X(e,t=globalThis.fetch,r=()=>globalThis.location.reload()){try{let n=new URL(e,"http://veryfront.local").searchParams.getAll("pins");if(n.length!==1||!n[0]?.startsWith("on:"))return!1;let o=await t(e,{cache:"no-store"});return await A(o,r)}catch{return!1}}var po=100;function yo(e,t){if(globalThis.__VF_CLIENT_MOD_CACHE??(globalThis.__VF_CLIENT_MOD_CACHE=new Map),globalThis.__VF_CLIENT_MOD_CACHE.size>=po){let r=globalThis.__VF_CLIENT_MOD_CACHE.keys().next().value;r&&globalThis.__VF_CLIENT_MOD_CACHE.delete(r)}globalThis.__VF_CLIENT_MOD_CACHE.set(e,t)}function tt(e){let t=e.match(/^\\/app\\/(.+)#([\\w$.-]+)$/);if(t)return{rel:`/${t[1]||""}`,exportName:t[2]||"default"};let r=e.match(/^(\\/_veryfront\\/[^#]+)#([\\w$.-]+)$/);return r?{moduleUrl:r[1],exportName:r[2]||"default"}:(l.debug("hydrate: unrecognised client ref format, skipping",{ref:e}),null)}function mo(e){let t=e.dataset?.rscProps;if(!t)return{};try{let r=JSON.parse(t);return r&&typeof r=="object"&&!Array.isArray(r)?r:{}}catch(r){return l.debug("hydrate: invalid client boundary props, using empty props",r),{}}}function Eo(e){return Qe(e.dataset?.rscChildren)}function Ro(e){return"/_veryfront/rsc/manifest"}function ho(e){return D(e)}async function _o(e=document){try{let t=S(e),r=await fetch(Ro(t),{headers:ho(t)});return r.ok?await r.json():(await A(r),null)}catch{return null}}async function rt(e,t,r,n={}){let o=xo(e,t,r,n.releaseAssetModules),s=t.moduleUrl??t.rel;if(!s)return null;let a=`${s}#${e.hash??""}`;try{let u=globalThis.__VF_CLIENT_MOD_CACHE?.get(a);if(u)return u}catch(u){l.debug("hydrate: cache get failed",u)}if(!o)return null;try{let u=await(n.importModule??(d=>import(d)))(o);try{yo(a,u)}catch(d){l.debug("hydrate: cache set failed",d)}return u}catch(u){return l.debug("hydrate: failed to import module",{moduleUrl:o,error:u}),await(n.recoverSnapshotFailure??X)(o),null}}function xo(e,t,r,n){if(t.moduleUrl)return B(t.moduleUrl,e.dependencyPinningCacheKey);if(!t.rel)return null;let o=e.graphIds?.client.find(s=>s.rel===t.rel)?.path;return G({strategy:r,rel:t.rel,absPath:o,version:e.hash,dependencyPinningCacheKey:e.dependencyPinningCacheKey,releaseAssetModules:n})}function To(e){let t=Array.from(e.querySelectorAll("[data-client-ref]")),r=new Set(t);return t.filter(n=>{let o=n.parentElement;for(;o;){if(r.has(o))return!1;o=o.parentElement}return!0})}async function nt(e=document){let t=null;try{t=await _o(e)}catch(c){l.debug("hydrate: fetch manifest failed",c)}if(!t){l.debug("hydrate: no manifest");return}let r=To(e);try{let c=globalThis.__VF_MANIFEST_HASH;if(!r.some(f=>f.dataset?.hydrated!=="true")&&c&&t.hash&&c===t.hash)return}catch(c){l.debug("hydrate: hmr hash read failed",c)}if(r.length===0){try{globalThis.__VF_MANIFEST_HASH=t.hash??""}catch(c){l.debug("hydrate: set hash failed",c)}return}let n=S(e),o=F(n),s=n?.releaseAssetModules;try{if(globalThis.__VF_TEST_MODE__){globalThis.__VF_HYDRATE_CALLED=!0,globalThis.__VF_MANIFEST_HASH=t.hash??"";return}}catch(c){l.debug("hydrate: test mode flags failed",c)}let a=j(e,n?.reactVersion),[{default:u},{createRoot:d}]=await Promise.all([import(a.react),import(a.reactDomClient)]);for(let c of r){let g=c.dataset?.clientRef??"";if(!g||c.dataset?.hydrated==="true")continue;let f=tt(g);if(!f)continue;let R=await rt(t,f,o,{releaseAssetModules:s});if(!R)continue;let P=R[f.exportName]??R.default;if(typeof P=="function")try{let h=d(c),J=mo(c),b=Eo(c),ot=await de(b,{Fragment:u.Fragment,createElement(H,Z,...U){return u.createElement(H,Z,...U)}},async H=>{let Z=t.modules.find(st=>st.id===H),U=t.components?.[H],ye=Z?.clientRef??(U?`${U}#default`:void 0);if(!ye)return null;let Q=tt(ye);if(!Q)return null;let ee=await rt(t,Q,o,{releaseAssetModules:s});if(!ee)return null;let me=ee[Q.exportName]??ee.default;return typeof me=="function"?me:null}),it=await W(u.createElement(P,J,...ot),n,e);h.render(it),c.dataset.hydrated="true"}catch(h){l.warn("hydrate: render failed",h)}}try{globalThis.__VF_MANIFEST_HASH=t.hash??""}catch(c){l.debug("hydrate: set hash failed (post)",c)}}var fe="data-vf-react-head-owner";var So=2*1024*1024,Zs=So*2;var Qs=64*1024,ea=1024*1024,ta=1024*1024;var ra=new TextEncoder;async function Co(){let e=S(document),t=j(document,e?.reactVersion),[r,n]=await Promise.all([import(t.react),import(t.reactDomClient)]);return{React:r,ReactDOM:n}}var Ao=new Set(["SCRIPT","STYLE","NOSCRIPT","TEMPLATE"]);function pe(e){let t=e.getAttribute("style")??"";return e.hasAttribute("data-veryfront-head")||e.hasAttribute("hidden")||/(?:^|;)\\s*display\\s*:\\s*none(?:\\s*;|$)/i.test(t)||Ao.has(e.tagName.toUpperCase())}function bo(e,t){return e.find(r=>r.tagName.toUpperCase()==="DIV"&&!!r.getAttribute("class")?.trim()&&!pe(r))??t}function Oo(e,t){return e===t}function Io(e,t){let r=document.createElement("div");r.setAttribute("data-veryfront-hydration-root","page");let n=e.find(o=>!pe(o));n?.parentNode===t?t.insertBefore(r,n):t.appendChild(r);for(let o of e)!pe(o)&&o.parentNode===t&&r.appendChild(o);return r}function No(e,t){for(let r of e){let n=[...r.hasAttribute(fe)?[r]:[],...r.querySelectorAll(`[${fe}]`)];for(let o of n)t.contains(o)||o.remove()}}function Do(e,t,r=document){return!!t?.pagePath&&typeof e?.__veryfrontRenderPage=="function"&&!!r.getElementById("root")}function wo(e,t){return t?.pagePath?!1:!!e.getElementById(N)}function Mo(e=import.meta.url){try{return new URL(e,"http://veryfront.local").searchParams.get("hydrate")==="1"}catch{return!1}}function Lo(e){return e==="rsc-module"}function Po(e,t){return e?e.startsWith("?")?e:`?${e}`:""}function Ho(e,t,r){return G({strategy:t,rel:e,releaseAssetModules:r?.releaseAssetModules,dependencyPinningCacheKey:r?.dependencyPinningCacheKey})}async function Uo(e,t){try{let r=await fetch(I+"stream"+e,{headers:D(t)});if(!r.ok)return await A(r)?"snapshot-conflict":"failure";if(!r.body)return"failure";let n=new AbortController;return addEventListener("pagehide",()=>n.abort(),{once:!0}),await Ze(r,document,n.signal),"success"}catch(r){return l.debug("tryStream failed",r),"failure"}}async function q(){try{await nt(document)}catch(e){l.debug("hydration failed",e)}}async function vo(e,t,r){try{let{React:n,ReactDOM:o}=await Co(),s=Ho(e,t,r);if(!s)return!1;l.debug("Loading component from:",s);let a;try{a=await import(s)}catch(R){throw await X(s),R}let u=a.default;if(typeof u!="function")return l.debug("Page component is not a function"),!1;let d=Array.from(document.body.children),c=bo(d,document.body),g=Oo(c,document.body)?Io(d,document.body):c;No(d,g);let f=await W(n.createElement(u,{}),r);return Lo(t)?o.createRoot(g).render(f):o.hydrateRoot(g,f,{identifierPrefix:"vf",onRecoverableError:()=>{}}),l.debug("Page component hydrated successfully"),!0}catch(n){return l.error("Page hydration failed",n),!1}}async function ko(e,t){try{let r=await fetch(I+"payload"+e,{headers:D(t)});if(!r.ok)return await A(r)?"snapshot-conflict":"failure";let n=await r.json();if(V(document,n?.dependencyPinningCacheKey),n?.slots){for(let[o,s]of Object.entries(n.slots))L(document,o).innerHTML=M(String(s||""));return"success"}return L(document,N).innerHTML=M(String(n?.html||"")),"success"}catch(r){return l.debug("payload fetch failed",r),"failure"}}async function $o(){try{let e=S(document),t=Po(globalThis.window?.location.search??"",e?.dependencyPinningCacheKey);if(Mo()){await q();return}let r=e?.pagePath,n=F(e);if(r){if(Do(globalThis.window,e,document)){l.debug("Page renderer owns hydration");return}l.debug("Found page component in hydration data:",r),await vo(r,n,e)&&l.debug("Client component hydrated successfully");return}if(!wo(document,e))return;let o=await Uo(t,e);if(o==="snapshot-conflict")return;if(o==="success"){await q();return}let s=await ko(t,e);if(s==="snapshot-conflict")return;if(s==="success"){await q();return}await q()}catch(e){l.error("boot failed",e)}}if(typeof document<"u"){let e=()=>{$o()};document.readyState==="loading"?document.addEventListener("DOMContentLoaded",e,{once:!0}):e()}export{$o as boot,Ho as buildPageHydrationModuleUrl,Po as buildRSCTransportQuery,No as retireAbandonedHeadOwnerMarkers,bo as selectHydrationRoot,wo as shouldAttemptRSCTransport,Mo as shouldHydrateOnly,Lo as shouldRenderPageComponent,Do as shouldUsePageRendererHydration,Oo as shouldWrapPageHydrationRoot};\n'; export const CLIENT_DOM_BUNDLE: string = - 'var xe=Object.defineProperty;var _e=(t,r,e)=>r in t?xe(t,r,{enumerable:!0,configurable:!0,writable:!0,value:e}):t[r]=e;var E=(t,r,e)=>_e(t,typeof r!="symbol"?r+"":r,e);var he=new Set(["CONFIG","BUILD","RUNTIME","ROUTE","MODULE","SERVER","BOUNDARY","DEV","DEPLOY","AGENT","GENERAL"]);function Se(t,...r){let e=Object.create(null),o=t.charAt(0).toUpperCase()+t.slice(1);for(let s of r)for(let[i,a]of Object.entries(s)){if(typeof a!="object"||a===null||!Object.hasOwn(a,"slug"))throw new Error(`${o} entry "${i}" must define a slug`);let c=a.slug;if(typeof c!="string")throw new Error(`${o} entry "${i}" must define a string slug`);if(c!==i)throw new Error(`${o} key "${i}" does not match entry slug "${c}"`);if(Object.hasOwn(e,i))throw new Error(`Duplicate ${t} slug "${i}"`);e[i]=a}return Object.freeze(e)}function P(...t){for(let r of t)for(let e of Object.values(r)){if(typeof e.slug!="string"||e.slug.length<3||e.slug.length>40||!/^[a-z][a-z0-9-]*[a-z0-9]$/.test(e.slug))throw new TypeError(`Registered error slug must be 3-40 characters of lowercase kebab-case, got "${e.slug}"`);if(typeof e.category!="string"||!he.has(e.category))throw new TypeError(`Registered error has unknown category "${e.category}"`);if(!Number.isInteger(e.status)||e.status<400||e.status>=600)throw new RangeError(`Registered error status must be an integer from 400 through 599, got ${e.status}`);if(typeof e.title!="string"||e.title.trim().length===0)throw new TypeError("Registered error title must be a non-empty string");if(e.suggestion!==void 0&&(typeof e.suggestion!="string"||e.suggestion.trim().length===0))throw new TypeError("Registered error suggestion must be non-empty when provided")}return Se("error registry",...t)}var I={reset:"\\x1B[0m",dim:"\\x1B[2m",gray:"\\x1B[90m",red:"\\x1B[31m",green:"\\x1B[32m",yellow:"\\x1B[33m",blue:"\\x1B[34m",magenta:"\\x1B[35m",cyan:"\\x1B[36m"},on={debug:I.gray,info:I.green,warn:I.yellow,error:I.red};var p="[REDACTED]",m=Reflect.apply;var $=RegExp.prototype.exec,y=RegExp.prototype[Symbol.replace],an=String.prototype.charCodeAt,k=String.prototype.slice,Ie=String.prototype.toLowerCase,Oe=/[^a-z0-9]/g;function D(t){let r=m(Ie,t,[]);return m(y,Oe,[r,""])}function O(t,r,e){return e===void 0?m(k,t,[r]):m(k,t,[r,e])}var Te=["password","passwd","pwd","passphrase","secret","clientsecret","token","apikey","accesskey","privatekey","credential","authorization","cookie","bearer","jwt","connectionstring","signature","sessionid","sid","otp","mfa","pin","salt","xsrf","csrf"],Ce=512,Ne=128,S=new Map;function F(t){let r=t.length<=Ne;if(r){let s=S.get(t);if(s!==void 0)return s}let e=D(t),o=Te.some(s=>e.includes(s));if(r){if(S.size>=Ce){let s=S.keys().next().value;s!==void 0&&S.delete(s)}S.set(t,o)}return o}var Ae=["access_token","accesstoken","refresh_token","api_key","apikey","code","token","secret","client_secret","password","passwd","pwd","state","sig","signature","auth","x-amz-credential","x-amz-signature","x-amz-security-token","x-goog-credential","x-goog-signature"],De=new Set(Ae.map(D)),be=/(\\b[a-z][a-z0-9+.-]*:\\/\\/|\\/\\/)([^/?#\\s]+)@/gi,Le=/(\\b[a-z][a-z0-9+.-]*:\\/\\/|\\/\\/)([a-z0-9._~!$&\'()*+,;=%-]+):([^/?#@\\r\\n \\t]+[ \\t][^/?#@\\r\\n]*)@/gi,Ue=3;function we(t){return t===" "||t==="\t"||t===","||t===";"||t==="&"||t==="?"||t==="#"}function ve(t){if(!t)return!1;let r=t.charCodeAt(0);return r>=65&&r<=90||r>=97&&r<=122}function H(t){return ve(t)||t==="_"||t==="$"}function Me(t){if(!t)return!1;let r=t.charCodeAt(0);return H(t)||r>=48&&r<=57||t==="."||t==="-"}function j(t,r){let e=r,o=t[e]===\'"\'||t[e]==="\'"?t[e++]:"";if(!H(t[e]))return!1;for(e++;Me(t[e]);)e++;if(o){if(t[e]!==o)return!1;e++}for(;t[e]===" "||t[e]==="\t";)e++;return t[e]===":"||t[e]==="="}function z(t){return t==="\\r"||t===`\n`||t==="}"||t==="]"||we(t)}function Y(t,r){let e=r;for(;e=t.length||j(t,e)}function Pe(t,r){let e=r,o=!0;if(t.startsWith(p,r)){let g=r+p.length;if(V(t,g))return{end:g,replacement:p};e=g,o=!1}let s=o&&(t[e]===\'"\'||t[e]==="\'"||t[e]==="`")?t[e]:"",i=!1,a=()=>s?`${s}${p}${i?s:""}`:p,c=[],d="",u=-1;for(let g=e;g0&&(f==="}"||f==="]")){if(c.at(-1)!==f)return{end:t.length,replacement:a()};if(c.pop(),g++,c.length===0&&V(t,g))return{end:g,replacement:a()};continue}if(c.length>0||!z(f)){g++;continue}let h=g;if(g=Y(t,g),g>=t.length||j(t,g))return{end:h,replacement:a()}}return{end:t.length,replacement:a()}}function G(t,r,e,o){let s=0,i="";for(let a=m($,r,[t]);a;a=m($,r,[t])){let c=a[e];if(!F(c))continue;let d=r.lastIndex,u=o===void 0?void 0:a[o],g=d+p.length;if((u==="?"||u==="&"||u===";")&&t.startsWith(p,d)&&t[g]==="#")continue;let f=Pe(t,d);i+=O(t,s,a.index),i+=a[0],i+=f.replacement,s=f.end,r.lastIndex=f.end}return s===0?t:i+O(t,s)}function $e(t,r,e){let o=e.search(/[ \\t]/);if(o<0)return!1;let s=`${r}:${O(e,0,o)}`,i=t==="//"?`https://${s}`:`${t}${s}`;try{let a=new URL(i);return a.username.length===0&&a.password.length===0}catch{return!1}}function ke(t){let r=t;for(let e=0;e{let i=s.indexOf(":");if(i===-1)return`${o}${p}@`;let a=O(s,0,i);return`${o}${a}:${p}@`}]);return r=m(y,Le,[r,(e,o,s,i)=>$e(o,s,i)?e:`${o}${s}:${p}@`]),r=m(y,/([?#&;])([-a-z0-9_.%\\[\\]]+)=([^&#;\\s]*)/gi,[r,(e,o,s,i)=>{let a=ke(s);return De.has(D(a))||F(a)?`${o}${s}=${p}`:e}]),r=m(y,/(^|[^a-z0-9_-])((?:set-cookie|cookie)\\s*:\\s*)[^\\r\\n]*/gi,[r,(e,o,s)=>`${o}${s}${p}`]),r=m(y,/\\b(authorization\\s*[:=]\\s*)[^\\r\\n]*/gi,[r,(e,o)=>`${o}${p}`]),r=m(y,/\\b(bearer|basic)(\\s+)(?:"[^"\\r\\n]*"|\'[^\'\\r\\n]*\'|[a-z0-9._~+/=-]+)/gi,[r,(e,o,s)=>`${o}${s}${p}`]),r=G(r,/(["\'])([_$a-z][a-z0-9_.$-]*)\\1(\\s*[:=]\\s*)/gi,2),r=G(r,/(^|[^a-z0-9_.$-])([_$a-z][a-z0-9_.$-]*)(\\s*[:=]\\s*)/gi,2,1),r}var Ve=2048;var dn=64*1024,Ge=256,Fe="https://veryfront.com/docs/errors/",B="...[truncated]",L="unknown-error";function W(t,r){if(t.length<=r)return t;let e=Math.max(0,r-B.length);return`${He(t,e)}${B}`}function He(t,r){let e=t.slice(0,r),o=e.charCodeAt(e.length-1);return o>=55296&&o<=56319&&(e=e.slice(0,-1)),e}function je(t){let r="";for(let e=0;e=55296&&o<=56319){let s=t.charCodeAt(e+1);s>=56320&&s<=57343?(r+=t.slice(e,e+2),e++):r+="\\uFFFD";continue}r+=o>=56320&&o<=57343?"\\uFFFD":t.charAt(e)}return r}function x(t){return typeof t!="string"?p:W(b(t),Ve)}function ze(t){let r=typeof t=="string"?b(t):L,e=W(r||L,Ge),o=je(e);return o==="."||o===".."?L:o}function T(t){let r=encodeURIComponent(ze(t));return`${Fe}${r}`}var Ye=Object.freeze,Be=Object.getOwnPropertyDescriptors,K=Number.isFinite,X=new WeakSet,We=new Set(["CONFIG","BUILD","RUNTIME","ROUTE","MODULE","SERVER","BOUNDARY","DEV","DEPLOY","AGENT","GENERAL"]);function n(t){let r={...t},e={...r,create(o){let s=o?.message,i=o?.detail,a=o?.cause,c=o?.instance,d=o?.context,u=o?.status??r.status;return new U(s||i||r.title,{slug:r.slug,category:r.category,status:u,title:r.title,suggestion:r.suggestion,exitCode:r.exitCode,detail:i,cause:a,instance:c,context:d})}};return Ye(e)}var U=class extends Error{constructor(e,o){super(e);E(this,"slug");E(this,"category");E(this,"status");E(this,"title");E(this,"suggestion");E(this,"exitCode");E(this,"detail");E(this,"cause");E(this,"instance");E(this,"context");X.add(this),this.name="VeryfrontError",this.slug=o.slug,this.category=o.category,this.status=o.status,this.title=o.title,this.suggestion=o.suggestion,this.exitCode=o.exitCode,this.detail=o.detail,this.cause=o.cause,this.instance=o.instance,this.context=o.context}toRFC9457(){let e=q(this);return e?{type:T(e.slug),title:x(e.title),status:e.status,detail:e.detail===void 0?void 0:x(e.detail),instance:e.instance===void 0?void 0:x(e.instance),category:e.category,suggestion:e.suggestion===void 0?void 0:x(e.suggestion),cause:typeof e.cause=="string"?x(e.cause):void 0}:{type:T("unknown-error"),title:"Unknown/unclassified error",status:500,category:"GENERAL"}}getDocsUrl(){let e=q(this);return T(e?.slug??"unknown-error")}};function J(t){return typeof t=="object"&&t!==null&&X.has(t)}function q(t){return J(t)?Ke(t):null}function Ke(t){try{if(!J(t))return null;let r=Be(t),e=ye=>{let A=r[ye];return A&&"value"in A?A.value:void 0},o=e("slug"),s=e("category"),i=e("status"),a=e("title"),c=e("message"),d=e("suggestion"),u=e("exitCode"),g=e("detail"),f=e("cause"),h=e("instance"),Re=e("context"),N=e("stack");return typeof o!="string"||!We.has(s)||typeof i!="number"||!K(i)||typeof a!="string"||typeof c!="string"||d!==void 0&&typeof d!="string"||u!==void 0&&(typeof u!="number"||!K(u))||g!==void 0&&typeof g!="string"||h!==void 0&&typeof h!="string"||N!==void 0&&typeof N!="string"?null:{slug:o,category:s,status:i,title:a,message:c,suggestion:d,exitCode:u,detail:g,cause:f,instance:h,context:Re,stack:N}}catch{return null}}var qe=n({slug:"config-not-found",category:"CONFIG",status:404,title:"Configuration file not found",suggestion:"Create veryfront.config.js, veryfront.config.ts, or veryfront.config.mjs in the project root"}),Xe=n({slug:"config-invalid",category:"CONFIG",status:400,title:"Invalid configuration format",suggestion:"Check the reported configuration path and validation details"}),Je=n({slug:"config-parse-error",category:"CONFIG",status:400,title:"Failed to parse configuration",suggestion:"Ensure your configuration file contains valid JavaScript or TypeScript"}),Ze=n({slug:"config-validation-error",category:"CONFIG",status:422,title:"Configuration validation failed",suggestion:"Check the configuration against the schema requirements"}),Qe=n({slug:"config-type-error",category:"CONFIG",status:400,title:"Configuration type mismatch",suggestion:"Ensure configuration values match expected types"}),et=n({slug:"import-map-invalid",category:"CONFIG",status:400,title:"Invalid import map configuration",suggestion:"Check your import map syntax and paths"}),tt=n({slug:"cors-config-invalid",category:"CONFIG",status:400,title:"Invalid CORS configuration",suggestion:"Review CORS settings in your configuration"}),rt=n({slug:"config-validation-failed",category:"CONFIG",status:400,title:"Configuration validation failed",suggestion:"Check configuration values against requirements"}),nt=n({slug:"webhook-config-invalid",category:"CONFIG",status:400,title:"Invalid webhook configuration",suggestion:"Check webhook definition fields, target settings, and eventFilter conditions"}),ot=n({slug:"schedule-config-invalid",category:"CONFIG",status:400,title:"Invalid schedule configuration",suggestion:"Check schedule definition fields, cron expression, target settings, and positive-integer limits"}),st=n({slug:"trigger-config-invalid",category:"CONFIG",status:400,title:"Invalid trigger configuration",suggestion:"Check trigger ID format (lowercase, alphanumeric, dots/slashes/hyphens) and ensure all input values are JSON-serializable"}),Z={"config-not-found":qe,"config-invalid":Xe,"config-parse-error":Je,"config-validation-error":Ze,"config-type-error":Qe,"import-map-invalid":et,"cors-config-invalid":tt,"config-validation-failed":rt,"webhook-config-invalid":nt,"schedule-config-invalid":ot,"trigger-config-invalid":st};var it=n({slug:"build-failed",category:"BUILD",status:500,title:"Build process failed",suggestion:"Check the build output for specific errors"}),at=n({slug:"bundle-error",category:"BUILD",status:500,title:"Bundle generation failed",suggestion:"Review bundler output for details"}),ct=n({slug:"typescript-error",category:"BUILD",status:500,title:"TypeScript compilation error",suggestion:"Fix TypeScript errors shown in the output"}),ut=n({slug:"mdx-compile-error",category:"BUILD",status:500,title:"MDX compilation failed",suggestion:"Check your MDX file syntax"}),lt=n({slug:"asset-optimization-error",category:"BUILD",status:500,title:"Asset optimization failed",suggestion:"Check asset file formats and paths"}),gt=n({slug:"ssg-generation-error",category:"BUILD",status:500,title:"Static site generation failed",suggestion:"Review SSG configuration and data fetching"}),dt=n({slug:"sourcemap-error",category:"BUILD",status:500,title:"Source map generation failed",suggestion:"Check source map configuration"}),ft=n({slug:"compilation-error",category:"BUILD",status:500,title:"Compilation failed",suggestion:"Review compiler output for specific errors"}),Q={"build-failed":it,"bundle-error":at,"typescript-error":ct,"mdx-compile-error":ut,"asset-optimization-error":lt,"ssg-generation-error":gt,"sourcemap-error":dt,"compilation-error":ft};var pt=n({slug:"hydration-mismatch",category:"RUNTIME",status:500,title:"Client/server hydration mismatch",suggestion:"Ensure server and client render the same content"}),Et=n({slug:"render-error",category:"RUNTIME",status:500,title:"Component render failed",suggestion:"Check component for runtime errors"}),mt=n({slug:"component-error",category:"RUNTIME",status:500,title:"Component execution error",suggestion:"Review component logic and props"}),Rt=n({slug:"layout-not-found",category:"RUNTIME",status:404,title:"Layout component not found",suggestion:"Ensure layout file exists at the expected path"}),yt=n({slug:"page-not-found",category:"RUNTIME",status:404,title:"Page component not found",suggestion:"Check that the page file exists in the routes directory"}),xt=n({slug:"api-error",category:"RUNTIME",status:500,title:"API route handler error",suggestion:"Review API route handler for errors"}),_t=n({slug:"middleware-error",category:"RUNTIME",status:500,title:"Middleware execution error",suggestion:"Check middleware function for errors"}),ht=n({slug:"trigger-target-not-found",category:"RUNTIME",status:404,title:"Trigger target not found",suggestion:"Ensure the referenced task or workflow ID is registered in the project"}),St=n({slug:"trigger-execution-failed",category:"RUNTIME",status:500,title:"Trigger target execution failed",suggestion:"Check the task or workflow for errors and review the trigger input"}),It=n({slug:"trigger-not-supported",category:"RUNTIME",status:501,title:"Trigger target type not supported in local runtime",suggestion:"Use a workflow or task target for local trigger runs; agent targets require the Cloud runtime"}),ee={"hydration-mismatch":pt,"render-error":Et,"component-error":mt,"layout-not-found":Rt,"page-not-found":yt,"api-error":xt,"middleware-error":_t,"trigger-target-not-found":ht,"trigger-execution-failed":St,"trigger-not-supported":It};var Ot=n({slug:"route-conflict",category:"ROUTE",status:409,title:"Conflicting route definitions",suggestion:"Rename or reorganize conflicting route files"}),Tt=n({slug:"invalid-route-file",category:"ROUTE",status:400,title:"Invalid route file structure",suggestion:"Ensure route file exports required functions"}),Ct=n({slug:"route-handler-invalid",category:"ROUTE",status:400,title:"Invalid route handler export",suggestion:"Export a valid handler function from the route file"}),Nt=n({slug:"dynamic-route-error",category:"ROUTE",status:500,title:"Dynamic route parsing failed",suggestion:"Check dynamic route segment syntax"}),At=n({slug:"route-params-error",category:"ROUTE",status:400,title:"Route parameters invalid",suggestion:"Validate route parameter values"}),Dt=n({slug:"api-route-error",category:"ROUTE",status:500,title:"API route definition error",suggestion:"Review API route configuration"}),te={"route-conflict":Ot,"invalid-route-file":Tt,"route-handler-invalid":Ct,"dynamic-route-error":Nt,"route-params-error":At,"api-route-error":Dt};var bt=n({slug:"module-not-found",category:"MODULE",status:404,title:"Module could not be resolved",suggestion:"Check the import path and ensure the module is installed"}),Lt=n({slug:"import-resolution-error",category:"MODULE",status:500,title:"Import path resolution failed",suggestion:"Verify import paths and module configuration"}),Ut=n({slug:"circular-dependency",category:"MODULE",status:500,title:"Circular dependency detected",suggestion:"Refactor imports to break the circular dependency"}),wt=n({slug:"invalid-import",category:"MODULE",status:400,title:"Invalid import statement",suggestion:"Fix import syntax or path"}),vt=n({slug:"dependency-missing",category:"MODULE",status:404,title:"Required dependency not installed",suggestion:"Install the missing dependency with your package manager"}),Mt=n({slug:"version-mismatch",category:"MODULE",status:409,title:"Dependency version mismatch",suggestion:"Update dependencies to compatible versions"}),re={"module-not-found":bt,"import-resolution-error":Lt,"circular-dependency":Ut,"invalid-import":wt,"dependency-missing":vt,"version-mismatch":Mt};var Pt=n({slug:"port-in-use",category:"SERVER",status:409,title:"Server port already in use",suggestion:"Use a different port or stop the process using this port"}),$t=n({slug:"server-start-error",category:"SERVER",status:500,title:"Server failed to start",suggestion:"Check server configuration and port availability"}),kt=n({slug:"cache-error",category:"SERVER",status:500,title:"Cache operation failed",suggestion:"Clear the cache and try again"}),Vt=n({slug:"file-watch-error",category:"SERVER",status:500,title:"File watcher error",suggestion:"Restart the development server"}),Gt=n({slug:"request-error",category:"SERVER",status:500,title:"HTTP request handling error",suggestion:"Check request handler and middleware"}),Ft=n({slug:"service-overloaded",category:"SERVER",status:503,title:"Service overloaded",suggestion:"Reduce load or scale up resources"}),Ht=n({slug:"semaphore-timeout",category:"SERVER",status:503,title:"Semaphore acquire timeout",suggestion:"Reduce concurrency or increase the semaphore acquire timeout"}),jt=n({slug:"circuit-breaker-open",category:"SERVER",status:503,title:"Circuit breaker is open",suggestion:"Wait for the breaker reset timeout before retrying"}),zt=n({slug:"cache-path-mismatch",category:"SERVER",status:500,title:"Cache path mismatch",suggestion:"Clear the cache directory and rebuild"}),Yt=n({slug:"network-error",category:"SERVER",status:502,title:"Network operation failed",suggestion:"Check network connectivity and retry"}),Bt=n({slug:"api-client-error",category:"SERVER",status:500,title:"API client request failed",suggestion:"Check API connectivity and authentication"}),Wt=n({slug:"token-storage-error",category:"SERVER",status:500,title:"Token storage operation failed",suggestion:"Check token storage backend and credentials"}),Kt=n({slug:"cache-invariant-violation",category:"SERVER",status:500,title:"Cache path invariant violated",suggestion:"Clear the cache and rebuild"}),qt=n({slug:"release-not-found",category:"SERVER",status:404,title:"No active release found",suggestion:"Deploy the project to create a release for this environment"}),Xt=n({slug:"fallback-exhausted",category:"SERVER",status:500,title:"Primary and fallback operations both failed",suggestion:"Check service availability and connectivity"}),ne={"port-in-use":Pt,"server-start-error":$t,"cache-error":kt,"file-watch-error":Vt,"request-error":Gt,"service-overloaded":Ft,"semaphore-timeout":Ht,"circuit-breaker-open":jt,"cache-path-mismatch":zt,"network-error":Yt,"api-client-error":Bt,"token-storage-error":Wt,"cache-invariant-violation":Kt,"release-not-found":qt,"fallback-exhausted":Xt};var Jt=n({slug:"client-boundary-violation",category:"BOUNDARY",status:400,title:"Client boundary rule violation",suggestion:"Add \'use client\' directive or move code to a client component"}),Zt=n({slug:"server-only-in-client",category:"BOUNDARY",status:400,title:"Server-only code in client component",suggestion:"Move server-only code to a server component"}),Qt=n({slug:"client-only-in-server",category:"BOUNDARY",status:400,title:"Client-only code in server component",suggestion:"Move client-only code to a client component"}),er=n({slug:"invalid-use-client",category:"BOUNDARY",status:400,title:"Invalid \'use client\' directive",suggestion:"Place \'use client\' at the top of the file"}),tr=n({slug:"invalid-use-server",category:"BOUNDARY",status:400,title:"Invalid \'use server\' directive",suggestion:"Place \'use server\' at the top of the file or function"}),rr=n({slug:"rsc-payload-error",category:"BOUNDARY",status:500,title:"RSC payload serialization error",suggestion:"Ensure props are serializable (no functions, symbols, etc.)"}),oe={"client-boundary-violation":Jt,"server-only-in-client":Zt,"client-only-in-server":Qt,"invalid-use-client":er,"invalid-use-server":tr,"rsc-payload-error":rr};var nr=n({slug:"hmr-error",category:"DEV",status:500,title:"Hot module replacement error",suggestion:"Restart the development server"}),or=n({slug:"dev-server-error",category:"DEV",status:500,title:"Development server error",suggestion:"Check the dev server logs and restart"}),sr=n({slug:"fast-refresh-error",category:"DEV",status:500,title:"Fast refresh failed",suggestion:"Save the file again or restart the dev server"}),ir=n({slug:"error-overlay-error",category:"DEV",status:500,title:"Error overlay failed",suggestion:"Check browser console for details"}),ar=n({slug:"source-map-error",category:"DEV",status:500,title:"Source map loading error",suggestion:"Rebuild or clear cache"}),se={"hmr-error":nr,"dev-server-error":or,"fast-refresh-error":sr,"error-overlay-error":ir,"source-map-error":ar};var cr=n({slug:"deployment-error",category:"DEPLOY",status:500,title:"Deployment process failed",suggestion:"Check deployment logs for details"}),ur=n({slug:"platform-error",category:"DEPLOY",status:500,title:"Platform-specific error",suggestion:"Check platform documentation and requirements"}),lr=n({slug:"env-var-missing",category:"DEPLOY",status:500,title:"Required environment variable missing",suggestion:"Set the required environment variable"}),gr=n({slug:"production-build-required",category:"DEPLOY",status:400,title:"Production build required",suggestion:"Run \'veryfront build\' before deploying"}),dr=n({slug:"environment-not-found",category:"DEPLOY",status:404,title:"Deployment environment not found",suggestion:"Check environment names with: veryfront config"}),fr=n({slug:"release-missing-version",category:"DEPLOY",status:500,title:"Release has no version",suggestion:"Try again or check the build logs in Studio"}),pr=n({slug:"release-build-timeout",category:"DEPLOY",status:408,title:"Release build timed out",suggestion:"Try again or check the build logs in Studio"}),Er=n({slug:"deployment-verification-timeout",category:"DEPLOY",status:408,title:"Deployment verification timed out",suggestion:"Try again or check the deployment status in Studio"}),mr=n({slug:"push-receipt-missing",category:"DEPLOY",status:400,title:"Push receipt not found",suggestion:"Run: veryfront push --branch main first"}),Rr=n({slug:"source-digest-mismatch",category:"DEPLOY",status:409,title:"Release source digest mismatch",suggestion:"Run veryfront push again to re-upload source files"}),yr=n({slug:"preview-hostname-too-long",category:"DEPLOY",status:400,title:"Preview hostname too long",suggestion:"Use a shorter project slug or branch name"}),xr=n({slug:"branch-not-found",category:"DEPLOY",status:404,title:"Branch not found",suggestion:"List branches in Studio or push a new one with: veryfront push --branch "}),ie={"deployment-error":cr,"platform-error":ur,"env-var-missing":lr,"production-build-required":gr,"environment-not-found":dr,"release-missing-version":fr,"release-build-timeout":pr,"deployment-verification-timeout":Er,"push-receipt-missing":mr,"source-digest-mismatch":Rr,"preview-hostname-too-long":yr,"branch-not-found":xr};var _r=n({slug:"agent-error",category:"AGENT",status:500,title:"Agent operation error",suggestion:"Check agent configuration and logs"}),hr=n({slug:"agent-not-found",category:"AGENT",status:404,title:"Agent not found",suggestion:"Verify the agent ID exists"}),Sr=n({slug:"agent-timeout",category:"AGENT",status:408,title:"Agent operation timed out",suggestion:"Increase timeout or simplify the request"}),Ir=n({slug:"agent-intent-error",category:"AGENT",status:400,title:"Agent intent parsing error",suggestion:"Rephrase the request more clearly"}),Or=n({slug:"orchestration-error",category:"AGENT",status:500,title:"Multi-agent orchestration error",suggestion:"Check agent coordination logic"}),Tr=n({slug:"cost-limit-exceeded",category:"AGENT",status:429,title:"Cost limit exceeded",suggestion:"Wait for the budget period to reset or increase the limit"}),Cr=n({slug:"tool-id-conflict",category:"AGENT",status:409,title:"Tool ID conflict",suggestion:"Use a unique tool ID or rename one of the conflicting tools"}),ae={"agent-error":_r,"agent-not-found":hr,"agent-timeout":Sr,"agent-intent-error":Ir,"orchestration-error":Or,"cost-limit-exceeded":Tr,"tool-id-conflict":Cr};var Nr=n({slug:"unknown-error",category:"GENERAL",status:500,title:"Unknown/unclassified error",suggestion:"Check logs for more details"}),Ar=n({slug:"authentication-required",category:"GENERAL",status:401,title:"Authentication required",suggestion:"Set VERYFRONT_API_TOKEN or run \'veryfront login\'"}),Dr=n({slug:"permission-denied",category:"GENERAL",status:403,title:"File/resource permission denied",suggestion:"Check file permissions and access rights"}),br=n({slug:"file-not-found",category:"GENERAL",status:404,title:"File not found",suggestion:"Verify the file path exists"}),Lr=n({slug:"resource-not-found",category:"GENERAL",status:404,title:"Requested resource not found",suggestion:"Verify the referenced resource ID or name exists"}),Ur=n({slug:"invalid-argument",category:"GENERAL",status:400,title:"Invalid function argument",suggestion:"Check argument types and values",exitCode:2}),wr=n({slug:"timeout-error",category:"GENERAL",status:408,title:"Operation timed out",suggestion:"Increase timeout or optimize the operation"}),vr=n({slug:"initialization-error",category:"GENERAL",status:500,title:"Initialization failed",suggestion:"Check initialization requirements and dependencies"}),Mr=n({slug:"not-supported",category:"GENERAL",status:501,title:"Feature not supported",suggestion:"Check documentation for supported features"}),w=n({slug:"security-violation",category:"GENERAL",status:403,title:"Security violation detected",suggestion:"Check for path traversal or unauthorized access attempts"}),Pr=n({slug:"input-validation-failed",category:"GENERAL",status:400,title:"Input validation failed",suggestion:"Check request input against validation rules"}),$r=n({slug:"project-source-empty",category:"GENERAL",status:400,title:"Project source is empty",suggestion:"Add project files or run \'veryfront init\'"}),ce={"unknown-error":Nr,"authentication-required":Ar,"permission-denied":Dr,"file-not-found":br,"resource-not-found":Lr,"invalid-argument":Ur,"timeout-error":wr,"initialization-error":vr,"not-supported":Mr,"security-violation":w,"input-validation-failed":Pr,"project-source-empty":$r};var eo=P(Z,Q,ee,te,re,ne,oe,se,ie,ae,ce);var kr=[{source:String.raw`]*>[\\s\\S]*?<\\/script>`,flags:"gi",name:"inline script"},{source:String.raw`javascript:`,flags:"gi",name:"javascript: URL"},{source:String.raw`\\bon\\w+\\s*=`,flags:"gi",name:"event handler attribute"},{source:String.raw`data:\\s*text\\/html`,flags:"gi",name:"data: HTML URL"}];function Vr(){return kr.map(({source:t,flags:r,name:e})=>({pattern:new RegExp(t,r),name:e}))}function Gr(){let t=globalThis;return t.__VERYFRONT_DEV__===!0||t.Deno?.env?.get?.("VERYFRONT_ENV")==="development"}function ue(t,r={}){let{allowInlineScripts:e=!1,strict:o=!1,warn:s=!0}=r;for(let{pattern:i,name:a}of Vr())if(!(e&&a==="inline script")&&(i.lastIndex=0,!!i.test(t)&&(s&&console.warn(`[Security] Suspicious ${a} detected in server HTML`),o||!Gr())))throw w.create({detail:`Potentially unsafe HTML: ${a} detected`});return t}var _=class{constructor(r,e){E(this,"prefix",r);E(this,"level",e)}log(r,e,o,...s){this.level>r||e?.(o,...s)}debug(r,...e){this.log(0,console.debug,`[${this.prefix}] DEBUG: ${r}`,...e)}info(r,...e){this.log(1,console.log,`[${this.prefix}] ${r}`,...e)}warn(r,...e){this.log(2,console.warn,`[${this.prefix}] WARN: ${r}`,...e)}error(r,...e){this.log(3,console.error,`[${this.prefix}] ERROR: ${r}`,...e)}};function Fr(){if(typeof window>"u")return 2;let t=globalThis;return t.__VERYFRONT_DEV__||t.__RSC_DEV__?t.__VERYFRONT_DEBUG__||t.__RSC_DEBUG__?0:1:2}var C=Fr(),R=new _("RSC",C),mo=new _("PREFETCH",C),Ro=new _("HYDRATE",C),yo=new _("VERYFRONT",C);var ho=Object.freeze({IPV4:"127.0.0.1",IPV6:"::1",HOSTNAME:"localhost"});var Hr=5e3,jr=1e4,Oo=16*1024*1024,zr=5e3;var Yr=100;var Br=Object.freeze([5,10,25,50,75,100,250,500,750,1e3,2500,5e3,7500,1e4]),To=Object.freeze([1,5,10,25,50,100,250,500,1e3,2500,5e3,1e4]),Co=Object.freeze({server:Object.freeze({port:3e3,hostname:"0.0.0.0"}),timeouts:Object.freeze({default:Hr,api:3e4,ssr:jr,hmr:3e4,sandbox:zr}),cache:Object.freeze({jit:Object.freeze({maxSize:Yr,tempDirPrefix:"vf-bundle-"})}),metrics:Object.freeze({ssrBoundaries:Br})});var l="/_veryfront",v={RSC:`${l}/rsc/`,FS:`${l}/fs/`,MODULES:`${l}/modules/`,PAGES:`${l}/pages/`,DATA:`${l}/data/`,LIB:`${l}/lib/`,CHUNKS:`${l}/chunks/`,CLIENT:`${l}/client/`},ge={HMR_RUNTIME:`${l}/hmr-runtime.js`,HMR:`${l}/hmr.js`,ERROR_OVERLAY:`${l}/error-overlay.js`,DEV_LOADER:`${l}/dev-loader.js`,CLIENT_LOG:`${l}/log`,CLIENT_JS:`${l}/client.js`,ROUTER_JS:`${l}/router.js`,PREFETCH_JS:`${l}/prefetch.js`,MANIFEST_JSON:`${l}/manifest.json`,APP_JS:`${l}/app.js`,RSC_CLIENT:`${l}/rsc/client.js`,RSC_MANIFEST:`${l}/rsc/manifest`,RSC_STREAM:`${l}/rsc/stream`,RSC_PAYLOAD:`${l}/rsc/payload`,RSC_RENDER:`${l}/rsc/render`,RSC_PAGE:`${l}/rsc/page`,RSC_MODULE:`${l}/rsc/module`,RSC_DOM:`${l}/rsc/dom.js`,LIB_CHAT_REACT:`${l}/lib/chat/react.js`,LIB_CHAT_COMPONENTS:`${l}/lib/chat/components.js`,LIB_CHAT_PRIMITIVES:`${l}/lib/chat/primitives.js`};var Wr={ROOT:".veryfront",CACHE:".veryfront/cache",KV:".veryfront/kv",LOGS:".veryfront/logs",TMP:".veryfront/tmp"},Ao=Wr.CACHE;var Do={HMR_RUNTIME:ge.HMR_RUNTIME,ERROR_OVERLAY:ge.ERROR_OVERLAY};var Kr=v.RSC,qr=v.FS;var de="rsc-root",M="x-veryfront-dependency-pins";var zo=Object.freeze({react:"","react-dom":"","react-dom/client":"","react-dom/server":"","react/jsx-runtime":"","react/jsx-dev-runtime":""});var Jr="veryfront-hydration-data";function fe(t){try{let r=[...t.querySelectorAll(`[id="${Jr}"]`)];if(r.length!==1)return null;let e=t.body;if(!e)return null;let o=r[0];return e.firstElementChild!==o&&o.parentElement!==e||o.tagName?.toLowerCase()!=="script"||o.getAttribute("type")?.trim().toLowerCase()!=="application/json"?null:o}catch{return null}}function pe(t,r){if(!r?.startsWith("on:"))return!1;try{let e=fe(t);if(!e)return!1;let o=JSON.parse(e.textContent||"{}");return o.dependencyPinningCacheKey=r,e.textContent=JSON.stringify(o),!0}catch(e){return R.debug("hydration dependency snapshot seed failed",e),!1}}function me(t,r){let e=r==="root"?de:`rsc-slot-${r}`,o=t.getElementById(e);if(o)return o;let s=t.createElement("div");return s.id=e,t.body.appendChild(s),s}function Zr(t,r){if(r.type!=="slot")return;let e=me(t,r.id);e.innerHTML=ue(String(r.html??""))}function Ee(t,r){let e=r.split(`\n`),o=e.pop()??"";for(let s of e){let i=s.trim();if(!i)continue;let a;try{a=JSON.parse(i)}catch(d){R.debug("[client-dom] malformed NDJSON line",{line:i,error:d instanceof Error?d.message:String(d)});continue}if(!a||typeof a!="object")continue;let c=a;if(c.type==="slot"){Zr(t,c);try{tn(t,c.id||"root")}catch(d){R.debug("[client-dom] hydration optional failed",d)}}}return o}function Qr(t){return new Promise((r,e)=>{let o=()=>e(new DOMException("aborted","AbortError"));if(t.aborted){o();return}t.addEventListener("abort",o,{once:!0})})}async function ls(t,r=document,e){let o="body"in t?t:null,s=o?.body??t;if(!s)return;o&&pe(r,o.headers.get(M));let i=s.getReader(),a=new TextDecoder,c="",d=!1;try{for(;;){if(e?.aborted)throw new DOMException("aborted","AbortError");let u=i.read(),{done:g,value:f}=e?await Promise.race([u,Qr(e)]):await u;if(g){d=!0;break}c+=a.decode(f,{stream:!0}),c=Ee(r,c)}c&&Ee(r,`${c}\n`)}catch(u){throw u instanceof Error&&u.name==="AbortError"||R.debug("[client-dom] consumeNdjsonStream error",u),u}finally{try{await i.cancel()}catch(u){d||R.debug("[client-dom] reader.cancel failed",u)}try{i.releaseLock()}catch(u){R.debug("[client-dom] reader.releaseLock failed",u)}if(typeof s.cancel=="function")try{await s.cancel()}catch(u){R.debug("[client-dom] stream.cancel failed",u)}if(typeof o?.body?.cancel=="function")try{await o.body.cancel()}catch(u){R.debug("[client-dom] response.body.cancel failed",u)}}}function en(t,r){let e=me(t,r),o=[],s=i=>{let a=i;a.dataset?.clientRef&&o.push(a);for(let c of i.children)s(c)};return s(e),o}function tn(t,r){let e=en(t,r);for(let o of e){let s=o.dataset?.clientRef;s&&(o.dataset.hydrated="true",R.debug("[client-dom] marked for hydration",s))}}export{ls as consumeNdjsonStream,me as getContainer};\n'; + 'var xe=Object.defineProperty;var _e=(t,r,e)=>r in t?xe(t,r,{enumerable:!0,configurable:!0,writable:!0,value:e}):t[r]=e;var E=(t,r,e)=>_e(t,typeof r!="symbol"?r+"":r,e);var he=new Set(["CONFIG","BUILD","RUNTIME","ROUTE","MODULE","SERVER","BOUNDARY","DEV","DEPLOY","AGENT","GENERAL"]);function Se(t,...r){let e=Object.create(null),o=t.charAt(0).toUpperCase()+t.slice(1);for(let s of r)for(let[i,a]of Object.entries(s)){if(typeof a!="object"||a===null||!Object.hasOwn(a,"slug"))throw new Error(`${o} entry "${i}" must define a slug`);let c=a.slug;if(typeof c!="string")throw new Error(`${o} entry "${i}" must define a string slug`);if(c!==i)throw new Error(`${o} key "${i}" does not match entry slug "${c}"`);if(Object.hasOwn(e,i))throw new Error(`Duplicate ${t} slug "${i}"`);e[i]=a}return Object.freeze(e)}function P(...t){for(let r of t)for(let e of Object.values(r)){if(typeof e.slug!="string"||e.slug.length<3||e.slug.length>40||!/^[a-z][a-z0-9-]*[a-z0-9]$/.test(e.slug))throw new TypeError(`Registered error slug must be 3-40 characters of lowercase kebab-case, got "${e.slug}"`);if(typeof e.category!="string"||!he.has(e.category))throw new TypeError(`Registered error has unknown category "${e.category}"`);if(!Number.isInteger(e.status)||e.status<400||e.status>=600)throw new RangeError(`Registered error status must be an integer from 400 through 599, got ${e.status}`);if(typeof e.title!="string"||e.title.trim().length===0)throw new TypeError("Registered error title must be a non-empty string");if(e.suggestion!==void 0&&(typeof e.suggestion!="string"||e.suggestion.trim().length===0))throw new TypeError("Registered error suggestion must be non-empty when provided")}return Se("error registry",...t)}var I={reset:"\\x1B[0m",dim:"\\x1B[2m",gray:"\\x1B[90m",red:"\\x1B[31m",green:"\\x1B[32m",yellow:"\\x1B[33m",blue:"\\x1B[34m",magenta:"\\x1B[35m",cyan:"\\x1B[36m"},an={debug:I.gray,info:I.green,warn:I.yellow,error:I.red};var p="[REDACTED]",m=Reflect.apply;var $=RegExp.prototype.exec,y=RegExp.prototype[Symbol.replace],un=String.prototype.charCodeAt,k=String.prototype.slice,Ie=String.prototype.toLowerCase,Oe=/[^a-z0-9]/g;function D(t){let r=m(Ie,t,[]);return m(y,Oe,[r,""])}function O(t,r,e){return e===void 0?m(k,t,[r]):m(k,t,[r,e])}var Te=["password","passwd","pwd","passphrase","secret","clientsecret","token","apikey","accesskey","privatekey","credential","authorization","cookie","bearer","jwt","connectionstring","signature","sessionid","sid","otp","mfa","pin","salt","xsrf","csrf"],Ce=512,Ne=128,S=new Map;function F(t){let r=t.length<=Ne;if(r){let s=S.get(t);if(s!==void 0)return s}let e=D(t),o=Te.some(s=>e.includes(s));if(r){if(S.size>=Ce){let s=S.keys().next().value;s!==void 0&&S.delete(s)}S.set(t,o)}return o}var Ae=["access_token","accesstoken","refresh_token","api_key","apikey","code","token","secret","client_secret","password","passwd","pwd","state","sig","signature","auth","x-amz-credential","x-amz-signature","x-amz-security-token","x-goog-credential","x-goog-signature"],De=new Set(Ae.map(D)),be=/(\\b[a-z][a-z0-9+.-]*:\\/\\/|\\/\\/)([^/?#\\s]+)@/gi,Le=/(\\b[a-z][a-z0-9+.-]*:\\/\\/|\\/\\/)([a-z0-9._~!$&\'()*+,;=%-]+):([^/?#@\\r\\n \\t]+[ \\t][^/?#@\\r\\n]*)@/gi,Ue=3;function we(t){return t===" "||t==="\t"||t===","||t===";"||t==="&"||t==="?"||t==="#"}function ve(t){if(!t)return!1;let r=t.charCodeAt(0);return r>=65&&r<=90||r>=97&&r<=122}function H(t){return ve(t)||t==="_"||t==="$"}function Me(t){if(!t)return!1;let r=t.charCodeAt(0);return H(t)||r>=48&&r<=57||t==="."||t==="-"}function j(t,r){let e=r,o=t[e]===\'"\'||t[e]==="\'"?t[e++]:"";if(!H(t[e]))return!1;for(e++;Me(t[e]);)e++;if(o){if(t[e]!==o)return!1;e++}for(;t[e]===" "||t[e]==="\t";)e++;return t[e]===":"||t[e]==="="}function z(t){return t==="\\r"||t===`\n`||t==="}"||t==="]"||we(t)}function Y(t,r){let e=r;for(;e=t.length||j(t,e)}function Pe(t,r){let e=r,o=!0;if(t.startsWith(p,r)){let g=r+p.length;if(V(t,g))return{end:g,replacement:p};e=g,o=!1}let s=o&&(t[e]===\'"\'||t[e]==="\'"||t[e]==="`")?t[e]:"",i=!1,a=()=>s?`${s}${p}${i?s:""}`:p,c=[],d="",u=-1;for(let g=e;g0&&(f==="}"||f==="]")){if(c.at(-1)!==f)return{end:t.length,replacement:a()};if(c.pop(),g++,c.length===0&&V(t,g))return{end:g,replacement:a()};continue}if(c.length>0||!z(f)){g++;continue}let h=g;if(g=Y(t,g),g>=t.length||j(t,g))return{end:h,replacement:a()}}return{end:t.length,replacement:a()}}function G(t,r,e,o){let s=0,i="";for(let a=m($,r,[t]);a;a=m($,r,[t])){let c=a[e];if(!F(c))continue;let d=r.lastIndex,u=o===void 0?void 0:a[o],g=d+p.length;if((u==="?"||u==="&"||u===";")&&t.startsWith(p,d)&&t[g]==="#")continue;let f=Pe(t,d);i+=O(t,s,a.index),i+=a[0],i+=f.replacement,s=f.end,r.lastIndex=f.end}return s===0?t:i+O(t,s)}function $e(t,r,e){let o=e.search(/[ \\t]/);if(o<0)return!1;let s=`${r}:${O(e,0,o)}`,i=t==="//"?`https://${s}`:`${t}${s}`;try{let a=new URL(i);return a.username.length===0&&a.password.length===0}catch{return!1}}function ke(t){let r=t;for(let e=0;e{let i=s.indexOf(":");if(i===-1)return`${o}${p}@`;let a=O(s,0,i);return`${o}${a}:${p}@`}]);return r=m(y,Le,[r,(e,o,s,i)=>$e(o,s,i)?e:`${o}${s}:${p}@`]),r=m(y,/([?#&;])([-a-z0-9_.%\\[\\]]+)=([^&#;\\s]*)/gi,[r,(e,o,s,i)=>{let a=ke(s);return De.has(D(a))||F(a)?`${o}${s}=${p}`:e}]),r=m(y,/(^|[^a-z0-9_-])((?:set-cookie|cookie)\\s*:\\s*)[^\\r\\n]*/gi,[r,(e,o,s)=>`${o}${s}${p}`]),r=m(y,/\\b(authorization\\s*[:=]\\s*)[^\\r\\n]*/gi,[r,(e,o)=>`${o}${p}`]),r=m(y,/\\b(bearer|basic)(\\s+)(?:"[^"\\r\\n]*"|\'[^\'\\r\\n]*\'|[a-z0-9._~+/=-]+)/gi,[r,(e,o,s)=>`${o}${s}${p}`]),r=G(r,/(["\'])([_$a-z][a-z0-9_.$-]*)\\1(\\s*[:=]\\s*)/gi,2),r=G(r,/(^|[^a-z0-9_.$-])([_$a-z][a-z0-9_.$-]*)(\\s*[:=]\\s*)/gi,2,1),r}var Ve=2048;var pn=64*1024,Ge=256,Fe="https://veryfront.com/docs/errors/",B="...[truncated]",L="unknown-error";function W(t,r){if(t.length<=r)return t;let e=Math.max(0,r-B.length);return`${He(t,e)}${B}`}function He(t,r){let e=t.slice(0,r),o=e.charCodeAt(e.length-1);return o>=55296&&o<=56319&&(e=e.slice(0,-1)),e}function je(t){let r="";for(let e=0;e=55296&&o<=56319){let s=t.charCodeAt(e+1);s>=56320&&s<=57343?(r+=t.slice(e,e+2),e++):r+="\\uFFFD";continue}r+=o>=56320&&o<=57343?"\\uFFFD":t.charAt(e)}return r}function x(t){return typeof t!="string"?p:W(b(t),Ve)}function ze(t){let r=typeof t=="string"?b(t):L,e=W(r||L,Ge),o=je(e);return o==="."||o===".."?L:o}function T(t){let r=encodeURIComponent(ze(t));return`${Fe}${r}`}var Ye=Object.freeze,Be=Object.getOwnPropertyDescriptors,K=Number.isFinite,X=new WeakSet,We=new Set(["CONFIG","BUILD","RUNTIME","ROUTE","MODULE","SERVER","BOUNDARY","DEV","DEPLOY","AGENT","GENERAL"]);function n(t){let r={...t},e={...r,create(o){let s=o?.message,i=o?.detail,a=o?.cause,c=o?.instance,d=o?.context,u=o?.status??r.status;return new U(s||i||r.title,{slug:r.slug,category:r.category,status:u,title:r.title,suggestion:r.suggestion,exitCode:r.exitCode,detail:i,cause:a,instance:c,context:d})}};return Ye(e)}var U=class extends Error{constructor(e,o){super(e);E(this,"slug");E(this,"category");E(this,"status");E(this,"title");E(this,"suggestion");E(this,"exitCode");E(this,"detail");E(this,"cause");E(this,"instance");E(this,"context");X.add(this),this.name="VeryfrontError",this.slug=o.slug,this.category=o.category,this.status=o.status,this.title=o.title,this.suggestion=o.suggestion,this.exitCode=o.exitCode,this.detail=o.detail,this.cause=o.cause,this.instance=o.instance,this.context=o.context}toRFC9457(){let e=q(this);return e?{type:T(e.slug),title:x(e.title),status:e.status,detail:e.detail===void 0?void 0:x(e.detail),instance:e.instance===void 0?void 0:x(e.instance),category:e.category,suggestion:e.suggestion===void 0?void 0:x(e.suggestion),cause:typeof e.cause=="string"?x(e.cause):void 0}:{type:T("unknown-error"),title:"Unknown/unclassified error",status:500,category:"GENERAL"}}getDocsUrl(){let e=q(this);return T(e?.slug??"unknown-error")}};function J(t){return typeof t=="object"&&t!==null&&X.has(t)}function q(t){return J(t)?Ke(t):null}function Ke(t){try{if(!J(t))return null;let r=Be(t),e=ye=>{let A=r[ye];return A&&"value"in A?A.value:void 0},o=e("slug"),s=e("category"),i=e("status"),a=e("title"),c=e("message"),d=e("suggestion"),u=e("exitCode"),g=e("detail"),f=e("cause"),h=e("instance"),Re=e("context"),N=e("stack");return typeof o!="string"||!We.has(s)||typeof i!="number"||!K(i)||typeof a!="string"||typeof c!="string"||d!==void 0&&typeof d!="string"||u!==void 0&&(typeof u!="number"||!K(u))||g!==void 0&&typeof g!="string"||h!==void 0&&typeof h!="string"||N!==void 0&&typeof N!="string"?null:{slug:o,category:s,status:i,title:a,message:c,suggestion:d,exitCode:u,detail:g,cause:f,instance:h,context:Re,stack:N}}catch{return null}}var qe=n({slug:"config-not-found",category:"CONFIG",status:404,title:"Configuration file not found",suggestion:"Create veryfront.config.js, veryfront.config.ts, or veryfront.config.mjs in the project root"}),Xe=n({slug:"config-invalid",category:"CONFIG",status:400,title:"Invalid configuration format",suggestion:"Check the reported configuration path and validation details"}),Je=n({slug:"config-parse-error",category:"CONFIG",status:400,title:"Failed to parse configuration",suggestion:"Ensure your configuration file contains valid JavaScript or TypeScript"}),Ze=n({slug:"config-validation-error",category:"CONFIG",status:422,title:"Configuration validation failed",suggestion:"Check the configuration against the schema requirements"}),Qe=n({slug:"config-type-error",category:"CONFIG",status:400,title:"Configuration type mismatch",suggestion:"Ensure configuration values match expected types"}),et=n({slug:"import-map-invalid",category:"CONFIG",status:400,title:"Invalid import map configuration",suggestion:"Check your import map syntax and paths"}),tt=n({slug:"cors-config-invalid",category:"CONFIG",status:400,title:"Invalid CORS configuration",suggestion:"Review CORS settings in your configuration"}),rt=n({slug:"config-validation-failed",category:"CONFIG",status:400,title:"Configuration validation failed",suggestion:"Check configuration values against requirements"}),nt=n({slug:"webhook-config-invalid",category:"CONFIG",status:400,title:"Invalid webhook configuration",suggestion:"Check webhook definition fields, target settings, and eventFilter conditions"}),ot=n({slug:"schedule-config-invalid",category:"CONFIG",status:400,title:"Invalid schedule configuration",suggestion:"Check schedule definition fields, cron expression, target settings, and positive-integer limits"}),st=n({slug:"trigger-config-invalid",category:"CONFIG",status:400,title:"Invalid trigger configuration",suggestion:"Check trigger ID format (lowercase, alphanumeric, dots/slashes/hyphens) and ensure all input values are JSON-serializable"}),Z={"config-not-found":qe,"config-invalid":Xe,"config-parse-error":Je,"config-validation-error":Ze,"config-type-error":Qe,"import-map-invalid":et,"cors-config-invalid":tt,"config-validation-failed":rt,"webhook-config-invalid":nt,"schedule-config-invalid":ot,"trigger-config-invalid":st};var it=n({slug:"build-failed",category:"BUILD",status:500,title:"Build process failed",suggestion:"Check the build output for specific errors"}),at=n({slug:"bundle-error",category:"BUILD",status:500,title:"Bundle generation failed",suggestion:"Review bundler output for details"}),ct=n({slug:"typescript-error",category:"BUILD",status:500,title:"TypeScript compilation error",suggestion:"Fix TypeScript errors shown in the output"}),ut=n({slug:"mdx-compile-error",category:"BUILD",status:500,title:"MDX compilation failed",suggestion:"Check your MDX file syntax"}),lt=n({slug:"asset-optimization-error",category:"BUILD",status:500,title:"Asset optimization failed",suggestion:"Check asset file formats and paths"}),gt=n({slug:"ssg-generation-error",category:"BUILD",status:500,title:"Static site generation failed",suggestion:"Review SSG configuration and data fetching"}),dt=n({slug:"sourcemap-error",category:"BUILD",status:500,title:"Source map generation failed",suggestion:"Check source map configuration"}),ft=n({slug:"compilation-error",category:"BUILD",status:500,title:"Compilation failed",suggestion:"Review compiler output for specific errors"}),Q={"build-failed":it,"bundle-error":at,"typescript-error":ct,"mdx-compile-error":ut,"asset-optimization-error":lt,"ssg-generation-error":gt,"sourcemap-error":dt,"compilation-error":ft};var pt=n({slug:"hydration-mismatch",category:"RUNTIME",status:500,title:"Client/server hydration mismatch",suggestion:"Ensure server and client render the same content"}),Et=n({slug:"render-error",category:"RUNTIME",status:500,title:"Component render failed",suggestion:"Check component for runtime errors"}),mt=n({slug:"component-error",category:"RUNTIME",status:500,title:"Component execution error",suggestion:"Review component logic and props"}),Rt=n({slug:"layout-not-found",category:"RUNTIME",status:404,title:"Layout component not found",suggestion:"Ensure layout file exists at the expected path"}),yt=n({slug:"page-not-found",category:"RUNTIME",status:404,title:"Page component not found",suggestion:"Check that the page file exists in the routes directory"}),xt=n({slug:"api-error",category:"RUNTIME",status:500,title:"API route handler error",suggestion:"Review API route handler for errors"}),_t=n({slug:"middleware-error",category:"RUNTIME",status:500,title:"Middleware execution error",suggestion:"Check middleware function for errors"}),ht=n({slug:"trigger-target-not-found",category:"RUNTIME",status:404,title:"Trigger target not found",suggestion:"Ensure the referenced task or workflow ID is registered in the project"}),St=n({slug:"trigger-execution-failed",category:"RUNTIME",status:500,title:"Trigger target execution failed",suggestion:"Check the task or workflow for errors and review the trigger input"}),It=n({slug:"trigger-not-supported",category:"RUNTIME",status:501,title:"Trigger target type not supported in local runtime",suggestion:"Use a workflow or task target for local trigger runs; agent targets require the Cloud runtime"}),ee={"hydration-mismatch":pt,"render-error":Et,"component-error":mt,"layout-not-found":Rt,"page-not-found":yt,"api-error":xt,"middleware-error":_t,"trigger-target-not-found":ht,"trigger-execution-failed":St,"trigger-not-supported":It};var Ot=n({slug:"route-conflict",category:"ROUTE",status:409,title:"Conflicting route definitions",suggestion:"Rename or reorganize conflicting route files"}),Tt=n({slug:"invalid-route-file",category:"ROUTE",status:400,title:"Invalid route file structure",suggestion:"Ensure route file exports required functions"}),Ct=n({slug:"route-handler-invalid",category:"ROUTE",status:400,title:"Invalid route handler export",suggestion:"Export a valid handler function from the route file"}),Nt=n({slug:"dynamic-route-error",category:"ROUTE",status:500,title:"Dynamic route parsing failed",suggestion:"Check dynamic route segment syntax"}),At=n({slug:"route-params-error",category:"ROUTE",status:400,title:"Route parameters invalid",suggestion:"Validate route parameter values"}),Dt=n({slug:"api-route-error",category:"ROUTE",status:500,title:"API route definition error",suggestion:"Review API route configuration"}),te={"route-conflict":Ot,"invalid-route-file":Tt,"route-handler-invalid":Ct,"dynamic-route-error":Nt,"route-params-error":At,"api-route-error":Dt};var bt=n({slug:"module-not-found",category:"MODULE",status:404,title:"Module could not be resolved",suggestion:"Check the import path and ensure the module is installed"}),Lt=n({slug:"import-resolution-error",category:"MODULE",status:500,title:"Import path resolution failed",suggestion:"Verify import paths and module configuration"}),Ut=n({slug:"circular-dependency",category:"MODULE",status:500,title:"Circular dependency detected",suggestion:"Refactor imports to break the circular dependency"}),wt=n({slug:"invalid-import",category:"MODULE",status:400,title:"Invalid import statement",suggestion:"Fix import syntax or path"}),vt=n({slug:"dependency-missing",category:"MODULE",status:404,title:"Required dependency not installed",suggestion:"Install the missing dependency with your package manager"}),Mt=n({slug:"version-mismatch",category:"MODULE",status:409,title:"Dependency version mismatch",suggestion:"Update dependencies to compatible versions"}),re={"module-not-found":bt,"import-resolution-error":Lt,"circular-dependency":Ut,"invalid-import":wt,"dependency-missing":vt,"version-mismatch":Mt};var Pt=n({slug:"port-in-use",category:"SERVER",status:409,title:"Server port already in use",suggestion:"Use a different port or stop the process using this port"}),$t=n({slug:"server-start-error",category:"SERVER",status:500,title:"Server failed to start",suggestion:"Check server configuration and port availability"}),kt=n({slug:"cache-error",category:"SERVER",status:500,title:"Cache operation failed",suggestion:"Clear the cache and try again"}),Vt=n({slug:"file-watch-error",category:"SERVER",status:500,title:"File watcher error",suggestion:"Restart the development server"}),Gt=n({slug:"request-error",category:"SERVER",status:500,title:"HTTP request handling error",suggestion:"Check request handler and middleware"}),Ft=n({slug:"service-overloaded",category:"SERVER",status:503,title:"Service overloaded",suggestion:"Reduce load or scale up resources"}),Ht=n({slug:"project-execution-unavailable",category:"SERVER",status:503,title:"Project execution unavailable",suggestion:"Route the project to a dedicated isolated runtime"}),jt=n({slug:"semaphore-timeout",category:"SERVER",status:503,title:"Semaphore acquire timeout",suggestion:"Reduce concurrency or increase the semaphore acquire timeout"}),zt=n({slug:"circuit-breaker-open",category:"SERVER",status:503,title:"Circuit breaker is open",suggestion:"Wait for the breaker reset timeout before retrying"}),Yt=n({slug:"cache-path-mismatch",category:"SERVER",status:500,title:"Cache path mismatch",suggestion:"Clear the cache directory and rebuild"}),Bt=n({slug:"network-error",category:"SERVER",status:502,title:"Network operation failed",suggestion:"Check network connectivity and retry"}),Wt=n({slug:"api-client-error",category:"SERVER",status:500,title:"API client request failed",suggestion:"Check API connectivity and authentication"}),Kt=n({slug:"token-storage-error",category:"SERVER",status:500,title:"Token storage operation failed",suggestion:"Check token storage backend and credentials"}),qt=n({slug:"cache-invariant-violation",category:"SERVER",status:500,title:"Cache path invariant violated",suggestion:"Clear the cache and rebuild"}),Xt=n({slug:"release-not-found",category:"SERVER",status:404,title:"No active release found",suggestion:"Deploy the project to create a release for this environment"}),Jt=n({slug:"fallback-exhausted",category:"SERVER",status:500,title:"Primary and fallback operations both failed",suggestion:"Check service availability and connectivity"}),ne={"port-in-use":Pt,"server-start-error":$t,"cache-error":kt,"file-watch-error":Vt,"request-error":Gt,"service-overloaded":Ft,"project-execution-unavailable":Ht,"semaphore-timeout":jt,"circuit-breaker-open":zt,"cache-path-mismatch":Yt,"network-error":Bt,"api-client-error":Wt,"token-storage-error":Kt,"cache-invariant-violation":qt,"release-not-found":Xt,"fallback-exhausted":Jt};var Zt=n({slug:"client-boundary-violation",category:"BOUNDARY",status:400,title:"Client boundary rule violation",suggestion:"Add \'use client\' directive or move code to a client component"}),Qt=n({slug:"server-only-in-client",category:"BOUNDARY",status:400,title:"Server-only code in client component",suggestion:"Move server-only code to a server component"}),er=n({slug:"client-only-in-server",category:"BOUNDARY",status:400,title:"Client-only code in server component",suggestion:"Move client-only code to a client component"}),tr=n({slug:"invalid-use-client",category:"BOUNDARY",status:400,title:"Invalid \'use client\' directive",suggestion:"Place \'use client\' at the top of the file"}),rr=n({slug:"invalid-use-server",category:"BOUNDARY",status:400,title:"Invalid \'use server\' directive",suggestion:"Place \'use server\' at the top of the file or function"}),nr=n({slug:"rsc-payload-error",category:"BOUNDARY",status:500,title:"RSC payload serialization error",suggestion:"Ensure props are serializable (no functions, symbols, etc.)"}),or=n({slug:"ssr-output-limit-exceeded",category:"BOUNDARY",status:500,title:"SSR output limit exceeded",suggestion:"Reduce the rendered HTML size or split the response into smaller pages"}),oe={"client-boundary-violation":Zt,"server-only-in-client":Qt,"client-only-in-server":er,"invalid-use-client":tr,"invalid-use-server":rr,"rsc-payload-error":nr,"ssr-output-limit-exceeded":or};var sr=n({slug:"hmr-error",category:"DEV",status:500,title:"Hot module replacement error",suggestion:"Restart the development server"}),ir=n({slug:"dev-server-error",category:"DEV",status:500,title:"Development server error",suggestion:"Check the dev server logs and restart"}),ar=n({slug:"fast-refresh-error",category:"DEV",status:500,title:"Fast refresh failed",suggestion:"Save the file again or restart the dev server"}),cr=n({slug:"error-overlay-error",category:"DEV",status:500,title:"Error overlay failed",suggestion:"Check browser console for details"}),ur=n({slug:"source-map-error",category:"DEV",status:500,title:"Source map loading error",suggestion:"Rebuild or clear cache"}),se={"hmr-error":sr,"dev-server-error":ir,"fast-refresh-error":ar,"error-overlay-error":cr,"source-map-error":ur};var lr=n({slug:"deployment-error",category:"DEPLOY",status:500,title:"Deployment process failed",suggestion:"Check deployment logs for details"}),gr=n({slug:"platform-error",category:"DEPLOY",status:500,title:"Platform-specific error",suggestion:"Check platform documentation and requirements"}),dr=n({slug:"env-var-missing",category:"DEPLOY",status:500,title:"Required environment variable missing",suggestion:"Set the required environment variable"}),fr=n({slug:"production-build-required",category:"DEPLOY",status:400,title:"Production build required",suggestion:"Run \'veryfront build\' before deploying"}),pr=n({slug:"environment-not-found",category:"DEPLOY",status:404,title:"Deployment environment not found",suggestion:"Check environment names with: veryfront config"}),Er=n({slug:"release-missing-version",category:"DEPLOY",status:500,title:"Release has no version",suggestion:"Try again or check the build logs in Studio"}),mr=n({slug:"release-build-timeout",category:"DEPLOY",status:408,title:"Release build timed out",suggestion:"Try again or check the build logs in Studio"}),Rr=n({slug:"deployment-verification-timeout",category:"DEPLOY",status:408,title:"Deployment verification timed out",suggestion:"Try again or check the deployment status in Studio"}),yr=n({slug:"push-receipt-missing",category:"DEPLOY",status:400,title:"Push receipt not found",suggestion:"Run: veryfront push --branch main first"}),xr=n({slug:"source-digest-mismatch",category:"DEPLOY",status:409,title:"Release source digest mismatch",suggestion:"Run veryfront push again to re-upload source files"}),_r=n({slug:"preview-hostname-too-long",category:"DEPLOY",status:400,title:"Preview hostname too long",suggestion:"Use a shorter project slug or branch name"}),hr=n({slug:"branch-not-found",category:"DEPLOY",status:404,title:"Branch not found",suggestion:"List branches in Studio or push a new one with: veryfront push --branch "}),ie={"deployment-error":lr,"platform-error":gr,"env-var-missing":dr,"production-build-required":fr,"environment-not-found":pr,"release-missing-version":Er,"release-build-timeout":mr,"deployment-verification-timeout":Rr,"push-receipt-missing":yr,"source-digest-mismatch":xr,"preview-hostname-too-long":_r,"branch-not-found":hr};var Sr=n({slug:"agent-error",category:"AGENT",status:500,title:"Agent operation error",suggestion:"Check agent configuration and logs"}),Ir=n({slug:"agent-not-found",category:"AGENT",status:404,title:"Agent not found",suggestion:"Verify the agent ID exists"}),Or=n({slug:"agent-timeout",category:"AGENT",status:408,title:"Agent operation timed out",suggestion:"Increase timeout or simplify the request"}),Tr=n({slug:"agent-intent-error",category:"AGENT",status:400,title:"Agent intent parsing error",suggestion:"Rephrase the request more clearly"}),Cr=n({slug:"orchestration-error",category:"AGENT",status:500,title:"Multi-agent orchestration error",suggestion:"Check agent coordination logic"}),Nr=n({slug:"cost-limit-exceeded",category:"AGENT",status:429,title:"Cost limit exceeded",suggestion:"Wait for the budget period to reset or increase the limit"}),Ar=n({slug:"tool-id-conflict",category:"AGENT",status:409,title:"Tool ID conflict",suggestion:"Use a unique tool ID or rename one of the conflicting tools"}),ae={"agent-error":Sr,"agent-not-found":Ir,"agent-timeout":Or,"agent-intent-error":Tr,"orchestration-error":Cr,"cost-limit-exceeded":Nr,"tool-id-conflict":Ar};var Dr=n({slug:"unknown-error",category:"GENERAL",status:500,title:"Unknown/unclassified error",suggestion:"Check logs for more details"}),br=n({slug:"authentication-required",category:"GENERAL",status:401,title:"Authentication required",suggestion:"Set VERYFRONT_API_TOKEN or run \'veryfront login\'"}),Lr=n({slug:"permission-denied",category:"GENERAL",status:403,title:"File/resource permission denied",suggestion:"Check file permissions and access rights"}),Ur=n({slug:"file-not-found",category:"GENERAL",status:404,title:"File not found",suggestion:"Verify the file path exists"}),wr=n({slug:"resource-not-found",category:"GENERAL",status:404,title:"Requested resource not found",suggestion:"Verify the referenced resource ID or name exists"}),vr=n({slug:"invalid-argument",category:"GENERAL",status:400,title:"Invalid function argument",suggestion:"Check argument types and values",exitCode:2}),Mr=n({slug:"timeout-error",category:"GENERAL",status:408,title:"Operation timed out",suggestion:"Increase timeout or optimize the operation"}),Pr=n({slug:"initialization-error",category:"GENERAL",status:500,title:"Initialization failed",suggestion:"Check initialization requirements and dependencies"}),$r=n({slug:"not-supported",category:"GENERAL",status:501,title:"Feature not supported",suggestion:"Check documentation for supported features"}),w=n({slug:"security-violation",category:"GENERAL",status:403,title:"Security violation detected",suggestion:"Check for path traversal or unauthorized access attempts"}),kr=n({slug:"input-validation-failed",category:"GENERAL",status:400,title:"Input validation failed",suggestion:"Check request input against validation rules"}),Vr=n({slug:"project-source-empty",category:"GENERAL",status:400,title:"Project source is empty",suggestion:"Add project files or run \'veryfront init\'"}),ce={"unknown-error":Dr,"authentication-required":br,"permission-denied":Lr,"file-not-found":Ur,"resource-not-found":wr,"invalid-argument":vr,"timeout-error":Mr,"initialization-error":Pr,"not-supported":$r,"security-violation":w,"input-validation-failed":kr,"project-source-empty":Vr};var ro=P(Z,Q,ee,te,re,ne,oe,se,ie,ae,ce);var Gr=[{source:String.raw`]*>[\\s\\S]*?<\\/script>`,flags:"gi",name:"inline script"},{source:String.raw`javascript:`,flags:"gi",name:"javascript: URL"},{source:String.raw`\\bon\\w+\\s*=`,flags:"gi",name:"event handler attribute"},{source:String.raw`data:\\s*text\\/html`,flags:"gi",name:"data: HTML URL"}];function Fr(){return Gr.map(({source:t,flags:r,name:e})=>({pattern:new RegExp(t,r),name:e}))}function Hr(){let t=globalThis;return t.__VERYFRONT_DEV__===!0||t.Deno?.env?.get?.("VERYFRONT_ENV")==="development"}function ue(t,r={}){let{allowInlineScripts:e=!1,strict:o=!1,warn:s=!0}=r;for(let{pattern:i,name:a}of Fr())if(!(e&&a==="inline script")&&(i.lastIndex=0,!!i.test(t)&&(s&&console.warn(`[Security] Suspicious ${a} detected in server HTML`),o||!Hr())))throw w.create({detail:`Potentially unsafe HTML: ${a} detected`});return t}var _=class{constructor(r,e){E(this,"prefix",r);E(this,"level",e)}log(r,e,o,...s){this.level>r||e?.(o,...s)}debug(r,...e){this.log(0,console.debug,`[${this.prefix}] DEBUG: ${r}`,...e)}info(r,...e){this.log(1,console.log,`[${this.prefix}] ${r}`,...e)}warn(r,...e){this.log(2,console.warn,`[${this.prefix}] WARN: ${r}`,...e)}error(r,...e){this.log(3,console.error,`[${this.prefix}] ERROR: ${r}`,...e)}};function jr(){if(typeof window>"u")return 2;let t=globalThis;return t.__VERYFRONT_DEV__||t.__RSC_DEV__?t.__VERYFRONT_DEBUG__||t.__RSC_DEBUG__?0:1:2}var C=jr(),R=new _("RSC",C),yo=new _("PREFETCH",C),xo=new _("HYDRATE",C),_o=new _("VERYFRONT",C);var Io=Object.freeze({IPV4:"127.0.0.1",IPV6:"::1",HOSTNAME:"localhost"});var zr=5e3,Yr=1e4,Co=16*1024*1024,Br=5e3;var Wr=100;var Kr=Object.freeze([5,10,25,50,75,100,250,500,750,1e3,2500,5e3,7500,1e4]),No=Object.freeze([1,5,10,25,50,100,250,500,1e3,2500,5e3,1e4]),Ao=Object.freeze({server:Object.freeze({port:3e3,hostname:"0.0.0.0"}),timeouts:Object.freeze({default:zr,api:3e4,ssr:Yr,hmr:3e4,sandbox:Br}),cache:Object.freeze({jit:Object.freeze({maxSize:Wr,tempDirPrefix:"vf-bundle-"})}),metrics:Object.freeze({ssrBoundaries:Kr})});var l="/_veryfront",v={RSC:`${l}/rsc/`,FS:`${l}/fs/`,MODULES:`${l}/modules/`,PAGES:`${l}/pages/`,DATA:`${l}/data/`,LIB:`${l}/lib/`,CHUNKS:`${l}/chunks/`,CLIENT:`${l}/client/`},ge={HMR_RUNTIME:`${l}/hmr-runtime.js`,HMR:`${l}/hmr.js`,ERROR_OVERLAY:`${l}/error-overlay.js`,DEV_LOADER:`${l}/dev-loader.js`,CLIENT_LOG:`${l}/log`,CLIENT_JS:`${l}/client.js`,ROUTER_JS:`${l}/router.js`,PREFETCH_JS:`${l}/prefetch.js`,MANIFEST_JSON:`${l}/manifest.json`,APP_JS:`${l}/app.js`,RSC_CLIENT:`${l}/rsc/client.js`,RSC_MANIFEST:`${l}/rsc/manifest`,RSC_STREAM:`${l}/rsc/stream`,RSC_PAYLOAD:`${l}/rsc/payload`,RSC_RENDER:`${l}/rsc/render`,RSC_PAGE:`${l}/rsc/page`,RSC_MODULE:`${l}/rsc/module`,RSC_DOM:`${l}/rsc/dom.js`,LIB_CHAT_REACT:`${l}/lib/chat/react.js`,LIB_CHAT_COMPONENTS:`${l}/lib/chat/components.js`,LIB_CHAT_PRIMITIVES:`${l}/lib/chat/primitives.js`};var qr={ROOT:".veryfront",CACHE:".veryfront/cache",KV:".veryfront/kv",LOGS:".veryfront/logs",TMP:".veryfront/tmp"},bo=qr.CACHE;var Lo={HMR_RUNTIME:ge.HMR_RUNTIME,ERROR_OVERLAY:ge.ERROR_OVERLAY};var Xr=v.RSC,Jr=v.FS;var de="rsc-root",M="x-veryfront-dependency-pins";var Bo=Object.freeze({react:"","react-dom":"","react-dom/client":"","react-dom/server":"","react/jsx-runtime":"","react/jsx-dev-runtime":""});var Qr="veryfront-hydration-data";function fe(t){try{let r=[...t.querySelectorAll(`[id="${Qr}"]`)];if(r.length!==1)return null;let e=t.body;if(!e)return null;let o=r[0];return e.firstElementChild!==o&&o.parentElement!==e||o.tagName?.toLowerCase()!=="script"||o.getAttribute("type")?.trim().toLowerCase()!=="application/json"?null:o}catch{return null}}function pe(t,r){if(!r?.startsWith("on:"))return!1;try{let e=fe(t);if(!e)return!1;let o=JSON.parse(e.textContent||"{}");return o.dependencyPinningCacheKey=r,e.textContent=JSON.stringify(o),!0}catch(e){return R.debug("hydration dependency snapshot seed failed",e),!1}}function me(t,r){let e=r==="root"?de:`rsc-slot-${r}`,o=t.getElementById(e);if(o)return o;let s=t.createElement("div");return s.id=e,t.body.appendChild(s),s}function en(t,r){if(r.type!=="slot")return;let e=me(t,r.id);e.innerHTML=ue(String(r.html??""))}function Ee(t,r){let e=r.split(`\n`),o=e.pop()??"";for(let s of e){let i=s.trim();if(!i)continue;let a;try{a=JSON.parse(i)}catch(d){R.debug("[client-dom] malformed NDJSON line",{line:i,error:d instanceof Error?d.message:String(d)});continue}if(!a||typeof a!="object")continue;let c=a;if(c.type==="slot"){en(t,c);try{nn(t,c.id||"root")}catch(d){R.debug("[client-dom] hydration optional failed",d)}}}return o}function tn(t){return new Promise((r,e)=>{let o=()=>e(new DOMException("aborted","AbortError"));if(t.aborted){o();return}t.addEventListener("abort",o,{once:!0})})}async function ds(t,r=document,e){let o="body"in t?t:null,s=o?.body??t;if(!s)return;o&&pe(r,o.headers.get(M));let i=s.getReader(),a=new TextDecoder,c="",d=!1;try{for(;;){if(e?.aborted)throw new DOMException("aborted","AbortError");let u=i.read(),{done:g,value:f}=e?await Promise.race([u,tn(e)]):await u;if(g){d=!0;break}c+=a.decode(f,{stream:!0}),c=Ee(r,c)}c&&Ee(r,`${c}\n`)}catch(u){throw u instanceof Error&&u.name==="AbortError"||R.debug("[client-dom] consumeNdjsonStream error",u),u}finally{try{await i.cancel()}catch(u){d||R.debug("[client-dom] reader.cancel failed",u)}try{i.releaseLock()}catch(u){R.debug("[client-dom] reader.releaseLock failed",u)}if(typeof s.cancel=="function")try{await s.cancel()}catch(u){R.debug("[client-dom] stream.cancel failed",u)}if(typeof o?.body?.cancel=="function")try{await o.body.cancel()}catch(u){R.debug("[client-dom] response.body.cancel failed",u)}}}function rn(t,r){let e=me(t,r),o=[],s=i=>{let a=i;a.dataset?.clientRef&&o.push(a);for(let c of i.children)s(c)};return s(e),o}function nn(t,r){let e=rn(t,r);for(let o of e){let s=o.dataset?.clientRef;s&&(o.dataset.hydrated="true",R.debug("[client-dom] marked for hydration",s))}}export{ds as consumeNdjsonStream,me as getContainer};\n'; diff --git a/src/server/services/rsc/endpoints/types.ts b/src/server/services/rsc/endpoints/types.ts index d56bc4339b..186e9752aa 100644 --- a/src/server/services/rsc/endpoints/types.ts +++ b/src/server/services/rsc/endpoints/types.ts @@ -35,6 +35,8 @@ export interface RSCEndpointParams { adapter: RuntimeAdapter; config?: VeryfrontConfig; isLocalProject?: boolean; + /** Host-owned capability for server-side project module execution. */ + allowHostProjectCodeExecution: boolean; mode?: "development" | "production"; nonce?: string; } diff --git a/src/server/shared/renderer/adapter.ts b/src/server/shared/renderer/adapter.ts index 44555bce2e..9032ece837 100644 --- a/src/server/shared/renderer/adapter.ts +++ b/src/server/shared/renderer/adapter.ts @@ -242,7 +242,11 @@ async function createContextFromHandler(ctx: HandlerContext): Promise + discoverTasksRaw({ ...options, allowHostProjectCodeExecution: true }); +const findTaskById: typeof findTaskByIdRaw = (taskId, options) => + findTaskByIdRaw(taskId, { ...options, allowHostProjectCodeExecution: true }); +const discoverProjectTaskRuntime: typeof discoverProjectTaskRuntimeRaw = (options) => + discoverProjectTaskRuntimeRaw({ + ...options, + allowHostProjectCodeExecution: true, + }); + function createMockAdapter(files: Record): FileSystemAdapter { const normalize = (path: string): string => path.replace(/^\/project\/?/, "").replace(/^\/+/, ""); const normalizedFiles = Object.fromEntries( diff --git a/src/task/discovery.ts b/src/task/discovery.ts index 243e53caf3..79df3719be 100644 --- a/src/task/discovery.ts +++ b/src/task/discovery.ts @@ -93,6 +93,9 @@ export interface TaskDiscoveryOptions { /** Enable debug logging */ debug?: boolean; + + /** Explicit host-owned capability for a trusted local or dedicated runtime. */ + allowHostProjectCodeExecution?: boolean; } /** @@ -162,10 +165,12 @@ async function loadTaskFromFile( id: string, adapter: RuntimeAdapter, projectDir: string, + allowHostProjectCodeExecution?: boolean, ): Promise { const module = await importDiscoveryModule(filePath, { adapter, projectDir, + allowHostProjectCodeExecution, }) as Record; const taskExport = extractTaskExport(module); if (!taskExport) return null; @@ -254,6 +259,7 @@ export async function discoverTasks( config, tasksDir = "tasks", debug = false, + allowHostProjectCodeExecution, } = options; const tasks: DiscoveredTask[] = []; @@ -286,6 +292,7 @@ export async function discoverTasks( deriveTaskId(file.path, baseDir), adapter, projectDir, + allowHostProjectCodeExecution, ); if (task) { tasks.push(task); @@ -329,6 +336,7 @@ export async function findTaskById( config, tasksDir = "tasks", debug = false, + allowHostProjectCodeExecution, } = options; const baseDir = resolveTasksBaseDir(projectDir, tasksDir, config); @@ -344,7 +352,13 @@ export async function findTaskById( if (id !== taskId) continue; try { - const task = await loadTaskFromFile(file.path, id, adapter, projectDir); + const task = await loadTaskFromFile( + file.path, + id, + adapter, + projectDir, + allowHostProjectCodeExecution, + ); if (task) { matches.push(task); } diff --git a/src/task/project-runtime.ts b/src/task/project-runtime.ts index 9dff2376b6..1262968bfe 100644 --- a/src/task/project-runtime.ts +++ b/src/task/project-runtime.ts @@ -28,6 +28,8 @@ export interface ProjectTaskRuntimeOptions { debug?: boolean; /** Reject the discovery when any colocated project primitive fails to load. */ throwOnErrors?: boolean; + /** Explicit host-owned capability for a trusted local or dedicated runtime. */ + allowHostProjectCodeExecution?: boolean; } function formatRuntimeDiscoveryError(error: DiscoveryResult["errors"][number]): string { @@ -52,6 +54,7 @@ export async function discoverProjectTaskRuntime( fsAdapter: options.fsAdapter, cacheKey: options.cacheKey, verbose: options.debug, + allowHostProjectCodeExecution: options.allowHostProjectCodeExecution, }); if (options.throwOnErrors && discovery.errors.length > 0) { diff --git a/src/tool/context7.test.ts b/src/tool/context7.test.ts index a7649cdeb9..7fde5114c0 100644 --- a/src/tool/context7.test.ts +++ b/src/tool/context7.test.ts @@ -16,7 +16,7 @@ describe("tool/context7", () => { const source = createContext7ToolSource({ apiKey: "c7-test-key", - endpoint: "https://mcp.test/mcp", + endpoint: "https://93.184.216.34/mcp", }); const tools = await withMockFetch( @@ -76,7 +76,7 @@ describe("tool/context7", () => { const source = createContext7ToolSource({ apiKey: "c7-test-key", - endpoint: "https://mcp.test/mcp", + endpoint: "https://93.184.216.34/mcp", }); const result = await withMockFetch( @@ -119,7 +119,7 @@ describe("tool/context7", () => { Deno.env.set("CONTEXT7_API_KEY", "env-fallback-key"); let capturedHeaders: Headers | undefined; - const source = createContext7ToolSource({ endpoint: "https://mcp.test/mcp" }); + const source = createContext7ToolSource({ endpoint: "https://93.184.216.34/mcp" }); await withMockFetch( async (input: string | URL | Request, init?: RequestInit) => { diff --git a/src/tool/remote-mcp.test.ts b/src/tool/remote-mcp.test.ts index acadf21d32..ba70c8468c 100644 --- a/src/tool/remote-mcp.test.ts +++ b/src/tool/remote-mcp.test.ts @@ -16,8 +16,7 @@ describe("tool/remote-mcp", () => { controller.abort("caller stopped"); const source = createRemoteMCPToolSource({ id: "docs", - endpoint: "https://mcp.test", - fetch: () => Promise.reject("caller stopped"), + endpoint: "https://93.184.216.34", }); await assertRejects( @@ -27,6 +26,26 @@ describe("tool/remote-mcp", () => { ); }); + it("rejects internal MCP endpoints before invoking the configured transport", async () => { + let calls = 0; + const source = createRemoteMCPToolSource({ + id: "private", + endpoint: "http://169.254.169.254/latest/meta-data", + }); + + await withMockFetch(() => { + calls++; + return Promise.resolve(Response.json({})); + }, async () => { + await assertRejects( + () => source.listTools(), + Error, + "internal host", + ); + }); + assertEquals(calls, 0); + }); + it("lists tools from a remote MCP server using the standard JSON-RPC contract", async () => { let requestUrl = ""; let requestMethod = ""; @@ -36,7 +55,7 @@ describe("tool/remote-mcp", () => { const source = createRemoteMCPToolSource({ id: "docs", - endpoint: (context) => `https://mcp.test/${context?.projectId ?? "default"}`, + endpoint: (context) => `https://93.184.216.34/${context?.projectId ?? "default"}`, headers: (context) => ({ Authorization: "Bearer remote-token", "x-project-id": String(context?.projectId ?? ""), @@ -69,7 +88,7 @@ describe("tool/remote-mcp", () => { async () => await source.listTools({ projectId: "proj_123" }), ); - assertEquals(requestUrl, "https://mcp.test/proj_123"); + assertEquals(requestUrl, "https://93.184.216.34/proj_123"); assertEquals(requestMethod, "POST"); assertEquals(projectHeader, "proj_123"); assertEquals(acceptHeader, "application/json, text/event-stream"); @@ -90,7 +109,7 @@ describe("tool/remote-mcp", () => { it("returns structured MCP tool errors instead of throwing for callTool isError results", async () => { const source = createRemoteMCPToolSource({ id: "docs", - endpoint: "https://mcp.test", + endpoint: "https://93.184.216.34", headers: { Authorization: "Bearer remote-token" }, }); @@ -119,7 +138,7 @@ describe("tool/remote-mcp", () => { let requestBody: Record | undefined; const source = createRemoteMCPToolSource({ id: "veryfront-mcp", - endpoint: "https://mcp.test", + endpoint: "https://93.184.216.34", headers: { Authorization: "Bearer remote-token" }, }); @@ -163,7 +182,7 @@ describe("tool/remote-mcp", () => { it("prefers structuredContent for MCP isError tool results", async () => { const source = createRemoteMCPToolSource({ id: "docs", - endpoint: "https://mcp.test", + endpoint: "https://93.184.216.34", headers: { Authorization: "Bearer remote-token" }, }); @@ -194,7 +213,7 @@ describe("tool/remote-mcp", () => { it("preserves MCP isError when structuredContent lacks an error field", async () => { const source = createRemoteMCPToolSource({ id: "docs", - endpoint: "https://mcp.test", + endpoint: "https://93.184.216.34", }); const result = await withMockFetch(async () => @@ -221,7 +240,7 @@ describe("tool/remote-mcp", () => { it("wraps non-object structured MCP errors with a canonical marker", async () => { const source = createRemoteMCPToolSource({ id: "docs", - endpoint: "https://mcp.test", + endpoint: "https://93.184.216.34", }); const result = await withMockFetch(async () => @@ -245,7 +264,7 @@ describe("tool/remote-mcp", () => { it("normalizes remote MCP tool responses with generic error markers", async () => { const source = createRemoteMCPToolSource({ id: "docs", - endpoint: "https://mcp.test", + endpoint: "https://93.184.216.34", }); const result = await withMockFetch(async () => @@ -269,7 +288,7 @@ describe("tool/remote-mcp", () => { it("normalizes OAuth invalid_grant refresh failures into reconnect-required tool output", async () => { const source = createRemoteMCPToolSource({ id: "veryfront-mcp", - endpoint: "https://api.example.com/mcp", + endpoint: "https://93.184.216.34/mcp", headers: { Authorization: "Bearer remote-token" }, }); @@ -296,7 +315,7 @@ describe("tool/remote-mcp", () => { error: "reconnect_required", code: "OAUTH_TOKEN_EXPIRED", integration: "calendar", - connectUrl: "https://api.example.com/oauth/connect/calendar?projectId=project-1", + connectUrl: "https://93.184.216.34/oauth/connect/calendar?projectId=project-1", message: "Calendar needs to be reconnected before this tool can run.", }); }); @@ -304,7 +323,7 @@ describe("tool/remote-mcp", () => { it("normalizes JSON-RPC invalid_grant errors into reconnect-required tool output", async () => { const source = createRemoteMCPToolSource({ id: "veryfront-mcp", - endpoint: "https://api.example.com/mcp", + endpoint: "https://93.184.216.34/mcp", }); const result = await withMockFetch( @@ -324,7 +343,7 @@ describe("tool/remote-mcp", () => { error: "reconnect_required", code: "OAUTH_TOKEN_EXPIRED", integration: "calendar", - connectUrl: "https://api.example.com/oauth/connect/calendar?projectId=project-1", + connectUrl: "https://93.184.216.34/oauth/connect/calendar?projectId=project-1", message: "Calendar needs to be reconnected before this tool can run.", }); }); @@ -332,7 +351,7 @@ describe("tool/remote-mcp", () => { it("normalizes HTTP invalid_grant failures into reconnect-required tool output", async () => { const source = createRemoteMCPToolSource({ id: "veryfront-mcp", - endpoint: "https://api.example.com/mcp", + endpoint: "https://93.184.216.34/mcp", }); const result = await withMockFetch( @@ -348,7 +367,7 @@ describe("tool/remote-mcp", () => { error: "reconnect_required", code: "OAUTH_TOKEN_EXPIRED", integration: "calendar", - connectUrl: "https://api.example.com/oauth/connect/calendar?projectId=project-1", + connectUrl: "https://93.184.216.34/oauth/connect/calendar?projectId=project-1", message: "Calendar needs to be reconnected before this tool can run.", }); }); @@ -356,7 +375,7 @@ describe("tool/remote-mcp", () => { it("does not surface remote HTTP error bodies", async () => { const source = createRemoteMCPToolSource({ id: "docs", - endpoint: "https://mcp.test", + endpoint: "https://93.184.216.34", }); const error = await assertRejects( @@ -377,7 +396,7 @@ describe("tool/remote-mcp", () => { const source = createRemoteMCPToolSource({ id: "docs", - endpoint: "https://mcp.test", + endpoint: "https://93.184.216.34", headers: { Accept: "application/vnd.custom+json", }, @@ -408,7 +427,7 @@ describe("tool/remote-mcp", () => { it("parses JSON-RPC results from SSE responses when the MCP server negotiates text/event-stream", async () => { const source = createRemoteMCPToolSource({ id: "docs", - endpoint: "https://mcp.test", + endpoint: "https://93.184.216.34", }); const tools = await withMockFetch( @@ -439,7 +458,7 @@ describe("tool/remote-mcp", () => { it("applies the tools/list response limit to SSE catalogs", async () => { const source = createRemoteMCPToolSource({ id: "docs", - endpoint: "https://mcp.test", + endpoint: "https://93.184.216.34", }); const padding = "x".repeat(MAX_REMOTE_MCP_CALL_RESPONSE_BYTES + 1_024); @@ -464,7 +483,7 @@ describe("tool/remote-mcp", () => { it("throws when the remote MCP server responds with a JSON-RPC error", async () => { const source = createRemoteMCPToolSource({ id: "docs", - endpoint: "https://mcp.test", + endpoint: "https://93.184.216.34", }); await assertRejects( @@ -486,7 +505,7 @@ describe("tool/remote-mcp", () => { it("rejects successful list responses whose declared body exceeds the limit", async () => { const source = createRemoteMCPToolSource({ id: "docs", - endpoint: "https://mcp.test", + endpoint: "https://93.184.216.34", }); await assertRejects( @@ -510,7 +529,7 @@ describe("tool/remote-mcp", () => { let canceled = false; const source = createRemoteMCPToolSource({ id: "docs", - endpoint: "https://mcp.test", + endpoint: "https://93.184.216.34", }); const body = new ReadableStream({ start(controller) { @@ -539,7 +558,7 @@ describe("tool/remote-mcp", () => { it("rejects JSON-RPC responses with a mismatched protocol version or request id", async () => { const source = createRemoteMCPToolSource({ id: "docs", - endpoint: "https://mcp.test", + endpoint: "https://93.184.216.34", }); await assertRejects( @@ -576,7 +595,7 @@ describe("tool/remote-mcp", () => { it("selects only the matching JSON-RPC response from an SSE stream", async () => { const source = createRemoteMCPToolSource({ id: "docs", - endpoint: "https://mcp.test", + endpoint: "https://93.184.216.34", }); const tools = await withMockFetch( @@ -600,7 +619,7 @@ describe("tool/remote-mcp", () => { it("rejects malformed tool entries atomically instead of returning a partial catalog", async () => { const source = createRemoteMCPToolSource({ id: "docs", - endpoint: "https://mcp.test", + endpoint: "https://93.184.216.34", }); await assertRejects( @@ -642,7 +661,7 @@ describe("tool/remote-mcp", () => { for (const tool of cases) { const source = createRemoteMCPToolSource({ id: "docs", - endpoint: "https://mcp.test", + endpoint: "https://93.184.216.34", }); await assertRejects( () => @@ -664,7 +683,7 @@ describe("tool/remote-mcp", () => { it("rejects compact remote schemas above the structural node budget", async () => { const source = createRemoteMCPToolSource({ id: "docs", - endpoint: "https://mcp.test", + endpoint: "https://93.184.216.34", }); const emptyValue: unknown[] = []; const inputSchema = { enum: new Array(4_096).fill(emptyValue) }; @@ -691,7 +710,7 @@ describe("tool/remote-mcp", () => { let callCount = 0; const duplicateSource = createRemoteMCPToolSource({ id: "docs", - endpoint: "https://mcp.test", + endpoint: "https://93.184.216.34", }); await assertRejects( @@ -721,7 +740,7 @@ describe("tool/remote-mcp", () => { callCount = 0; const repeatedCursorSource = createRemoteMCPToolSource({ id: "docs", - endpoint: "https://mcp.test", + endpoint: "https://93.184.216.34", }); await assertRejects( () => @@ -748,7 +767,7 @@ describe("tool/remote-mcp", () => { it("rejects catalogs above the definition and pagination ceilings", async () => { const oversizedCatalogSource = createRemoteMCPToolSource({ id: "docs", - endpoint: "https://mcp.test", + endpoint: "https://93.184.216.34", }); const tools = Array.from( { length: MAX_REMOTE_MCP_TOOL_DEFINITIONS + 1 }, @@ -777,7 +796,7 @@ describe("tool/remote-mcp", () => { let page = 0; const endlessSource = createRemoteMCPToolSource({ id: "docs", - endpoint: "https://mcp.test", + endpoint: "https://93.184.216.34", }); await assertRejects( () => @@ -801,7 +820,7 @@ describe("tool/remote-mcp", () => { it("rejects unsafe endpoints and disables redirects for authenticated requests", async () => { const credentialEndpointSource = createRemoteMCPToolSource({ id: "docs", - endpoint: "https://user:secret@mcp.test", + endpoint: "https://user:secret@93.184.216.34", }); await assertRejects( () => credentialEndpointSource.listTools(), @@ -812,40 +831,43 @@ describe("tool/remote-mcp", () => { let redirectMode: RequestRedirect | undefined; const redirectSafeSource = createRemoteMCPToolSource({ id: "docs", - endpoint: "https://mcp.test", + endpoint: "https://93.184.216.34", headers: { Authorization: "Bearer remote-token" }, - fetch: (async (_input: RequestInfo | URL, init?: RequestInit) => { - redirectMode = init?.redirect; - return Response.json({ - jsonrpc: "2.0", - id: "docs:tools:list", - result: { tools: [] }, - }); - }) as typeof fetch, }); - await redirectSafeSource.listTools(); - assertEquals(redirectMode, "error"); + await withMockFetch(async (_input: RequestInfo | URL, init?: RequestInit) => { + redirectMode = init?.redirect; + return Response.json({ + jsonrpc: "2.0", + id: "docs:tools:list", + result: { tools: [] }, + }); + }, async () => await redirectSafeSource.listTools()); + // The guarded transport must observe redirects itself so it can reject the + // destination before fetch follows it. The caller-visible mode remains + // `error`: any redirect response is rejected by guardedOutboundFetch. + assertEquals(redirectMode, "manual"); }); it("rejects cyclic outbound arguments before invoking the remote fetch", async () => { let fetchCalled = false; const source = createRemoteMCPToolSource({ id: "docs", - endpoint: "https://mcp.test", - fetch: (async () => { - fetchCalled = true; - return Response.json({}); - }) as typeof fetch, + endpoint: "https://93.184.216.34", }); - const cyclic: Record = {}; - cyclic.self = cyclic; + await withMockFetch(async () => { + fetchCalled = true; + return Response.json({}); + }, async () => { + const cyclic: Record = {}; + cyclic.self = cyclic; - await assertRejects( - () => source.executeTool("search_docs", cyclic), - TypeError, - "bounded JSON object", - ); + await assertRejects( + () => source.executeTool("search_docs", cyclic), + TypeError, + "bounded JSON object", + ); + }); assertEquals(fetchCalled, false); }); }); diff --git a/src/tool/remote-mcp.ts b/src/tool/remote-mcp.ts index c63f885628..64d01dcd3c 100644 --- a/src/tool/remote-mcp.ts +++ b/src/tool/remote-mcp.ts @@ -5,6 +5,7 @@ import type { JsonSchema } from "./schema/json-schema.ts"; import { hasToolExecutionErrorMarker } from "./result.ts"; import type { RemoteToolSource, ToolDefinition, ToolExecutionContext } from "./types.ts"; import { readResponseTextPrefix } from "#veryfront/utils/response-body.ts"; +import { guardedOutboundFetch } from "#veryfront/security/http/outbound-fetch.ts"; /** Default timeout for a single outbound remote MCP request. */ const REMOTE_MCP_REQUEST_TIMEOUT_MS = 30_000; @@ -51,7 +52,6 @@ export interface RemoteMCPToolSourceConfig { id?: string; endpoint: ResolvableValue; headers?: ResolvableValue; - fetch?: typeof fetch; listMethod?: string; callMethod?: string; } @@ -666,7 +666,6 @@ async function postJsonRpc( endpoint: string, headers: Headers, body: Record, - fetchImpl: typeof fetch, callerSignal: AbortSignal | undefined, maxResponseBytes: number, ): Promise { @@ -678,7 +677,7 @@ async function postJsonRpc( const requestScope = createRequestSignalScope(callerSignal); try { - const response = await fetchImpl(endpoint, { + const response = await guardedOutboundFetch(endpoint, { method: "POST", headers, body: serializedBody, @@ -830,7 +829,6 @@ export function createRemoteMCPToolSource( async listTools(context) { const endpoint = validateEndpoint(await resolveValue(config.endpoint, context)); const headers = await resolveHeaders(config.headers, context); - const fetchImpl = config.fetch ?? globalThis.fetch; const definitions: ToolDefinition[] = []; const definitionNames = new Set(); @@ -847,7 +845,6 @@ export function createRemoteMCPToolSource( method: listMethod, ...(cursor !== undefined ? { params: { cursor } } : {}), }, - fetchImpl, context?.abortSignal, MAX_REMOTE_MCP_TOOL_LIST_RESPONSE_BYTES, ); @@ -915,7 +912,6 @@ export function createRemoteMCPToolSource( ...(meta ? { _meta: meta } : {}), }, }, - config.fetch ?? globalThis.fetch, context?.abortSignal, MAX_REMOTE_MCP_CALL_RESPONSE_BYTES, ); diff --git a/src/transforms/esm/http-cache.test.ts b/src/transforms/esm/http-cache.test.ts index 5f72815daf..e215746d1a 100644 --- a/src/transforms/esm/http-cache.test.ts +++ b/src/transforms/esm/http-cache.test.ts @@ -33,6 +33,7 @@ import { buildHttpCacheIdentity } from "./http-cache-helpers.ts"; import { simpleHash } from "#veryfront/utils/hash-utils.ts"; import { withMockFetch } from "#veryfront/testing/mock-fetch.ts"; import { MAX_BUNDLE_CHUNK_SIZE_BYTES } from "#veryfront/utils/constants/buffers.ts"; +import { OutboundRequestBlockedError } from "#veryfront/security/http/outbound-fetch.ts"; /** Duplicated from http-cache.ts for isolated unit testing of the pattern. */ const BUNDLE_RE = /file:\/\/([^"'\s]+veryfront-http-bundle\/http-([a-f0-9]+)\.mjs)/gi; @@ -95,6 +96,25 @@ async function withIsolatedHttpCache( } describe("HTTP Bundle Cache", { sanitizeResources: false, sanitizeOps: false }, () => { + it("rejects internal module URLs before invoking fetch", async () => { + let fetchCount = 0; + await withIsolatedHttpCache( + "vf-esm-internal-egress-", + (() => { + fetchCount += 1; + return Promise.resolve(new Response("unexpected")); + }) as typeof fetch, + async (tempDir) => { + await assertRejects( + () => cacheModuleToLocal("http://169.254.169.254/module.js", tempDir), + Error, + "internal host", + ); + }, + ); + assertEquals(fetchCount, 0); + }); + it("retries transient esm.sh failures before failing a render", async () => { const moduleUrl = "https://esm.sh/react@19.0.0/jsx-runtime?target=es2022"; let fetchCount = 0; @@ -343,7 +363,7 @@ describe("HTTP Bundle Cache", { sanitizeResources: false, sanitizeOps: false }, importMap: { imports: { react: "https://esm.sh/react@19.2.4?target=es2022", - unrelated: "https://cdn.example.com/a.js", + unrelated: "https://93.184.216.35/a.js", }, scopes: {}, }, @@ -353,7 +373,7 @@ describe("HTTP Bundle Cache", { sanitizeResources: false, sanitizeOps: false }, importMap: { imports: { react: "https://esm.sh/react@19.2.4?target=es2022", - unrelated: "https://cdn.example.com/b.js", + unrelated: "https://93.184.216.35/b.js", }, scopes: {}, }, @@ -424,7 +444,7 @@ describe("HTTP Bundle Cache", { sanitizeResources: false, sanitizeOps: false }, it("isolates rewritten modules with the same URL and React version by import map", async () => { const tempDir = await makeTempDir({ prefix: "vf-import-map-cache-" }); const originalFetch = globalThis.fetch; - const rootUrl = "https://modules.example.com/root.js"; + const rootUrl = "https://93.184.216.34/root.js"; __injectCachesForTests({ cachedPaths: new Map(), @@ -453,12 +473,12 @@ describe("HTTP Bundle Cache", { sanitizeResources: false, sanitizeOps: false }, reactVersion: "19.0.0", importMap: { imports: { - "mapped-dependency": "https://cdn.example.com/dependency-a.js", - unused: "https://cdn.example.com/unused.js", + "mapped-dependency": "https://93.184.216.35/dependency-a.js", + unused: "https://93.184.216.35/unused.js", }, scopes: { - "/scope-b/": { z: "https://cdn.example.com/z.js", a: "https://cdn.example.com/a.js" }, - "/scope-a/": { x: "https://cdn.example.com/x.js" }, + "/scope-b/": { z: "https://93.184.216.35/z.js", a: "https://93.184.216.35/a.js" }, + "/scope-a/": { x: "https://93.184.216.35/x.js" }, }, }, }); @@ -466,7 +486,7 @@ describe("HTTP Bundle Cache", { sanitizeResources: false, sanitizeOps: false }, cacheDir: tempDir, reactVersion: "19.0.0", importMap: { - imports: { "mapped-dependency": "https://cdn.example.com/dependency-b.js" }, + imports: { "mapped-dependency": "https://93.184.216.35/dependency-b.js" }, scopes: {}, }, }); @@ -475,12 +495,12 @@ describe("HTTP Bundle Cache", { sanitizeResources: false, sanitizeOps: false }, reactVersion: "19.0.0", importMap: { imports: { - unused: "https://cdn.example.com/unused.js", - "mapped-dependency": "https://cdn.example.com/dependency-a.js", + unused: "https://93.184.216.35/unused.js", + "mapped-dependency": "https://93.184.216.35/dependency-a.js", }, scopes: { - "/scope-a/": { x: "https://cdn.example.com/x.js" }, - "/scope-b/": { a: "https://cdn.example.com/a.js", z: "https://cdn.example.com/z.js" }, + "/scope-a/": { x: "https://93.184.216.35/x.js" }, + "/scope-b/": { a: "https://93.184.216.35/a.js", z: "https://93.184.216.35/z.js" }, }, }, }); @@ -489,15 +509,15 @@ describe("HTTP Bundle Cache", { sanitizeResources: false, sanitizeOps: false }, reactVersion: "19.0.0", importMap: { imports: { - "mapped-dependency": "https://cdn.example.com/dependency-a.js", - unused: "https://cdn.example.com/unused.js", + "mapped-dependency": "https://93.184.216.35/dependency-a.js", + unused: "https://93.184.216.35/unused.js", }, scopes: { "/scope-b/": { - z: "https://cdn.example.com/z-v2.js", - a: "https://cdn.example.com/a.js", + z: "https://93.184.216.35/z-v2.js", + a: "https://93.184.216.35/a.js", }, - "/scope-a/": { x: "https://cdn.example.com/x.js" }, + "/scope-a/": { x: "https://93.184.216.35/x.js" }, }, }, }); @@ -526,7 +546,7 @@ describe("HTTP Bundle Cache", { sanitizeResources: false, sanitizeOps: false }, it("does not coalesce concurrent modules whose import maps collide under legacy hashing", async () => { const tempDir = await makeTempDir({ prefix: "vf-import-map-collision-" }); const originalFetch = globalThis.fetch; - const rootUrl = "https://modules.example.com/collision.js"; + const rootUrl = "https://93.184.216.34/collision.js"; let fetchCount = 0; __injectCachesForTests({ @@ -574,12 +594,12 @@ describe("HTTP Bundle Cache", { sanitizeResources: false, sanitizeOps: false }, it("tracks circular processing by the full cache identity", async () => { const tempDir = await makeTempDir({ prefix: "vf-processing-identity-" }); const originalFetch = globalThis.fetch; - const rootUrl = "https://modules.example.com/circular-identity.js"; + const rootUrl = "https://93.184.216.34/circular-identity.js"; const options = { cacheDir: tempDir, reactVersion: "19.0.0", importMap: { - imports: { dependency: "https://cdn.example.com/dependency.js" }, + imports: { dependency: "https://93.184.216.35/dependency.js" }, scopes: {}, }, }; @@ -635,11 +655,11 @@ describe("HTTP Bundle Cache", { sanitizeResources: false, sanitizeOps: false }, } }); - it("does not persist a module whose lazy dependency failed to prefetch", async () => { + it("rejects and does not persist a module whose lazy dependency failed to prefetch", async () => { const tempDir = await makeTempDir({ prefix: "vf-degraded-artifact-" }); const originalFetch = globalThis.fetch; - const parentUrl = "https://modules.example.com/degraded-parent.js"; - const childUrl = "https://modules.example.com/degraded-child.js"; + const parentUrl = "https://93.184.216.34/degraded-parent.js"; + const childUrl = "https://93.184.216.34/degraded-child.js"; const distributed = new Map(); let parentFetches = 0; @@ -665,13 +685,11 @@ describe("HTTP Bundle Cache", { sanitizeResources: false, sanitizeOps: false }, const source = `import { load } from "${parentUrl}"; export { load };`; const options = { cacheDir: tempDir, importMap: { imports: {}, scopes: {} } }; - const first = await cacheHttpImportsToLocal(source, options); - const firstPath = first.code.match(/file:\/\/([^"']+\.mjs)/)?.[1]; - assert(firstPath, "Expected the render to keep working with a local parent module"); + await assertRejects(() => cacheHttpImportsToLocal(source, options), Error, "Failed to fetch"); assertEquals(parentFetches, 1); assertEquals(distributed.size, 0); - await cacheHttpImportsToLocal(source, options); + await assertRejects(() => cacheHttpImportsToLocal(source, options), Error, "Failed to fetch"); assertEquals(parentFetches, 2); assertEquals(distributed.size, 0); } finally { @@ -683,11 +701,53 @@ describe("HTTP Bundle Cache", { sanitizeResources: false, sanitizeOps: false }, } }); + it("fails instead of emitting an internal dynamic import after egress denial", async () => { + const tempDir = await makeTempDir({ prefix: "vf-egress-denied-dynamic-import-" }); + const originalFetch = globalThis.fetch; + const parentUrl = "https://93.184.216.34/parent.js"; + const internalUrl = "http://169.254.169.254/latest/meta-data"; + let internalFetches = 0; + + __injectCachesForTests({ + cachedPaths: new Map(), + processingStack: new Set(), + lastDistributedRefresh: new Map(), + }); + __setDistributedCacheAccessorForTests(() => Promise.resolve(null)); + globalThis.fetch = ((input: string | URL | Request) => { + if (String(input) === internalUrl) internalFetches += 1; + return Promise.resolve( + new Response(`export const load = () => import("${internalUrl}");`, { + headers: { "content-type": "application/javascript" }, + }), + ); + }) as typeof fetch; + + try { + await assertRejects( + () => + cacheHttpImportsToLocal( + `import { load } from "${parentUrl}"; export { load };`, + { cacheDir: tempDir, importMap: { imports: {}, scopes: {} } }, + ), + OutboundRequestBlockedError, + "internal host", + ); + assertEquals(internalFetches, 0); + } finally { + globalThis.fetch = originalFetch; + __injectCachesForTests(null); + __setDistributedCacheAccessorForTests(null); + __clearInFlightHttpFetches(); + await remove(tempDir, { recursive: true }); + } + }); + it("persists a module whose lazy dependency prefetched successfully", async () => { const tempDir = await makeTempDir({ prefix: "vf-healthy-artifact-" }); const originalFetch = globalThis.fetch; - const parentUrl = "https://modules.example.com/healthy-parent.js"; - const childUrl = "https://modules.example.com/healthy-child.js"; + const parentUrl = "https://93.184.216.34/healthy-parent.js"; + const childUrl = "https://93.184.216.34/healthy-child.js"; const distributed = new Map(); let parentFetches = 0; @@ -733,8 +793,8 @@ describe("HTTP Bundle Cache", { sanitizeResources: false, sanitizeOps: false }, }); it("creates the same complete manifest for network, disk, and memory cache hits", async () => { - const rootUrl = "https://modules.example.com/manifest-root.js"; - const childUrl = "https://modules.example.com/manifest-child.js"; + const rootUrl = "https://93.184.216.34/manifest-root.js"; + const childUrl = "https://93.184.216.34/manifest-child.js"; let fetchCount = 0; const mockFetch = ((input: string | URL | Request) => { @@ -771,7 +831,7 @@ describe("HTTP Bundle Cache", { sanitizeResources: false, sanitizeOps: false }, }); it("creates complete manifests for every request sharing an in-flight fetch", async () => { - const moduleUrl = "https://modules.example.com/in-flight-manifest.js"; + const moduleUrl = "https://93.184.216.34/in-flight-manifest.js"; let fetchCount = 0; const mockFetch = (async () => { fetchCount += 1; @@ -797,8 +857,8 @@ describe("HTTP Bundle Cache", { sanitizeResources: false, sanitizeOps: false }, }); it("reconstructs a complete manifest from distributed-cache bundles", async () => { - const rootUrl = "https://modules.example.com/distributed-manifest-root.js"; - const childUrl = "https://modules.example.com/distributed-manifest-child.js"; + const rootUrl = "https://93.184.216.34/distributed-manifest-root.js"; + const childUrl = "https://93.184.216.34/distributed-manifest-child.js"; const distributed = new Map(); let fetchCount = 0; const mockFetch = ((input: string | URL | Request) => { @@ -840,10 +900,10 @@ describe("HTTP Bundle Cache", { sanitizeResources: false, sanitizeOps: false }, }); }); - it("does not publish a partial manifest when one bundle is degraded", async () => { - const healthyUrl = "https://modules.example.com/healthy-manifest-entry.js"; - const parentUrl = "https://modules.example.com/degraded-manifest-entry.js"; - const childUrl = "https://modules.example.com/unavailable-lazy-entry.js"; + it("rejects instead of publishing a partial manifest", async () => { + const healthyUrl = "https://93.184.216.34/healthy-manifest-entry.js"; + const parentUrl = "https://93.184.216.34/degraded-manifest-entry.js"; + const childUrl = "https://93.184.216.34/unavailable-lazy-entry.js"; const mockFetch = ((input: string | URL | Request) => { const url = String(input); if (url === childUrl) { @@ -860,16 +920,19 @@ describe("HTTP Bundle Cache", { sanitizeResources: false, sanitizeOps: false }, }) as typeof fetch; await withIsolatedHttpCache("vf-partial-manifest-", mockFetch, async (tempDir) => { - const result = await cacheHttpImportsToLocal( - [ - `import { healthy } from "${healthyUrl}";`, - `import { load } from "${parentUrl}";`, - "export { healthy, load };", - ].join("\n"), - { cacheDir: tempDir, importMap: { imports: {}, scopes: {} } }, + await assertRejects( + () => + cacheHttpImportsToLocal( + [ + `import { healthy } from "${healthyUrl}";`, + `import { load } from "${parentUrl}";`, + "export { healthy, load };", + ].join("\n"), + { cacheDir: tempDir, importMap: { imports: {}, scopes: {} } }, + ), + Error, + "Failed to fetch", ); - - assertEquals(result.bundleManifestId, undefined); }); }); diff --git a/src/transforms/esm/http-cache.ts b/src/transforms/esm/http-cache.ts index 5e293118de..5cab28a0dd 100644 --- a/src/transforms/esm/http-cache.ts +++ b/src/transforms/esm/http-cache.ts @@ -31,10 +31,14 @@ import { import { looksLikeHtmlContent as looksLikeHtmlNotJs } from "./html-content.ts"; import { HttpModuleBodyError, readHttpModuleText } from "../shared/http-module-response.ts"; import { MAX_BUNDLE_CHUNK_SIZE_BYTES } from "#veryfront/utils/constants/buffers.ts"; +import { + guardedOutboundFetch, + OutboundRequestBlockedError, +} from "#veryfront/security/http/outbound-fetch.ts"; // Extracted modules import { embedSourceUrl, extractSourceUrl } from "./source-url-embed.ts"; -import { isDegradedArtifact, markDegradedArtifact } from "./degraded-artifact.ts"; +import { isDegradedArtifact } from "./degraded-artifact.ts"; import { buildHttpCacheIdentity, buildHttpCacheIdentityMetadata, @@ -55,7 +59,6 @@ import { extractBundleDeps, validateBundleDepsExist } from "./bundle-deps-valida import { bundleAccumulatorStorage, createBundleAccumulator, - markBundleAccumulatorIncomplete, trackCachedBundleGraph, trackWrittenBundle, } from "./bundle-accumulator.ts"; @@ -140,7 +143,7 @@ async function fetchHttpModuleAttempt( try { const startedAt = performance.now(); - response = await fetch(url, { + response = await guardedOutboundFetch(url, { headers: { "user-agent": "Mozilla/5.0 Veryfront/1.0" }, signal, redirect: "follow", @@ -171,7 +174,10 @@ async function fetchHttpModuleAttempt( contentType: response.headers.get("content-type") ?? "", }; } catch (error) { - if (error instanceof HttpModuleResponseError || error instanceof HttpModuleBodyError) { + if ( + error instanceof HttpModuleResponseError || error instanceof HttpModuleBodyError || + error instanceof OutboundRequestBlockedError + ) { throw error; } if (response) await discardResponseBody(response); @@ -204,6 +210,8 @@ async function fetchHttpModule(url: string): Promise { timeoutMs: HTTP_FETCH_TIMEOUT_MS, shouldRetry: (error) => error instanceof HttpModuleBodyError + ? false + : error instanceof OutboundRequestBlockedError ? false : !(error instanceof HttpModuleResponseError) || shouldRetryHttpModuleFetch(error.status), @@ -234,6 +242,12 @@ async function fetchHttpModule(url: string): Promise { detail: `Failed to fetch ${safeUrl}: ${error.message}`, }); } + if (error instanceof OutboundRequestBlockedError) { + // Preserve the policy-denial type so downstream specifier resolution can + // distinguish it from a transient fetch failure. Dynamic imports must + // never degrade into an unguarded runtime fetch after a policy denial. + throw error; + } throw error; } } @@ -437,7 +451,6 @@ async function cacheHttpModuleInternal(url: string, options: CacheOptions): Prom } processingStack.add(cacheIdentity); - let degraded: readonly string[] = []; try { const rewritten = await rewriteModuleImports( code, @@ -446,13 +459,11 @@ async function cacheHttpModuleInternal(url: string, options: CacheOptions): Prom cacheHttpModule, ); code = rewritten.code; - degraded = rewritten.degraded; } finally { processingStack.delete(cacheIdentity); } code = embedSourceUrl(code, normalizedUrl); - if (degraded.length > 0) code = markDegradedArtifact(code); if (!isHttpBundleCodeWithinLimit(code)) { throw BUNDLE_ERROR.create({ detail: `Rewritten HTTP module exceeds ${MAX_CACHED_HTTP_BUNDLE_BYTES} bytes`, @@ -469,21 +480,6 @@ async function cacheHttpModuleInternal(url: string, options: CacheOptions): Prom }); } - if (degraded.length > 0) { - // The file on disk carries this render through, but the artifact is not - // the one this URL is supposed to produce. Keeping it out of the - // distributed cache and the in-memory path map means the next render - // retries the prefetch instead of inheriting one upstream blip for the - // lifetime of the distributed entry. - httpCacheLog.warn("Not caching a module with unresolved dynamic imports", { - url: safeUrl, - hash, - degraded: degraded.map(sanitizeUrlForSpan), - }); - markBundleAccumulatorIncomplete(); - return cachePath; - } - try { await httpBundleCache.setCode( String(hash), diff --git a/src/transforms/esm/specifier-resolver.test.ts b/src/transforms/esm/specifier-resolver.test.ts index 230cbcf243..0ae9a5b057 100644 --- a/src/transforms/esm/specifier-resolver.test.ts +++ b/src/transforms/esm/specifier-resolver.test.ts @@ -4,6 +4,7 @@ import { describe, it } from "#veryfront/testing/bdd.ts"; import type { CacheHttpModuleFn } from "./specifier-resolver.ts"; import { buildReplacements, rewriteModuleImports } from "./specifier-resolver.ts"; import type { CacheOptions } from "./http-cache-helpers.ts"; +import { OutboundRequestBlockedError } from "#veryfront/security/http/outbound-fetch.ts"; describe("transforms/esm/specifier-resolver", () => { const defaultOptions: CacheOptions = { @@ -163,21 +164,16 @@ describe("transforms/esm/specifier-resolver", () => { assertEquals(result.replacements.size, 0); }); - it("skips a dynamic specifier whose cache lookup throws instead of aborting", async () => { - // The motivating case: a lazy `import(...)` that never runs at render - // time. Pre-fetching it is an optimisation, so the specifier is left in - // place for the runtime to resolve at call time, and one upstream 500 - // does not abort the whole SSR transform. + it("fails closed when a dynamic absolute URL cannot be cached", async () => { const code = `export const load = () => import("https://esm.sh/foo");`; - const result = await buildReplacements( - code, - undefined, - defaultOptions, - async () => { - throw new Error("cache failed"); - }, + await assertRejects( + () => + buildReplacements(code, undefined, defaultOptions, async () => { + throw new Error("cache failed"); + }), + Error, + "cache failed", ); - assertEquals(result.replacements.size, 0); }); it("aborts when a static specifier's cache lookup throws", async () => { @@ -208,20 +204,28 @@ describe("transforms/esm/specifier-resolver", () => { ); }); - it("reports a skipped dynamic absolute URL as degraded", async () => { + it("fails closed when an absolute URL cache returns no artifact", async () => { const code = `export const load = () => import("https://esm.sh/foo");`; - const result = await buildReplacements( - code, - undefined, - defaultOptions, - async () => { - throw new Error("cache failed"); - }, + await assertRejects( + () => buildReplacements(code, undefined, defaultOptions, async () => null), + Error, + "Failed to cache absolute HTTP module", + ); + }); + + it("never degrades an outbound-policy denial into a runtime import", async () => { + const code = `export const load = () => import("http://169.254.169.254/metadata");`; + await assertRejects( + () => + buildReplacements(code, undefined, defaultOptions, async () => { + throw new OutboundRequestBlockedError("internal destination blocked"); + }), + OutboundRequestBlockedError, + "internal destination blocked", ); - assertEquals(result.degraded, ["https://esm.sh/foo"]); }); - it("reports nothing as degraded when every specifier resolves", async () => { + it("returns the complete replacement set when every specifier resolves", async () => { const code = `import ok from "https://esm.sh/ok";`; const result = await buildReplacements( code, @@ -229,7 +233,7 @@ describe("transforms/esm/specifier-resolver", () => { defaultOptions, async () => "/tmp/cache/http-ok.mjs", ); - assertEquals(result.degraded, []); + assertEquals(result.replacements.size, 1); }); it("aborts when a dynamic relative specifier fails to resolve", async () => { @@ -278,7 +282,6 @@ describe("transforms/esm/specifier-resolver", () => { ); assertEquals(cacheCalls, 0, `${specifier} must not hit esm.sh`); assertEquals(result.replacements.size, 0, `${specifier} must be left in place`); - assertEquals(result.degraded, []); } }); @@ -294,16 +297,18 @@ describe("transforms/esm/specifier-resolver", () => { ); }); - it("still resolves other specifiers when a dynamic one throws", async () => { + it("rejects the entire artifact when any dynamic absolute URL fails", async () => { const code = `import ok from "https://esm.sh/ok";\n` + `export const load = () => import("https://esm.sh/broken");`; const cache: CacheHttpModuleFn = async (url) => { if (url === "https://esm.sh/broken") throw new Error("upstream 500"); return "/tmp/cache/http-ok.mjs"; }; - const result = await buildReplacements(code, undefined, defaultOptions, cache); - assertEquals(result.replacements.get("https://esm.sh/ok"), "file:///tmp/cache/http-ok.mjs"); - assertEquals(result.replacements.has("https://esm.sh/broken"), false); + await assertRejects( + () => buildReplacements(code, undefined, defaultOptions, cache), + Error, + "upstream 500", + ); }); }); @@ -322,32 +327,26 @@ describe("transforms/esm/specifier-resolver", () => { assertEquals(result.code.includes("https://esm.sh/react@18"), false); }); - it("leaves a dynamic specifier untouched when its cache lookup throws", async () => { - // Same split contract as buildReplacements: a lazy import that fails to - // pre-fetch stays in the emitted code for the runtime to resolve. + it("does not emit a dynamic absolute URL when caching throws", async () => { const original = `export const load = () => import("https://esm.sh/foo");`; - const result = await rewriteModuleImports( - original, - "https://esm.sh/parent", - defaultOptions, - async () => { - throw new Error("cache failed"); - }, + await assertRejects( + () => + rewriteModuleImports(original, "https://esm.sh/parent", defaultOptions, async () => { + throw new Error("cache failed"); + }), + Error, + "cache failed", ); - assertEquals(result.code, original); }); - it("reports the specifiers left in place as degraded", async () => { + it("does not emit a dynamic absolute URL when caching returns null", async () => { const original = `export const load = () => import("https://esm.sh/foo");`; - const result = await rewriteModuleImports( - original, - "https://esm.sh/parent", - defaultOptions, - async () => { - throw new Error("cache failed"); - }, + await assertRejects( + () => + rewriteModuleImports(original, "https://esm.sh/parent", defaultOptions, async () => null), + Error, + "Failed to cache absolute HTTP module", ); - assertEquals(result.degraded, ["https://esm.sh/foo"]); }); it("aborts when a static specifier's cache lookup throws", async () => { diff --git a/src/transforms/esm/specifier-resolver.ts b/src/transforms/esm/specifier-resolver.ts index 6229a619bf..35da187c59 100644 --- a/src/transforms/esm/specifier-resolver.ts +++ b/src/transforms/esm/specifier-resolver.ts @@ -9,12 +9,11 @@ import { basename } from "#veryfront/compat/path/index.ts"; import { resolveImport } from "#veryfront/modules/import-map/resolver.ts"; -import { rendererLogger } from "#veryfront/utils"; +import { OutboundRequestBlockedError } from "#veryfront/security/http/outbound-fetch.ts"; import { parseBarePackageSpecifier } from "../shared/package-specifier.ts"; import { isServerOnlyPackage } from "../shared/server-only-packages.ts"; -import { type ImportSpecifier, parseImports, replaceSpecifiers } from "./lexer.ts"; +import { parseImports, replaceSpecifiers } from "./lexer.ts"; -const logger = rendererLogger.component("specifier-resolver"); import { type CacheOptions, isCanonicalReactEsmUrl, @@ -91,7 +90,9 @@ async function resolveSpecifier( } const cached = await cacheHttpModule(specifier, options); - if (!cached) return null; + if (!cached) { + throw new Error(`Failed to cache absolute HTTP module ${specifier}`); + } if (isParentHttpModule(baseUrl)) { return `./${basename(cached)}`; @@ -118,63 +119,22 @@ async function resolveSpecifier( return resolveSpecifier(mapped, baseUrl, options, cacheHttpModule); } -/** - * Specifiers the runtime can still resolve on its own if prefetching fails. - * - * Two conditions must hold together. The specifier must be reached only - * through `import(...)`, because a static import is part of the emitted - * module's own import graph: every static dependency resolves to a local path - * before the module is handed to the runtime loader, and a failure to do that - * is fatal, exactly as it was before graceful degradation existed. A dynamic - * specifier is resolved by the runtime at call time and is routinely guarded by - * the caller (`platform/adapters/redis/modules.js` only calls - * `await import("redis")` when the redis adapter is actually used), so failing - * to prefetch it leaves the specifier in place rather than taking down a render - * that would never have imported it. - * - * The specifier must also be an absolute http(s) URL, because that is the only - * form the runtime can resolve without the transform. A relative specifier left - * in place resolves against the local bundle cache directory, where the chunk - * was never written; `npm:` and bare specifiers need the import map the cached - * module no longer carries. Those failures stay fatal. - */ -function runtimeResolvableSpecifiers(imports: readonly ImportSpecifier[]): Set { - const dynamic = new Set(); - const staticSpecifiers = new Set(); - - for (const imp of imports) { - if (!imp.n) continue; - (imp.d > -1 ? dynamic : staticSpecifiers).add(imp.n); - } - - for (const specifier of staticSpecifiers) dynamic.delete(specifier); - for (const specifier of [...dynamic]) { - if (!isHttpUrl(specifier)) dynamic.delete(specifier); - } - return dynamic; -} - -/** Specifier replacements plus the specifiers that were left unresolved. */ +/** Complete specifier replacements for one module. */ export interface SpecifierReplacements { readonly replacements: ReadonlyMap; - /** Specifiers left in place because prefetching them failed. */ - readonly degraded: readonly string[]; } -/** Rewritten module code plus the specifiers that were left unresolved. */ +/** Module code whose resolvable imports have been rewritten. */ export interface RewrittenModule { readonly code: string; - /** Specifiers left in place because prefetching them failed. */ - readonly degraded: readonly string[]; } /** * Build a map of specifier replacements by resolving all imports in the code. * - * Resolution failure is fatal unless the runtime can resolve the specifier on - * its own. See {@link runtimeResolvableSpecifiers}. Every specifier left in - * place is reported as degraded so callers can decide whether the resulting - * code is fit to cache. + * Resolution failure is fatal. In particular, an absolute dynamic HTTP import + * may never be emitted unresolved: doing so would let the runtime loader bypass + * the guarded fetch and DNS-pinning policy used while populating the cache. */ export async function buildReplacements( code: string, @@ -184,7 +144,6 @@ export async function buildReplacements( ): Promise { const imports = await parseImports(code); const uniqueSpecifiers = [...new Set(imports.map((imp) => imp.n).filter(Boolean))] as string[]; - const runtimeResolvable = runtimeResolvableSpecifiers(imports); const settled = await Promise.allSettled( uniqueSpecifiers.map(async (specifier) => ({ @@ -194,7 +153,6 @@ export async function buildReplacements( ); const replacements = new Map(); - const degraded: string[] = []; for (let i = 0; i < settled.length; i++) { const outcome = settled[i]; const specifier = uniqueSpecifiers[i]; @@ -202,31 +160,30 @@ export async function buildReplacements( if (outcome.status === "fulfilled") { const { specifier: resolvedFor, resolved } = outcome.value; + if (!resolved && isHttpUrl(resolvedFor)) { + throw new Error(`Failed to resolve absolute HTTP module ${resolvedFor}`); + } if (resolved && resolved !== resolvedFor) replacements.set(resolvedFor, resolved); continue; } - // Anything the runtime cannot resolve on its own must resolve here. - // Leaving one unresolved would emit a module whose own import graph reaches - // outside the local cache, which is not what the runtime loader is handed - // anywhere else. - if (!runtimeResolvable.has(specifier)) throw outcome.reason; + // An egress-policy denial is an authorization decision, not a transient + // prefetch failure. Leaving the original absolute import in the emitted + // bundle would let the runtime resolve it with its unrestricted loader and + // bypass the guarded transport entirely. + if (outcome.reason instanceof OutboundRequestBlockedError) throw outcome.reason; - degraded.push(specifier); - logger.warn("Leaving an unresolvable dynamic specifier for runtime resolution", { - specifier, - error: outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason), - }); + throw outcome.reason; } - return { replacements, degraded }; + return { replacements }; } /** * Rewrite all HTTP/npm/bare import specifiers in module code to local cached paths. * - * Reports any specifier left in place, so the caller can keep the resulting - * code out of the caches that outlive this render. + * Resolution is atomic: a failed absolute HTTP import rejects instead of + * emitting a partially rewritten module. */ export async function rewriteModuleImports( code: string, @@ -234,16 +191,15 @@ export async function rewriteModuleImports( options: CacheOptions, cacheHttpModule: CacheHttpModuleFn, ): Promise { - const { replacements, degraded } = await buildReplacements( + const { replacements } = await buildReplacements( code, moduleUrl, options, cacheHttpModule, ); - if (replacements.size === 0) return { code, degraded }; + if (replacements.size === 0) return { code }; return { code: await replaceSpecifiers(code, (specifier) => replacements.get(specifier) ?? null), - degraded, }; } diff --git a/src/transforms/import-rewriter/route-adapter.ts b/src/transforms/import-rewriter/route-adapter.ts index c607e18e6d..178bde8147 100644 --- a/src/transforms/import-rewriter/route-adapter.ts +++ b/src/transforms/import-rewriter/route-adapter.ts @@ -8,6 +8,7 @@ import { resolveExportEntry as resolveRouteExportEntry, toCjsDestructureBindings, } from "#veryfront/routing/api/module-loader/loader-helpers.ts"; +import { rethrowProjectBoundaryViolation } from "#veryfront/routing/api/module-loader/project-source-snapshot.ts"; const logger = serverLogger.component("api"); @@ -61,7 +62,8 @@ export async function readProjectDependenciesForRoute( const content = await fs.readTextFile(pathHelper.join(projectDir, "package.json")); const pkg = JSON.parse(content) as { dependencies?: Record }; return new Map(Object.entries(pkg.dependencies ?? {})); - } catch (_) { + } catch (error) { + rethrowProjectBoundaryViolation(error); /* expected: package.json may not exist */ return new Map(); } @@ -129,7 +131,7 @@ function resolveEsmEntry(pkgJson: Record): string | undefined { */ export async function resolveEsmUserDependenciesForRoute( projectDir: string, - fs: FileSystem, + fs: Pick, userDeps: Map, ): Promise> { const esmDeps = new Map(); @@ -160,7 +162,8 @@ export async function resolveEsmUserDependenciesForRoute( entryUrl: pathHelper.toFileUrl(entryPath).href, packageDir, }); - } catch (_) { + } catch (error) { + rethrowProjectBoundaryViolation(error); /* expected: package.json missing/invalid -> treat as CJS */ } } @@ -296,7 +299,7 @@ export function rewriteCompiledUserDependencyImportsForRoute( export async function rewriteDenoNpmDependencyImportsForRoute( code: string, projectDir: string, - fs: FileSystem, + fs: Pick, userDeps: Map, ): Promise { const importedSpecifiers = new Set( @@ -319,7 +322,8 @@ export async function rewriteDenoNpmDependencyImportsForRoute( const pkgContent = await fs.readTextFile(pkgPath); const pkg = JSON.parse(pkgContent) as { version?: string }; if (pkg.version) resolvedVersion = pkg.version; - } catch (_) { + } catch (error) { + rethrowProjectBoundaryViolation(error); /* expected: installed package.json may not exist, fall back to declared range */ } diff --git a/src/transforms/mdx/esm-module-loader/module-writer.test.ts b/src/transforms/mdx/esm-module-loader/module-writer.test.ts index 78d64862dd..b2cc144781 100644 --- a/src/transforms/mdx/esm-module-loader/module-writer.test.ts +++ b/src/transforms/mdx/esm-module-loader/module-writer.test.ts @@ -5,6 +5,7 @@ import { buildMdxModuleCacheIdentity } from "./module-writer.ts"; import { mdxRenderer } from "../index.ts"; import { denoAdapter } from "#veryfront/platform/adapters/deno.ts"; import { hashString } from "#veryfront/cache/hash.ts"; +import { withMockFetch } from "#veryfront/testing/mock-fetch.ts"; function cacheKeyForDependencies( dependencies: Readonly>, @@ -94,43 +95,42 @@ describe("MDX root module cache identity", () => { const snapshotPinKey = cacheKeyForDependencies(dependencies); let validRequests = 0; let rawRequests = 0; - const server = Deno.serve( - { hostname: "127.0.0.1", port: 0, onListen: () => {} }, - (request) => { - const url = new URL(request.url); - if (url.pathname !== modulePath) return new Response("not found", { status: 404 }); - if ( - url.searchParams.get("pins") !== snapshotPinKey || - url.searchParams.get("ssr") !== "true" - ) { - rawRequests++; - return new Response("missing dependency snapshot", { status: 409 }); - } - - validRequests++; - return new Response('export default "STRICT_CHILD_OK";', { - headers: { "content-type": "application/javascript" }, - }); - }, - ); - const address = server.addr; - if (address.transport !== "tcp") throw new Error("expected TCP test server"); - const origin = `http://127.0.0.1:${address.port}`; + const origin = "https://93.184.216.34"; const projectDir = await Deno.makeTempDir({ prefix: "vf-mdx-origin-" }); try { - const mod = await mdxRenderer.loadModuleESM( - `import child from "${origin}${modulePath}";\nexport default child;`, - denoAdapter, - `project-${crypto.randomUUID()}`, - projectDir, - "strict-origin", - `source-${crypto.randomUUID()}`, - "19.1.1", - snapshotPinKey, - dependencies, - projectDir, - origin, + const mod = await withMockFetch( + async (input, init) => { + const request = new Request(input, init); + const url = new URL(request.url); + if (url.pathname !== modulePath) return new Response("not found", { status: 404 }); + if ( + url.searchParams.get("pins") !== snapshotPinKey || + url.searchParams.get("ssr") !== "true" + ) { + rawRequests++; + return new Response("missing dependency snapshot", { status: 409 }); + } + + validRequests++; + return new Response('export default "STRICT_CHILD_OK";', { + headers: { "content-type": "application/javascript" }, + }); + }, + () => + mdxRenderer.loadModuleESM( + `import child from "${origin}${modulePath}";\nexport default child;`, + denoAdapter, + `project-${crypto.randomUUID()}`, + projectDir, + "strict-origin", + `source-${crypto.randomUUID()}`, + "19.1.1", + snapshotPinKey, + dependencies, + projectDir, + origin, + ), ); assertEquals(mod.default as unknown, "STRICT_CHILD_OK"); @@ -138,8 +138,6 @@ describe("MDX root module cache identity", () => { assertEquals(rawRequests, 0); } finally { mdxRenderer.clearCache(); - await server.shutdown(); - await server.finished; await Deno.remove(projectDir, { recursive: true }); const esbuild = await import("veryfront/extensions/bundler"); await esbuild.stop(); diff --git a/src/trigger/discovery.ts b/src/trigger/discovery.ts index 8a7d93d16e..f680266f8f 100644 --- a/src/trigger/discovery.ts +++ b/src/trigger/discovery.ts @@ -65,6 +65,8 @@ interface TriggerDiscoveryBaseOptions { config?: VeryfrontConfig; /** Source definition kind used in diagnostics. */ sourceKind: SourceTriggerKind; + /** Explicit host-owned capability for a trusted local or dedicated runtime. */ + allowHostProjectCodeExecution?: boolean; } /** Shared filesystem, directory, and source-kind options for trigger discovery. */ @@ -361,6 +363,7 @@ export async function discoverSourceTriggers( sourceKind, validate, normalize = (value: T): T => value, + allowHostProjectCodeExecution, } = options; validateDiscoveryProjectDir(projectDir); if (sourceKind !== "schedule" && sourceKind !== "webhook") { @@ -392,6 +395,7 @@ export async function discoverSourceTriggers( const module = await importDiscoveryModule(file.path, { adapter, projectDir, + allowHostProjectCodeExecution, }) as Record; const triggerExport = selectTriggerExport(module, validate); diff --git a/src/trigger/local-runner.ts b/src/trigger/local-runner.ts index afe5059430..af60720cd3 100644 --- a/src/trigger/local-runner.ts +++ b/src/trigger/local-runner.ts @@ -109,6 +109,7 @@ async function discoverRuntimeOrThrow(options: NormalizedRunTriggerTargetOptions cacheKey: options.cacheKey, debug: options.debug, throwOnErrors: true, + allowHostProjectCodeExecution: true, }); options.signal?.throwIfAborted(); return discovery; diff --git a/src/trigger/runtime.test.ts b/src/trigger/runtime.test.ts index f7740898d4..a7bdfacd7f 100644 --- a/src/trigger/runtime.test.ts +++ b/src/trigger/runtime.test.ts @@ -6,7 +6,7 @@ import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; import { afterAll, afterEach, describe, it } from "#veryfront/testing/bdd.ts"; import { toolRegistry } from "#veryfront/tool/registry.ts"; import { stop as stopEsbuild } from "veryfront/extensions/bundler"; -import { discoverSourceTriggers } from "./discovery.ts"; +import { discoverSourceTriggers as discoverSourceTriggersRaw } from "./discovery.ts"; import { runTriggerTarget } from "./local-runner.ts"; interface FixtureTrigger { @@ -14,6 +14,12 @@ interface FixtureTrigger { marker: string; } +const discoverSourceTriggers: typeof discoverSourceTriggersRaw = (options) => + discoverSourceTriggersRaw({ + ...options, + allowHostProjectCodeExecution: true, + }); + function normalizePath(path: string): string { return path.replace(/^\/project\/?/, "").replace(/^\/+/, ""); } diff --git a/src/types/server.ts b/src/types/server.ts index 54caac1c51..b7e811e87c 100644 --- a/src/types/server.ts +++ b/src/types/server.ts @@ -68,6 +68,12 @@ export interface HandlerContext { requestContext?: RequestContext; /** Whether this request targets a local filesystem project (per-request, from adapter resolution). */ isLocalProject?: boolean; + /** + * Host-owned capability for executing this runtime's project code in the + * server process. Dedicated single-project runtimes may grant it without + * enabling development-only local-project behavior. + */ + allowHostProjectCodeExecution?: boolean; /** Environment ID for per-project env var resolution (from proxy x-environment-id header) */ environmentId?: string; /** diff --git a/src/webhook/discovery.ts b/src/webhook/discovery.ts index 988366313b..6716c53229 100644 --- a/src/webhook/discovery.ts +++ b/src/webhook/discovery.ts @@ -18,6 +18,8 @@ export interface WebhookDiscoveryOptions { config?: VeryfrontConfig; /** Explicit webhook directory override relative to `projectDir`. */ webhooksDir?: string; + /** Explicit host-owned capability for a trusted local or dedicated runtime. */ + allowHostProjectCodeExecution?: boolean; } /** Valid webhooks and bounded per-file discovery diagnostics. */ @@ -41,5 +43,6 @@ export async function discoverWebhooks( sourceKind: "webhook", validate: isWebhookDefinition, normalize: normalizeWebhookDefinition, + allowHostProjectCodeExecution: options.allowHostProjectCodeExecution, }); } diff --git a/src/workflow/blob/blob-id.test.ts b/src/workflow/blob/blob-id.test.ts index a4830f25e8..0becf550ff 100644 --- a/src/workflow/blob/blob-id.test.ts +++ b/src/workflow/blob/blob-id.test.ts @@ -7,12 +7,14 @@ Deno.test("blob ids accept only the framework's portable identifier alphabet", ( assertSafeBlobId(id); } - for (const id of [undefined, null, 1, "", "a/b", "a b", "a?b", "\ud800"]) { + for ( + const id of [undefined, null, 1, "", "a/b", "a b", "a?b", "\ud800", "a".repeat(257)] + ) { assertFalse(isSafeBlobId(id)); assertThrows( () => assertSafeBlobId(id), Error, - "Blob IDs must contain only alphanumeric characters, hyphens, and underscores", + "Blob IDs must contain at most 256 alphanumeric characters, hyphens, and underscores", ); } }); diff --git a/src/workflow/blob/blob-id.ts b/src/workflow/blob/blob-id.ts index a2dc5b4b54..9f3fed2c00 100644 --- a/src/workflow/blob/blob-id.ts +++ b/src/workflow/blob/blob-id.ts @@ -1,10 +1,12 @@ import { INVALID_ARGUMENT } from "#veryfront/errors"; const SAFE_BLOB_ID_PATTERN = /^[a-zA-Z0-9_-]+$/; +const MAX_BLOB_ID_LENGTH = 256; /** Return whether a runtime value is a framework-safe blob identifier. */ export function isSafeBlobId(id: unknown): id is string { - return typeof id === "string" && SAFE_BLOB_ID_PATTERN.test(id); + return typeof id === "string" && id.length <= MAX_BLOB_ID_LENGTH && + SAFE_BLOB_ID_PATTERN.test(id); } /** Validate an identifier before any blob backend constructs a storage path. */ @@ -13,6 +15,6 @@ export function assertSafeBlobId(id: unknown): asserts id is string { throw INVALID_ARGUMENT.create({ detail: - "Invalid blob id. Blob IDs must contain only alphanumeric characters, hyphens, and underscores.", + `Invalid blob id. Blob IDs must contain at most ${MAX_BLOB_ID_LENGTH} alphanumeric characters, hyphens, and underscores.`, }); } diff --git a/src/workflow/blob/veryfront-cloud-storage.test.ts b/src/workflow/blob/veryfront-cloud-storage.test.ts index d6b524fced..d87e78082b 100644 --- a/src/workflow/blob/veryfront-cloud-storage.test.ts +++ b/src/workflow/blob/veryfront-cloud-storage.test.ts @@ -2,6 +2,7 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals, assertExists, assertRejects } from "#veryfront/testing/assert.ts"; import { afterEach, describe, it } from "#veryfront/testing/bdd.ts"; import { runWithRequestContext } from "#veryfront/platform/adapters/fs/veryfront/multi-project-adapter.ts"; +import { runWithVeryfrontCloudContext } from "#veryfront/provider"; import { VeryfrontCloudBlobStorage } from "./veryfront-cloud-storage.ts"; const originalFetch = globalThis.fetch; @@ -26,6 +27,21 @@ interface StoredUpload { createdAt: string; } +async function beforeDeadline(operation: Promise, timeoutMs = 250): Promise { + let timeout: number | undefined; + const deadline = new Promise((_resolve, reject) => { + timeout = setTimeout( + () => reject(new Error("Operation exceeded the test deadline")), + timeoutMs, + ); + }); + try { + return await Promise.race([operation, deadline]); + } finally { + clearTimeout(timeout); + } +} + function makeStorageKey(projectSlug: string, path: string): string { return `${projectSlug}:${path}`; } @@ -47,7 +63,7 @@ function createMockUploadService() { headers, }); - if (url.origin === "https://api.test") { + if (url.origin === "https://93.184.216.34") { const authHeader = headers.get("Authorization"); if (!authHeader?.startsWith("Bearer ")) { return new Response("Unauthorized", { status: 401 }); @@ -78,7 +94,7 @@ function createMockUploadService() { }); return Response.json({ - file_upload_url: `https://upload.test/${encodeURIComponent(projectSlug)}/${ + file_upload_url: `https://93.184.216.35/${encodeURIComponent(projectSlug)}/${ encodeURIComponent(body.file_path) }`, file_path: `${projectSlug}/${body.file_path}`, @@ -99,7 +115,7 @@ function createMockUploadService() { } return Response.json({ - signed_url: `https://download.test/${encodeURIComponent(projectSlug)}/${ + signed_url: `https://93.184.216.36/${encodeURIComponent(projectSlug)}/${ encodeURIComponent(path) }`, expires_at: new Date(FIXED_NOW.getTime() + 30 * 60 * 1000).toISOString(), @@ -140,7 +156,7 @@ function createMockUploadService() { } } - if (url.origin === "https://upload.test" && method === "PUT") { + if (url.origin === "https://93.184.216.35" && method === "PUT") { const [, encodedProjectSlug = "", encodedPath = ""] = url.pathname.split("/"); const projectSlug = decodeURIComponent(encodedProjectSlug); const path = decodeURIComponent(encodedPath); @@ -164,7 +180,7 @@ function createMockUploadService() { return new Response(null, { status: 200 }); } - if (url.origin === "https://download.test" && method === "GET") { + if (url.origin === "https://93.184.216.36" && method === "GET") { const [, encodedProjectSlug = "", encodedPath = ""] = url.pathname.split("/"); const projectSlug = decodeURIComponent(encodedProjectSlug); const path = decodeURIComponent(encodedPath); @@ -200,7 +216,7 @@ describe("VeryfrontCloudBlobStorage", () => { it("stores, retrieves, stats, and deletes blobs via project uploads", async () => { const service = createMockUploadService(); const storage = new VeryfrontCloudBlobStorage({ - apiBaseUrl: "https://api.test", + apiBaseUrl: "https://93.184.216.34", apiToken: "vf_config_token", projectSlug: "demo-project", prefix: ".vf-test/", @@ -230,7 +246,7 @@ describe("VeryfrontCloudBlobStorage", () => { assertEquals(stat.size, 16); assertEquals( stat.url, - `https://download.test/demo-project/${encodeURIComponent(`.vf-test/${ref.id}.blob`)}`, + `https://93.184.216.36/demo-project/${encodeURIComponent(`.vf-test/${ref.id}.blob`)}`, ); assertEquals(stat.createdAt.toISOString(), FIXED_NOW.toISOString()); assertEquals( @@ -247,7 +263,7 @@ describe("VeryfrontCloudBlobStorage", () => { assertEquals(service.uploads.size, 0); const firstCreate = service.fetchCalls.find((call) => - call.method === "POST" && call.url === "https://api.test/projects/demo-project/uploads" + call.method === "POST" && call.url === "https://93.184.216.34/projects/demo-project/uploads" ); assertExists(firstCreate); assertEquals(firstCreate.headers.get("Authorization"), "Bearer vf_config_token"); @@ -259,7 +275,7 @@ describe("VeryfrontCloudBlobStorage", () => { it("lists stored blobs (newest first) with sidecar filenames", async () => { const service = createMockUploadService(); const storage = new VeryfrontCloudBlobStorage({ - apiBaseUrl: "https://api.test", + apiBaseUrl: "https://93.184.216.34", apiToken: "vf_config_token", projectSlug: "demo-project", prefix: ".vf-test/", @@ -287,7 +303,7 @@ describe("VeryfrontCloudBlobStorage", () => { assertExists(byId.get(first.id)?.url); const listCall = service.fetchCalls.find((call) => - call.method === "GET" && call.url === "https://api.test/projects/demo-project/uploads" + call.method === "GET" && call.url === "https://93.184.216.34/projects/demo-project/uploads" ); assertExists(listCall); } finally { @@ -298,7 +314,7 @@ describe("VeryfrontCloudBlobStorage", () => { it("returns an empty list when nothing is stored", async () => { const service = createMockUploadService(); const storage = new VeryfrontCloudBlobStorage({ - apiBaseUrl: "https://api.test", + apiBaseUrl: "https://93.184.216.34", apiToken: "vf_config_token", projectSlug: "demo-project", prefix: ".vf-test/", @@ -315,7 +331,7 @@ describe("VeryfrontCloudBlobStorage", () => { it("falls back to upload metadata when the sidecar is missing", async () => { const service = createMockUploadService(); const storage = new VeryfrontCloudBlobStorage({ - apiBaseUrl: "https://api.test", + apiBaseUrl: "https://93.184.216.34", apiToken: "vf_config_token", projectSlug: "demo-project", prefix: ".vf-test/", @@ -337,7 +353,7 @@ describe("VeryfrontCloudBlobStorage", () => { assertEquals(stat.expiresAt, undefined); assertEquals( stat.url, - `https://download.test/demo-project/${encodeURIComponent(`.vf-test/${ref.id}.blob`)}`, + `https://93.184.216.36/demo-project/${encodeURIComponent(`.vf-test/${ref.id}.blob`)}`, ); assertEquals(await storage.exists(ref.id), true); } finally { @@ -345,10 +361,11 @@ describe("VeryfrontCloudBlobStorage", () => { } }); - it("resolves request-scoped auth and project slug without explicit config overrides", async () => { + it("keeps explicit blob endpoints paired with their explicit credential", async () => { const service = createMockUploadService(); const storage = new VeryfrontCloudBlobStorage({ - apiBaseUrl: "https://api.test", + apiBaseUrl: "https://93.184.216.34", + apiToken: "vf_scoped_token", prefix: ".vf-test/", now: () => FIXED_NOW, }); @@ -366,18 +383,54 @@ describe("VeryfrontCloudBlobStorage", () => { ); const createCall = service.fetchCalls.find((call) => - call.method === "POST" && call.url === "https://api.test/projects/request-project/uploads" + call.method === "POST" && + call.url === "https://93.184.216.34/projects/request-project/uploads" ); assertExists(createCall); - assertEquals(createCall.headers.get("Authorization"), "Bearer vf_request_token"); + assertEquals(createCall.headers.get("Authorization"), "Bearer vf_scoped_token"); } finally { service.restore(); } }); + it("never pairs host credentials with a source-selected cloud endpoint", async () => { + const originalApiBaseUrl = Deno.env.get("VERYFRONT_API_BASE_URL"); + const originalApiToken = Deno.env.get("VERYFRONT_API_TOKEN"); + Deno.env.set("VERYFRONT_API_BASE_URL", "https://93.184.216.34"); + Deno.env.set("VERYFRONT_API_TOKEN", "host-token"); + let fetchCalls = 0; + globalThis.fetch = (() => { + fetchCalls++; + return Promise.resolve(new Response("unexpected")); + }) as typeof fetch; + + try { + await runWithVeryfrontCloudContext( + { + apiBaseUrl: "https://93.184.216.35", + projectSlug: "tenant-project", + }, + async () => { + const storage = new VeryfrontCloudBlobStorage(); + await assertRejects( + () => storage.put("secret"), + Error, + "VeryfrontCloudBlobStorage requires auth", + ); + }, + ); + assertEquals(fetchCalls, 0); + } finally { + if (originalApiBaseUrl === undefined) Deno.env.delete("VERYFRONT_API_BASE_URL"); + else Deno.env.set("VERYFRONT_API_BASE_URL", originalApiBaseUrl); + if (originalApiToken === undefined) Deno.env.delete("VERYFRONT_API_TOKEN"); + else Deno.env.set("VERYFRONT_API_TOKEN", originalApiToken); + } + }); + it("rejects blob IDs containing path traversal sequences", async () => { const storage = new VeryfrontCloudBlobStorage({ - apiBaseUrl: "https://api.test", + apiBaseUrl: "https://93.184.216.34", apiToken: "vf_test", projectSlug: "my-project", }); @@ -400,4 +453,249 @@ describe("VeryfrontCloudBlobStorage", () => { "Invalid blob id", ); }); + + it("times out and cancels a stalled signed-download body", async () => { + let cancelled = false; + globalThis.fetch = ((input: string | URL | Request) => { + const url = new URL(String(input)); + if (url.origin === "https://93.184.216.34") { + return Promise.resolve(Response.json({ + signed_url: "https://93.184.216.35/download", + expires_at: FIXED_NOW.toISOString(), + })); + } + return Promise.resolve( + new Response( + new ReadableStream({ + pull: () => new Promise(() => {}), + cancel() { + cancelled = true; + }, + }), + ), + ); + }) as typeof fetch; + const storage = new VeryfrontCloudBlobStorage({ + apiBaseUrl: "https://93.184.216.34", + apiToken: "vf_test", + projectSlug: "project", + requestTimeoutMs: 5, + }); + + await assertRejects(() => storage.getText("blob-id"), Error, "timed out"); + assertEquals(cancelled, true); + }); + + it("rejects oversized signed-download bodies", async () => { + globalThis.fetch = ((input: string | URL | Request) => { + const url = new URL(String(input)); + if (url.origin === "https://93.184.216.34") { + return Promise.resolve(Response.json({ + signed_url: "https://93.184.216.35/download", + expires_at: FIXED_NOW.toISOString(), + })); + } + return Promise.resolve(new Response(new Uint8Array([1, 2, 3, 4, 5]))); + }) as typeof fetch; + const storage = new VeryfrontCloudBlobStorage({ + apiBaseUrl: "https://93.184.216.34", + apiToken: "vf_test", + projectSlug: "project", + maxResponseBytes: 4, + }); + + await assertRejects(() => storage.getBytes("blob-id"), RangeError, "exceeds 4 bytes"); + }); + + it("rejects known-size uploads before opening a network request", async () => { + let fetchCalls = 0; + globalThis.fetch = (() => { + fetchCalls++; + return Promise.resolve(new Response("unexpected")); + }) as typeof fetch; + const storage = new VeryfrontCloudBlobStorage({ + apiBaseUrl: "https://93.184.216.34", + apiToken: "vf_test", + projectSlug: "project", + maxUploadBytes: 4, + }); + + for ( + const data of [ + "12345", + new Uint8Array([1, 2, 3, 4, 5]), + new Blob([new Uint8Array([1, 2, 3, 4, 5])]), + ] + ) { + await assertRejects( + () => storage.put(data), + Error, + "upload exceeds 4 bytes", + ); + } + assertEquals(fetchCalls, 0); + }); + + it("bounds and cancels streamed upload preprocessing", async () => { + let cancelled = false; + let fetchCalls = 0; + globalThis.fetch = (() => { + fetchCalls++; + return Promise.resolve(new Response("unexpected")); + }) as typeof fetch; + const input = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2, 3])); + controller.enqueue(new Uint8Array([4, 5, 6])); + }, + cancel() { + cancelled = true; + }, + }); + const storage = new VeryfrontCloudBlobStorage({ + apiBaseUrl: "https://93.184.216.34", + apiToken: "vf_test", + projectSlug: "project", + maxUploadBytes: 5, + }); + + await assertRejects(() => storage.put(input), Error, "Blob upload exceeds 5 bytes"); + assertEquals(cancelled, true); + assertEquals(fetchCalls, 0); + }); + + it("does not await a project stream cancellation that never settles", async () => { + let cancelCalls = 0; + let fetchCalls = 0; + globalThis.fetch = (() => { + fetchCalls++; + return Promise.resolve(new Response("unexpected")); + }) as typeof fetch; + const input = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2])); + }, + cancel() { + cancelCalls++; + return new Promise(() => {}); + }, + }); + const storage = new VeryfrontCloudBlobStorage({ + apiBaseUrl: "https://93.184.216.34", + apiToken: "vf_test", + projectSlug: "project", + maxUploadBytes: 1, + }); + + await beforeDeadline( + assertRejects(() => storage.put(input), Error, "Blob upload exceeds 1 bytes"), + ); + assertEquals(cancelCalls, 1); + assertEquals(fetchCalls, 0); + }); + + it("bounds blob identity and metadata before consuming the upload stream", async () => { + let getterCalls = 0; + const accessorMetadata = Object.defineProperty({}, "secret", { + enumerable: true, + get() { + getterCalls++; + return "unexpected"; + }, + }); + const accessorOptions = Object.defineProperty({}, "id", { + enumerable: true, + get() { + getterCalls++; + return "unexpected"; + }, + }); + const cases: Array<{ options: Record; message: string }> = [ + { + options: { id: "a".repeat(257) }, + message: "Blob IDs must contain at most 256", + }, + { + options: { mimeType: "x".repeat(1_025) }, + message: "Blob mimeType exceeds 1024 bytes", + }, + { + options: { + metadata: Object.fromEntries( + Array.from({ length: 129 }, (_, index) => [`key-${index}`, "value"]), + ), + }, + message: "Blob metadata must contain at most 128 entries", + }, + { + options: { metadata: { key: "x".repeat(8 * 1024 + 1) } }, + message: 'Blob metadata value for "key" exceeds 8192 bytes', + }, + { + options: { metadata: accessorMetadata }, + message: "Blob metadata must contain enumerable data properties only", + }, + { + options: accessorOptions, + message: 'Blob storage option "id" must be a data property', + }, + ]; + + for (const testCase of cases) { + let pulls = 0; + let fetchCalls = 0; + globalThis.fetch = (() => { + fetchCalls++; + return Promise.resolve(new Response("unexpected")); + }) as typeof fetch; + const input = new ReadableStream( + { + pull(controller) { + pulls++; + controller.close(); + }, + }, + { highWaterMark: 0 }, + ); + const storage = new VeryfrontCloudBlobStorage({ + apiBaseUrl: "https://93.184.216.34", + apiToken: "vf_test", + projectSlug: "project", + }); + + await assertRejects( + () => storage.put(input, testCase.options as never), + Error, + testCase.message, + ); + assertEquals(pulls, 0); + assertEquals(fetchCalls, 0); + } + assertEquals(getterCalls, 0); + }); + + it("times out and cancels a stalled upload stream before network access", async () => { + let cancelled = false; + let fetchCalls = 0; + globalThis.fetch = (() => { + fetchCalls++; + return Promise.resolve(new Response("unexpected")); + }) as typeof fetch; + const input = new ReadableStream({ + pull: () => new Promise(() => {}), + cancel() { + cancelled = true; + }, + }); + const storage = new VeryfrontCloudBlobStorage({ + apiBaseUrl: "https://93.184.216.34", + apiToken: "vf_test", + projectSlug: "project", + requestTimeoutMs: 5, + }); + + await assertRejects(() => storage.put(input), Error, "timed out"); + assertEquals(cancelled, true); + assertEquals(fetchCalls, 0); + }); }); diff --git a/src/workflow/blob/veryfront-cloud-storage.ts b/src/workflow/blob/veryfront-cloud-storage.ts index ca167e6126..b3290c9e75 100644 --- a/src/workflow/blob/veryfront-cloud-storage.ts +++ b/src/workflow/blob/veryfront-cloud-storage.ts @@ -1,18 +1,37 @@ import { defineSchema, lazySchema } from "#veryfront/schemas/index.ts"; import type { InferSchema } from "#veryfront/extensions/schema/index.ts"; import { agentLogger as logger } from "#veryfront/utils"; +import { readResponseTextPrefix } from "#veryfront/utils/response-body.ts"; import { API_ERROR, CONFIG_INVALID, INVALID_ARGUMENT } from "#veryfront/errors"; import { - getVeryfrontCloudAuthToken, getVeryfrontCloudBootstrap, + getVeryfrontCloudHostBootstrap, getVeryfrontCloudProjectSlug, } from "#veryfront/platform/cloud/resolver.ts"; +import { + guardedOutboundFetch, + OutboundRequestBlockedError, +} from "#veryfront/security/http/outbound-fetch.ts"; import type { BlobRef, BlobStorage, StoreBlobOptions } from "./types.ts"; import { assertSafeBlobId, isSafeBlobId } from "./blob-id.ts"; const DEFAULT_PREFIX = ".veryfront/blobs/"; const DATA_SUFFIX = ".blob"; const META_SUFFIX = ".meta.json"; +const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; +const DEFAULT_MAX_RESPONSE_BYTES = 64 * 1024 * 1024; +const MAX_RESPONSE_BYTES = 128 * 1024 * 1024; +const DEFAULT_MAX_UPLOAD_BYTES = 64 * 1024 * 1024; +const MAX_UPLOAD_BYTES = 128 * 1024 * 1024; +const ERROR_RESPONSE_BYTES = 8 * 1024; +const MAX_BLOB_MIME_TYPE_BYTES = 1_024; +const MAX_BLOB_METADATA_ENTRIES = 128; +const MAX_BLOB_METADATA_KEY_BYTES = 256; +const MAX_BLOB_METADATA_VALUE_BYTES = 8 * 1024; +const MAX_BLOB_USER_METADATA_BYTES = 64 * 1024; +const MAX_BLOB_METADATA_ENVELOPE_BYTES = 4 * 1024; +const MAX_BLOB_METADATA_SIDECAR_BYTES = 128 * 1024; +const textEncoder = new TextEncoder(); const getUploadCreateResponseSchema = defineSchema((v) => v.object({ @@ -88,6 +107,12 @@ export interface VeryfrontCloudBlobStorageConfig { downloadTtl?: number; /** Time source for tests. */ now?: () => Date; + /** Full-operation outbound deadline, including response-body consumption. */ + requestTimeoutMs?: number; + /** Maximum decoded API or signed-download response body size. */ + maxResponseBytes?: number; + /** Maximum bytes accepted for one blob upload, including streamed input. */ + maxUploadBytes?: number; } interface ResolvedConfig { @@ -98,6 +123,237 @@ interface ResolvedConfig { defaultTtl?: number; downloadTtl?: number; now: () => Date; + requestTimeoutMs: number; + maxResponseBytes: number; + maxUploadBytes: number; +} + +function requirePositiveInteger(value: number, name: string, maximum: number): number { + if (!Number.isSafeInteger(value) || value <= 0 || value > maximum) { + throw CONFIG_INVALID.create({ + detail: `${name} must be a positive integer no greater than ${maximum}`, + }); + } + return value; +} + +function createRequestScope(timeoutMs: number): { signal: AbortSignal; dispose(): void } { + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(new DOMException("Blob request timed out", "TimeoutError")), + timeoutMs, + ); + return { signal: controller.signal, dispose: () => clearTimeout(timeout) }; +} + +async function readResponseBytes( + response: Response, + maximumBytes: number, + signal: AbortSignal, +): Promise { + if (!response.body) return new Uint8Array(); + return await readStreamBytes(response.body, maximumBytes, signal, "Blob response"); +} + +async function readStreamBytes( + stream: ReadableStream, + maximumBytes: number, + signal: AbortSignal, + label: string, +): Promise> { + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let length = 0; + let complete = false; + let failure: unknown; + const read = async (): Promise> => + await new Promise>((resolve, reject) => { + const onAbort = () => reject(signal.reason); + signal.addEventListener("abort", onAbort, { once: true }); + if (signal.aborted) { + onAbort(); + return; + } + reader.read().then(resolve, reject).finally(() => + signal.removeEventListener("abort", onAbort) + ); + }); + try { + for (;;) { + signal.throwIfAborted(); + const { done, value } = await read(); + if (done) { + complete = true; + break; + } + if (length > maximumBytes - value.byteLength) { + throw new RangeError(`${label} exceeds ${maximumBytes} bytes`); + } + chunks.push(value); + length += value.byteLength; + } + } catch (error) { + failure = error; + throw error; + } finally { + if (!complete) { + // A project-owned stream can return a cancellation promise that never + // settles. Start cleanup, but never let it extend the operation deadline. + try { + void reader.cancel(failure ?? signal.reason).catch(() => {}); + } catch { + // Cancellation is best effort after the bounded read has failed. + } + } + try { + reader.releaseLock(); + } catch { + // A pending hostile read can keep the lock until cancellation settles. + } + } + const bytes = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +} + +function invalidBlobOption(detail: string): never { + throw INVALID_ARGUMENT.create({ detail }); +} + +function boundedUtf8Length(value: string, maximumBytes: number, label: string): number { + // UTF-8 uses at least one byte per UTF-16 code unit for valid scalar text. + // Reject by code-unit length first so a huge string is never encoded merely + // to discover that it exceeds the byte ceiling. + if (value.length > maximumBytes) { + return invalidBlobOption(`${label} exceeds ${maximumBytes} bytes`); + } + const length = textEncoder.encode(value).byteLength; + if (length > maximumBytes) { + return invalidBlobOption(`${label} exceeds ${maximumBytes} bytes`); + } + return length; +} + +function normalizeMimeType(value: unknown): string { + if (typeof value !== "string" || value.length === 0 || value !== value.trim()) { + return invalidBlobOption("Blob mimeType must be a non-empty trimmed string"); + } + for (let index = 0; index < value.length; index++) { + const codeUnit = value.charCodeAt(index); + if (codeUnit <= 0x1f || codeUnit === 0x7f) { + return invalidBlobOption("Blob mimeType must not contain control characters"); + } + } + boundedUtf8Length(value, MAX_BLOB_MIME_TYPE_BYTES, "Blob mimeType"); + return value; +} + +function snapshotBlobMetadata( + value: unknown, + maximumBytes: number, +): Record | undefined { + if (value === undefined) return undefined; + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return invalidBlobOption("Blob metadata must be a plain string record"); + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + return invalidBlobOption("Blob metadata must be a plain string record"); + } + + const descriptors = Object.getOwnPropertyDescriptors(value); + const keys = Reflect.ownKeys(descriptors); + if (keys.length > MAX_BLOB_METADATA_ENTRIES) { + return invalidBlobOption( + `Blob metadata must contain at most ${MAX_BLOB_METADATA_ENTRIES} entries`, + ); + } + + const snapshot = Object.create(null) as Record; + let rawBytes = 0; + for (const key of keys) { + if (typeof key !== "string") { + return invalidBlobOption("Blob metadata keys must be strings"); + } + const descriptor = descriptors[key]; + if (!descriptor || !descriptor.enumerable || !("value" in descriptor)) { + return invalidBlobOption("Blob metadata must contain enumerable data properties only"); + } + if (typeof descriptor.value !== "string") { + return invalidBlobOption("Blob metadata values must be strings"); + } + const keyBytes = boundedUtf8Length( + key, + MAX_BLOB_METADATA_KEY_BYTES, + "Blob metadata key", + ); + const valueBytes = boundedUtf8Length( + descriptor.value, + MAX_BLOB_METADATA_VALUE_BYTES, + `Blob metadata value for "${key}"`, + ); + if (rawBytes > maximumBytes - keyBytes - valueBytes) { + return invalidBlobOption(`Blob metadata exceeds ${maximumBytes} bytes`); + } + rawBytes += keyBytes + valueBytes; + snapshot[key] = descriptor.value; + } + + const serialized = JSON.stringify(snapshot); + boundedUtf8Length(serialized, maximumBytes, "Blob metadata"); + return snapshot; +} + +function snapshotStoreBlobOptions(value: unknown): { + id: unknown; + mimeType: unknown; + metadata: unknown; + ttl: unknown; +} { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return invalidBlobOption("Blob storage options must be a plain object"); + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + return invalidBlobOption("Blob storage options must be a plain object"); + } + + const read = (name: string): unknown => { + const descriptor = Object.getOwnPropertyDescriptor(value, name); + if (!descriptor) return undefined; + if (!("value" in descriptor)) { + return invalidBlobOption(`Blob storage option "${name}" must be a data property`); + } + return descriptor.value; + }; + return { + id: read("id"), + mimeType: read("mimeType"), + metadata: read("metadata"), + ttl: read("ttl"), + }; +} + +function normalizeBlobTtl(value: unknown): number | undefined { + if (value === undefined) return undefined; + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { + return invalidBlobOption("Blob ttl must be a non-negative safe integer"); + } + return value; +} + +async function readErrorBody(response: Response, signal: AbortSignal): Promise { + try { + return (await readResponseTextPrefix(response, ERROR_RESPONSE_BYTES, signal, { + fatalUtf8: true, + })).text; + } catch { + return ""; + } } function normalizePrefix(prefix: string | undefined): string { @@ -156,23 +412,45 @@ async function attachSignedUrl( async function normalizeUploadBody( data: string | Uint8Array | Blob | ReadableStream, + maximumBytes: number, + signal: AbortSignal, ): Promise<{ body: BodyInit; size: number }> { + const assertWithinLimit = (size: number): void => { + if (size > maximumBytes) { + throw INVALID_ARGUMENT.create({ + detail: `Veryfront Cloud blob upload exceeds ${maximumBytes} bytes`, + }); + } + }; + + signal.throwIfAborted(); if (typeof data === "string") { const bytes = new TextEncoder().encode(data); + assertWithinLimit(bytes.byteLength); return { body: bytes, size: bytes.byteLength }; } if (data instanceof Uint8Array) { const bytes = Uint8Array.from(data); + assertWithinLimit(bytes.byteLength); return { body: bytes, size: bytes.byteLength }; } if (data instanceof Blob) { + assertWithinLimit(data.size); return { body: data, size: data.size }; } if (data instanceof ReadableStream) { - const bytes = new Uint8Array(await new Response(data).arrayBuffer()); + let bytes: Uint8Array; + try { + bytes = await readStreamBytes(data, maximumBytes, signal, "Blob upload"); + } catch (error) { + if (error instanceof RangeError) { + throw INVALID_ARGUMENT.create({ detail: error.message, cause: error }); + } + throw error; + } return { body: bytes, size: bytes.byteLength }; } @@ -193,68 +471,95 @@ export class VeryfrontCloudBlobStorage implements BlobStorage { options: StoreBlobOptions = {}, ): Promise { const resolved = this.resolveConfig(); - const id = options.id ?? crypto.randomUUID(); - const mimeType = options.mimeType ?? "application/octet-stream"; - const { body, size } = await normalizeUploadBody(data); - const createdAt = resolved.now(); - const ttl = options.ttl ?? resolved.defaultTtl; - const expiresAt = ttl ? new Date(createdAt.getTime() + ttl * 1000) : undefined; - - const blobRef: BlobRef = { - __kind: "blob", - id, - size, - mimeType, - createdAt, - expiresAt, - metadata: options.metadata, - }; - - const metadataPayload = BlobMetadataSchema.parse({ - version: 1, - id, - size, - mimeType, - createdAt: createdAt.toISOString(), - expiresAt: expiresAt?.toISOString(), - metadata: options.metadata, - }); - - const dataPath = this.getDataPath(id, resolved.prefix); - const metadataPath = this.getMetadataPath(id, resolved.prefix); - const metadataBytes = new TextEncoder().encode(JSON.stringify(metadataPayload)); - - await this.uploadFile(dataPath, mimeType, size, body, resolved); - + const scope = createRequestScope(resolved.requestTimeoutMs); try { - await this.uploadFile( - metadataPath, - "application/json", - metadataBytes.byteLength, - metadataBytes, - resolved, + const optionSnapshot = snapshotStoreBlobOptions(options); + const id = optionSnapshot.id ?? crypto.randomUUID(); + assertSafeBlobId(id); + const mimeType = normalizeMimeType( + optionSnapshot.mimeType ?? "application/octet-stream", ); - } catch (error) { - logger.warn("Failed to upload blob metadata sidecar, cleaning up primary upload", { + const metadata = snapshotBlobMetadata( + optionSnapshot.metadata, + Math.min(resolved.maxUploadBytes, MAX_BLOB_USER_METADATA_BYTES), + ); + const { body, size } = await normalizeUploadBody( + data, + resolved.maxUploadBytes, + scope.signal, + ); + const createdAt = resolved.now(); + const ttl = normalizeBlobTtl(optionSnapshot.ttl ?? resolved.defaultTtl); + const expiresAt = ttl ? new Date(createdAt.getTime() + ttl * 1000) : undefined; + + const blobRef: BlobRef = { + __kind: "blob", id, - dataPath, - error: error instanceof Error ? error.message : String(error), + size, + mimeType, + createdAt, + expiresAt, + metadata, + }; + + const metadataPayload = BlobMetadataSchema.parse({ + version: 1, + id, + size, + mimeType, + createdAt: createdAt.toISOString(), + expiresAt: expiresAt?.toISOString(), + metadata, }); + const dataPath = this.getDataPath(id, resolved.prefix); + const metadataPath = this.getMetadataPath(id, resolved.prefix); + const metadataBytes = new TextEncoder().encode(JSON.stringify(metadataPayload)); + const metadataSidecarLimit = Math.min( + MAX_BLOB_METADATA_SIDECAR_BYTES, + resolved.maxUploadBytes + MAX_BLOB_METADATA_ENVELOPE_BYTES, + ); + if (metadataBytes.byteLength > metadataSidecarLimit) { + throw INVALID_ARGUMENT.create({ + detail: `Blob metadata sidecar exceeds ${metadataSidecarLimit} bytes`, + }); + } + + await this.uploadFile(dataPath, mimeType, size, body, resolved, scope.signal); + try { - await this.deleteUpload(dataPath, resolved); - } catch (cleanupError) { - logger.warn("Failed to clean up primary upload after metadata failure", { + await this.uploadFile( + metadataPath, + "application/json", + metadataBytes.byteLength, + metadataBytes, + resolved, + scope.signal, + ); + } catch (error) { + logger.warn("Failed to upload blob metadata sidecar, cleaning up primary upload", { id, dataPath, - error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError), + error: error instanceof Error ? error.message : String(error), }); + + try { + await this.deleteUpload(dataPath, resolved); + } catch (cleanupError) { + logger.warn("Failed to clean up primary upload after metadata failure", { + id, + dataPath, + error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError), + }); + } + + throw error; } - throw error; + return blobRef; + } finally { + scope.dispose(); } - - return blobRef; } async getStream(id: string): Promise { @@ -352,8 +657,20 @@ export class VeryfrontCloudBlobStorage implements BlobStorage { } private resolveConfig(): ResolvedConfig { - const apiBaseUrl = this.config.apiBaseUrl ?? getVeryfrontCloudBootstrap().apiBaseUrl; - const apiToken = this.config.apiToken ?? getVeryfrontCloudAuthToken(); + const bootstrap = getVeryfrontCloudBootstrap(); + const hostBootstrap = getVeryfrontCloudHostBootstrap(); + if (this.config.apiBaseUrl && !this.config.apiToken) { + throw CONFIG_INVALID.create({ + detail: + "VeryfrontCloudBlobStorage apiBaseUrl requires an explicit apiToken. A caller-selected endpoint cannot use request- or host-owned credentials.", + }); + } + const connection = this.config.apiBaseUrl && this.config.apiToken + ? { apiBaseUrl: this.config.apiBaseUrl, apiToken: this.config.apiToken } + : this.config.apiToken + ? { apiBaseUrl: hostBootstrap.apiBaseUrl, apiToken: this.config.apiToken } + : { apiBaseUrl: bootstrap.apiBaseUrl, apiToken: bootstrap.apiToken }; + const { apiBaseUrl, apiToken } = connection; const projectSlug = this.config.projectSlug ?? getVeryfrontCloudProjectSlug(); if (!apiToken) { @@ -378,6 +695,21 @@ export class VeryfrontCloudBlobStorage implements BlobStorage { defaultTtl: this.config.defaultTtl, downloadTtl: this.config.downloadTtl, now: this.config.now ?? (() => new Date()), + requestTimeoutMs: requirePositiveInteger( + this.config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, + "requestTimeoutMs", + DEFAULT_REQUEST_TIMEOUT_MS, + ), + maxResponseBytes: requirePositiveInteger( + this.config.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES, + "maxResponseBytes", + MAX_RESPONSE_BYTES, + ), + maxUploadBytes: requirePositiveInteger( + this.config.maxUploadBytes ?? DEFAULT_MAX_UPLOAD_BYTES, + "maxUploadBytes", + MAX_UPLOAD_BYTES, + ), }; } @@ -397,6 +729,7 @@ export class VeryfrontCloudBlobStorage implements BlobStorage { size: number, body: BodyInit, resolved: ResolvedConfig, + signal?: AbortSignal, ): Promise { const upload = UploadCreateResponseSchema.parse( await this.requestJson( @@ -410,6 +743,7 @@ export class VeryfrontCloudBlobStorage implements BlobStorage { content_type: mimeType, size, }), + signal, }, ), ); @@ -417,20 +751,31 @@ export class VeryfrontCloudBlobStorage implements BlobStorage { const headers = new Headers(upload.required_headers); if (!headers.has("Content-Type")) headers.set("Content-Type", mimeType); - const response = await fetch(upload.file_upload_url, { - method: "PUT", - headers, - body, - }); - - if (!response.ok) { - const errorBody = await response.text().catch(() => ""); - throw API_ERROR.create({ - detail: - `Veryfront Cloud upload failed for "${path}": ${response.status} ${response.statusText}${ - errorBody ? ` - ${errorBody}` : "" - }`, + const scope = signal ? undefined : createRequestScope(resolved.requestTimeoutMs); + const requestSignal = signal ?? scope?.signal; + if (!requestSignal) throw new TypeError("Blob upload request signal is unavailable"); + let response: Response; + try { + response = await guardedOutboundFetch(upload.file_upload_url, { + method: "PUT", + headers, + body, + redirect: "error", + signal: requestSignal, }); + + if (!response.ok) { + const errorBody = await readErrorBody(response, requestSignal); + throw API_ERROR.create({ + detail: + `Veryfront Cloud upload failed for "${path}": ${response.status} ${response.statusText}${ + errorBody ? ` - ${errorBody}` : "" + }`, + }); + } + void response.body?.cancel().catch(() => {}); + } finally { + scope?.dispose(); } } @@ -499,20 +844,32 @@ export class VeryfrontCloudBlobStorage implements BlobStorage { const download = await this.getDownloadUrl(path, resolved); if (!download) return null; - const response = await fetch(download.signedUrl); - if (response.status === 404) return null; - - if (!response.ok) { - const errorBody = await response.text().catch(() => ""); - throw API_ERROR.create({ - detail: - `Veryfront Cloud download failed for "${path}": ${response.status} ${response.statusText}${ - errorBody ? ` - ${errorBody}` : "" - }`, + const scope = createRequestScope(resolved.requestTimeoutMs); + try { + const response = await guardedOutboundFetch(download.signedUrl, { + redirect: "error", + signal: scope.signal, }); + if (response.status === 404) { + void response.body?.cancel().catch(() => {}); + return null; + } + if (!response.ok) { + const errorBody = await readErrorBody(response, scope.signal); + throw API_ERROR.create({ + detail: + `Veryfront Cloud download failed for "${path}": ${response.status} ${response.statusText}${ + errorBody ? ` - ${errorBody}` : "" + }`, + }); + } + const bytes = await readResponseBytes(response, resolved.maxResponseBytes, scope.signal); + return new Blob([ + bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer, + ]).stream(); + } finally { + scope.dispose(); } - - return response.body; } private async downloadUploadText( @@ -533,35 +890,57 @@ export class VeryfrontCloudBlobStorage implements BlobStorage { body?: BodyInit; allowNotFound?: boolean; expectEmptyBody?: boolean; + signal?: AbortSignal; } = {}, ): Promise { const headers = new Headers(options.headers); headers.set("Authorization", `Bearer ${resolved.apiToken}`); - const response = await fetch(joinUrl(resolved.apiBaseUrl, path), { - method, - headers, - body: options.body, - }); + const scope = options.signal ? undefined : createRequestScope(resolved.requestTimeoutMs); + const signal = options.signal ?? scope?.signal; + if (!signal) throw new TypeError("Blob request signal is unavailable"); + try { + const response = await guardedOutboundFetch( + joinUrl(resolved.apiBaseUrl, path), + { method, headers, body: options.body, redirect: "error", signal }, + { + authorizeUrl: (target) => { + if (target.origin !== new URL(resolved.apiBaseUrl).origin) { + throw new OutboundRequestBlockedError( + "Veryfront Cloud Blob request blocked: destination origin is not authorized", + ); + } + }, + }, + ); - if (options.allowNotFound && response.status === 404) { - return null; - } + if (options.allowNotFound && response.status === 404) { + void response.body?.cancel().catch(() => {}); + return null; + } - if (!response.ok) { - const errorBody = await response.text().catch(() => ""); - throw API_ERROR.create({ - detail: - `Veryfront Cloud request failed: ${method} ${path} -> ${response.status} ${response.statusText}${ - errorBody ? ` - ${errorBody}` : "" - }`, - }); - } + if (!response.ok) { + const errorBody = await readErrorBody(response, signal); + throw API_ERROR.create({ + detail: + `Veryfront Cloud request failed: ${method} ${path} -> ${response.status} ${response.statusText}${ + errorBody ? ` - ${errorBody}` : "" + }`, + }); + } - if (options.expectEmptyBody || response.status === 204) { - return null; - } + if (options.expectEmptyBody || response.status === 204) { + void response.body?.cancel().catch(() => {}); + return null; + } - return response.json(); + return JSON.parse( + new TextDecoder().decode( + await readResponseBytes(response, resolved.maxResponseBytes, signal), + ), + ); + } finally { + scope?.dispose(); + } } } diff --git a/src/workflow/discovery/workflow-discovery.test.ts b/src/workflow/discovery/workflow-discovery.test.ts index f7ec112b6a..2bfa19ab63 100644 --- a/src/workflow/discovery/workflow-discovery.test.ts +++ b/src/workflow/discovery/workflow-discovery.test.ts @@ -4,7 +4,18 @@ import { afterAll, afterEach, describe, it } from "#veryfront/testing/bdd.ts"; import type { FileSystemAdapter, RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; import { stop as stopEsbuild } from "veryfront/extensions/bundler"; import { clearTranspileCache } from "#veryfront/discovery/transpiler.ts"; -import { discoverWorkflows, findWorkflowById } from "./workflow-discovery.ts"; +import { + discoverWorkflows as discoverWorkflowsRaw, + findWorkflowById as findWorkflowByIdRaw, +} from "./workflow-discovery.ts"; + +const discoverWorkflows: typeof discoverWorkflowsRaw = (options) => + discoverWorkflowsRaw({ ...options, allowHostProjectCodeExecution: true }); +const findWorkflowById: typeof findWorkflowByIdRaw = (workflowId, options) => + findWorkflowByIdRaw(workflowId, { + ...options, + allowHostProjectCodeExecution: true, + }); function createMockAdapter(files: Record): FileSystemAdapter { const normalize = (path: string): string => path.replace(/^\/project\/?/, "").replace(/^\/+/, ""); diff --git a/src/workflow/discovery/workflow-discovery.ts b/src/workflow/discovery/workflow-discovery.ts index d58f4c7e32..b8697a7552 100644 --- a/src/workflow/discovery/workflow-discovery.ts +++ b/src/workflow/discovery/workflow-discovery.ts @@ -67,6 +67,9 @@ export interface WorkflowDiscoveryOptions { /** Enable debug logging */ debug?: boolean; + + /** Explicit host-owned capability for a trusted local or dedicated runtime. */ + allowHostProjectCodeExecution?: boolean; } /** @@ -127,6 +130,7 @@ export async function discoverWorkflows( config, workflowsDir = "workflows", debug = false, + allowHostProjectCodeExecution, } = options; const workflows: DiscoveredWorkflow[] = []; @@ -170,6 +174,7 @@ export async function discoverWorkflows( const module = await importDiscoveryModule(file.path, { adapter, projectDir, + allowHostProjectCodeExecution, }); // Extract workflows from module exports diff --git a/src/workflow/worker/dynamic-run-entrypoint.ts b/src/workflow/worker/dynamic-run-entrypoint.ts index 926ff1ece8..78c63e6e52 100644 --- a/src/workflow/worker/dynamic-run-entrypoint.ts +++ b/src/workflow/worker/dynamic-run-entrypoint.ts @@ -159,6 +159,7 @@ export async function runDynamicWorkflowRun( cacheKey: tenant.projectId ?? tenant.projectSlug, verbose: debug, sourceIntegrationPolicy, + allowHostProjectCodeExecution: true, }); if (discoveryResult.errors.length > 0 && debug) { diff --git a/tests/docs/guide-examples.test.ts b/tests/docs/guide-examples.test.ts index 87e5ca7831..88647fa905 100644 --- a/tests/docs/guide-examples.test.ts +++ b/tests/docs/guide-examples.test.ts @@ -332,7 +332,7 @@ describe("Guide: runs.mdx", () => { await withMockedFetch(responses, async (calls) => { const runs = createRunsClient({ - apiUrl: "https://api.test.com", + apiUrl: "https://93.184.216.34", authToken: "test-token", projectReference: "dreamy-haven", }); diff --git a/tests/e2e/regressions/rsc-proxy-hydration.test.ts b/tests/e2e/regressions/rsc-proxy-hydration.test.ts index d63906f5d2..54301d67dc 100644 --- a/tests/e2e/regressions/rsc-proxy-hydration.test.ts +++ b/tests/e2e/regressions/rsc-proxy-hydration.test.ts @@ -198,7 +198,7 @@ export default function Page() { ); } -function getProxyHeaders( +function getHostedHeaders( environment: "preview" | "production", ): Record { return { @@ -211,15 +211,26 @@ function getProxyHeaders( }; } -async function withProxyBrowserPage( +async function withHostedBrowserPage( browser: import("npm:playwright").Browser, context: TestProjectContext, + topology: "dedicated" | "shared", headers: Record, run: ( page: import("npm:playwright").Page, diagnostics: import("../../_helpers/playwright.ts").BrowserDiagnostics, + response: import("npm:playwright").Response, ) => Promise, ): Promise { + const environment = headers["x-environment"]; + let dedicatedEnvironment: "preview" | "production" | undefined; + if (topology === "dedicated") { + if (environment !== "preview" && environment !== "production") { + throw new TypeError("Dedicated test runtimes require an explicit environment"); + } + dedicatedEnvironment = environment; + } + const port = await context.allocatePort(); const controller = new AbortController(); const previousDispatchPublicKey = Deno.env.get(DISPATCH_PUBLIC_KEY_ENV); @@ -237,20 +248,22 @@ async function withProxyBrowserPage( const adapter = await runtime.get(); const bootstrap = await bootstrapProd(context.projectDir, adapter); disposeBootstrap = bootstrap.dispose; - bootstrap.config = validateVeryfrontConfig({ - experimental: { rsc: true }, - fs: { - type: "veryfront-api", - veryfront: { - proxyMode: true, - apiBaseUrl: "https://api.veryfront.com", + if (topology === "shared") { + bootstrap.config = validateVeryfrontConfig({ + experimental: { rsc: true }, + fs: { + type: "veryfront-api", + veryfront: { + proxyMode: true, + apiBaseUrl: "https://api.veryfront.com", + }, }, - }, - }); - await writeTextFile( - join(context.projectDir, "veryfront.config.js"), - PROXY_MODE_CONFIG_SOURCE, - ); + }); + await writeTextFile( + join(context.projectDir, "veryfront.config.js"), + PROXY_MODE_CONFIG_SOURCE, + ); + } server = await startProductionServer({ projectDir: context.projectDir, @@ -259,26 +272,31 @@ async function withProxyBrowserPage( signal: controller.signal, defaultProjectSlug: context.projectId, defaultProjectId: context.projectId, + defaultEnvironment: dedicatedEnvironment, bootstrapResult: bootstrap, }); await server.ready; await registerTailwindExtension(); await waitForReady(port); - const browserContext = await browser.newContext({ - extraHTTPHeaders: { + // A dedicated runtime gets its environment and project identity from + // host-owned startup options. Forwarded project headers belong only to the + // shared proxy topology, where the dispatch signature establishes trust. + const extraHTTPHeaders = topology === "shared" + ? { ...headers, "x-veryfront-dispatch-jws": await mintTrustedDispatchJws(context.projectId), - }, - }); + } + : undefined; + const browserContext = await browser.newContext({ extraHTTPHeaders }); await installEsmShCorsShim(browserContext); try { const page = await browserContext.newPage(); const diagnostics = captureBrowserDiagnostics(page); const response = await page.goto(`http://127.0.0.1:${port}/`); - assertEquals(response?.status(), 200); - await run(page, diagnostics); + if (!response) throw new Error("Browser navigation did not produce an HTTP response"); + await run(page, diagnostics, response); } finally { await browserContext.unrouteAll({ behavior: "ignoreErrors" }); await browserContext.close(); @@ -295,6 +313,25 @@ async function withProxyBrowserPage( } } +async function assertSharedRuntimeExecutionUnavailable( + response: import("npm:playwright").Response, +): Promise { + assertEquals(response.status(), 503); + assertEquals(response.headers()["cache-control"], "no-store"); + + const problem = await response.json() as { + type?: string; + status?: number; + detail?: string; + }; + assertEquals( + problem.type, + "https://veryfront.com/docs/errors/project-execution-unavailable", + ); + assertEquals(problem.status, 503); + assertEquals(problem.detail?.startsWith("Shared runtimes"), true); +} + async function installEsmShCorsShim( browserContext: import("npm:playwright").BrowserContext, ): Promise { @@ -511,22 +548,50 @@ describe( } }); - it("hydrates a remote-production client page and becomes interactive", async () => { + it("fails closed for production rendering in a shared runtime", async () => { const browser = await launchChromium(); if (!browser) return; try { - await withTestContext("rsc-proxy-browser-hydration", async (context) => { + await withTestContext("rsc-shared-browser-boundary", async (context) => { await writeClientCounterApp( context.projectDir, PROXY_MODE_CONFIG_SOURCE, ); - await withProxyBrowserPage( + await withHostedBrowserPage( browser, context, - getProxyHeaders("production"), - async (page, diagnostics) => { + "shared", + getHostedHeaders("production"), + async (_page, _diagnostics, response) => { + await assertSharedRuntimeExecutionUnavailable(response); + }, + ); + }); + } finally { + await browser.close(); + } + }); + + it("hydrates a dedicated production client page and becomes interactive", async () => { + const browser = await launchChromium(); + if (!browser) return; + + try { + await withTestContext("rsc-dedicated-browser-hydration", async (context) => { + await writeClientCounterApp( + context.projectDir, + LOCAL_RSC_CONFIG_SOURCE, + ); + + await withHostedBrowserPage( + browser, + context, + "dedicated", + getHostedHeaders("production"), + async (page, diagnostics, response) => { + assertEquals(response.status(), 200); await assertCounterHydration(page, diagnostics, { expectedStrategy: "rsc-module", expectedModulePath: "/_veryfront/rsc/module?", @@ -544,27 +609,24 @@ describe( } }); - it("hydrates a preview client page and becomes interactive", async () => { + it("hydrates a dedicated preview client page and becomes interactive", async () => { const browser = await launchChromium(); if (!browser) return; try { - await withTestContext("rsc-preview-browser-hydration", async (context) => { + await withTestContext("rsc-dedicated-preview-hydration", async (context) => { await writeClientCounterApp( context.projectDir, - PROXY_MODE_CONFIG_SOURCE, + LOCAL_RSC_CONFIG_SOURCE, ); - await withProxyBrowserPage( + await withHostedBrowserPage( browser, context, - getProxyHeaders("preview"), - async (page, diagnostics) => { - // Preview pods hydrate via the RSC module endpoint, same as - // production. The `fs` strategy + `/_veryfront/fs/` module - // loader are dev-only surfaces gated on `isLocalProject` under - // VULN-SRV-1/2 — a trusted `x-environment: preview` header - // cannot unlock them because they serve raw project source. + "dedicated", + getHostedHeaders("preview"), + async (page, diagnostics, response) => { + assertEquals(response.status(), 200); await assertCounterHydration(page, diagnostics, { expectedStrategy: "rsc-module", expectedModulePath: "/_veryfront/rsc/module?", @@ -582,7 +644,7 @@ describe( } }); - it("keeps preview chat pages styled after hydration", async () => { + it("keeps dedicated preview chat pages styled after hydration", async () => { const browser = await launchChromium(); if (!browser) return; @@ -590,14 +652,16 @@ describe( await withTestContext("rsc-preview-chat-browser-styling", async (context) => { await writePreviewChatApp( context.projectDir, - PROXY_MODE_CONFIG_SOURCE, + LOCAL_RSC_CONFIG_SOURCE, ); - await withProxyBrowserPage( + await withHostedBrowserPage( browser, context, - getProxyHeaders("preview"), - async (page, diagnostics) => { + "dedicated", + getHostedHeaders("preview"), + async (page, diagnostics, response) => { + assertEquals(response.status(), 200); await assertPreviewChatStyling(page); const hydrationErrors = findHydrationOrCspFailures( diff --git a/tests/integration/compiled-binary-e2e.test.ts b/tests/integration/compiled-binary-e2e.test.ts index cd80191430..55535fb707 100644 --- a/tests/integration/compiled-binary-e2e.test.ts +++ b/tests/integration/compiled-binary-e2e.test.ts @@ -663,7 +663,11 @@ export default function RootLayout({ children }: { children: React.ReactNode }) const response = await fetch(`http://127.0.0.1:${server.port}/`); const html = await response.text(); - assertEquals(response.status, 200, "Should return 200"); + assertEquals( + response.status, + 200, + `Should return 200\n${server.logs.join("").slice(-16000)}`, + ); const normalizedHtml = stripReactSSRMarkers(html); // The layout rendered the page's getServerData value at SSR — proving // server data reaches a layout via usePageContext().data without drilling. @@ -695,14 +699,19 @@ export function GET() { await withServer(projectDir, async (server) => { const response = await fetch(`http://127.0.0.1:${server.port}/api/hello`); - const json = await response.json(); + const body = await response.text(); - assertEquals(response.status, 200, "Should return 200"); + assertEquals( + response.status, + 200, + `Should return 200\nResponse body: ${body}\n${server.logs.join("").slice(-16000)}`, + ); assertEquals( response.headers.get("content-type")?.includes("application/json"), true, "Should be JSON", ); + const json = JSON.parse(body); assertEquals(json.message, "Hello from API", "Should return correct message"); assert(json.timestamp > 0, "Should have timestamp"); }); @@ -739,7 +748,11 @@ export function GET() { await withServer(projectDir, async (server) => { const response = await fetch(`http://127.0.0.1:${server.port}/api/events`); - assertEquals(response.status, 200, "Should start the SSE response"); + assertEquals( + response.status, + 200, + `Should start the SSE response\n${server.logs.join("").slice(-16000)}`, + ); const reader = response.body?.getReader(); assert(reader, "Should expose the SSE response body"); @@ -815,7 +828,11 @@ export function GET() { await withServer(projectDir, async (server) => { const response = await fetch(`http://127.0.0.1:${server.port}/api/users/list`); - assertEquals(response.status, 200, "Should return 200"); + assertEquals( + response.status, + 200, + `Should return 200\n${server.logs.join("").slice(-16000)}`, + ); const json = await response.json(); assertEquals(json.count, 2, "Should return user count"); @@ -2275,7 +2292,11 @@ export function GET() { await withServer(projectDir, async (server) => { const response = await fetch(`http://127.0.0.1:${server.port}/api/status`); - assertEquals(response.status, 201, "Should return custom status 201"); + assertEquals( + response.status, + 201, + `Should return custom status 201\n${server.logs.join("").slice(-16000)}`, + ); const json = await response.json(); assertEquals(json.status, "ok", "Should return ok status"); diff --git a/tests/integration/core/api-handler.test.ts b/tests/integration/core/api-handler.test.ts index 9f62c2ef53..fd00319dbd 100644 --- a/tests/integration/core/api-handler.test.ts +++ b/tests/integration/core/api-handler.test.ts @@ -9,13 +9,35 @@ import { mkdir, remove, writeTextFile } from "#veryfront/testing/deno-compat"; import { getAdapter } from "#veryfront/platform/adapters/detect.ts"; import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; import { APIRouteHandler } from "#veryfront/routing/api/index.ts"; +import type { HandlerContext } from "#veryfront/types"; import { withTestContext } from "../../_helpers/context.ts"; // Track all handlers to clean up after tests const handlers: APIRouteHandler[] = []; -function createHandler(projectDir: string, adapter?: RuntimeAdapter): APIRouteHandler { - const handler = new APIRouteHandler(projectDir, adapter); +class TrustedLocalAPIRouteHandler extends APIRouteHandler { + readonly #context: HandlerContext; + + constructor(projectDir: string, adapter: RuntimeAdapter) { + super(projectDir, adapter); + this.#context = { + projectDir, + adapter, + securityConfig: null, + cspUserHeader: null, + isLocalProject: true, + }; + } + + override handle(request: Request, context?: HandlerContext): Promise { + return super.handle(request, context ?? this.#context); + } +} + +function createHandler(projectDir: string, adapter: RuntimeAdapter): APIRouteHandler { + // This suite writes and executes source in a local temporary project. Keep + // that trusted-local capability explicit so hosted execution remains closed. + const handler = new TrustedLocalAPIRouteHandler(projectDir, adapter); handlers.push(handler); return handler; } diff --git a/tests/integration/server/production-server.test.ts b/tests/integration/server/production-server.test.ts index 10215b3774..d4c3315139 100644 --- a/tests/integration/server/production-server.test.ts +++ b/tests/integration/server/production-server.test.ts @@ -497,7 +497,7 @@ describe( } }); - it("loads shared proxy middleware after trusted request context is resolved", async () => { + it("refuses shared proxy middleware after trusted request context is resolved", async () => { const projectSlug = "shared-middleware-project"; const projectId = "shared-middleware-project-id"; const releaseId = "shared-middleware-release"; @@ -507,8 +507,12 @@ describe( headers: { "x-shared-middleware": "applied" }, }); }`; + let middlewareSourceReads = 0; const readMiddlewareSource = (path: string) => { - if (path === "/app/middleware.ts") return middlewareSource; + if (path === "/app/middleware.ts") { + middlewareSourceReads++; + return middlewareSource; + } throw new Deno.errors.NotFound(path); }; const projectFs = { @@ -516,7 +520,9 @@ describe( readFile: (path: string) => Promise.resolve(readMiddlewareSource(path)), readTextFile: (path: string) => Promise.resolve(readMiddlewareSource(path)), readOptionalTextFile: (path: string) => - Promise.resolve(path === "/app/middleware.ts" ? middlewareSource : undefined), + Promise.resolve( + path === "/app/middleware.ts" ? readMiddlewareSource(path) : undefined, + ), }; const resolvedContexts: Array<{ projectSlug: string; @@ -606,9 +612,12 @@ describe( }), ); - assertEquals(response.status, 418); - assertEquals(await response.text(), "shared middleware"); - assertEquals(response.headers.get("x-shared-middleware"), "applied"); + assertEquals(response.status, 503); + assertEquals(response.headers.get("cache-control"), "no-store"); + assertEquals(response.headers.get("content-type"), "application/problem+json"); + const problem = await response.json(); + assertEquals(problem.title, "Project execution unavailable"); + assertEquals(middlewareSourceReads, 0); assert( resolvedContexts.length >= 1 && resolvedContexts.every((context) => diff --git a/tests/node/resolver-hooks.mjs b/tests/node/resolver-hooks.mjs index b45fac14ca..17a32e8b12 100644 --- a/tests/node/resolver-hooks.mjs +++ b/tests/node/resolver-hooks.mjs @@ -16,6 +16,8 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const projectRoot = pathResolve(__dirname, "../.."); const importMap = {}; +const workspacePackageMap = {}; +const workspacePackagePatterns = []; const stdImportMap = { "#std/assert": "./src/testing/assert.ts", @@ -60,12 +62,62 @@ const fallbackAliasMap = { ...reactImportMap, }; +function registerWorkspaceExport(packageName, exportName, target, workspaceDir) { + if (typeof target !== "string") return; + const suffix = exportName === "." + ? "" + : exportName.startsWith("./") + ? `/${exportName.slice(2)}` + : null; + if (suffix === null) return; + + const specifier = `${packageName}${suffix}`; + const absoluteTarget = pathResolve(workspaceDir, target); + if (specifier.includes("*") && absoluteTarget.includes("*")) { + const [specifierPrefix, specifierSuffix = ""] = specifier.split("*"); + const [targetPrefix, targetSuffix = ""] = absoluteTarget.split("*"); + workspacePackagePatterns.push({ + specifierPrefix, + specifierSuffix, + targetPrefix, + targetSuffix, + }); + return; + } + if (!specifier.includes("*")) workspacePackageMap[specifier] = absoluteTarget; +} + +function registerWorkspacePackage(workspaceEntry) { + if (typeof workspaceEntry !== "string") return; + const workspaceDir = pathResolve(projectRoot, workspaceEntry); + try { + const config = JSON.parse(readFileSync(pathResolve(workspaceDir, "deno.json"), "utf-8")); + if (typeof config.name !== "string" || !config.name) return; + if (typeof config.exports === "string") { + registerWorkspaceExport(config.name, ".", config.exports, workspaceDir); + return; + } + if (!config.exports || typeof config.exports !== "object" || Array.isArray(config.exports)) { + return; + } + for (const [exportName, target] of Object.entries(config.exports)) { + registerWorkspaceExport(config.name, exportName, target, workspaceDir); + } + } catch { + // Invalid workspace metadata is surfaced by the normal Node resolver when + // a test imports that package; unrelated test files remain runnable. + } +} + try { const denoJsonPath = pathResolve(projectRoot, "deno.json"); const denoJson = JSON.parse(readFileSync(denoJsonPath, "utf-8")); for (const [key, value] of Object.entries(denoJson.imports || {})) { if (typeof value === "string") importMap[key] = value; } + for (const workspaceEntry of denoJson.workspace || []) { + registerWorkspacePackage(workspaceEntry); + } } catch (e) { console.warn("Could not read deno.json:", e.message); } @@ -177,6 +229,25 @@ function resolveAliasSpecifier(specifier) { return null; } +function resolveWorkspacePackage(specifier) { + const exact = workspacePackageMap[specifier]; + if (exact) return findActualFile(exact); + for (const pattern of workspacePackagePatterns) { + if ( + !specifier.startsWith(pattern.specifierPrefix) || + !specifier.endsWith(pattern.specifierSuffix) + ) { + continue; + } + const matched = specifier.slice( + pattern.specifierPrefix.length, + specifier.length - pattern.specifierSuffix.length, + ); + return findActualFile(`${pattern.targetPrefix}${matched}${pattern.targetSuffix}`); + } + return null; +} + function resolveJsrStdSpecifier(specifier) { if (!specifier.startsWith("jsr:@std/")) return null; const jsrSubpath = specifier.slice("jsr:@std/".length); @@ -205,6 +276,14 @@ export async function resolve(specifier, context, nextResolve) { }; } + const workspacePath = resolveWorkspacePackage(cleanSpecifier); + if (workspacePath) { + return { + shortCircuit: true, + url: pathToFileURL(workspacePath).href + querySuffix, + }; + } + // Handle npm: protocol (Deno-specific) -> strip npm: prefix if (cleanSpecifier.startsWith("npm:")) { const packageSpec = cleanSpecifier.slice(4); diff --git a/tests/node/run-tests.mjs b/tests/node/run-tests.mjs index bb0af30c9d..89f0d06a2e 100644 --- a/tests/node/run-tests.mjs +++ b/tests/node/run-tests.mjs @@ -87,6 +87,10 @@ function buildNodeArgs(files, perShardConcurrency) { } const env = { ...process.env }; +// Match the Deno test tasks' explicit host-test contract. This keeps guarded +// outbound consumers on deterministic injected transports in Node tests while +// production processes, which never run through this harness, remain pinned. +env.DENO_TESTING = "1"; if (!env.VF_DISABLE_LRU_INTERVAL) env.VF_DISABLE_LRU_INTERVAL = "1"; if (!env.NODE_ENV) env.NODE_ENV = "production"; if (!env.LOG_FORMAT) env.LOG_FORMAT = "text";