Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/api-reference/veryfront/index.client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
4 changes: 2 additions & 2 deletions docs/api-reference/veryfront/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
101 changes: 101 additions & 0 deletions src/routing/api/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -384,6 +386,105 @@ 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");
// Must reach route execution too, not just the handler.
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,
// Dedicated runtime capability, but no operator grant.
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", () => {
Expand Down
38 changes: 37 additions & 1 deletion src/routing/api/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,13 @@ 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 {
isIsolatedApiPreparationSupported,
ISOLATED_API_PREPARATION_UNSUPPORTED_REASON,
} from "#veryfront/security/sandbox/isolation-capability.ts";
import { createApplicationRequest } from "#veryfront/security/http/application-request.ts";
import {
isHostProjectCodeExecutionAllowed,
Expand Down Expand Up @@ -264,7 +269,38 @@ export class APIRouteHandler {
config: this.corsConfig ?? undefined,
}) ?? unavailable;
}
const useHostRealm = allowHostProjectCodeExecution && !isWorkerIsolationEnabled();
const useHostRealm = isHostRealmApiExecution(allowHostProjectCodeExecution);

// 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", {
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";
Expand Down
24 changes: 24 additions & 0 deletions src/routing/api/module-loader/loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -169,6 +170,29 @@ 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,
})
);
// Names the linkage, not a missing transpiler.
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");
Expand Down
9 changes: 7 additions & 2 deletions src/routing/api/module-loader/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ import {
type PreparedWorkerModule,
} from "#veryfront/security/sandbox/worker-types.ts";
import { isExplicitHostProjectCodeExecutionAllowed } from "#veryfront/security/project-locality.ts";
import {
isIsolatedApiPreparationSupported,
ISOLATED_API_PREPARATION_UNSUPPORTED_REASON,
} from "#veryfront/security/sandbox/isolation-capability.ts";
import {
createProjectSourceSnapshot,
ProjectBoundaryViolationError,
Expand Down Expand Up @@ -94,11 +98,12 @@ export function prepareHandlerModule(options: LoadModuleOptions): Promise<Prepar
const { projectDir, modulePath, adapter, config } = options;
validateModulePath(modulePath, projectDir);

if (isCompiledBinary()) {
// Fail-closed backstop. API ownership reports a typed 503 before this.
if (!isIsolatedApiPreparationSupported()) {
throw toError(
createError({
type: "api",
message: "Isolated API route preparation is unavailable in this compiled runtime",
message: ISOLATED_API_PREPARATION_UNSUPPORTED_REASON,
}),
);
}
Expand Down
11 changes: 3 additions & 8 deletions src/routing/api/route-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,7 @@ 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 type { HandlerContext } from "#veryfront/types";
import {
getWorkerPool,
isWorkerIsolationEnabled,
} from "#veryfront/security/sandbox/worker-pool.ts";
import { getWorkerPool, isHostRealmApiExecution } from "#veryfront/security/sandbox/worker-pool.ts";
import {
resolveWorkerGeneration,
snapshotWorkerGenerationIdentity,
Expand Down Expand Up @@ -1231,8 +1228,7 @@ export function executeAppRoute(
): Promise<Response> {
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.
Expand Down Expand Up @@ -1309,8 +1305,7 @@ export function executePagesRoute(
): Promise<Response> {
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
Expand Down
20 changes: 20 additions & 0 deletions src/security/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,26 @@ before source reads or custom not-found rendering.
Defined invalid flags and pool limits are startup errors; they are not silently
replaced with defaults.

`WORKER_ISOLATION_API` is consulted only by API route execution
(`routing/api/handler.ts` and `routing/api/route-executor.ts`), which resolve it
through the single `isHostRealmApiExecution` accessor. Data fetchers and SSR
have their own flags. Agent streams are gated by `allowHostProjectCodeExecution`
alone, so on a shared runtime granted host project execution, API routes execute
in the same host realm as streams.

A runtime that cannot honour a configured isolation flag never fakes it. A
compiled binary cannot prepare isolated API route source
(`security/sandbox/isolation-capability.ts`), so `WORKER_ISOLATION_API=1` in a
compiled deployment resolves one of two ways, both logged once at startup: where
the operator has explicitly granted host project code execution through
`VERYFRONT_HOST_ALLOW_PROJECT_EXECUTION`, the flag is downgraded and execution
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`, 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
explicitly trusted local projects; remote requests fail closed before route
Expand Down
Loading