From 49e0f657681da603c864a180e96d9f4df064fd23 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Thu, 6 Aug 2026 14:13:59 +0200 Subject: [PATCH 1/3] fix(security): downgrade WORKER_ISOLATION_API only under an explicit host-execution grant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every project API route on the compiled production server returned HTTP 500 with a masked body "Handler not found". The real reason appeared only in server logs: "Isolated API route preparation is unavailable in this compiled runtime". Production carries two contradictory host-owned postures, both "1": VERYFRONT_HOST_ALLOW_PROJECT_EXECUTION (tenant code may run in this shared process) and WORKER_ISOLATION_API (tenant code must never run in this shared process). The second is unimplementable in this build, so useHostRealm resolves false, prepareHandlerModule throws, and handler.ts flattens it to a 500. WORKER_ISOLATION_API=1 was inert in production for its entire life: before #3285 there was no isolation branch for API routes at all. #3285 introduced both useHostRealm and prepareHandlerModule in one commit and made a long-inert production flag fatal. The rule this encodes: a build that cannot honour WORKER_ISOLATION_API may fall back to the host realm only where an operator has explicitly granted host project execution; absent that grant the request fails closed with the typed project-execution-unavailable 503 naming the flag — never a masked 500, and never a silent fallback. The compiled binary does ship esbuild and does transpile tenant API routes on every host-realm request, so preparation does not fail for want of a transpiler. It fails on linkage: the compiled rewrite emits relative ./_vf_*.mjs specifiers that a worker data: URL cannot resolve. That reason is now recorded on the capability predicate so the refusal is not deleted as a leftover. The fix lives at the flag accessor, not handler.ts:267. route-executor.ts recomputes the isolation decision independently, so a handler-only fix moves the 500 rather than removing it. No test in this repo had ever set WORKER_ISOLATION_* or VERYFRONT_HOST_ALLOW_PROJECT_EXECUTION. That gap is why #3285 shipped. --- src/routing/api/handler.test.ts | 105 ++++++++++++++++++ src/routing/api/handler.ts | 39 +++++++ src/routing/api/module-loader/loader.test.ts | 26 +++++ src/routing/api/module-loader/loader.ts | 12 +- src/security/README.md | 15 ++- .../sandbox/isolation-capability.test.ts | 47 ++++++++ src/security/sandbox/isolation-capability.ts | 73 ++++++++++++ src/security/sandbox/worker-pool.test.ts | 60 ++++++++++ src/security/sandbox/worker-pool.ts | 57 +++++++++- src/server/production-server.ts | 25 +++++ tests/integration/compiled-binary-e2e.test.ts | 79 +++++++++++++ 11 files changed, 534 insertions(+), 4 deletions(-) create mode 100644 src/security/sandbox/isolation-capability.test.ts create mode 100644 src/security/sandbox/isolation-capability.ts diff --git a/src/routing/api/handler.test.ts b/src/routing/api/handler.test.ts index a1c89c9381..0af886df17 100644 --- a/src/routing/api/handler.test.ts +++ b/src/routing/api/handler.test.ts @@ -11,6 +11,8 @@ import { sanitizeLoadErrorForResponse, } from "./handler.ts"; import { __resetPoolForTests } from "#veryfront/security/sandbox/worker-pool.ts"; +import { __setCompiledBinaryForTests } from "#veryfront/security/sandbox/isolation-capability.ts"; +import { HOST_PROJECT_EXECUTION_OVERRIDE_ENV } from "#veryfront/security/host-execution-policy.ts"; import { runWithExactSourceIntegrationPolicy } from "#veryfront/integrations/source-policy-context.ts"; import { normalizeSourceIntegrationPolicy } from "#veryfront/integrations/source-policy.ts"; @@ -384,6 +386,109 @@ describe("APIRouteHandler", () => { assertEquals(hostLoads, 0); assertEquals(preparations, 1); }); + + describe("when the runtime cannot prepare an isolated module", () => { + afterEach(() => { + __setCompiledBinaryForTests(undefined); + Deno.env.delete(HOST_PROJECT_EXECUTION_OVERRIDE_ENV); + }); + + it("serves through the host realm when the operator has granted host execution", async () => { + const adapter = createMockAdapter(); + adapter.fs.files.set( + "/test/project/pages/api/hosted.ts", + "export function GET() { return new Response('discovery-only'); }", + ); + let hostLoads = 0; + let preparations = 0; + __injectDepsForTests({ + loadHandlerModule: () => { + hostLoads++; + return Promise.resolve({ + GET: () => new Response("hosted"), + }); + }, + prepareHandlerModule: () => { + preparations++; + throw new Error("prepared an isolated module this runtime cannot link"); + }, + }); + Deno.env.set("WORKER_ISOLATION_ENABLED", "1"); + Deno.env.set("WORKER_ISOLATION_API", "1"); + Deno.env.set(HOST_PROJECT_EXECUTION_OVERRIDE_ENV, "1"); + __setCompiledBinaryForTests(true); + await __resetPoolForTests(); + + const handler = await createInitializedHandler("/test/project", adapter); + const response = await handler.handle( + new Request("http://localhost/api/hosted"), + { + projectDir: "/test/project", + adapter, + securityConfig: null, + isLocalProject: false, + allowHostProjectCodeExecution: true, + }, + ); + + assertEquals(response?.status, 200); + assertEquals(await response?.text(), "hosted"); + // The downgrade has to reach route execution too. route-executor.ts + // recomputes the isolation decision independently, so a handler-only + // fix 500s here with "Isolated API execution requires prepared route + // source". + assertEquals(hostLoads, 1); + assertEquals(preparations, 0); + }); + + it("fails closed with a typed 503 when host execution is not granted", async () => { + const adapter = createMockAdapter(); + adapter.fs.files.set( + "/test/project/pages/api/hosted.ts", + "export function GET() { return new Response('discovery-only'); }", + ); + let hostLoads = 0; + let preparations = 0; + __injectDepsForTests({ + loadHandlerModule: () => { + hostLoads++; + throw new Error("host fallback under an ungranted isolation posture"); + }, + prepareHandlerModule: () => { + preparations++; + throw new Error("unreachable"); + }, + }); + Deno.env.set("WORKER_ISOLATION_ENABLED", "1"); + Deno.env.set("WORKER_ISOLATION_API", "1"); + // Deliberately no VERYFRONT_HOST_ALLOW_PROJECT_EXECUTION. + __setCompiledBinaryForTests(true); + await __resetPoolForTests(); + + const handler = await createInitializedHandler("/test/project", adapter); + const response = await handler.handle( + new Request("http://localhost/api/hosted"), + { + projectDir: "/test/project", + adapter, + securityConfig: null, + isLocalProject: false, + // A dedicated runtime carries the capability implicitly; the + // operator grant that would license the downgrade is still absent. + allowHostProjectCodeExecution: true, + }, + ); + + assertEquals(response?.status, 503); + assert( + response?.headers.get("content-type")?.includes("application/problem+json"), + ); + const body = await response?.json(); + assert(String(body.detail).includes("WORKER_ISOLATION_API")); + assertEquals(hostLoads, 0); // no silent host-realm fallback + assertEquals(preparations, 0); // and no masked 500 from the loader + }); + }); }); describe("OPTIONS/CORS handling", () => { diff --git a/src/routing/api/handler.ts b/src/routing/api/handler.ts index 4db9c430e3..09e78ea2dd 100644 --- a/src/routing/api/handler.ts +++ b/src/routing/api/handler.ts @@ -31,6 +31,10 @@ import { evictWorkerScopeIfPresent, isWorkerIsolationEnabled, } from "#veryfront/security/sandbox/worker-pool.ts"; +import { + ISOLATED_API_PREPARATION_UNSUPPORTED_REASON, + isIsolatedApiPreparationSupported, +} from "#veryfront/security/sandbox/isolation-capability.ts"; import { createApplicationRequest } from "#veryfront/security/http/application-request.ts"; import { isHostProjectCodeExecutionAllowed, @@ -265,6 +269,41 @@ export class APIRouteHandler { }) ?? unavailable; } const useHostRealm = allowHostProjectCodeExecution && !isWorkerIsolationEnabled(); + + // The isolated path is the only one left and this build cannot prepare + // an isolated module. Every continuation dead-ends inside loadRoute and + // is flattened to "Handler not found" below, which sends operators + // hunting a routing bug that does not exist. Name the flag, at the + // status code that means "not servable here, route it elsewhere". + // + // Gated on !useHostRealm rather than on the isolation flag so the + // dedicated-but-ungranted runtime — which skips the shared-runtime 503 + // above because isSharedProjectRuntime is false — gets a typed answer too. + if (!useHostRealm && !isIsolatedApiPreparationSupported()) { + const isolationRequested = isWorkerIsolationEnabled(); + logger.error("API route unservable under the configured execution posture", { + pathname, + reason: ISOLATED_API_PREPARATION_UNSUPPORTED_REASON, + workerIsolationApi: isolationRequested, + allowHostProjectCodeExecution, + }); + const unservable = createErrorResponseFromDefinition( + PROJECT_EXECUTION_UNAVAILABLE, + { + detail: isolationRequested + ? "WORKER_ISOLATION_API is set but this runtime cannot prepare isolated API route source" + : "Host project code execution is not granted and this runtime cannot prepare isolated API route source", + instance: pathname, + }, + ); + unservable.headers.set("cache-control", "no-store"); + return await applyCORSHeaders({ + request, + response: unservable, + config: this.corsConfig ?? undefined, + }) ?? unservable; + } + const { route, errorMessage } = await this.loadRoute(match, useHostRealm); if (!route) { const msg = errorMessage ?? "Handler not found"; diff --git a/src/routing/api/module-loader/loader.test.ts b/src/routing/api/module-loader/loader.test.ts index f7de53133c..d11f7f5c8d 100644 --- a/src/routing/api/module-loader/loader.test.ts +++ b/src/routing/api/module-loader/loader.test.ts @@ -17,6 +17,7 @@ import { rewriteNodeExternalImports, toCjsDestructureBindings, } from "./loader.ts"; +import { __setCompiledBinaryForTests } from "#veryfront/security/sandbox/isolation-capability.ts"; import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; import { createFileSystem } from "#veryfront/platform/compat/fs.ts"; import { env, getEnv, setEnv } from "#veryfront/compat/process.ts"; @@ -169,6 +170,31 @@ describe("loadHandlerModule", { sanitizeResources: false, sanitizeOps: false }, assertMatch(prepared.source, /__vf_prepare_route_host_marker__/); }); + it("refuses to prepare an isolated handler when the runtime cannot link one", async () => { + const projectDir = await makeTempDir(); + const modulePath = join(projectDir, "unlinkable-handler.ts"); + await fs.writeTextFile(modulePath, `export const GET = () => new Response("ok");`); + + __setCompiledBinaryForTests(true); + try { + const error = await assertRejects(() => + prepareHandlerModule({ + projectDir, + modulePath, + adapter, + config: undefined, + }) + ); + // The reason must name the linkage, not a missing transpiler: the + // compiled binary transpiles this same source on every host-realm + // request. See security/sandbox/isolation-capability.ts. + assertMatch(String((error as Error).message), /_vf_/); + assertMatch(String((error as Error).message), /data:/); + } finally { + __setCompiledBinaryForTests(undefined); + } + }); + it("keeps an authenticated hosted empty remote-host policy fail-closed", async () => { const projectDir = await makeTempDir(); const modulePath = join(projectDir, "hosted-handler.ts"); diff --git a/src/routing/api/module-loader/loader.ts b/src/routing/api/module-loader/loader.ts index 0ca4aaf550..112c193428 100644 --- a/src/routing/api/module-loader/loader.ts +++ b/src/routing/api/module-loader/loader.ts @@ -28,6 +28,10 @@ import { type PreparedWorkerModule, } from "#veryfront/security/sandbox/worker-types.ts"; import { isExplicitHostProjectCodeExecutionAllowed } from "#veryfront/security/project-locality.ts"; +import { + ISOLATED_API_PREPARATION_UNSUPPORTED_REASON, + isIsolatedApiPreparationSupported, +} from "#veryfront/security/sandbox/isolation-capability.ts"; import { createProjectSourceSnapshot, ProjectBoundaryViolationError, @@ -94,11 +98,15 @@ export function prepareHandlerModule(options: LoadModuleOptions): Promise { + __setCompiledBinaryForTests(undefined); +}); + +describe("isolation capability", () => { + it("reports preparation supported when not running a compiled binary", () => { + assertEquals(isIsolatedApiPreparationSupported(false), true); + }); + + it("reports preparation unsupported in a compiled binary", () => { + assertEquals(isIsolatedApiPreparationSupported(true), false); + }); + + it("honours the test override in place of runtime detection", () => { + __setCompiledBinaryForTests(true); + assertEquals(isIsolatedApiPreparationSupported(), false); + + __setCompiledBinaryForTests(false); + assertEquals(isIsolatedApiPreparationSupported(), true); + }); + + it("restores runtime detection when the override is cleared", () => { + __setCompiledBinaryForTests(true); + __setCompiledBinaryForTests(undefined); + + // `deno test` never runs from a compiled binary. + assertEquals(isIsolatedApiPreparationSupported(), true); + }); + + it("states the real blocker, which is module linkage rather than transpilation", () => { + // The reason is user-facing and is asserted on by the loader, the handler + // and the compiled-binary e2e suite. It must keep naming the linkage, so a + // future reader does not re-derive the false "no transpiler" premise that + // sent the original investigation down a dead end. + assert(ISOLATED_API_PREPARATION_UNSUPPORTED_REASON.includes("_vf_")); + assert(ISOLATED_API_PREPARATION_UNSUPPORTED_REASON.includes("data:")); + }); +}); diff --git a/src/security/sandbox/isolation-capability.ts b/src/security/sandbox/isolation-capability.ts new file mode 100644 index 0000000000..db2760857a --- /dev/null +++ b/src/security/sandbox/isolation-capability.ts @@ -0,0 +1,73 @@ +/** + * Whether this build can prepare an isolated API route module. + * + * This is a capability report, not a policy. + * + * It is deliberately not a statement about the transpiler. The compiled binary + * ships a working esbuild and runs the *same* `buildTranspiledModuleSource` on + * every host-realm API request — `loadModule` routes through it precisely + * because a compiled binary cannot import raw `.ts` + * (routing/api/module-loader/loader.ts). Preparation does not fail for want of a + * transpiler. + * + * What does not survive is the shape of the compiled output. Under a compiled + * binary the rewrite turns `from "veryfront"` into a *relative* + * `./_vf_runtime.mjs`, and `from "veryfront/"` into `./_vf_.mjs` + * (transforms/import-rewriter/route-adapter.ts). The host realm gets away with + * that because it writes those sidecars next to a temporary `handler.mjs` and + * imports it as a `file:` URL, where a relative specifier resolves. The worker + * imports prepared source as a base64 `data:` URL + * (security/sandbox/worker-script.ts), and a relative specifier cannot resolve + * from a `data:` URL at all. So a compiled prepared module fails to link inside + * the worker for any handler that imports the framework — which is most of them. + * + * Preparation is therefore refused in a compiled binary until that linkage is + * closed. Do not delete the refusal without closing it; it is not a leftover. + * + * This module is the single source of truth. The loader enforces it, API + * ownership reports it as a typed 503, and the worker-pool flag resolver + * consults it to decide whether a configured isolation posture can be honoured. + * They must not drift. + * + * @module security/sandbox/isolation-capability + */ + +import { isCompiledBinary } from "#veryfront/utils"; + +let compiledOverrideForTests: boolean | undefined; + +/** + * Operator-facing reason. Shared verbatim by every surface that reports the + * limitation, including the log line operators already grep for. + */ +export const ISOLATED_API_PREPARATION_UNSUPPORTED_REASON = + "Isolated API route preparation is unavailable in this compiled runtime: prepared " + + "route source links framework imports to relative ./_vf_*.mjs specifiers that a " + + "worker data: URL cannot resolve"; + +/** + * Whether isolated API route preparation can succeed in this runtime. + * + * @param compiled Override runtime detection, primarily for deterministic tests. + */ +export function isIsolatedApiPreparationSupported( + compiled: boolean = compiledOverrideForTests ?? isCompiledBinary(), +): boolean { + return !compiled; +} + +/** + * Force compiled-binary detection — for testing only. Pass `undefined` to restore. + * + * `isCompiledBinary()` reads a module-load-time const + * (platform/compat/runtime.ts) that is always false under `deno test`, and Deno + * has no module mocking, so the compiled branch is otherwise unreachable in a + * unit test. + * + * Callers that also read worker-pool isolation flags must call + * `__resetPoolForTests()` after this, because those flags are memoized + * independently. + */ +export function __setCompiledBinaryForTests(value: boolean | undefined): void { + compiledOverrideForTests = value; +} diff --git a/src/security/sandbox/worker-pool.test.ts b/src/security/sandbox/worker-pool.test.ts index 53515f1b9c..9128505541 100644 --- a/src/security/sandbox/worker-pool.test.ts +++ b/src/security/sandbox/worker-pool.test.ts @@ -11,6 +11,8 @@ 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 { HOST_PROJECT_EXECUTION_OVERRIDE_ENV } from "#veryfront/security/host-execution-policy.ts"; +import { __setCompiledBinaryForTests } from "./isolation-capability.ts"; import type { ProjectWorker, ProjectWorkerOptions } from "./project-worker.ts"; import { __resetPoolForTests, @@ -1495,9 +1497,67 @@ describe("Feature flag caching", () => { try { Deno.env.delete("WORKER_REQUEST_TIMEOUT_MS"); } catch { /* ok */ } + try { + Deno.env.delete(HOST_PROJECT_EXECUTION_OVERRIDE_ENV); + } catch { /* ok */ } + __setCompiledBinaryForTests(undefined); await __resetPoolForTests(); }); + describe("when the runtime cannot prepare an isolated API module", () => { + it("downgrades WORKER_ISOLATION_API under an explicit host-execution grant", async () => { + Deno.env.set("WORKER_ISOLATION_ENABLED", "1"); + Deno.env.set("WORKER_ISOLATION_API", "1"); + Deno.env.set(HOST_PROJECT_EXECUTION_OVERRIDE_ENV, "1"); + __setCompiledBinaryForTests(true); + await __resetPoolForTests(); + + assertEquals(isWorkerIsolationEnabled(), false); + }); + + it("keeps WORKER_ISOLATION_API set when host execution is not granted", async () => { + Deno.env.set("WORKER_ISOLATION_ENABLED", "1"); + Deno.env.set("WORKER_ISOLATION_API", "1"); + __setCompiledBinaryForTests(true); + await __resetPoolForTests(); + + // Fails closed rather than silently downgrading. API ownership turns this + // into a typed 503; it must never become host-realm execution. + assertEquals(isWorkerIsolationEnabled(), true); + }); + + it("does not downgrade when the runtime can prepare an isolated module", async () => { + Deno.env.set("WORKER_ISOLATION_ENABLED", "1"); + Deno.env.set("WORKER_ISOLATION_API", "1"); + Deno.env.set(HOST_PROJECT_EXECUTION_OVERRIDE_ENV, "1"); + __setCompiledBinaryForTests(false); + await __resetPoolForTests(); + + assertEquals(isWorkerIsolationEnabled(), true); + }); + + it("never downgrades data isolation, which uses a different transport", async () => { + Deno.env.set("WORKER_ISOLATION_ENABLED", "1"); + Deno.env.set("WORKER_ISOLATION_API", "1"); + Deno.env.set("WORKER_ISOLATION_DATA", "1"); + Deno.env.set(HOST_PROJECT_EXECUTION_OVERRIDE_ENV, "1"); + __setCompiledBinaryForTests(true); + await __resetPoolForTests(); + + assertEquals(isWorkerIsolationEnabled(), false); + assertEquals(isDataIsolationEnabled(), true); + }); + + it("does not enable API isolation that was never requested", async () => { + Deno.env.set("WORKER_ISOLATION_ENABLED", "1"); + Deno.env.set(HOST_PROJECT_EXECUTION_OVERRIDE_ENV, "1"); + __setCompiledBinaryForTests(true); + await __resetPoolForTests(); + + assertEquals(isWorkerIsolationEnabled(), false); + }); + }); + it("returns false when master switch is off", async () => { await __resetPoolForTests(); assertEquals(isWorkerIsolationEnabled(), false); diff --git a/src/security/sandbox/worker-pool.ts b/src/security/sandbox/worker-pool.ts index b3be7d0656..7f2758b73e 100644 --- a/src/security/sandbox/worker-pool.ts +++ b/src/security/sandbox/worker-pool.ts @@ -36,6 +36,11 @@ import { } from "./worker-egress-guard.ts"; import { isWorkerGenerationInScope } from "./worker-generation.ts"; import { buildWorkerPermissions } from "./worker-permissions.ts"; +import { isHostProjectExecutionOverrideEnabled } from "#veryfront/security/host-execution-policy.ts"; +import { + ISOLATED_API_PREPARATION_UNSUPPORTED_REASON, + isIsolatedApiPreparationSupported, +} from "./isolation-capability.ts"; import type { RenderSSRRequest, WorkerPoolConfig, @@ -1238,15 +1243,65 @@ let _apiIsolation = false; let _dataIsolation = false; let _ssrIsolation = false; +/** + * Resolve the host-owned isolation flags once per process. + * + * `WORKER_ISOLATION_API` asks for a posture this build may be unable to provide. + * When it cannot, there is no configuration that serves traffic: every project + * API route dead-ends in `prepareHandlerModule` and is masked as a 500. The flag + * is downgraded in exactly one case — the operator has *already* granted + * host-realm project execution via VERYFRONT_HOST_ALLOW_PROJECT_EXECUTION, in + * writing, at the host-owned entrypoint. That is a documented, explicit, + * operator-granted precondition, and the downgrade cannot grant anything beyond + * it: `useHostRealm` (routing/api/handler.ts) and `isolationRequired` + * (routing/api/route-executor.ts) are both conjunctions with + * `allowHostProjectCodeExecution`. Removing the isolation term never removes + * that one. + * + * Without the grant the flag stands and the request fails closed — as a typed + * `project-execution-unavailable` 503 from API ownership, not a masked 500. + * + * Remove the downgrade once isolated preparation works in a compiled binary; the + * blocker is documented on `isIsolatedApiPreparationSupported`. + */ function resolveFlags(): void { if (_flagsResolved) return; // 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); + const apiRequested = master && getHostEnvBoolean("WORKER_ISOLATION_API", false); _dataIsolation = master && getHostEnvBoolean("WORKER_ISOLATION_DATA", false); _ssrIsolation = master && getHostEnvBoolean("WORKER_ISOLATION_SSR", false); + + const preparationSupported = isIsolatedApiPreparationSupported(); + const hostExecutionGranted = isHostProjectExecutionOverrideEnabled(); + const downgraded = apiRequested && !preparationSupported && hostExecutionGranted; + + _apiIsolation = apiRequested && !downgraded; _flagsResolved = true; + + if (downgraded) { + logger.warn( + "WORKER_ISOLATION_API downgraded to host-realm execution under an explicit operator grant", + { + flag: "WORKER_ISOLATION_API", + requested: true, + effective: false, + reason: ISOLATED_API_PREPARATION_UNSUPPORTED_REASON, + grantedBy: "VERYFRONT_HOST_ALLOW_PROJECT_EXECUTION", + }, + ); + } else if (apiRequested && !preparationSupported) { + logger.error( + "WORKER_ISOLATION_API cannot be honoured by this runtime and host project execution is not granted; project API routes will fail closed", + { + flag: "WORKER_ISOLATION_API", + requested: true, + effective: true, + reason: ISOLATED_API_PREPARATION_UNSUPPORTED_REASON, + }, + ); + } } /** diff --git a/src/server/production-server.ts b/src/server/production-server.ts index 9a6120aea6..abe89a7ddb 100644 --- a/src/server/production-server.ts +++ b/src/server/production-server.ts @@ -38,6 +38,8 @@ import { isHostProjectExecutionOverrideEnabled, } from "#veryfront/security/host-execution-policy.ts"; import { isSharedProjectRuntime } from "#veryfront/security/project-locality.ts"; +import { isWorkerIsolationEnabled } from "#veryfront/security/sandbox/worker-pool.ts"; +import { isIsolatedApiPreparationSupported } from "#veryfront/security/sandbox/isolation-capability.ts"; import { runStartupDiscovery } from "./startup-discovery.ts"; const serverLog = logger.component("server"); @@ -257,6 +259,29 @@ export function startProductionServer( const operatorGrant = isHostProjectExecutionOverrideEnabled(); const allowHostProjectCodeExecution = isolatedRuntimeGrant || operatorGrant; + // Resolve the host-owned isolation flags here, at boot, instead of + // lazily on the first request. security/README.md already promises that + // malformed WORKER_ISOLATION_* values are startup errors; until now + // resolveFlags() was first driven from the API handler, so a typo + // surfaced as a per-request masked 500 instead. This is also the only + // point where both host-owned execution postures are known, which makes + // it the only place a contradiction between them is visible. + const apiIsolation = isWorkerIsolationEnabled(); + serverLog.info("Execution posture", { + isolatedRuntimeGrant, + operatorGrant, + allowHostProjectCodeExecution, + apiIsolation, + isolatedApiPreparationSupported: isIsolatedApiPreparationSupported(), + }); + if (apiIsolation && operatorGrant) { + serverLog.warn( + "Contradictory execution posture: VERYFRONT_HOST_ALLOW_PROJECT_EXECUTION and " + + "WORKER_ISOLATION_API are both set. Isolation wins for API routes; the host " + + "execution grant is inert for them.", + ); + } + // Run primitive discovery before serving (registries must be populated before first request) if (discoveryConfig) { try { diff --git a/tests/integration/compiled-binary-e2e.test.ts b/tests/integration/compiled-binary-e2e.test.ts index dca2735491..f817479c4f 100644 --- a/tests/integration/compiled-binary-e2e.test.ts +++ b/tests/integration/compiled-binary-e2e.test.ts @@ -717,6 +717,85 @@ export function GET() { }); }); + // A compiled binary cannot prepare isolated API route source, so + // WORKER_ISOLATION_API=1 asks for a posture it cannot serve. Production ran + // exactly this combination and every project API route returned a masked 500. + // `deno compile` passing is not evidence for any of this; only a genuinely + // compiled binary is. + it("serves API routes when a compiled binary cannot honour WORKER_ISOLATION_API but host execution is granted", async () => { + const projectDir = await createTestProject( + "isolation-downgrade-test", + ` +export default function Home() { + return
Home Page
; +} +`, + { + "pages/api/hello.ts": ` +export function GET() { + return Response.json({ message: "Hello from API" }); +} +`, + }, + ); + + await withServer(projectDir, async (server) => { + const response = await fetch(`http://127.0.0.1:${server.port}/api/hello`); + const body = await response.text(); + + assertEquals( + response.status, + 200, + `Should return 200\nResponse body: ${body}\n${server.logs.join("").slice(-16000)}`, + ); + assertEquals(JSON.parse(body).message, "Hello from API"); + assertStringIncludes( + server.logs.join(""), + "WORKER_ISOLATION_API downgraded", + "the downgrade must be logged", + ); + }, "production", { + WORKER_ISOLATION_ENABLED: "1", + WORKER_ISOLATION_API: "1", + VERYFRONT_HOST_ALLOW_PROJECT_EXECUTION: "1", + }); + }); + + it("returns a typed 503 for API routes when WORKER_ISOLATION_API is unserviceable and ungranted", async () => { + const projectDir = await createTestProject( + "isolation-unserviceable-test", + ` +export default function Home() { + return
Home Page
; +} +`, + { + "pages/api/hello.ts": ` +export function GET() { + return Response.json({ message: "Hello from API" }); +} +`, + }, + ); + + await withServer(projectDir, async (server) => { + const response = await fetch(`http://127.0.0.1:${server.port}/api/hello`); + const body = await response.text(); + + assertEquals( + response.status, + 503, + `Should fail closed as 503, not a masked 500\nResponse body: ${body}\n${ + server.logs.join("").slice(-16000) + }`, + ); + assertStringIncludes(body, "WORKER_ISOLATION_API", "the response must name the flag"); + }, "production", { + WORKER_ISOLATION_ENABLED: "1", + WORKER_ISOLATION_API: "1", + }); + }); + it("should drain an active SSE response before production serve exits on SIGTERM", async () => { const projectDir = await createTestProject( "sigterm-drain-test", From 644d07a4dcc3884dc68db4e85715c9b722e1d870 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Thu, 6 Aug 2026 14:43:25 +0200 Subject: [PATCH 2/3] refactor(api): resolve the API execution realm in one canonical place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handler.ts and both sites in route-executor.ts each recomputed the same decision. That is why patching only the handler moved the failure instead of removing it. All three now call isHostRealmApiExecution(). On a shared runtime granted host project execution this returns true, so API routes execute in the same host realm as agent streams — which never consult isolation at all. Also drops the boot-time posture logging from production-server.ts. It was not needed for the fix and is not certified by the compiled-binary suite. --- src/routing/api/handler.ts | 3 ++- src/routing/api/route-executor.ts | 8 +++----- src/security/README.md | 13 ++++++++++--- src/security/sandbox/worker-pool.ts | 17 +++++++++++++++++ src/server/production-server.ts | 25 ------------------------- 5 files changed, 32 insertions(+), 34 deletions(-) diff --git a/src/routing/api/handler.ts b/src/routing/api/handler.ts index 09e78ea2dd..3df48a38be 100644 --- a/src/routing/api/handler.ts +++ b/src/routing/api/handler.ts @@ -29,6 +29,7 @@ import type { HandlerContext } from "#veryfront/types"; import type { PreparedWorkerModule } from "#veryfront/security/sandbox/worker-types.ts"; import { evictWorkerScopeIfPresent, + isHostRealmApiExecution, isWorkerIsolationEnabled, } from "#veryfront/security/sandbox/worker-pool.ts"; import { @@ -268,7 +269,7 @@ export class APIRouteHandler { config: this.corsConfig ?? undefined, }) ?? unavailable; } - const useHostRealm = allowHostProjectCodeExecution && !isWorkerIsolationEnabled(); + const useHostRealm = isHostRealmApiExecution(allowHostProjectCodeExecution); // The isolated path is the only one left and this build cannot prepare // an isolated module. Every continuation dead-ends inside loadRoute and diff --git a/src/routing/api/route-executor.ts b/src/routing/api/route-executor.ts index e53a8b4298..06cf23a5e2 100644 --- a/src/routing/api/route-executor.ts +++ b/src/routing/api/route-executor.ts @@ -22,7 +22,7 @@ import { serverLogger as logger } from "#veryfront/utils"; import type { HandlerContext } from "#veryfront/types"; import { getWorkerPool, - isWorkerIsolationEnabled, + isHostRealmApiExecution, } from "#veryfront/security/sandbox/worker-pool.ts"; import { resolveWorkerGeneration, @@ -1231,8 +1231,7 @@ export function executeAppRoute( ): Promise { const routeOptions = snapshotExecuteRouteOptions(options); const isLocalProject = routeOptions.isLocalProject === true; - const isolationRequired = isWorkerIsolationEnabled() || - !routeOptions.allowHostProjectCodeExecution; + const isolationRequired = !isHostRealmApiExecution(routeOptions.allowHostProjectCodeExecution); // Routes without an explicit host-execution capability require prepared // worker execution. Local development projects retain the legacy capability. @@ -1309,8 +1308,7 @@ export function executePagesRoute( ): Promise { const routeOptions = snapshotExecuteRouteOptions(options); const isLocalProject = routeOptions.isLocalProject === true; - const isolationRequired = isWorkerIsolationEnabled() || - !routeOptions.allowHostProjectCodeExecution; + const isolationRequired = !isHostRealmApiExecution(routeOptions.allowHostProjectCodeExecution); const isolatedProjectDir = routeOptions.projectDir ?? projectDir; // Routes without an explicit host-execution capability require prepared diff --git a/src/security/README.md b/src/security/README.md index 41aadab65d..6e59030c3e 100644 --- a/src/security/README.md +++ b/src/security/README.md @@ -282,8 +282,14 @@ 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. The isolation flags are resolved once at server startup -so that promise holds, and the resolved posture is logged there. +replaced with defaults. + +`WORKER_ISOLATION_API` is the only isolation flag any execution surface +consults, and only API routes consult it (`routing/api/handler.ts` and +`routing/api/route-executor.ts`). Agent streams and other shared-runtime +execution surfaces are gated by `allowHostProjectCodeExecution` alone. On a +shared runtime that has been granted host project execution, API routes +therefore execute in the same host realm as those surfaces. A runtime that cannot honour a configured isolation flag never fakes it. A compiled binary cannot prepare isolated API route source @@ -295,7 +301,8 @@ uses the host realm the operator already opted into; where that grant is absent, the flag stands and API ownership returns the typed `project-execution-unavailable` 503 naming it. The downgrade cannot grant execution on its own — every execution gate is a conjunction with -`allowHostProjectCodeExecution`. +`allowHostProjectCodeExecution`, so the downgrade only ever lands API routes in +the realm that grant already licenses for every other surface. OpenAPI metadata is currently attached to handler functions. Because reading it requires route evaluation, runtime OpenAPI generation is available only for diff --git a/src/security/sandbox/worker-pool.ts b/src/security/sandbox/worker-pool.ts index 7f2758b73e..92cf16294c 100644 --- a/src/security/sandbox/worker-pool.ts +++ b/src/security/sandbox/worker-pool.ts @@ -1313,6 +1313,23 @@ export function isWorkerIsolationEnabled(): boolean { return _apiIsolation; } +/** + * The one place that decides which realm a project API route executes in. + * + * `routing/api/handler.ts` and both sites in `routing/api/route-executor.ts` + * used to recompute this independently, which is why patching only the handler + * moved the failure instead of removing it. Every API execution surface asks + * here now. + * + * On a shared runtime that has been granted host project execution this returns + * true, so API routes run in the same host realm as agent streams and every + * other shared-runtime execution surface — none of which consult isolation at + * all. + */ +export function isHostRealmApiExecution(allowHostProjectCodeExecution: boolean): boolean { + return allowHostProjectCodeExecution === true && !isWorkerIsolationEnabled(); +} + /** * Whether worker isolation is enabled for data fetchers (getServerData). * Controlled by WORKER_ISOLATION_DATA=1 (requires WORKER_ISOLATION_ENABLED=1). diff --git a/src/server/production-server.ts b/src/server/production-server.ts index abe89a7ddb..9a6120aea6 100644 --- a/src/server/production-server.ts +++ b/src/server/production-server.ts @@ -38,8 +38,6 @@ import { isHostProjectExecutionOverrideEnabled, } from "#veryfront/security/host-execution-policy.ts"; import { isSharedProjectRuntime } from "#veryfront/security/project-locality.ts"; -import { isWorkerIsolationEnabled } from "#veryfront/security/sandbox/worker-pool.ts"; -import { isIsolatedApiPreparationSupported } from "#veryfront/security/sandbox/isolation-capability.ts"; import { runStartupDiscovery } from "./startup-discovery.ts"; const serverLog = logger.component("server"); @@ -259,29 +257,6 @@ export function startProductionServer( const operatorGrant = isHostProjectExecutionOverrideEnabled(); const allowHostProjectCodeExecution = isolatedRuntimeGrant || operatorGrant; - // Resolve the host-owned isolation flags here, at boot, instead of - // lazily on the first request. security/README.md already promises that - // malformed WORKER_ISOLATION_* values are startup errors; until now - // resolveFlags() was first driven from the API handler, so a typo - // surfaced as a per-request masked 500 instead. This is also the only - // point where both host-owned execution postures are known, which makes - // it the only place a contradiction between them is visible. - const apiIsolation = isWorkerIsolationEnabled(); - serverLog.info("Execution posture", { - isolatedRuntimeGrant, - operatorGrant, - allowHostProjectCodeExecution, - apiIsolation, - isolatedApiPreparationSupported: isIsolatedApiPreparationSupported(), - }); - if (apiIsolation && operatorGrant) { - serverLog.warn( - "Contradictory execution posture: VERYFRONT_HOST_ALLOW_PROJECT_EXECUTION and " + - "WORKER_ISOLATION_API are both set. Isolation wins for API routes; the host " + - "execution grant is inert for them.", - ); - } - // Run primitive discovery before serving (registries must be populated before first request) if (discoveryConfig) { try { From b8fd58239adf75f620489c7db4f3d0ca2e7dcf16 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Thu, 6 Aug 2026 15:12:36 +0200 Subject: [PATCH 3/3] fix(ci): format, regenerate api-reference, trim comments Also narrows the README claim: WORKER_ISOLATION_API is consulted only by API route execution, not every surface. Data fetchers and SSR have their own flags. --- docs/api-reference/veryfront/index.client.md | 4 +- docs/api-reference/veryfront/index.md | 4 +- src/routing/api/handler.test.ts | 8 +-- src/routing/api/handler.ts | 16 ++---- src/routing/api/module-loader/loader.test.ts | 4 +- src/routing/api/module-loader/loader.ts | 7 +-- src/routing/api/route-executor.ts | 5 +- src/security/README.md | 14 ++--- .../sandbox/isolation-capability.test.ts | 8 +-- src/security/sandbox/isolation-capability.ts | 55 +++++-------------- src/security/sandbox/worker-pool.test.ts | 3 +- src/security/sandbox/worker-pool.ts | 34 +++--------- tests/integration/compiled-binary-e2e.test.ts | 7 +-- 13 files changed, 52 insertions(+), 117 deletions(-) diff --git a/docs/api-reference/veryfront/index.client.md b/docs/api-reference/veryfront/index.client.md index f0acd974fa..df87407e97 100644 --- a/docs/api-reference/veryfront/index.client.md +++ b/docs/api-reference/veryfront/index.client.md @@ -65,8 +65,8 @@ export function GET() { | Name | Description | Source | | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | | `APIContext` | Context object passed to API route handlers. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/routing/api/context-builder.ts#L10) | -| `APIHandler` | Function signature for API route handlers. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/routing/api/handler.ts#L117) | -| `APIResponse` | Structured response shape for API route helpers. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/routing/api/handler.ts#L110) | +| `APIHandler` | Function signature for API route handlers. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/routing/api/handler.ts#L122) | +| `APIResponse` | Structured response shape for API route helpers. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/routing/api/handler.ts#L115) | | `APIRoute` | Route module shape with method handlers and an optional default handler. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/routing/api/module-loader/types.ts#L30) | | `DataContext` | Context passed to `getServerData()`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/schemas/data.schema.ts#L54) | | `InferGetServerDataProps` | Utility type to infer props from a page with data | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/types.ts#L28) | diff --git a/docs/api-reference/veryfront/index.md b/docs/api-reference/veryfront/index.md index 4bf7f5863f..c53edc15b8 100644 --- a/docs/api-reference/veryfront/index.md +++ b/docs/api-reference/veryfront/index.md @@ -86,8 +86,8 @@ export function getServerData(ctx: DataContext) { | Name | Description | Source | | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | | `APIContext` | Context object passed to API route handlers. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/routing/api/context-builder.ts#L10) | -| `APIHandler` | Function signature for API route handlers. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/routing/api/handler.ts#L117) | -| `APIResponse` | Structured response shape for API route helpers. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/routing/api/handler.ts#L110) | +| `APIHandler` | Function signature for API route handlers. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/routing/api/handler.ts#L122) | +| `APIResponse` | Structured response shape for API route helpers. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/routing/api/handler.ts#L115) | | `APIRoute` | Route module shape with method handlers and an optional default handler. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/routing/api/module-loader/types.ts#L30) | | `DataContext` | Context passed to `getServerData()`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/schemas/data.schema.ts#L54) | | `InferGetServerDataProps` | Utility type to infer props from a page with data | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/types.ts#L28) | diff --git a/src/routing/api/handler.test.ts b/src/routing/api/handler.test.ts index 0af886df17..a8cb1bc5d2 100644 --- a/src/routing/api/handler.test.ts +++ b/src/routing/api/handler.test.ts @@ -433,10 +433,7 @@ describe("APIRouteHandler", () => { assertEquals(response?.status, 200); assertEquals(await response?.text(), "hosted"); - // The downgrade has to reach route execution too. route-executor.ts - // recomputes the isolation decision independently, so a handler-only - // fix 500s here with "Isolated API execution requires prepared route - // source". + // Must reach route execution too, not just the handler. assertEquals(hostLoads, 1); assertEquals(preparations, 0); }); @@ -473,8 +470,7 @@ describe("APIRouteHandler", () => { adapter, securityConfig: null, isLocalProject: false, - // A dedicated runtime carries the capability implicitly; the - // operator grant that would license the downgrade is still absent. + // Dedicated runtime capability, but no operator grant. allowHostProjectCodeExecution: true, }, ); diff --git a/src/routing/api/handler.ts b/src/routing/api/handler.ts index 3df48a38be..ae985eaf1d 100644 --- a/src/routing/api/handler.ts +++ b/src/routing/api/handler.ts @@ -33,8 +33,8 @@ import { isWorkerIsolationEnabled, } from "#veryfront/security/sandbox/worker-pool.ts"; import { - ISOLATED_API_PREPARATION_UNSUPPORTED_REASON, isIsolatedApiPreparationSupported, + ISOLATED_API_PREPARATION_UNSUPPORTED_REASON, } from "#veryfront/security/sandbox/isolation-capability.ts"; import { createApplicationRequest } from "#veryfront/security/http/application-request.ts"; import { @@ -271,15 +271,11 @@ export class APIRouteHandler { } const useHostRealm = isHostRealmApiExecution(allowHostProjectCodeExecution); - // The isolated path is the only one left and this build cannot prepare - // an isolated module. Every continuation dead-ends inside loadRoute and - // is flattened to "Handler not found" below, which sends operators - // hunting a routing bug that does not exist. Name the flag, at the - // status code that means "not servable here, route it elsewhere". - // - // Gated on !useHostRealm rather than on the isolation flag so the - // dedicated-but-ungranted runtime — which skips the shared-runtime 503 - // above because isSharedProjectRuntime is false — gets a typed answer too. + // Only the isolated path is left and this build cannot prepare a module + // for it, so every continuation dead-ends in loadRoute and is flattened + // to "Handler not found" below. Name the flag instead. Gated on + // !useHostRealm so a dedicated-but-ungranted runtime, which skips the + // shared-runtime 503 above, also gets a typed answer. if (!useHostRealm && !isIsolatedApiPreparationSupported()) { const isolationRequested = isWorkerIsolationEnabled(); logger.error("API route unservable under the configured execution posture", { diff --git a/src/routing/api/module-loader/loader.test.ts b/src/routing/api/module-loader/loader.test.ts index d11f7f5c8d..d86ec3c7e8 100644 --- a/src/routing/api/module-loader/loader.test.ts +++ b/src/routing/api/module-loader/loader.test.ts @@ -185,9 +185,7 @@ describe("loadHandlerModule", { sanitizeResources: false, sanitizeOps: false }, config: undefined, }) ); - // The reason must name the linkage, not a missing transpiler: the - // compiled binary transpiles this same source on every host-realm - // request. See security/sandbox/isolation-capability.ts. + // Names the linkage, not a missing transpiler. assertMatch(String((error as Error).message), /_vf_/); assertMatch(String((error as Error).message), /data:/); } finally { diff --git a/src/routing/api/module-loader/loader.ts b/src/routing/api/module-loader/loader.ts index 112c193428..be60d9b337 100644 --- a/src/routing/api/module-loader/loader.ts +++ b/src/routing/api/module-loader/loader.ts @@ -29,8 +29,8 @@ import { } from "#veryfront/security/sandbox/worker-types.ts"; import { isExplicitHostProjectCodeExecutionAllowed } from "#veryfront/security/project-locality.ts"; import { - ISOLATED_API_PREPARATION_UNSUPPORTED_REASON, isIsolatedApiPreparationSupported, + ISOLATED_API_PREPARATION_UNSUPPORTED_REASON, } from "#veryfront/security/sandbox/isolation-capability.ts"; import { createProjectSourceSnapshot, @@ -98,10 +98,7 @@ export function prepareHandlerModule(options: LoadModuleOptions): Promise { @@ -37,10 +37,8 @@ describe("isolation capability", () => { }); it("states the real blocker, which is module linkage rather than transpilation", () => { - // The reason is user-facing and is asserted on by the loader, the handler - // and the compiled-binary e2e suite. It must keep naming the linkage, so a - // future reader does not re-derive the false "no transpiler" premise that - // sent the original investigation down a dead end. + // Must keep naming the linkage, so nobody re-derives the false + // "no transpiler" premise. assert(ISOLATED_API_PREPARATION_UNSUPPORTED_REASON.includes("_vf_")); assert(ISOLATED_API_PREPARATION_UNSUPPORTED_REASON.includes("data:")); }); diff --git a/src/security/sandbox/isolation-capability.ts b/src/security/sandbox/isolation-capability.ts index db2760857a..56303ef382 100644 --- a/src/security/sandbox/isolation-capability.ts +++ b/src/security/sandbox/isolation-capability.ts @@ -1,34 +1,6 @@ /** * Whether this build can prepare an isolated API route module. * - * This is a capability report, not a policy. - * - * It is deliberately not a statement about the transpiler. The compiled binary - * ships a working esbuild and runs the *same* `buildTranspiledModuleSource` on - * every host-realm API request — `loadModule` routes through it precisely - * because a compiled binary cannot import raw `.ts` - * (routing/api/module-loader/loader.ts). Preparation does not fail for want of a - * transpiler. - * - * What does not survive is the shape of the compiled output. Under a compiled - * binary the rewrite turns `from "veryfront"` into a *relative* - * `./_vf_runtime.mjs`, and `from "veryfront/"` into `./_vf_.mjs` - * (transforms/import-rewriter/route-adapter.ts). The host realm gets away with - * that because it writes those sidecars next to a temporary `handler.mjs` and - * imports it as a `file:` URL, where a relative specifier resolves. The worker - * imports prepared source as a base64 `data:` URL - * (security/sandbox/worker-script.ts), and a relative specifier cannot resolve - * from a `data:` URL at all. So a compiled prepared module fails to link inside - * the worker for any handler that imports the framework — which is most of them. - * - * Preparation is therefore refused in a compiled binary until that linkage is - * closed. Do not delete the refusal without closing it; it is not a leftover. - * - * This module is the single source of truth. The loader enforces it, API - * ownership reports it as a typed 503, and the worker-pool flag resolver - * consults it to decide whether a configured isolation posture can be honoured. - * They must not drift. - * * @module security/sandbox/isolation-capability */ @@ -36,10 +8,7 @@ import { isCompiledBinary } from "#veryfront/utils"; let compiledOverrideForTests: boolean | undefined; -/** - * Operator-facing reason. Shared verbatim by every surface that reports the - * limitation, including the log line operators already grep for. - */ +/** Shared by every surface that reports the limitation, including the logs. */ export const ISOLATED_API_PREPARATION_UNSUPPORTED_REASON = "Isolated API route preparation is unavailable in this compiled runtime: prepared " + "route source links framework imports to relative ./_vf_*.mjs specifiers that a " + @@ -48,6 +17,14 @@ export const ISOLATED_API_PREPARATION_UNSUPPORTED_REASON = /** * Whether isolated API route preparation can succeed in this runtime. * + * Not a statement about the transpiler: a compiled binary ships esbuild and + * transpiles tenant API routes on every host-realm request. It is linkage that + * fails. The compiled rewrite emits relative `./_vf_*.mjs` sidecars + * (transforms/import-rewriter/route-adapter.ts), which resolve from the host's + * temp `handler.mjs` but not from the worker's base64 `data:` URL + * (security/sandbox/worker-script.ts). Do not delete the refusal without + * closing that. + * * @param compiled Override runtime detection, primarily for deterministic tests. */ export function isIsolatedApiPreparationSupported( @@ -57,16 +34,12 @@ export function isIsolatedApiPreparationSupported( } /** - * Force compiled-binary detection — for testing only. Pass `undefined` to restore. - * - * `isCompiledBinary()` reads a module-load-time const - * (platform/compat/runtime.ts) that is always false under `deno test`, and Deno - * has no module mocking, so the compiled branch is otherwise unreachable in a - * unit test. + * Force compiled-binary detection — testing only. Pass `undefined` to restore. * - * Callers that also read worker-pool isolation flags must call - * `__resetPoolForTests()` after this, because those flags are memoized - * independently. + * `isCompiledBinary()` reads a module-load-time const and Deno has no module + * mocking, so the compiled branch is otherwise unreachable in a unit test. + * Callers that also read worker-pool isolation flags must follow this with + * `__resetPoolForTests()`. */ export function __setCompiledBinaryForTests(value: boolean | undefined): void { compiledOverrideForTests = value; diff --git a/src/security/sandbox/worker-pool.test.ts b/src/security/sandbox/worker-pool.test.ts index 9128505541..b08bc3d20b 100644 --- a/src/security/sandbox/worker-pool.test.ts +++ b/src/security/sandbox/worker-pool.test.ts @@ -1521,8 +1521,7 @@ describe("Feature flag caching", () => { __setCompiledBinaryForTests(true); await __resetPoolForTests(); - // Fails closed rather than silently downgrading. API ownership turns this - // into a typed 503; it must never become host-realm execution. + // Fails closed; must never become host-realm execution. assertEquals(isWorkerIsolationEnabled(), true); }); diff --git a/src/security/sandbox/worker-pool.ts b/src/security/sandbox/worker-pool.ts index 92cf16294c..c513583b3a 100644 --- a/src/security/sandbox/worker-pool.ts +++ b/src/security/sandbox/worker-pool.ts @@ -38,8 +38,8 @@ import { isWorkerGenerationInScope } from "./worker-generation.ts"; import { buildWorkerPermissions } from "./worker-permissions.ts"; import { isHostProjectExecutionOverrideEnabled } from "#veryfront/security/host-execution-policy.ts"; import { - ISOLATED_API_PREPARATION_UNSUPPORTED_REASON, isIsolatedApiPreparationSupported, + ISOLATED_API_PREPARATION_UNSUPPORTED_REASON, } from "./isolation-capability.ts"; import type { RenderSSRRequest, @@ -1246,23 +1246,13 @@ let _ssrIsolation = false; /** * Resolve the host-owned isolation flags once per process. * - * `WORKER_ISOLATION_API` asks for a posture this build may be unable to provide. - * When it cannot, there is no configuration that serves traffic: every project - * API route dead-ends in `prepareHandlerModule` and is masked as a 500. The flag - * is downgraded in exactly one case — the operator has *already* granted - * host-realm project execution via VERYFRONT_HOST_ALLOW_PROJECT_EXECUTION, in - * writing, at the host-owned entrypoint. That is a documented, explicit, - * operator-granted precondition, and the downgrade cannot grant anything beyond - * it: `useHostRealm` (routing/api/handler.ts) and `isolationRequired` - * (routing/api/route-executor.ts) are both conjunctions with - * `allowHostProjectCodeExecution`. Removing the isolation term never removes - * that one. - * - * Without the grant the flag stands and the request fails closed — as a typed - * `project-execution-unavailable` 503 from API ownership, not a masked 500. - * - * Remove the downgrade once isolated preparation works in a compiled binary; the - * blocker is documented on `isIsolatedApiPreparationSupported`. + * A build that cannot honour `WORKER_ISOLATION_API` has no configuration that + * serves traffic: every API route dead-ends in `prepareHandlerModule`. The flag + * is downgraded in exactly one case — the operator already granted host-realm + * execution via VERYFRONT_HOST_ALLOW_PROJECT_EXECUTION. The downgrade cannot + * grant more than that, since every execution gate is a conjunction with + * `allowHostProjectCodeExecution`. Without the grant the flag stands and API + * ownership fails closed with a typed 503. */ function resolveFlags(): void { if (_flagsResolved) return; @@ -1318,13 +1308,7 @@ export function isWorkerIsolationEnabled(): boolean { * * `routing/api/handler.ts` and both sites in `routing/api/route-executor.ts` * used to recompute this independently, which is why patching only the handler - * moved the failure instead of removing it. Every API execution surface asks - * here now. - * - * On a shared runtime that has been granted host project execution this returns - * true, so API routes run in the same host realm as agent streams and every - * other shared-runtime execution surface — none of which consult isolation at - * all. + * moved the failure instead of removing it. */ export function isHostRealmApiExecution(allowHostProjectCodeExecution: boolean): boolean { return allowHostProjectCodeExecution === true && !isWorkerIsolationEnabled(); diff --git a/tests/integration/compiled-binary-e2e.test.ts b/tests/integration/compiled-binary-e2e.test.ts index f817479c4f..102da121c1 100644 --- a/tests/integration/compiled-binary-e2e.test.ts +++ b/tests/integration/compiled-binary-e2e.test.ts @@ -717,11 +717,8 @@ export function GET() { }); }); - // A compiled binary cannot prepare isolated API route source, so - // WORKER_ISOLATION_API=1 asks for a posture it cannot serve. Production ran - // exactly this combination and every project API route returned a masked 500. - // `deno compile` passing is not evidence for any of this; only a genuinely - // compiled binary is. + // Production ran exactly this flag combination and every project API route + // returned a masked 500. it("serves API routes when a compiled binary cannot honour WORKER_ISOLATION_API but host execution is granted", async () => { const projectDir = await createTestProject( "isolation-downgrade-test",