diff --git a/cli/commands/serve/proxy-extension-composition.ts b/cli/commands/serve/proxy-extension-composition.ts index eff11adb8d..2b5935cbd1 100644 --- a/cli/commands/serve/proxy-extension-composition.ts +++ b/cli/commands/serve/proxy-extension-composition.ts @@ -10,7 +10,7 @@ import { cliLogger } from "veryfront/utils/logger"; import { type ExtensionFactory, ExtensionLoader } from "veryfront/extensions"; import { importFirstPartyExtensionModule } from "veryfront/extensions/first-party-import"; -import { getEnv, setEnv } from "veryfront/platform/env"; +import { getEnv, setEnv } from "veryfront/platform"; import { createProxyShutdownAggregateError, type RegisterProxyShutdownHook, diff --git a/cli/commands/serve/proxy-runtime.ts b/cli/commands/serve/proxy-runtime.ts index f30a380b89..6ec4b01cd8 100644 --- a/cli/commands/serve/proxy-runtime.ts +++ b/cli/commands/serve/proxy-runtime.ts @@ -1,4 +1,4 @@ -import { getEnv, setEnv } from "veryfront/platform/env"; +import { getEnv, setEnv } from "veryfront/platform"; import { cliLogger } from "veryfront/utils/logger"; import denoConfig from "../../../deno.json" with { type: "json" }; import { ensureCliSchemaValidator } from "../../shared/default-contracts.ts"; diff --git a/deno.json b/deno.json index cc2af10f25..2ec1c9e05d 100644 --- a/deno.json +++ b/deno.json @@ -173,7 +173,7 @@ "./extensions/observability": "./src/extensions/observability/index.ts", "./extensions/websocket": "./src/extensions/websocket/index.ts", "./platform": "./src/platform/index.ts", - "./platform/env": "./src/platform/compat/process/env-public.ts", + "./platform/env": "./src/platform/env.ts", "./platform/path": "./src/platform/compat/path/index.ts", "./testing": "./src/testing/index.ts", "./testing/assert": "./src/testing/assert.ts", @@ -244,7 +244,7 @@ "veryfront/transforms/mdx-cache": "./src/transforms/mdx/esm-module-loader/cache/index.ts", "veryfront/observability/otlp-setup": "./src/observability/tracing/otlp-setup.ts", "veryfront/platform/esbuild-init": "./src/platform/compat/esbuild-init.ts", - "veryfront/platform/env": "./src/platform/compat/process/env-public.ts", + "veryfront/platform/env": "./src/platform/env.ts", "veryfront/platform/path": "./src/platform/compat/path/index.ts", "veryfront/platform/http": "./src/platform/compat/http/index.ts", "veryfront/cli": "./cli/main.ts", diff --git a/docs/api-reference/veryfront/index.client.md b/docs/api-reference/veryfront/index.client.md index 0a0276c2cc..3d6ba4add5 100644 --- a/docs/api-reference/veryfront/index.client.md +++ b/docs/api-reference/veryfront/index.client.md @@ -49,7 +49,7 @@ export function GET() { | `defineConfig` | Define a Veryfront project configuration object. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/config/define-config-core.ts#L4) | | `defineConfigWithEnv` | Define a Veryfront project configuration from the current environment name. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/config/define-config.ts#L8) | | `forbidden` | Create a 403 Forbidden response. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/http/responses.ts#L134) | -| `getEnv` | Read an environment variable from the active project scope. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L155) | +| `getEnv` | Read an environment variable from the active project scope. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L229) | | `json` | Create a JSON response with the correct content type. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/http/responses.ts#L67) | | `mergeConfigs` | Merge multiple partial Veryfront configuration objects into one config object. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/config/define-config-core.ts#L17) | | `notFound` | Render the 404 page from a data loader. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/helpers.ts#L63) | diff --git a/docs/api-reference/veryfront/index.md b/docs/api-reference/veryfront/index.md index 9f239ca103..3003064151 100644 --- a/docs/api-reference/veryfront/index.md +++ b/docs/api-reference/veryfront/index.md @@ -67,7 +67,7 @@ export function getServerData(ctx: DataContext) { | `defineConfig` | Define a Veryfront project configuration object. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/config/define-config-core.ts#L4) | | `defineConfigWithEnv` | Define a Veryfront project configuration from the current environment name. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/config/define-config.ts#L8) | | `forbidden` | Create a 403 Forbidden response. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/http/responses.ts#L134) | -| `getEnv` | Read an environment variable from the active project scope. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L155) | +| `getEnv` | Read an environment variable from the active project scope. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L229) | | `json` | Create a JSON response with the correct content type. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/http/responses.ts#L67) | | `mergeConfigs` | Merge multiple partial Veryfront configuration objects into one config object. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/config/define-config-core.ts#L17) | | `notFound` | Render the 404 page from a data loader. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/helpers.ts#L63) | diff --git a/docs/api-reference/veryfront/observability.md b/docs/api-reference/veryfront/observability.md index c8bb98cb3b..ccdbcd3abd 100644 --- a/docs/api-reference/veryfront/observability.md +++ b/docs/api-reference/veryfront/observability.md @@ -53,7 +53,6 @@ const result = await withSpan("load-data", async () => { | `getActiveContext` | Context for get active. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/tracing/index.ts#L98) | | `getErrorCollector` | Return error collector. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/error-collector.ts#L406) | | `getGlobalMetricsAPI` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/tracing/api-shim.ts#L684) | -| `getHostTelemetryEnv` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/tracing/telemetry-env.ts#L7) | | `getLogBuffer` | Return log buffer. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/log-buffer.ts#L231) | | `getMetricsState` | State for get metrics. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/metrics/index.ts#L38) | | `getTraceContext` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/observability/tracing/otlp-setup.ts#L651) | diff --git a/docs/api-reference/veryfront/platform.md b/docs/api-reference/veryfront/platform.md index 08cdbd2186..be3cb21d61 100644 --- a/docs/api-reference/veryfront/platform.md +++ b/docs/api-reference/veryfront/platform.md @@ -31,16 +31,16 @@ import { | `createKVStore` | Create a cross-runtime KV store. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/kv/factory.ts#L82) | | `createMockAdapter` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/adapters/mock.ts#L133) | | `cwd` | Return the current working directory. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/lifecycle.ts#L26) | -| `deleteEnv` | Delete a process environment variable. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L255) | +| `deleteEnv` | Delete a process environment variable. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L338) | | `enhanceAdapterWithFS` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/adapters/fs/integration.ts#L64) | -| `env` | Read and write process environment variables. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L57) | +| `env` | Read and write process environment variables. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L62) | | `execPath` | Get the executable path of the current runtime | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/lifecycle.ts#L332) | | `exists` | Return false for a missing path and propagate every other filesystem error. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L522) | | `exit` | Exit the process with an optional code (cross-runtime: Deno.exit or process.exit). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/lifecycle.ts#L18) | | `getAdapter` | Get the runtime adapter for the current environment | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/adapters/detect.ts#L24) | | `getArgs` | Get command-line arguments (cross-runtime: Deno.args or process.argv). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/lifecycle.ts#L10) | | `getDenoRuntime` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/runtime.ts#L33) | -| `getEnv` | Read an environment variable from the active project scope. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L155) | +| `getEnv` | Read an environment variable from the active project scope. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L229) | | `getLocalAdapter` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/adapters/registry.ts#L230) | | `getOsType` | Get the operating system type Returns: "darwin" (macOS), "linux", "windows", or the raw platform string | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/lifecycle.ts#L164) | | `getRuntimeVersion` | Get runtime version string | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/lifecycle.ts#L150) | @@ -65,7 +65,7 @@ import { | `remove` | Remove a file or directory, rejecting when the path does not exist. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L577) | | `resolveHostAddresses` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/dns.ts#L292) | | `runCommand` | Run a command and return the result. Works across Deno, Node.js, and Bun. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/command.ts#L449) | -| `setEnv` | Sets env. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L235) | +| `setEnv` | Sets env. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L309) | | `setRawMode` | Set raw mode on stdin (enables character-by-character input) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/stdin.ts#L55) | | `writeStdout` | Write text directly to stdout (sync) No-op if stdout is not available | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/lifecycle.ts#L377) | | `writeStdoutAsync` | Write data to stdout asynchronously Returns a promise that resolves when the write is complete | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/lifecycle.ts#L385) | @@ -108,29 +108,26 @@ These import paths group focused functionality under this module. Each is a sepa ### `veryfront/platform/env` -Project-scoped environment helpers for cross-runtime applications. Host environment access and the trusted project-snapshot bridge remain internal framework controls and are not exported from this module. +Public environment facade for the `veryfront/platform/env` subpath. Exposes only project-scoped readers. Privileged or mutating accessors (`getHostEnv`, `env`, `setEnv`, `deleteEnv`) stay internal so a tenant project cannot read or alter the host process environment through a supported package export. ```ts -import { deleteEnv, env, getEnv } from "veryfront/platform/env"; +import { getEnv, getEnvBoolean, getEnvNumber } from "veryfront/platform/env"; ``` #### Functions | Name | Description | Source | | --------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -| `deleteEnv` | Delete a process environment variable. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L255) | -| `env` | Read and write process environment variables. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L57) | -| `getEnv` | Read an environment variable from the active project scope. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L155) | -| `getEnvBoolean` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L209) | -| `getEnvNumber` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L195) | -| `getEnvString` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L187) | -| `setEnv` | Sets env. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L235) | +| `getEnv` | Read an environment variable from the active project scope. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L229) | +| `getEnvBoolean` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L283) | +| `getEnvNumber` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L269) | +| `getEnvString` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L261) | #### Types | Name | Description | Source | | ------------------- | ----------- | ------------------------------------------------------------------------------------------------------- | -| `EnvBooleanOptions` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L172) | +| `EnvBooleanOptions` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L246) | ### `veryfront/platform/path` diff --git a/docs/api-reference/veryfront/server.md b/docs/api-reference/veryfront/server.md index ecd6e64f47..4e81983217 100644 --- a/docs/api-reference/veryfront/server.md +++ b/docs/api-reference/veryfront/server.md @@ -54,7 +54,7 @@ await server.fetch(new Request("https://example.com/health")); | `parseProjectDomain` | Extract project slug and branch from domain/host header | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/utils/domain-parser.ts#L125) | | `startDevServer` | Starts dev server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/dev-server/index.ts#L20) | | `startNodeVeryfrontServer` | Starts node veryfront server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/service-server.ts#L575) | -| `startProductionServer` | Starts production server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/production-server.ts#L182) | +| `startProductionServer` | Starts production server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/production-server.ts#L183) | | `startServer` | Start a Veryfront server in development or production mode. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/index.ts#L531) | | `startVeryfrontServer` | Starts veryfront server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/service-server.ts#L555) | | `toNodeHandler` | Convert a Web API request handler into a Node.js HTTP listener. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/node-handler.ts#L5) | @@ -75,17 +75,17 @@ await server.fetch(new Request("https://example.com/health")); | `CreateVeryfrontServerOptions` | Options accepted by create veryfront server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/service-server.ts#L32) | | `DevServerHandler` | Public handler returned by a handler-only dev server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/dev-server/types.ts#L2) | | `DevServerOptions` | Options accepted by dev server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/dev-server/types.ts#L5) | -| `DiscoveryOptions` | Configuration for AI primitives discovery during server startup | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/production-server.ts#L120) | +| `DiscoveryOptions` | Configuration for AI primitives discovery during server startup | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/production-server.ts#L121) | | `FileWatcherMetrics` | Public API contract for file watcher metrics. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/dev-server/types.ts#L40) | | `GracefulProductionShutdownOptions` | Inputs required to drain and stop a production server process. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/graceful-shutdown.ts#L25) | | `HostedEnvironmentName` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/utils/domain-parser.ts#L49) | | `NodeVeryfrontServiceServer` | Public API contract for node veryfront service server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/service-server.ts#L83) | | `RouteDirectory` | Public API contract for route directory. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/dev-server/types.ts#L34) | -| `ServerHandle` | Public API contract for server handle. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/production-server.ts#L157) | +| `ServerHandle` | Public API contract for server handle. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/production-server.ts#L158) | | `StartDevModeOptions` | Options accepted by start dev mode. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/index.ts#L120) | | `StartNodeVeryfrontServerOptions` | Options accepted by start node veryfront server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/service-server.ts#L47) | | `StartProductionModeOptions` | Options accepted by start production mode. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/index.ts#L129) | -| `StartProductionServerOptions` | Options accepted by start production server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/production-server.ts#L163) | +| `StartProductionServerOptions` | Options accepted by start production server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/production-server.ts#L164) | | `StartServerOptions` | Server options. Defaults to development mode with HMR. Set `mode: "production"` for a production server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/index.ts#L145) | | `StartVeryfrontServerOptions` | Options accepted by start veryfront server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/service-server.ts#L59) | | `VeryfrontHandler` | Web API request handler with WebSocket upgrade and HMR helpers. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/index.ts#L160) | diff --git a/docs/api-reference/veryfront/testing.md b/docs/api-reference/veryfront/testing.md index 528cdc7482..8395f85d5a 100644 --- a/docs/api-reference/veryfront/testing.md +++ b/docs/api-reference/veryfront/testing.md @@ -35,8 +35,8 @@ describe("math", () => { | Name | Description | Source | | ------------------------ | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | -| `afterAll` | Register a hook after all BDD tests in a group. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L759) | -| `afterEach` | Register a hook after each BDD test. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L734) | +| `afterAll` | Register a hook after all BDD tests in a group. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L765) | +| `afterEach` | Register a hook after each BDD test. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L740) | | `assert` | Assert that a value is truthy. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/assert.ts#L286) | | `assertEquals` | Assert that two values are deeply equal. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/assert.ts#L271) | | `assertExists` | Assert that a value is not null or undefined. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/assert.ts#L291) | @@ -53,25 +53,25 @@ describe("math", () => { | `assertStrictEquals` | Assert that two values are strictly equal. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/assert.ts#L281) | | `assertStringIncludes` | Assert that a string contains another string. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/assert.ts#L316) | | `assertThrows` | Assert that a synchronous function throws. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/assert.ts#L296) | -| `beforeAll` | Register a hook before all BDD tests in a group. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L744) | -| `beforeEach` | Register a hook before each BDD test. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L725) | +| `beforeAll` | Register a hook before all BDD tests in a group. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L750) | +| `beforeEach` | Register a hook before each BDD test. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L731) | | `chmod` | Change file permissions, rejecting operational failures. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L599) | | `createFileSystem` | Create the runtime-native filesystem implementation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L449) | | `cwd` | Return the current working directory. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/lifecycle.ts#L26) | | `deepEquals` | ********************* Shared utility functions for cross-runtime testing. ********************* | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/utils.ts#L5) | | `delay` | Wait for a duration in milliseconds. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/deno-compat.ts#L123) | -| `deleteEnv` | Delete a process environment variable. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L255) | -| `describe` | Group related BDD tests. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L611) | -| `env` | Read and write process environment variables. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L57) | +| `deleteEnv` | Delete a process environment variable. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L338) | +| `describe` | Group related BDD tests. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L617) | +| `env` | Read and write process environment variables. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L62) | | `exists` | Return false for a missing path and propagate every other filesystem error. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L522) | | `exit` | Exit the current process. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/deno-compat.ts#L128) | | `fail` | Fail the current assertion immediately. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/assert.ts#L336) | | `getArgs` | Get command-line arguments (cross-runtime: Deno.args or process.argv). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/lifecycle.ts#L10) | -| `getEnv` | Read an environment variable from the active project scope. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L155) | +| `getEnv` | Read an environment variable from the active project scope. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L229) | | `getTestTimeScale` | Return the current test time scale. Preserved for compatibility. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/timing.ts#L4) | | `isAlreadyExistsError` | Error shape for is already exists. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L628) | | `isNotFoundError` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/not-found-error.ts#L210) | -| `it` | Define a BDD test case. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L667) | +| `it` | Define a BDD test case. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L673) | | `makeTempDir` | Atomically create a unique directory beneath the operating-system temp root. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L594) | | `makeTempDirWithOptions` | Options accepted by make temp dir with. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/deno-compat.ts#L73) | | `makeTempFile` | Create temp file. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/deno-compat.ts#L48) | @@ -84,7 +84,7 @@ describe("math", () => { | `resetAllTestState` | Comprehensive reset of ALL test state across the application. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/isolation.ts#L64) | | `safeStringify` | Serialize unknown values safely for test output. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/utils.ts#L34) | | `scaleMs` | Scale a duration for the current test runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/timing.ts#L9) | -| `setEnv` | Sets env. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L235) | +| `setEnv` | Sets env. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L309) | | `stat` | Read file metadata. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L527) | | `testDelay` | Wait for a test-scaled duration. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/timing.ts#L15) | | `waitFor` | Wait until a condition succeeds. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/deno-compat.ts#L99) | @@ -98,8 +98,8 @@ describe("math", () => { | Name | Description | Source | | ---------------- | ------------------------------------------------------ | -------------------------------------------------------------------------------------- | -| `BddTestContext` | Context passed to hooks and tests (BDD-specific) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L33) | -| `TestOptions` | Test options for Deno sanitizers (ignored in Node/Bun) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L22) | +| `BddTestContext` | Context passed to hooks and tests (BDD-specific) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L34) | +| `TestOptions` | Test options for Deno sanitizers (ignored in Node/Bun) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L23) | ### Constants @@ -108,7 +108,7 @@ describe("math", () => { | `isBun` | True if running in Bun runtime (Bun also exposes process.versions.node). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/runtime.ts#L94) | | `isDeno` | True if running in the real Deno runtime rather than a dnt shim. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/runtime.ts#L103) | | `isNode` | True if running in Node.js rather than a more specific compatible host. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/runtime.ts#L100) | -| `test` | Shared test value. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L774) | +| `test` | Shared test value. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L780) | ## Deep imports @@ -154,23 +154,23 @@ import { afterAll, afterEach, beforeAll } from "veryfront/testing/bdd"; | Name | Description | Source | | ------------ | ------------------------------------------------ | --------------------------------------------------------------------------------------- | -| `afterAll` | Register a hook after all BDD tests in a group. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L759) | -| `afterEach` | Register a hook after each BDD test. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L734) | -| `beforeAll` | Register a hook before all BDD tests in a group. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L744) | -| `beforeEach` | Register a hook before each BDD test. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L725) | -| `describe` | Group related BDD tests. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L611) | -| `initBdd` | Initialize the BDD test adapter. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L777) | -| `it` | Define a BDD test case. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L667) | +| `afterAll` | Register a hook after all BDD tests in a group. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L765) | +| `afterEach` | Register a hook after each BDD test. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L740) | +| `beforeAll` | Register a hook before all BDD tests in a group. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L750) | +| `beforeEach` | Register a hook before each BDD test. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L731) | +| `describe` | Group related BDD tests. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L617) | +| `initBdd` | Initialize the BDD test adapter. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L783) | +| `it` | Define a BDD test case. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L673) | #### Types | Name | Description | Source | | ---------------- | ------------------------------------------------------ | -------------------------------------------------------------------------------------- | -| `BddTestContext` | Context passed to hooks and tests (BDD-specific) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L33) | -| `TestOptions` | Test options for Deno sanitizers (ignored in Node/Bun) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L22) | +| `BddTestContext` | Context passed to hooks and tests (BDD-specific) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L34) | +| `TestOptions` | Test options for Deno sanitizers (ignored in Node/Bun) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L23) | #### Constants | Name | Description | Source | | ------ | ------------------ | --------------------------------------------------------------------------------------- | -| `test` | Shared test value. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L774) | +| `test` | Shared test value. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L780) | diff --git a/docs/architecture/20-support-matrix.md b/docs/architecture/20-support-matrix.md index 2d2d4d52a4..4bf98739a2 100644 --- a/docs/architecture/20-support-matrix.md +++ b/docs/architecture/20-support-matrix.md @@ -23,7 +23,7 @@ These are the runtime capability profiles modeled by the framework. | ------------------ | ---------- | ---------- | ----------------------------- | ------------------------------------------------------------------------------------- | | Deno | Yes | Yes | Yes | Primary local/runtime target in this repo. | | Node.js | Yes | Yes | Yes | Full runtime profile. | -| Bun | Yes | Yes | Yes | Full runtime profile. | +| Bun | Yes | Yes | Yes | Full runtime profile. Shared tenant execution uses isolated runtimes. | | Cloudflare Workers | No | No | Limited | Streaming is recommended; the runtime uses conservative step, CPU, and memory limits. | | Unknown runtime | No | No | Limited | Falls back to a constrained compatibility profile. | diff --git a/extensions/ext-eval-report-mlflow/deno.json b/extensions/ext-eval-report-mlflow/deno.json index 45c67d95ce..38aa1d2d09 100644 --- a/extensions/ext-eval-report-mlflow/deno.json +++ b/extensions/ext-eval-report-mlflow/deno.json @@ -49,7 +49,7 @@ "veryfront/eval": "../../src/eval/index.ts", "veryfront/extensions": "../../src/extensions/index.ts", "veryfront/extensions/eval": "../../src/extensions/eval/index.ts", - "veryfront/platform/env": "../../src/platform/compat/process/env.ts" + "veryfront/platform/env": "../../src/platform/env.ts" }, "tasks": { "test": "deno test --no-check --allow-all src/" diff --git a/extensions/ext-redis/deno.json b/extensions/ext-redis/deno.json index dce7a3a608..e15261db71 100644 --- a/extensions/ext-redis/deno.json +++ b/extensions/ext-redis/deno.json @@ -28,7 +28,7 @@ "veryfront/extensions/distributed/rate-limit-support": "../../src/extensions/distributed/rate-limit-support.ts", "veryfront/extensions/distributed/routing-invalidation-support": "../../src/extensions/distributed/routing-invalidation-support.ts", "veryfront/extensions/types": "../../src/extensions/types.ts", - "veryfront/platform/env": "../../src/platform/compat/process/env.ts", + "veryfront/platform/env": "../../src/platform/env.ts", "veryfront/observability": "../../src/observability/index.ts", "veryfront/observability/otlp-setup": "../../src/observability/tracing/otlp-setup.ts", "veryfront/utils/logger": "../../src/utils/logger/index.ts", diff --git a/scripts/build/npm-package-metadata.test.ts b/scripts/build/npm-package-metadata.test.ts index 71bc5d4d43..2ba222478d 100644 --- a/scripts/build/npm-package-metadata.test.ts +++ b/scripts/build/npm-package-metadata.test.ts @@ -83,7 +83,7 @@ it("keeps host environment controls outside public platform exports", async () = const exports = denoConfig.exports as Record; const imports = denoConfig.imports as Record; const paths = tsconfig.compilerOptions.paths as Record; - const publicEnvModule = "./src/platform/compat/process/env-public.ts"; + const publicEnvModule = "./src/platform/env.ts"; assertEquals(exports["./platform/env"], publicEnvModule); assertEquals(imports["veryfront/platform/env"], publicEnvModule); diff --git a/src/observability/index.test.ts b/src/observability/index.test.ts index c32aad3253..323938f180 100644 --- a/src/observability/index.test.ts +++ b/src/observability/index.test.ts @@ -22,7 +22,6 @@ const expectedRuntimeExports = [ "getActiveContext", "getErrorCollector", "getGlobalMetricsAPI", - "getHostTelemetryEnv", "getLogBuffer", "getMetricsState", "getTraceContext", @@ -108,5 +107,6 @@ describe("veryfront/observability public export surface", () => { it("does not expose process-wide error-reporter mutators", () => { assertEquals("initializeApplicationErrorReporter" in observability, false); assertEquals("setApplicationErrorReporter" in observability, false); + assertEquals("getHostTelemetryEnv" in observability, false); }); }); diff --git a/src/observability/index.ts b/src/observability/index.ts index d4fb8b0b28..8bfe7e9d37 100644 --- a/src/observability/index.ts +++ b/src/observability/index.ts @@ -96,10 +96,7 @@ export type { } from "./tracing/api-shim.ts"; // Shared-runtime telemetry environment helpers -export { - getHostTelemetryEnv, - isReservedSharedRuntimeTelemetryEnvKey, -} from "./tracing/telemetry-env.ts"; +export { isReservedSharedRuntimeTelemetryEnvKey } from "./tracing/telemetry-env.ts"; // Per-request profiling export { diff --git a/src/platform/compat/framework-source-resolver.test.ts b/src/platform/compat/framework-source-resolver.test.ts index 9f42b10d6c..6dc78165b1 100644 --- a/src/platform/compat/framework-source-resolver.test.ts +++ b/src/platform/compat/framework-source-resolver.test.ts @@ -5,6 +5,7 @@ import { FRAMEWORK_EMBEDDED_SRC_DIR, FRAMEWORK_SRC_DIR, getFrameworkSourceLookupDirs, + isPublicFrameworkSourceKey, resolveFrameworkSourcePath, resolveRelativeFrameworkSourceImport, } from "./framework-source-resolver.ts"; @@ -309,3 +310,24 @@ describe("framework-source-resolver (VULN-FS-3) — path containment", () => { assertEquals(result?.path, target); }); }); + +describe("framework-source-resolver public entry keys", () => { + it("accepts public export targets and rejects internal wrappers", () => { + assertEquals(isPublicFrameworkSourceKey("platform/env.js"), true); + assertEquals(isPublicFrameworkSourceKey("react/runtime/core.ts"), true); + assertEquals( + isPublicFrameworkSourceKey("react/public.js"), + true, + "the supported veryfront/react SSR override must remain loadable", + ); + assertEquals( + isPublicFrameworkSourceKey("workflow/react/index.js"), + true, + "the supported veryfront/workflow SSR override must remain loadable", + ); + assertEquals(isPublicFrameworkSourceKey("config/index.ts"), false); + assertEquals(isPublicFrameworkSourceKey("platform/compat/process/env-public.js"), false); + assertEquals(isPublicFrameworkSourceKey("observability/tracing/telemetry-env.ts"), false); + assertEquals(isPublicFrameworkSourceKey("platform/cloud/resolver.ts"), false); + }); +}); diff --git a/src/platform/compat/framework-source-resolver.ts b/src/platform/compat/framework-source-resolver.ts index a12b218ce6..5eb1a872e6 100644 --- a/src/platform/compat/framework-source-resolver.ts +++ b/src/platform/compat/framework-source-resolver.ts @@ -5,6 +5,7 @@ import { isCompiledBinary } from "#veryfront/utils/platform.ts"; import { createFileSystem, isNotFoundError } from "./fs.ts"; import { PUBLISHED_RUNTIME_HELPERS } from "./published-runtime-helpers.ts"; import { getFrameworkRoot, getFrameworkRootFromMeta } from "./vfs-paths.ts"; +import denoConfig from "#deno-config" with { type: "json" }; /** * Reject candidate paths that contain traversal indicators — plain `..`, @@ -33,6 +34,53 @@ export function isSafeFrameworkSourceKey(candidate: string): boolean { return !hasDangerousSegments(candidate); } +const FRAMEWORK_SOURCE_KEY_EXT_RE = /\.(?:src|tsx|ts|jsx|js|mjs|cjs|mdx|md|json)$/; + +function normalizeFrameworkSourceKey(candidate: string): string { + let normalized = candidate.replace(/\?.*$/, "").replace(/^\.\/src\//, "").replace(/\/+$/, ""); + while (FRAMEWORK_SOURCE_KEY_EXT_RE.test(normalized)) { + normalized = normalized.replace(FRAMEWORK_SOURCE_KEY_EXT_RE, ""); + } + return normalized; +} + +const publicConfig = denoConfig as { + exports?: Record; + imports?: Record; +}; +const publicExportSpecifiers = new Set( + Object.keys(publicConfig.exports ?? {}).map((specifier) => + specifier === "." ? "veryfront" : `veryfront/${specifier.replace(/^\.\//, "")}` + ), +); +const supportedFrameworkOverrideTargetSpecifiers = [ + "veryfront/index.client", + "veryfront/workflow/react", +] as const; +const PUBLIC_FRAMEWORK_SOURCE_KEYS = new Set( + [ + ...Object.values(publicConfig.exports ?? {}), + ...Object.entries(publicConfig.imports ?? {}) + .filter(([specifier]) => publicExportSpecifiers.has(specifier)) + .map(([, target]) => target), + ...supportedFrameworkOverrideTargetSpecifiers + .flatMap((targetSpecifier) => { + const target = publicConfig.imports?.[targetSpecifier]; + return target === undefined ? [] : [target]; + }), + // The SSR import map intentionally serves the browser-safe React barrel + // instead of the server-capable internal import target. + "./src/react/public.ts", + ] + .filter((target) => target.startsWith("./src/")) + .map(normalizeFrameworkSourceKey), +); + +/** Return whether a framework source key is the target of a public export. */ +export function isPublicFrameworkSourceKey(candidate: string): boolean { + return PUBLIC_FRAMEWORK_SOURCE_KEYS.has(normalizeFrameworkSourceKey(candidate)); +} + export const FRAMEWORK_ROOT = getFrameworkRootFromMeta(import.meta.url); export const FRAMEWORK_SRC_DIR = join(FRAMEWORK_ROOT, "src"); export const FRAMEWORK_EMBEDDED_SRC_DIR = join(FRAMEWORK_ROOT, "dist", "framework-src"); diff --git a/src/platform/compat/process.test.ts b/src/platform/compat/process.test.ts index f9fe011cab..d7b9e28395 100644 --- a/src/platform/compat/process.test.ts +++ b/src/platform/compat/process.test.ts @@ -128,6 +128,32 @@ describe("Process Compat", () => { assertEquals(getEnv(testKey), specialValue); }); + it("contains setEnv writes to the active project scope", () => { + setEnv(testKey, "host-value"); + + runWithProjectEnv({ [testKey]: "project-value" }, () => { + setEnv(testKey, "project-write"); + assertEquals(getEnv(testKey), "project-write"); + assertEquals(env()[testKey], "project-write"); + }); + + assertEquals(getEnv(testKey), "host-value"); + assertEquals(getHostEnv(testKey), "host-value"); + }); + + it("contains deleteEnv to the active project scope", () => { + setEnv(testKey, "host-value"); + + runWithProjectEnv({ [testKey]: "project-value" }, () => { + deleteEnv(testKey); + assertEquals(getEnv(testKey), undefined); + assertEquals(env()[testKey], undefined); + }); + + assertEquals(getEnv(testKey), "host-value"); + assertEquals(getHostEnv(testKey), "host-value"); + }); + it("keeps direct env readers aligned inside the test overlay", () => { setEnv(testKey, testValue); @@ -165,21 +191,25 @@ describe("Process Compat", () => { }); }); - it("returns undefined instead of throwing when an env read is denied", () => { + it("returns undefined instead of throwing when an env read is denied", async () => { // Under a tightened env permission allowlist (project isolation workers), // Deno.env.get throws NotCapable for a non-allowlisted key. getHostEnv must // degrade to undefined rather than propagating the throw and crashing the - // request. Simulate the denial by stubbing Deno.env.get. - if (typeof Deno === "undefined") return; - const original = Deno.env.get; - try { - Deno.env.get = () => { - throw new Error("Requires env access, run again with the --allow-env flag"); - }; - assertEquals(getHostEnv("__DENIED_BY_ALLOWLIST__"), undefined); - } finally { - Deno.env.get = original; - } + // request. Exercise the real permission boundary in a child process because + // the installed project-scoped Deno.env view is intentionally immutable. + if (!isDeno) return; + const envModuleUrl = new URL("./process/env.ts", import.meta.url).href; + const configPath = new URL("../../../deno.json", import.meta.url).pathname; + const script = `const { getHostEnv } = await import(${JSON.stringify(envModuleUrl)});` + + `console.log(String(getHostEnv("__DENIED_BY_ALLOWLIST__")));`; + const output = await new Deno.Command(Deno.execPath(), { + args: ["eval", "--config", configPath, script], + stdout: "piped", + stderr: "piped", + }).output(); + + assertEquals(output.success, true, new TextDecoder().decode(output.stderr)); + assertEquals(new TextDecoder().decode(output.stdout).trim(), "undefined"); }); }); diff --git a/src/platform/compat/process/env-public.ts b/src/platform/compat/process/env-public.ts deleted file mode 100644 index c456d38a6f..0000000000 --- a/src/platform/compat/process/env-public.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Project-scoped environment helpers for cross-runtime applications. - * - * Host environment access and the trusted project-snapshot bridge remain - * internal framework controls and are not exported from this module. - * - * @module platform/env - */ - -export { - deleteEnv, - env, - type EnvBooleanOptions, - getEnv, - getEnvBoolean, - getEnvNumber, - getEnvString, - setEnv, -} from "./env.ts"; diff --git a/src/platform/compat/process/env.test.ts b/src/platform/compat/process/env.test.ts index 3b1e41b0a3..364bf5379e 100644 --- a/src/platform/compat/process/env.test.ts +++ b/src/platform/compat/process/env.test.ts @@ -1,11 +1,117 @@ -import { assert, assertEquals } from "#veryfront/testing/assert.ts"; +import { assert, assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { isDeno } from "#veryfront/platform/compat/runtime.ts"; import { fromFileUrl } from "#std/path"; +import { registerTrustedProjectEnvSnapshot } from "./env.ts"; +import { createProjectScopedDenoEnvView } from "./scoped-process-env.ts"; const denoOnlyIt = isDeno ? it : it.skip; describe("host environment access", () => { + it("creates an immutable scoped Deno environment facade", () => { + const view = createProjectScopedDenoEnvView({ + get: () => undefined, + set: () => {}, + delete: () => {}, + has: () => false, + toObject: () => ({}), + }, () => undefined); + + assertEquals(Object.isFrozen(view), true); + for (const method of ["get", "set", "delete", "has", "toObject"] as const) { + const descriptor = Object.getOwnPropertyDescriptor(view, method); + assertEquals(descriptor?.writable, false); + assertEquals(descriptor?.configurable, false); + } + assertThrows(() => Object.defineProperty(view, "get", { value: () => "intercepted" })); + }); + + denoOnlyIt("passes only the active project environment to direct subprocesses", async () => { + const hostKey = "VF_SCOPE_SUBPROCESS_HOST_ONLY"; + const projectKey = "VF_SCOPE_SUBPROCESS_PROJECT_ONLY"; + const previousHost = Deno.env.get(hostKey); + Deno.env.set(hostKey, "host-secret"); + let snapshot: Readonly> | undefined; + registerTrustedProjectEnvSnapshot(() => snapshot); + snapshot = { [projectKey]: "project-value" }; + + try { + const output = await new Deno.Command(Deno.execPath(), { + args: [ + "eval", + `console.log(JSON.stringify({ host: Deno.env.get(${ + JSON.stringify(hostKey) + }) ?? null, project: Deno.env.get(${JSON.stringify(projectKey)}) ?? null }))`, + ], + stdout: "piped", + stderr: "piped", + }).output(); + assert(output.success, new TextDecoder().decode(output.stderr)); + assertEquals( + JSON.parse(new TextDecoder().decode(output.stdout).trim()), + { host: null, project: "project-value" }, + ); + } finally { + snapshot = undefined; + if (previousHost === undefined) Deno.env.delete(hostKey); + else Deno.env.set(hostKey, previousHost); + } + }); + + denoOnlyIt("loads the testing overlay after the project environment facade", async () => { + const storageUrl = new URL("../../../server/project-env/storage.ts", import.meta.url).href; + const bddUrl = new URL("../../../testing/bdd.ts", import.meta.url).href; + const source = ` + const { runWithProjectEnv } = await import(${JSON.stringify(storageUrl)}); + await import(${JSON.stringify(bddUrl)}); + Deno.env.set("VF_LATE_TEST_ROOT", "root-value"); + const scoped = runWithProjectEnv({ VF_LATE_TEST_PROJECT: "project-value" }, () => ({ + root: Deno.env.get("VF_LATE_TEST_ROOT") ?? null, + project: Deno.env.get("VF_LATE_TEST_PROJECT") ?? null, + processRoot: process.env.VF_LATE_TEST_ROOT ?? null, + processProject: process.env.VF_LATE_TEST_PROJECT ?? null, + })); + console.log(JSON.stringify({ + root: Deno.env.get("VF_LATE_TEST_ROOT") ?? null, + processRoot: process.env.VF_LATE_TEST_ROOT ?? null, + scoped, + })); + `; + const child = new Deno.Command(Deno.execPath(), { + args: [ + "run", + "--allow-env", + `--allow-read=${fromFileUrl(new URL("../../../../", import.meta.url))}`, + "-", + ], + clearEnv: true, + env: { DENO_TESTING: "1" }, + stdin: "piped", + stdout: "piped", + stderr: "piped", + }).spawn(); + const writer = child.stdin.getWriter(); + await writer.write(new TextEncoder().encode(source)); + await writer.close(); + const output = await child.output(); + const stderr = new TextDecoder().decode(output.stderr); + + assert(output.success, stderr); + assertEquals( + JSON.parse(new TextDecoder().decode(output.stdout).trim()), + { + root: "root-value", + processRoot: "root-value", + scoped: { + root: null, + project: "project-value", + processRoot: null, + processProject: "project-value", + }, + }, + ); + }); + denoOnlyIt("ignores forged test overlays when env permission is granted", async () => { const moduleUrl = new URL("./env.ts", import.meta.url).href; const source = ` diff --git a/src/platform/compat/process/env.ts b/src/platform/compat/process/env.ts index 863006e7d7..c173d95f46 100644 --- a/src/platform/compat/process/env.ts +++ b/src/platform/compat/process/env.ts @@ -1,9 +1,12 @@ import { getDenoRuntime, isDeno as IS_DENO } from "../runtime.ts"; import { hostProcessEnv, runtimeProcess } from "./runtime-process.ts"; import { + createProjectScopedDenoEnvView, + deleteProjectScopedEnv, installProjectScopedProcessEnv, projectScopedEnvRecord, readProjectScopedEnv, + writeProjectScopedEnv, } from "./scoped-process-env.ts"; import type { ProjectEnvSnapshot } from "./project-env-contract.ts"; @@ -11,6 +14,8 @@ type EnvOverlayValue = string | null; type EnvOverlayStore = Map; const apply = Reflect.apply; +const ObjectDefineProperty = Object.defineProperty; +const ObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; const denoRuntime = IS_DENO ? getDenoRuntime() : undefined; const denoEnv = denoRuntime?.env; const denoEnvGet = denoEnv?.get; @@ -124,6 +129,70 @@ export function getHostEnv(key: string): string | undefined { } let _trustedProjectEnvSnapshot: (() => ProjectEnvSnapshot | undefined) | null = null; +let denoEnvViewInstalled = false; +let denoCommandViewInstalled = false; + +function installProjectScopedDenoCommand( + getSnapshot: () => ProjectEnvSnapshot | undefined, +): void { + if (denoCommandViewInstalled || !denoRuntime) return; + + const HostCommand = denoRuntime.Command; + const hostOutput = HostCommand.prototype.output; + const hostOutputSync = HostCommand.prototype.outputSync; + const hostSpawn = HostCommand.prototype.spawn; + + class ProjectScopedCommand { + readonly #command: Deno.Command; + + constructor(command: string | URL, options: Deno.CommandOptions = {}) { + const snapshot = getSnapshot(); + const scopedOptions = snapshot === undefined ? options : { + ...options, + clearEnv: true, + env: { ...projectScopedEnvRecord(snapshot), ...options.env }, + }; + this.#command = new HostCommand(command, scopedOptions); + } + + output(): Promise { + return apply(hostOutput, this.#command, []); + } + + outputSync(): Deno.CommandOutput { + return apply(hostOutputSync, this.#command, []); + } + + spawn(): Deno.ChildProcess { + return apply(hostSpawn, this.#command, []); + } + } + + ObjectDefineProperty(denoRuntime, "Command", { + value: ProjectScopedCommand, + writable: false, + enumerable: true, + configurable: false, + }); + denoCommandViewInstalled = true; +} + +function installProjectScopedDenoEnv( + getSnapshot: () => ProjectEnvSnapshot | undefined, +): void { + if (denoEnvViewInstalled || !denoRuntime || !denoEnv) return; + + const descriptor = ObjectGetOwnPropertyDescriptor(denoRuntime, "env"); + const getOverlay = allowHostEnvTestOverlay ? getEnvOverlayStore : undefined; + const view = createProjectScopedDenoEnvView(denoEnv, getSnapshot, getOverlay); + ObjectDefineProperty(denoRuntime, "env", { + value: view, + writable: false, + enumerable: descriptor?.enumerable ?? true, + configurable: false, + }); + denoEnvViewInstalled = true; +} /** * Register the server-owned project environment snapshot bridge. @@ -142,8 +211,13 @@ export function registerTrustedProjectEnvSnapshot( if (_trustedProjectEnvSnapshot && _trustedProjectEnvSnapshot !== getter) { throw new Error("Project environment snapshot bridge is already registered"); } + installProjectScopedDenoEnv(getter); + installProjectScopedDenoCommand(getter); _trustedProjectEnvSnapshot = getter; - installProjectScopedProcessEnv(getTrustedProjectEnvSnapshot); + installProjectScopedProcessEnv( + getTrustedProjectEnvSnapshot, + allowHostEnvTestOverlay ? getEnvOverlayStore : undefined, + ); } /** Return the active server-owned project env snapshot, if registered. */ @@ -233,6 +307,15 @@ export function getEnvBoolean( /** Sets env. */ export function setEnv(key: string, value: string): void { + const projectEnv = getTrustedProjectEnvSnapshot(); + if (projectEnv !== undefined) { + // Same rule as getEnv() and the process.env view: while a project scope is + // active its snapshot is the whole environment, so a write stays contained + // to that scope instead of mutating the shared host process environment. + writeProjectScopedEnv(projectEnv, key, value); + return; + } + const overlay = getEnvOverlayStore(); if (overlay) { overlay.set(key, value); @@ -253,6 +336,13 @@ export function setEnv(key: string, value: string): void { /** Delete a process environment variable. */ export function deleteEnv(key: string): void { + const projectEnv = getTrustedProjectEnvSnapshot(); + if (projectEnv !== undefined) { + // Contained to the active project scope for the same reason as setEnv(). + deleteProjectScopedEnv(projectEnv, key); + return; + } + const overlay = getEnvOverlayStore(); if (overlay) { overlay.set(key, null); diff --git a/src/platform/compat/process/scoped-process-env.ts b/src/platform/compat/process/scoped-process-env.ts index b6eafa612c..047c907df2 100644 --- a/src/platform/compat/process/scoped-process-env.ts +++ b/src/platform/compat/process/scoped-process-env.ts @@ -39,6 +39,10 @@ import type { ProjectEnvSnapshot } from "./project-env-contract.ts"; type EnvRecord = Record; +type EnvOverlayStore = Map; + +/** Returns the active host-only test overlay, or null outside test execution. */ +export type EnvOverlayGetter = () => EnvOverlayStore | null; /** Returns the active project environment snapshot, or undefined outside one. */ export type ProjectEnvSnapshotGetter = () => ProjectEnvSnapshot | undefined; @@ -49,6 +53,8 @@ type ScopedWrites = Map; const ObjectKeys = Object.keys; const ObjectCreate = Object.create; const ObjectDefineProperty = Object.defineProperty; +const ObjectFreeze = Object.freeze; +const ReflectApply = Reflect.apply; const ObjectGetPrototypeOf = Object.getPrototypeOf; const ReflectDefineProperty = Reflect.defineProperty; const ReflectDeleteProperty = Reflect.deleteProperty; @@ -59,10 +65,58 @@ const ReflectOwnKeys = Reflect.ownKeys; const ReflectSet = Reflect.set; const ReflectSetPrototypeOf = Reflect.setPrototypeOf; const INSPECT_CUSTOM = Symbol.for("nodejs.util.inspect.custom"); +const testOverlayAwareDenoEnvViews = new WeakSet(); // Keyed by the snapshot object, which the scope owns for exactly its lifetime. const writesBySnapshot = new WeakMap(); +/** Minimal contract implemented by the Deno environment capability. */ +export interface DenoEnvView { + get(key: string): string | undefined; + set(key: string, value: string): void; + delete(key: string): void; + has(key: string): boolean; + toObject(): Record; +} + +/** Whether a Deno environment view already composes with the supported test overlay. */ +export function isTestOverlayAwareDenoEnvView(value: unknown): boolean { + return typeof value === "object" && value !== null && testOverlayAwareDenoEnvViews.has(value); +} + +function readOverlay( + getOverlay: EnvOverlayGetter | undefined, + key: string, +): { found: boolean; value: string | undefined } { + const overlay = getOverlay?.(); + if (!overlay?.has(key)) return { found: false, value: undefined }; + return { found: true, value: overlay.get(key) ?? undefined }; +} + +function recordOverlay( + getOverlay: EnvOverlayGetter | undefined, + key: string, + value: string | null, +): boolean { + const overlay = getOverlay?.(); + if (!overlay) return false; + overlay.set(key, value); + return true; +} + +function applyOverlay( + record: Record, + getOverlay: EnvOverlayGetter | undefined, +): Record { + const overlay = getOverlay?.(); + if (!overlay) return record; + for (const [key, value] of overlay) { + if (value === null) delete record[key]; + else record[key] = value; + } + return record; +} + function writesFor(snapshot: ProjectEnvSnapshot): ScopedWrites { const existing = writesBySnapshot.get(snapshot); if (existing) return existing; @@ -100,6 +154,90 @@ export function readProjectScopedEnv( return readScoped(snapshot, key); } +/** + * Record a write against the active snapshot instead of the host environment. + * + * Exported so the mutating accessors (`setEnv()`) apply writes through exactly + * the same rule as the raw `process.env` view: while a project scope is + * active, its snapshot owns the whole environment and a write must stay + * contained to that scope rather than reaching the shared host process. + */ +export function writeProjectScopedEnv( + snapshot: ProjectEnvSnapshot, + key: string, + value: string, +): void { + writesFor(snapshot).set(key, value); +} + +/** Record a deletion against the active snapshot (masks the snapshot entry). */ +export function deleteProjectScopedEnv( + snapshot: ProjectEnvSnapshot, + key: string, +): void { + writesFor(snapshot).set(key, null); +} + +/** + * Create an ambient-scope-aware view over the Deno environment capability. + * + * The host methods are captured before tenant code runs. Project reads and + * writes use the same snapshot log as process.env, while calls outside a + * project scope preserve native Deno behavior and permission checks. + */ +export function createProjectScopedDenoEnvView( + hostEnv: DenoEnvView, + getSnapshot: ProjectEnvSnapshotGetter, + getOverlay?: EnvOverlayGetter, +): DenoEnvView { + const hostGet = hostEnv.get; + const hostSet = hostEnv.set; + const hostDelete = hostEnv.delete; + const hostHas = hostEnv.has; + const hostToObject = hostEnv.toObject; + + const view: DenoEnvView = { + get(key) { + const snapshot = getSnapshot(); + if (snapshot !== undefined) return readScoped(snapshot, key); + const overlay = readOverlay(getOverlay, key); + return overlay.found ? overlay.value : ReflectApply(hostGet, hostEnv, [key]); + }, + set(key, value) { + const snapshot = getSnapshot(); + if (snapshot === undefined) { + if (recordOverlay(getOverlay, key, value)) return; + ReflectApply(hostSet, hostEnv, [key, value]); + return; + } + writeProjectScopedEnv(snapshot, key, value); + }, + delete(key) { + const snapshot = getSnapshot(); + if (snapshot === undefined) { + if (recordOverlay(getOverlay, key, null)) return; + ReflectApply(hostDelete, hostEnv, [key]); + return; + } + deleteProjectScopedEnv(snapshot, key); + }, + has(key) { + const snapshot = getSnapshot(); + if (snapshot !== undefined) return readScoped(snapshot, key) !== undefined; + const overlay = readOverlay(getOverlay, key); + return overlay.found ? overlay.value !== undefined : ReflectApply(hostHas, hostEnv, [key]); + }, + toObject() { + const snapshot = getSnapshot(); + return snapshot === undefined + ? applyOverlay(ReflectApply(hostToObject, hostEnv, []), getOverlay) + : projectScopedEnvRecord(snapshot); + }, + }; + if (getOverlay) testOverlayAwareDenoEnvViews.add(view); + return ObjectFreeze(view); +} + /** The scoped view as a plain record, for the bulk accessor. */ export function projectScopedEnvRecord( snapshot: ProjectEnvSnapshot, @@ -179,6 +317,7 @@ function assertEnvDataDescriptor( function createHostViewHandler( hostEnv: EnvRecord, getSnapshot: ProjectEnvSnapshotGetter, + getOverlay?: EnvOverlayGetter, ): ProxyHandler { /** Resolve the snapshot for a string key, or undefined to defer to the host record. */ const scopeFor = (prop: string | symbol): ProjectEnvSnapshot | undefined => @@ -188,43 +327,87 @@ function createHostViewHandler( get(target, prop) { if (prop === INSPECT_CUSTOM) return ReflectGet(target, prop); const snapshot = scopeFor(prop); - if (!snapshot) return ReflectGet(hostEnv, prop); + if (!snapshot) { + if (typeof prop === "string") { + const overlay = readOverlay(getOverlay, prop); + if (overlay.found) return overlay.value; + } + return ReflectGet(hostEnv, prop); + } return readScoped(snapshot, prop as string); }, set(_target, prop, value) { const snapshot = scopeFor(prop); - if (!snapshot) return ReflectSet(hostEnv, prop, value); + if (!snapshot) { + if (typeof prop === "string" && recordOverlay(getOverlay, prop, String(value))) return true; + return ReflectSet(hostEnv, prop, value); + } recordScopedWrite(snapshot, prop as string, String(value)); return true; }, deleteProperty(_target, prop) { const snapshot = scopeFor(prop); - if (!snapshot) return ReflectDeleteProperty(hostEnv, prop); + if (!snapshot) { + if (typeof prop === "string" && recordOverlay(getOverlay, prop, null)) return true; + return ReflectDeleteProperty(hostEnv, prop); + } recordScopedWrite(snapshot, prop as string, null); return true; }, has(target, prop) { if (prop === INSPECT_CUSTOM) return ReflectHas(target, prop); const snapshot = scopeFor(prop); - if (!snapshot) return ReflectHas(hostEnv, prop); + if (!snapshot) { + if (typeof prop === "string") { + const overlay = readOverlay(getOverlay, prop); + if (overlay.found) return overlay.value !== undefined; + } + return ReflectHas(hostEnv, prop); + } return readScoped(snapshot, prop as string) !== undefined; }, ownKeys(_target) { const snapshot = getSnapshot(); - if (!snapshot) return ReflectOwnKeys(hostEnv); + if (!snapshot) { + const overlay = getOverlay?.(); + if (!overlay) return ReflectOwnKeys(hostEnv); + const keys = new Set(ReflectOwnKeys(hostEnv)); + for (const [key, value] of overlay) { + if (value === null) keys.delete(key); + else keys.add(key); + } + return [...keys]; + } return scopedKeys(snapshot); }, getOwnPropertyDescriptor(target, prop) { if (prop === INSPECT_CUSTOM) return ReflectGetOwnPropertyDescriptor(target, prop); const snapshot = scopeFor(prop); - if (!snapshot) return ReflectGetOwnPropertyDescriptor(hostEnv, prop); + if (!snapshot) { + if (typeof prop === "string") { + const overlay = readOverlay(getOverlay, prop); + if (overlay.found) { + return overlay.value === undefined + ? undefined + : { value: overlay.value, writable: true, enumerable: true, configurable: true }; + } + } + return ReflectGetOwnPropertyDescriptor(hostEnv, prop); + } const value = readScoped(snapshot, prop as string); if (value === undefined) return undefined; return { value, writable: true, enumerable: true, configurable: true }; }, defineProperty(_target, prop, descriptor) { const snapshot = scopeFor(prop); - if (!snapshot) return ReflectDefineProperty(hostEnv, prop, descriptor); + if (!snapshot) { + if (typeof prop === "string" && getOverlay?.()) { + assertEnvDataDescriptor(descriptor); + recordOverlay(getOverlay, prop, String(descriptor.value)); + return true; + } + return ReflectDefineProperty(hostEnv, prop, descriptor); + } assertEnvDataDescriptor(descriptor); recordScopedWrite(snapshot, prop as string, String(descriptor.value)); return true; @@ -253,6 +436,7 @@ function createHostViewHandler( export function createProjectScopedProcessEnvView( hostEnv: EnvRecord, getSnapshot: ProjectEnvSnapshotGetter, + getOverlay?: EnvOverlayGetter, ): EnvRecord { const target = ObjectCreate(ObjectGetPrototypeOf(hostEnv)) as EnvRecord; ObjectDefineProperty(target, "", { @@ -264,11 +448,13 @@ export function createProjectScopedProcessEnvView( ObjectDefineProperty(target, INSPECT_CUSTOM, { value: () => { const snapshot = getSnapshot(); - return snapshot ? projectScopedEnvRecord(snapshot) : { ...hostEnv }; + return snapshot + ? projectScopedEnvRecord(snapshot) + : applyOverlay({ ...hostEnv } as Record, getOverlay); }, configurable: true, }); - return new Proxy(target, createHostViewHandler(hostEnv, getSnapshot)); + return new Proxy(target, createHostViewHandler(hostEnv, getSnapshot, getOverlay)); } /** @@ -320,6 +506,7 @@ let installed = false; */ export function installProjectScopedProcessEnv( getSnapshot: ProjectEnvSnapshotGetter, + getOverlay?: EnvOverlayGetter, ): void { if (installed) return; @@ -328,7 +515,7 @@ export function installProjectScopedProcessEnv( if (!processLike || !hostEnv) return; installed = true; - const hostView = createProjectScopedProcessEnvView(hostEnv, getSnapshot); + const hostView = createProjectScopedProcessEnvView(hostEnv, getSnapshot, getOverlay); try { ObjectDefineProperty(processLike, "env", { get: () => hostView, diff --git a/src/platform/env.test.ts b/src/platform/env.test.ts new file mode 100644 index 0000000000..87bfe131ee --- /dev/null +++ b/src/platform/env.test.ts @@ -0,0 +1,24 @@ +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import * as publicEnv from "./env.ts"; +import denoConfig from "#deno-config" with { type: "json" }; + +describe("platform/env", () => { + it("only exports the project-scoped environment readers", () => { + assertEquals(Object.keys(publicEnv).sort(), [ + "getEnv", + "getEnvBoolean", + "getEnvNumber", + "getEnvString", + ]); + }); + + it("backs both package maps with the restricted facade", () => { + const config = denoConfig as { + exports: Record; + imports: Record; + }; + assertEquals(config.exports["./platform/env"], "./src/platform/env.ts"); + assertEquals(config.imports["veryfront/platform/env"], "./src/platform/env.ts"); + }); +}); diff --git a/src/platform/env.ts b/src/platform/env.ts new file mode 100644 index 0000000000..2d5c7a7d2d --- /dev/null +++ b/src/platform/env.ts @@ -0,0 +1,25 @@ +/** + * Public environment facade for the `veryfront/platform/env` subpath. + * + * Exposes only project-scoped readers. Privileged or mutating accessors + * (`getHostEnv`, `env`, `setEnv`, `deleteEnv`) stay internal so a tenant + * project cannot read or alter the host process environment through a + * supported package export. + * + * @module platform/env + */ + +/** Options accepted by the boolean environment reader. */ +export type { EnvBooleanOptions } from "./compat/process/env.ts"; + +/** Read an environment variable from the active project scope. */ +export { getEnv } from "./compat/process/env.ts"; + +/** Read a boolean environment variable from the active project scope. */ +export { getEnvBoolean } from "./compat/process/env.ts"; + +/** Read an integer environment variable from the active project scope. */ +export { getEnvNumber } from "./compat/process/env.ts"; + +/** Read a string environment variable with an optional fallback. */ +export { getEnvString } from "./compat/process/env.ts"; diff --git a/src/proxy/tracing.ts b/src/proxy/tracing.ts index 941cf991e0..c47e979af3 100644 --- a/src/proxy/tracing.ts +++ b/src/proxy/tracing.ts @@ -28,7 +28,7 @@ import { trace as shimTrace, type Tracer, } from "#veryfront/observability/tracing/api-shim.ts"; -import { getHostTelemetryEnv } from "#veryfront/observability"; +import { getHostTelemetryEnv } from "#veryfront/observability/tracing/telemetry-env.ts"; import type { TracingExporter } from "#veryfront/extensions/observability/tracing-exporter.ts"; import { importFirstPartyExtensionModule, diff --git a/src/security/README.md b/src/security/README.md index a27a352f75..10e0e8cdfb 100644 --- a/src/security/README.md +++ b/src/security/README.md @@ -419,8 +419,8 @@ 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 keeps the requested isolation posture and API ownership -returns the typed `project-execution-unavailable` 503 naming it. The broad -`VERYFRONT_HOST_ALLOW_PROJECT_EXECUTION` grant does not override the +returns the typed `project-execution-unavailable` 503 naming it. The deprecated +`VERYFRONT_HOST_ALLOW_PROJECT_EXECUTION` setting does not override the API-specific isolation flag. OpenAPI metadata is currently attached to handler functions. Because reading @@ -435,29 +435,21 @@ dedicated single-project runtimes grant the capability at their host-owned entrypoints. Shared proxy runtimes reject these operations before reading or evaluating tenant modules. -## Operator-granted shared execution - -`VERYFRONT_HOST_ALLOW_PROJECT_EXECUTION=1` grants the host-execution capability -to a shared runtime whose deployment intends that runtime to _be_ the project -executor. Absent and unrecognized values fail closed, matching -`VERYFRONT_HOST_ALLOW_INTERNAL_EGRESS`. - -The override is read exactly once, at server startup in -`server/production-server.ts`, and the resulting capability is fixed into the -handler for the process lifetime. It is read through `getHostEnv`, which -bypasses the project env overlay, so a project environment variable of the -same name cannot grant execution. Reading once at startup keeps a deployment's -posture fixed and declared in a single place. - -This is a deliberate posture, not a bypass. With the override set, tenant -project code is evaluated in the shared host process. Per-request separation is -the `runWithContext` source scope and the project-scoped registry transaction, -not a process, memory, or CPU boundary between tenants. Deno Workers do not -change that; they share the host process. - -Operators who need a genuine tenant boundary must leave the override unset and -route execution to an external or dedicated isolated project runtime. Unsetting -it re-arms every surface above with no code change. +## Deprecated shared execution override + +`VERYFRONT_HOST_ALLOW_PROJECT_EXECUTION` no longer grants host execution. A +shared runtime always rejects same-process tenant execution and routes it to a +dedicated isolated project runtime. A dedicated single-project runtime already +has the required capability, so the setting changes no supported topology. + +Veryfront still reads an affirmative value at server startup so it can report +stale deployment configuration. The startup warning states that the setting is +ignored and explains whether the runtime is shared or already dedicated. The +read uses `getHostEnv`, so a project environment variable cannot manufacture +the operator diagnostic. + +Remove the deprecated setting. Shared multi-project execution must use a +separately limited external process or container. `WORKER_ISOLATION_SSR=1` additionally requires explicit registration of an `IsolatedSsrRendererProvider`. The provider supplies a local, offline renderer diff --git a/src/security/host-execution-policy.test.ts b/src/security/host-execution-policy.test.ts index 565e485244..09e9fd3892 100644 --- a/src/security/host-execution-policy.test.ts +++ b/src/security/host-execution-policy.test.ts @@ -5,10 +5,26 @@ import { withEnv } from "#veryfront/testing"; import { runWithProjectEnv } from "#veryfront/server/project-env/storage.ts"; import { HOST_PROJECT_EXECUTION_OVERRIDE_ENV, - isHostProjectExecutionOverrideEnabled, + isHostProjectExecutionOverrideConfigured, + resolveHostProjectExecutionPosture, } from "./host-execution-policy.ts"; describe("security/host-execution-policy operator override", () => { + it("treats the deprecated override as ignored in every runtime topology", () => { + assertEquals( + resolveHostProjectExecutionPosture({ sharedRuntime: true, overrideConfigured: true }), + { allowHostProjectCodeExecution: false, overrideIgnored: true }, + ); + assertEquals( + resolveHostProjectExecutionPosture({ sharedRuntime: false, overrideConfigured: true }), + { allowHostProjectCodeExecution: true, overrideIgnored: true }, + ); + assertEquals( + resolveHostProjectExecutionPosture({ sharedRuntime: false, overrideConfigured: false }), + { allowHostProjectCodeExecution: true, overrideIgnored: false }, + ); + }); + it("names the operator-owned environment variable", () => { assertEquals( HOST_PROJECT_EXECUTION_OVERRIDE_ENV, @@ -17,20 +33,20 @@ describe("security/host-execution-policy operator override", () => { ); }); - it("denies execution when the override is absent", () => { + it("reports the override as unconfigured when it is absent", () => { assertEquals( - isHostProjectExecutionOverrideEnabled(undefined), + isHostProjectExecutionOverrideConfigured(undefined), false, - "an unset override must leave the shared runtime denied", + "an unset override must be reported as unconfigured", ); }); - it("grants execution for accepted affirmative values", () => { + it("recognizes accepted affirmative values", () => { for (const value of ["1", "true", "yes", "on", " TRUE ", "On"]) { assertEquals( - isHostProjectExecutionOverrideEnabled(value), + isHostProjectExecutionOverrideConfigured(value), true, - `"${value}" should grant host project execution`, + `"${value}" should identify the deprecated override`, ); } }); @@ -38,9 +54,9 @@ describe("security/host-execution-policy operator override", () => { it("fails closed for negative, empty, and unrecognized values", () => { for (const value of ["", " ", "0", "false", "no", "off", "maybe", "2", "enabled"]) { assertEquals( - isHostProjectExecutionOverrideEnabled(value), + isHostProjectExecutionOverrideConfigured(value), false, - `"${value}" must not grant host project execution`, + `"${value}" must not identify an active override`, ); } }); @@ -51,7 +67,7 @@ describe("security/host-execution-policy operator override", () => { // never executed and the wiring could silently break. await withEnv({ [HOST_PROJECT_EXECUTION_OVERRIDE_ENV]: "1" }, () => { assertEquals( - isHostProjectExecutionOverrideEnabled(), + isHostProjectExecutionOverrideConfigured(), true, "the override must be readable from the host environment", ); @@ -60,7 +76,7 @@ describe("security/host-execution-policy operator override", () => { await withEnv({ [HOST_PROJECT_EXECUTION_OVERRIDE_ENV]: "0" }, () => { assertEquals( - isHostProjectExecutionOverrideEnabled(), + isHostProjectExecutionOverrideConfigured(), false, "a negative host value must fail closed", ); @@ -70,14 +86,14 @@ describe("security/host-execution-policy operator override", () => { it("uses the host environment rather than project env", async () => { // getHostEnv deliberately bypasses the project env overlay, so a project - // environment variable of the same name cannot grant host execution. A + // environment variable of the same name cannot configure the host. A // competing project scope has to be registered for that to mean anything. await withEnv({ [HOST_PROJECT_EXECUTION_OVERRIDE_ENV]: "0" }, () => { runWithProjectEnv({ [HOST_PROJECT_EXECUTION_OVERRIDE_ENV]: "1" }, () => { assertEquals( - isHostProjectExecutionOverrideEnabled(), + isHostProjectExecutionOverrideConfigured(), false, - "a project env value must never grant host-realm project execution", + "a project env value must never configure the host-owned override", ); }); return Promise.resolve(); @@ -86,9 +102,9 @@ describe("security/host-execution-policy operator override", () => { await withEnv({ [HOST_PROJECT_EXECUTION_OVERRIDE_ENV]: "1" }, () => { runWithProjectEnv({ [HOST_PROJECT_EXECUTION_OVERRIDE_ENV]: "0" }, () => { assertEquals( - isHostProjectExecutionOverrideEnabled(), + isHostProjectExecutionOverrideConfigured(), true, - "the host grant must still win while a project overlay is active", + "the host configuration must remain visible while a project overlay is active", ); }); return Promise.resolve(); diff --git a/src/security/host-execution-policy.ts b/src/security/host-execution-policy.ts index 215ff4e6fd..a2a0f467e2 100644 --- a/src/security/host-execution-policy.ts +++ b/src/security/host-execution-policy.ts @@ -1,21 +1,14 @@ /** - * Operator-owned host execution posture. + * Deprecated operator-owned host execution override. * * A shared multi-project runtime denies tenant code execution by default, and - * routes the request to a dedicated isolated project runtime instead. Some - * deployments intend the shared runtime to *be* the executor. Those operators - * grant the capability explicitly through this override at the host-owned - * entrypoint, where it is logged at startup. + * routes the request to a dedicated isolated project runtime instead. Dedicated + * single-project runtimes already carry the host execution capability. The + * former override therefore grants no additional topology and is retained only + * so startup can identify and report stale operator configuration. * - * The override does not weaken any other boundary. Worker permissions, outbound - * egress policy, and credential binding are unaffected; it only supplies the - * `allowHostProjectCodeExecution` capability that every execution surface - * already consults. - * - * Read this once at startup rather than per request. `getHostEnv` already - * bypasses the project env overlay, so a project variable of the same name - * cannot grant execution; reading once keeps the deployment's posture fixed - * for the process lifetime and visible in one place. + * `getHostEnv` bypasses the project env overlay, so a project variable of the + * same name cannot manufacture the diagnostic. */ import { getHostEnv } from "#veryfront/platform/compat/process.ts"; @@ -23,11 +16,10 @@ import { getHostEnv } from "#veryfront/platform/compat/process.ts"; export const HOST_PROJECT_EXECUTION_OVERRIDE_ENV = "VERYFRONT_HOST_ALLOW_PROJECT_EXECUTION"; /** - * Read the operator override. Absent and unrecognized values fail closed, which - * matches `isInternalEgressOverrideEnabled` for - * `VERYFRONT_HOST_ALLOW_INTERNAL_EGRESS`. + * Detect the deprecated operator override. Absent and unrecognized values are + * treated as unconfigured. */ -export function isHostProjectExecutionOverrideEnabled( +export function isHostProjectExecutionOverrideConfigured( value: string | undefined = getHostEnv(HOST_PROJECT_EXECUTION_OVERRIDE_ENV), ): boolean { if (value === undefined) return false; @@ -41,3 +33,17 @@ export function isHostProjectExecutionOverrideEnabled( return false; } } + +/** @deprecated The override no longer enables host execution. */ +export const isHostProjectExecutionOverrideEnabled = isHostProjectExecutionOverrideConfigured; + +/** Resolve the only two supported production execution topologies. */ +export function resolveHostProjectExecutionPosture(options: { + sharedRuntime: boolean; + overrideConfigured: boolean; +}): { allowHostProjectCodeExecution: boolean; overrideIgnored: boolean } { + return { + allowHostProjectCodeExecution: !options.sharedRuntime, + overrideIgnored: options.overrideConfigured, + }; +} diff --git a/src/security/project-locality.test.ts b/src/security/project-locality.test.ts index a71626400b..bf377a0829 100644 --- a/src/security/project-locality.test.ts +++ b/src/security/project-locality.test.ts @@ -1,7 +1,13 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; -import { isSharedProjectRuntime, requiresIsolatedProjectRuntime } from "./project-locality.ts"; +import { isBun } from "#veryfront/platform/compat/runtime.ts"; +import { + isExplicitHostProjectCodeExecutionAllowed, + isHostProjectCodeExecutionAllowed, + isSharedProjectRuntime, + requiresIsolatedProjectRuntime, +} from "./project-locality.ts"; describe("security/project-locality shared runtime topology", () => { it("recognizes hosted-config and multi-project runtime boundaries", () => { @@ -95,11 +101,11 @@ describe("security/project-locality isolated runtime requirement", () => { ); }); - it("allows execution once the host-owned entrypoint grants the capability", () => { + it("denies shared host execution even when the entrypoint grants the capability", () => { assertEquals( requiresIsolatedProjectRuntime({ ...sharedRuntime, allowHostProjectCodeExecution: true }), - false, - "an operator-granted shared executor may run project code", + true, + "ambient runtime channels keep shared host execution unsafe", ); }); @@ -119,6 +125,24 @@ describe("security/project-locality isolated runtime requirement", () => { ); }); + it("keeps the host capability usable in dedicated Bun runtimes", () => { + if (!isBun) return; + const capableDedicatedRuntime = { + ...dedicatedRuntime, + allowHostProjectCodeExecution: true, + }; + assertEquals(isExplicitHostProjectCodeExecutionAllowed(capableDedicatedRuntime), true); + assertEquals(isHostProjectCodeExecutionAllowed(capableDedicatedRuntime), true); + assertEquals( + isHostProjectCodeExecutionAllowed({ + ...sharedRuntime, + allowHostProjectCodeExecution: true, + }), + false, + "the topology-aware boundary must still deny shared Bun execution", + ); + }); + it("fails closed when the topology signal is ambiguous", () => { assertEquals( requiresIsolatedProjectRuntime({ @@ -140,6 +164,25 @@ describe("security/project-locality isolated runtime requirement", () => { ); }); + it("uses one topology snapshot for the host-execution decision", () => { + let calls = 0; + const runtime = { + allowHostProjectCodeExecution: true, + adapter: { + fs: { + isMultiProjectMode: () => calls++ === 0, + }, + }, + }; + + assertEquals( + requiresIsolatedProjectRuntime(runtime), + true, + "a shared topology snapshot must stay authoritative for the whole decision", + ); + assertEquals(calls, 1, "the topology provider must be sampled once"); + }); + it("rejects capabilities that are not own boolean data properties", () => { // Defined on the object itself: spreading an accessor would invoke the // getter and silently produce the data property this test must reject. diff --git a/src/security/project-locality.ts b/src/security/project-locality.ts index 4c7a2a3293..7295a131c6 100644 --- a/src/security/project-locality.ts +++ b/src/security/project-locality.ts @@ -95,16 +95,25 @@ export function isExplicitlyLocalProject(value: unknown): boolean { } /** - * Host-realm project-code execution requires an explicit runtime capability. + * Host-realm project-code execution requires a dedicated runtime capability. * * An explicitly local development project retains the historical capability. - * Standalone production runtimes can grant the narrower capability without + * A standalone production runtime can grant the narrower capability without * enabling development-only rendering, caching, diagnostics, or HTTP policy. - * Every ambiguous value fails closed. + * A shared runtime cannot grant it because ambient runtime channels can expose + * host state outside the scoped environment facade. Every ambiguous value + * fails closed. */ export function isHostProjectCodeExecutionAllowed(value: unknown): boolean { + return isHostProjectCodeExecutionAllowedForTopology(value, isSharedProjectRuntime(value)); +} + +function isHostProjectCodeExecutionAllowedForTopology( + value: unknown, + sharedRuntime: boolean, +): boolean { return isExplicitlyLocalProject(value) || - isExplicitHostProjectCodeExecutionAllowed(value); + (!sharedRuntime && isExplicitHostProjectCodeExecutionAllowed(value)); } /** @@ -121,16 +130,16 @@ export function isExplicitHostProjectCodeExecutionAllowed( /** * Decide whether a surface must refuse to execute tenant project code. * - * Execution is denied only when the runtime is shared *and* its host-owned - * entrypoint did not grant the host-execution capability. Local development, - * dedicated single-project runtimes, and operator-granted shared executors all - * carry the capability. Every ambiguous value fails closed. + * Execution is denied when the runtime is shared and the project is not an + * explicit local-development project. Dedicated single-project runtimes can + * use the host-owned capability. Every ambiguous value fails closed. * * Every execution surface shares this single predicate so their boundaries * cannot drift apart. */ export function requiresIsolatedProjectRuntime(value: unknown): boolean { - return !isHostProjectCodeExecutionAllowed(value) && isSharedProjectRuntime(value); + const sharedRuntime = isSharedProjectRuntime(value); + return !isHostProjectCodeExecutionAllowedForTopology(value, sharedRuntime) && sharedRuntime; } /** diff --git a/src/security/sandbox/isolation-posture.test.ts b/src/security/sandbox/isolation-posture.test.ts index ff79f5b684..87ff85f740 100644 --- a/src/security/sandbox/isolation-posture.test.ts +++ b/src/security/sandbox/isolation-posture.test.ts @@ -112,10 +112,11 @@ describe("security/sandbox isolation posture reporting", () => { assertEquals(posture.ssr.requested, false); assertEquals(posture.ssr.effective, false); assertEquals(posture.hostExecutionGranted, false); + assertEquals(posture.hostExecutionOverrideConfigured, false); assertEquals(posture.inForce, true); }); - it("keeps API isolation in force when preparation is unsupported and no grant exists", async () => { + it("keeps API isolation in force when preparation is unsupported", async () => { setEnv("WORKER_ISOLATION_ENABLED", "1"); setEnv("WORKER_ISOLATION_API", "1"); __setCompiledBinaryForTests(true); @@ -123,16 +124,17 @@ describe("security/sandbox isolation posture reporting", () => { const posture = getIsolationPosture(); - // Without an operator grant the flag stands and API ownership fails closed - // with a typed 503, so the surface stays effective rather than downgrading. + // The flag stands and API ownership fails closed with a typed 503, so the + // surface stays effective rather than downgrading. assertEquals(posture.api.requested, true); assertEquals(posture.api.effective, true); assertEquals(posture.apiPreparationSupported, false); assertEquals(posture.hostExecutionGranted, false); + assertEquals(posture.hostExecutionOverrideConfigured, false); assertEquals(posture.inForce, true); }); - it("keeps API isolation in force when preparation is unsupported under a grant", async () => { + it("keeps API isolation in force when the deprecated override is configured", async () => { setEnv("WORKER_ISOLATION_ENABLED", "1"); setEnv("WORKER_ISOLATION_API", "1"); setEnv(HOST_PROJECT_EXECUTION_OVERRIDE_ENV, "1"); @@ -144,7 +146,8 @@ describe("security/sandbox isolation posture reporting", () => { assertEquals(posture.api.requested, true); assertEquals(posture.api.effective, true); assertEquals(posture.apiPreparationSupported, false); - assertEquals(posture.hostExecutionGranted, true); + assertEquals(posture.hostExecutionGranted, false); + assertEquals(posture.hostExecutionOverrideConfigured, true); assertEquals(posture.inForce, true); }); diff --git a/src/security/sandbox/worker-pool.test.ts b/src/security/sandbox/worker-pool.test.ts index b01371e03a..103b1493b0 100644 --- a/src/security/sandbox/worker-pool.test.ts +++ b/src/security/sandbox/worker-pool.test.ts @@ -1629,7 +1629,7 @@ describe("Feature flag caching", () => { }); describe("when the runtime cannot prepare an isolated API module", () => { - it("keeps WORKER_ISOLATION_API set under a broad host-execution grant", async () => { + it("keeps WORKER_ISOLATION_API set with the deprecated override configured", async () => { Deno.env.set("WORKER_ISOLATION_ENABLED", "1"); Deno.env.set("WORKER_ISOLATION_API", "1"); Deno.env.set(HOST_PROJECT_EXECUTION_OVERRIDE_ENV, "1"); diff --git a/src/security/sandbox/worker-pool.ts b/src/security/sandbox/worker-pool.ts index d9f1228bee..551ac2a27c 100644 --- a/src/security/sandbox/worker-pool.ts +++ b/src/security/sandbox/worker-pool.ts @@ -23,7 +23,7 @@ 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 { isHostProjectExecutionOverrideEnabled } from "#veryfront/security/host-execution-policy.ts"; +import { isHostProjectExecutionOverrideConfigured } from "#veryfront/security/host-execution-policy.ts"; import { resolve as resolveExtensionContract } from "#veryfront/extensions/contracts.ts"; import { IsolatedSsrRendererProviderName, @@ -1256,9 +1256,9 @@ export interface IsolationSurfacePosture { * The resolved isolation configuration, as an operator would need to read it. * * `requested` and `effective` are separate fields so posture remains explicit - * if a runtime capability changes. A host-execution grant never makes requested - * API isolation ineffective: unsupported runtimes keep the gate enabled and - * fail closed. `inForce` answers whether any surface is isolated at all. + * if a runtime capability changes. The deprecated host-execution override never + * makes requested API isolation ineffective: unsupported runtimes keep the gate + * enabled and fail closed. `inForce` answers whether any surface is isolated. */ export interface IsolationPosture { /** WORKER_ISOLATION_ENABLED. On its own it enables no surface. */ @@ -1272,8 +1272,10 @@ export interface IsolationPosture { * `api.effective` true, API routes fail closed rather than execute. */ apiPreparationSupported: boolean; - /** VERYFRONT_HOST_ALLOW_PROJECT_EXECUTION. */ - hostExecutionGranted: boolean; + /** @deprecated Always false. The former override no longer grants execution. */ + hostExecutionGranted: false; + /** Whether the deprecated VERYFRONT_HOST_ALLOW_PROJECT_EXECUTION setting is present. */ + hostExecutionOverrideConfigured: boolean; /** True when at least one surface actually resolved to isolated execution. */ inForce: boolean; } @@ -1281,8 +1283,8 @@ export interface IsolationPosture { /** * Resolve the host-owned isolation flags once per process. * - * A build that cannot honour `WORKER_ISOLATION_API` must fail closed. A broad - * host-execution grant does not override an API-specific isolation posture. + * A build that cannot honour `WORKER_ISOLATION_API` must fail closed. The + * deprecated host-execution override does not alter an API-specific posture. */ function resolveFlags(): void { if (_flagsResolved) return; @@ -1297,7 +1299,7 @@ function resolveFlags(): void { _ssrIsolation = master && ssrFlag; const preparationSupported = isIsolatedApiPreparationSupported(); - const hostExecutionGranted = isHostProjectExecutionOverrideEnabled(); + const hostExecutionOverrideConfigured = isHostProjectExecutionOverrideConfigured(); _apiIsolation = apiRequested; _flagsResolved = true; @@ -1309,7 +1311,8 @@ function resolveFlags(): void { data: { requested: _dataIsolation, effective: _dataIsolation }, ssr: { requested: _ssrIsolation, effective: _ssrIsolation }, apiPreparationSupported: preparationSupported, - hostExecutionGranted, + hostExecutionGranted: false, + hostExecutionOverrideConfigured, inForce: effectiveSurfaces > 0, }; @@ -1387,6 +1390,7 @@ export function getIsolationPosture(): IsolationPosture { ssr: { requested: false, effective: false }, apiPreparationSupported: isIsolatedApiPreparationSupported(), hostExecutionGranted: false, + hostExecutionOverrideConfigured: false, inForce: false, }; } diff --git a/src/server/handlers/preview/markdown-preview.handler.test.ts b/src/server/handlers/preview/markdown-preview.handler.test.ts index 01d16b8ea5..cedd1ae8b7 100644 --- a/src/server/handlers/preview/markdown-preview.handler.test.ts +++ b/src/server/handlers/preview/markdown-preview.handler.test.ts @@ -1,12 +1,6 @@ import "#veryfront/schemas/_test-setup.ts"; import "#veryfront/transforms/mdx/compiler/__tests__/content-processor-setup.ts"; -import { - assertEquals, - assertInstanceOf, - assertNotEquals, - assertRejects, - assertStringIncludes, -} from "#veryfront/testing/assert.ts"; +import { assertEquals, assertInstanceOf, assertRejects } from "#veryfront/testing/assert.ts"; import type { HandlerContext } from "../types.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { MarkdownPreviewHandler } from "./markdown-preview.handler.ts"; @@ -108,13 +102,7 @@ it("MarkdownPreviewHandler fails closed before shared source reads", async () => }); describe("MarkdownPreviewHandler host-execution capability", () => { - it("renders once the host grants execution", async () => { - // The granted counterpart of the shared-runtime denial above. #3364 - // collapsed the execution surfaces onto requiresIsolatedProjectRuntime so - // they could not drift apart, but markdown preview kept a bare - // isSharedProjectRuntime check and denied unconditionally. Without this - // case, an unconditional denial here is indistinguishable from a correct - // fail-closed guard. + it("keeps shared preview execution denied despite a host grant", async () => { let reads = 0; const ctx = { projectDir: "/remote/project", @@ -156,24 +144,8 @@ describe("MarkdownPreviewHandler host-execution capability", () => { ctx, ); - assertEquals( - result.response?.status, - 200, - "a granted shared executor must serve the rendered preview", - ); - assertEquals( - result.response?.headers.get("content-type"), - "text/html; charset=utf-8", - "the granted preview must be served as HTML", - ); - assertStringIncludes( - await result.response!.text(), - "Readme", - "the rendered HTML must carry the markdown heading", - ); - // Not merely "did not 503": the granted request has to actually reach the - // shared filesystem, otherwise a fallthrough returning no response passes. - assertNotEquals(reads, 0, "the granted path must reach the project source read"); + assertEquals(result.response?.status, 503); + assertEquals(reads, 0); }); }); @@ -185,7 +157,7 @@ it("MarkdownPreviewHandler rejects a generation newer than its bound config", as projectSlug: "project", projectId: "project-1", proxyToken: "token", - isLocalProject: false, + isLocalProject: true, requestContext: { branch: "feature", mode: "preview" }, parsedDomain: { branch: null }, adapter: { @@ -235,7 +207,7 @@ it("MarkdownPreviewHandler rejects a generation that changes during rendering", projectSlug: "project", projectId: "project-1", proxyToken: "token", - isLocalProject: false, + isLocalProject: true, requestContext: { branch: "feature", mode: "preview" }, parsedDomain: { branch: null }, adapter: { 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 735118651d..8e3b8f8961 100644 --- a/src/server/handlers/request/api/api-handler-wrapper.test.ts +++ b/src/server/handlers/request/api/api-handler-wrapper.test.ts @@ -1,5 +1,5 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals, assertNotEquals, assertRejects } from "#veryfront/testing/assert.ts"; +import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import type { HandlerContext } from "#veryfront/types"; import { ApiHandlerWrapper } from "./api-handler-wrapper.ts"; @@ -70,6 +70,7 @@ describe("ApiHandlerWrapper", () => { it("propagates strict source freshness failures before downstream handlers run", async () => { const ctx = createCtx({}); + ctx.isLocalProject = true; ctx.allowHostProjectCodeExecution = true; ctx.requestContext!.mode = "preview"; ctx.releaseId = undefined; @@ -103,6 +104,7 @@ describe("ApiHandlerWrapper", () => { let sourceSnapshotRefreshes = 0; let pageReads = 0; const ctx = createCtx({}); + ctx.isLocalProject = true; ctx.allowHostProjectCodeExecution = true; const fs = ctx.adapter.fs as unknown as { runWithContext: ( @@ -192,6 +194,7 @@ describe("ApiHandlerWrapper", () => { it("checks preview source freshness before resolving page ownership", async () => { const events: string[] = []; const ctx = createCtx({}); + ctx.isLocalProject = true; ctx.allowHostProjectCodeExecution = true; ctx.requestContext!.mode = "preview"; ctx.releaseId = undefined; @@ -258,6 +261,7 @@ describe("ApiHandlerWrapper", () => { it("refreshes page ownership before SSR can shed for memory pressure", async () => { const ctx = createCtx({}); + ctx.isLocalProject = true; ctx.allowHostProjectCodeExecution = true; ctx.requestContext!.mode = "preview"; ctx.releaseId = undefined; @@ -332,6 +336,7 @@ describe("ApiHandlerWrapper", () => { // outage for API routes that never render anything. let refreshes = 0; const ctx = createCtx({}); + ctx.isLocalProject = true; ctx.allowHostProjectCodeExecution = true; ctx.projectSlug = "pressured-api-project"; ctx.config = { router: "pages" }; @@ -381,6 +386,7 @@ describe("ApiHandlerWrapper", () => { const events: string[] = []; let routeIsCurrent = false; const ctx = createCtx({}); + ctx.isLocalProject = true; ctx.allowHostProjectCodeExecution = true; ctx.projectSlug = "pressured-route-transition"; ctx.config = { router: "pages" }; @@ -451,6 +457,7 @@ describe("ApiHandlerWrapper", () => { // it even during overload, so the SSR shedding shortcut must not leave it // without a current source snapshot. const ctx = createCtx({}); + ctx.isLocalProject = true; ctx.allowHostProjectCodeExecution = true; ctx.requestContext!.mode = "preview"; ctx.releaseId = undefined; @@ -488,6 +495,7 @@ describe("ApiHandlerWrapper", () => { it("preserves fresh API discovery when no page owns a non-API path", async () => { let sourceSnapshotRefreshes = 0; const ctx = createCtx({}); + ctx.isLocalProject = true; ctx.allowHostProjectCodeExecution = true; ctx.projectSlug = "unmatched-preview-project"; ctx.config = { router: "pages" }; @@ -656,9 +664,7 @@ describe("ApiHandlerWrapper", () => { ); }); - it("starts shared-runtime API discovery once the host grants execution", async () => { - // The granted counterpart of the fail-closed case above. Without this, - // nothing pins that the operator grant actually reaches this surface. + it("keeps shared-runtime API discovery denied despite a host grant", async () => { let projectContextEntries = 0; let filesystemReads = 0; const ctx = createCtx({}); @@ -691,17 +697,9 @@ describe("ApiHandlerWrapper", () => { ctx, ); - assertNotEquals( - result.response?.status, - 503, - "a granted shared executor must not return project-execution-unavailable", - ); + assertEquals(result.response?.status, 503); assertEquals(projectContextEntries, 1); - assertEquals( - filesystemReads > 0, - true, - "the request must reach source resolution instead of failing at the guard", - ); + assertEquals(filesystemReads, 0); }); it("rejects a shared contextual adapter before mutating or reading it", async () => { diff --git a/src/server/handlers/request/api/app-router-handler.test.ts b/src/server/handlers/request/api/app-router-handler.test.ts index 8cf973ae62..2f1c45c6b0 100644 --- a/src/server/handlers/request/api/app-router-handler.test.ts +++ b/src/server/handlers/request/api/app-router-handler.test.ts @@ -1,5 +1,5 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals, assertNotEquals } from "#veryfront/testing/assert.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"; @@ -33,10 +33,7 @@ describe("server API app-router compatibility handler", () => { assertEquals(filesystemCalls, 0); }); - it("reaches route discovery once the host grants execution", async () => { - // The granted counterpart of the case above. Without it, nothing pins that - // the operator grant actually reaches this surface, and a hardcoded denial - // here would look identical to a correct fail-closed guard. + it("keeps shared route discovery denied despite a host grant", async () => { let filesystemCalls = 0; const ctx = { projectDir: "/remote/project", @@ -59,15 +56,7 @@ describe("server API app-router compatibility handler", () => { ctx, ); - assertNotEquals( - response?.status, - 503, - "a granted shared executor must not return project-execution-unavailable", - ); - assertEquals( - filesystemCalls > 0, - true, - "a granted shared executor must reach route discovery", - ); + assertEquals(response?.status, 503); + assertEquals(filesystemCalls, 0); }); }); diff --git a/src/server/handlers/request/api/project-discovery.test.ts b/src/server/handlers/request/api/project-discovery.test.ts index e5e78af35d..db1b11e36a 100644 --- a/src/server/handlers/request/api/project-discovery.test.ts +++ b/src/server/handlers/request/api/project-discovery.test.ts @@ -151,14 +151,11 @@ describe( assertEquals((globalThis as Record)[marker], undefined); }); - it("discovers primitives in a shared runtime once the host grants execution", async () => { - // Regression for issue-inbox#356: agent chat 500'd on every hosted project - // with agents/*.ts because this guard ignored the host-execution - // capability that the rest of the execution surfaces already honor. + it("keeps shared primitive discovery denied despite a host grant", async () => { const ctx = createHandlerContext("/granted-project", "granted", "preview"); ctx.isLocalProject = false; // Present marks a shared multi-project runtime, as veryfront-server is. - // Rejecting also asserts discovery never invokes it on the granted path. + // Rejecting also asserts discovery never invokes this tenant boundary. ctx.prepareHostedConfigContext = () => Promise.reject(new Error("must not be called")); ctx.allowHostProjectCodeExecution = true; await ctx.adapter.fs.writeFile( @@ -184,23 +181,12 @@ describe( return readFile(path); }; - const result = await ensureProjectDiscovery(ctx); - - assertExists(result, "discovery must return a result instead of throwing"); - assertEquals( - reads > 0, - true, - "an operator-granted shared executor must actually read project source", - ); - // `reads > 0` alone is satisfied by any incidental probe, so pin the - // primitive itself: discovery must have found the granted project's tool. - assertEquals( - result.tools.has("granted_tool"), - true, - `granted discovery must surface the project tool, got ${ - JSON.stringify([...result.tools.keys()]) - }`, + await assertRejects( + () => ensureProjectDiscovery(ctx), + Error, + "isolated project runtime", ); + assertEquals(reads, 0); }); afterAll(async () => { diff --git a/src/server/handlers/request/api/project-discovery.ts b/src/server/handlers/request/api/project-discovery.ts index 2f63a5850b..d2ff976cc3 100644 --- a/src/server/handlers/request/api/project-discovery.ts +++ b/src/server/handlers/request/api/project-discovery.ts @@ -6,7 +6,6 @@ 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 { HOST_PROJECT_EXECUTION_OVERRIDE_ENV } from "#veryfront/security/host-execution-policy.ts"; import { requiresIsolatedProjectRuntime } from "#veryfront/security/project-locality.ts"; import type { HandlerContext } from "../../types.ts"; @@ -145,8 +144,7 @@ export async function ensureProjectDiscovery(ctx: HandlerContext): Promise { - it("serves a shared runtime the host granted execution", async () => { - // The granted counterpart to the test above. Without it, a handler that - // simply denies every shared runtime, which is the pre-#366 behaviour, - // passes the whole suite. + it("keeps shared snippet execution denied despite a host grant", async () => { + // An entrypoint grant cannot override shared-runtime topology. Pin the + // source-read boundary so this does not regress to capability-only checks. let readPath: string | undefined; const fs = { symlinkSemantics: "none" as const, @@ -156,12 +155,8 @@ describe("SnippetHandler host-execution capability", () => { ctx, ); - assertNotEquals( - result.response?.status, - 503, - "a granted shared executor must not return project-execution-unavailable", - ); - assertNotEquals(readPath, undefined, "the granted path must reach the source read"); + assertEquals(result.response?.status, 503); + assertEquals(readPath, undefined); }); it("passes the canonical enriched release identity to snippet rendering", async () => { diff --git a/src/server/handlers/request/ssr/ssr.handler.test.ts b/src/server/handlers/request/ssr/ssr.handler.test.ts index 51223638b6..6ad42b9d77 100644 --- a/src/server/handlers/request/ssr/ssr.handler.test.ts +++ b/src/server/handlers/request/ssr/ssr.handler.test.ts @@ -843,11 +843,7 @@ describe("server/handlers/request/ssr/ssr.handler", () => { assertEquals(renderCalls, 0); }); - it("renders once the host grants execution", async () => { - // The granted counterpart to the fail-closed test above. veryfront-code - // #3364 shipped a hardcoded `true` on a sibling surface that survived - // review because a fail-closed test cannot tell a correct predicate from - // a literal denial. Only this direction can. + it("keeps shared rendering denied despite a host grant", async () => { let renderCalls = 0; const handler = new SSRHandler(createMockSSRService({ renderPage: () => { @@ -870,8 +866,8 @@ describe("server/handlers/request/ssr/ssr.handler", () => { } as Partial), ); - assertEquals(result.response?.status, 200); - assertEquals(renderCalls, 1); + assertEquals(result.response?.status, 503); + assertEquals(renderCalls, 0); }); it("returns response from renderPage result", async () => { @@ -991,6 +987,7 @@ describe("server/handlers/request/ssr/ssr.handler", () => { new Request("http://localhost/page"), makeCtx({ adapter, + isLocalProject: true, allowHostProjectCodeExecution: true, projectSlug: "preview-project", proxyToken: "token", @@ -1360,6 +1357,7 @@ describe("server/handlers/request/ssr/ssr.handler", () => { }); const handler = new SSRHandler(mockService); const { ctx } = makeExtendedCtx({}, { + isLocalProject: true, allowHostProjectCodeExecution: true, projectSlug: "preview-project", projectId: "project-1", @@ -1404,6 +1402,7 @@ describe("server/handlers/request/ssr/ssr.handler", () => { let renderedBranch: string | null | undefined; const handler = new SSRHandler(createMockSSRService()); const { ctx } = makeExtendedCtx({}, { + isLocalProject: true, allowHostProjectCodeExecution: true, projectSlug: "preview-project", projectId: "project-1", diff --git a/src/server/production-server.ts b/src/server/production-server.ts index 3025bfdbb0..49cc72a87e 100644 --- a/src/server/production-server.ts +++ b/src/server/production-server.ts @@ -35,7 +35,8 @@ import type { FileSystemAdapter } from "#veryfront/platform/adapters/base.ts"; import { snapshotNodeWebSocketServerProvider } from "#veryfront/extensions/websocket"; import { HOST_PROJECT_EXECUTION_OVERRIDE_ENV, - isHostProjectExecutionOverrideEnabled, + isHostProjectExecutionOverrideConfigured, + resolveHostProjectExecutionPosture, } from "#veryfront/security/host-execution-policy.ts"; import { isSharedProjectRuntime } from "#veryfront/security/project-locality.ts"; import { getIsolationPosture } from "#veryfront/security/sandbox/worker-pool.ts"; @@ -248,16 +249,20 @@ export function startProductionServer( enableSSRClientOnlyFetching(); // A dedicated single-project runtime carries the capability implicitly. - // A shared runtime intended to be the executor must be granted it by an - // operator, deliberately and visibly. + // Shared runtimes always route executable project code to an isolated + // project runtime. The former operator override is diagnostic only. // // Computed before discovery so startup and request handling share one // value. They disagreed before issue-inbox#363: discovery hardcoded a // grant while the handler computed the real posture. - const isolatedRuntimeGrant = bootstrap.config.fs?.veryfront?.proxyMode !== true && - !isSharedProjectRuntime({ adapter }); - const operatorGrant = isHostProjectExecutionOverrideEnabled(); - const allowHostProjectCodeExecution = isolatedRuntimeGrant || operatorGrant; + const sharedRuntime = bootstrap.config.fs?.veryfront?.proxyMode === true || + isSharedProjectRuntime({ adapter }); + const operatorOverrideConfigured = isHostProjectExecutionOverrideConfigured(); + const executionPosture = resolveHostProjectExecutionPosture({ + sharedRuntime, + overrideConfigured: operatorOverrideConfigured, + }); + const { allowHostProjectCodeExecution } = executionPosture; // Run primitive discovery before serving (registries must be populated before first request) if (discoveryConfig) { @@ -269,6 +274,7 @@ export function startProductionServer( const outcome = await runStartupDiscovery({ config: discoveryConfig, + runtimeAdapter: adapter, allowHostProjectCodeExecution, discoverAll, isExtendedFSAdapter, @@ -292,10 +298,12 @@ export function startProductionServer( // operator looks for it. getIsolationPosture(); - if (operatorGrant && !isolatedRuntimeGrant) { - logger.warn("Shared runtime is executing tenant project code by operator grant", { + if (executionPosture.overrideIgnored) { + logger.warn("Host project execution override is ignored", { overrideEnv: HOST_PROJECT_EXECUTION_OVERRIDE_ENV, - proxyMode: bootstrap.config.fs?.veryfront?.proxyMode === true, + reason: sharedRuntime + ? "shared runtimes require isolated project execution" + : "dedicated runtimes already allow project execution", }); } diff --git a/src/server/project-env/process-env-scope.test.ts b/src/server/project-env/process-env-scope.test.ts index 02ef25512d..c8536da842 100644 --- a/src/server/project-env/process-env-scope.test.ts +++ b/src/server/project-env/process-env-scope.test.ts @@ -95,6 +95,44 @@ describe("process.env under an active project env snapshot", () => { }); }); + it("scopes direct Deno.env access to the active project snapshot", () => { + const deno = Reflect.get(globalThis, "Deno") as + | { + env?: { + get(key: string): string | undefined; + set(key: string, value: string): void; + delete(key: string): void; + has(key: string): boolean; + toObject(): Record; + }; + } + | undefined; + if (!deno?.env) return; + + const hostKey = "VF_SCOPE_PROBE_DENO_HOST"; + const scopedKey = "VF_SCOPE_PROBE_DENO_WRITE"; + withHostVar(hostKey, "host-value", () => { + try { + runWithProjectEnv({ PROJECT_VAR: "project-value" }, () => { + assertEquals(deno.env!.get(hostKey), undefined); + assertEquals(deno.env!.get("PROJECT_VAR"), "project-value"); + assertEquals(deno.env!.has(hostKey), false); + assertEquals(deno.env!.toObject(), { PROJECT_VAR: "project-value" }); + + deno.env!.set(scopedKey, "scoped-value"); + deno.env!.delete("PROJECT_VAR"); + assertEquals(deno.env!.get(scopedKey), "scoped-value"); + assertEquals(deno.env!.get("PROJECT_VAR"), undefined); + }); + + assertEquals(deno.env!.get(hostKey), "host-value"); + assertEquals(deno.env!.get(scopedKey), undefined); + } finally { + deno.env!.delete(scopedKey); + } + }); + }); + it("does not expose the host environment through inspection", () => { withHostVar("VF_SCOPE_PROBE_INSPECT_ONLY", "host-inspection-value", () => { runWithProjectEnv({ PROJECT_VAR: "project-value" }, () => { diff --git a/src/server/startup-discovery.test.ts b/src/server/startup-discovery.test.ts index 4728788ef7..1310fa4927 100644 --- a/src/server/startup-discovery.test.ts +++ b/src/server/startup-discovery.test.ts @@ -2,7 +2,7 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals, assertStrictEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import type { DiscoveryConfig, DiscoveryResult } from "#veryfront/discovery/types.ts"; -import type { FileSystemAdapter } from "#veryfront/platform/adapters/base.ts"; +import type { FileSystemAdapter, RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; import type { ExtendedFileSystemAdapter } from "#veryfront/platform/adapters/fs/wrapper.ts"; import { runStartupDiscovery } from "./startup-discovery.ts"; @@ -35,6 +35,7 @@ function recorder() { /** Placeholder base dir: recorder() never touches the filesystem. */ const PROJECT_DIR = ""; +const dedicatedRuntimeAdapter = {} as RuntimeAdapter; /** No adapter is extended, so discovery takes the unscoped branch. */ const noExtendedAdapters = (_fs: FileSystemAdapter): _fs is ExtendedFileSystemAdapter => false; @@ -48,6 +49,7 @@ describe("server/startup-discovery", () => { await runStartupDiscovery({ config: { baseDir: PROJECT_DIR }, + runtimeAdapter: dedicatedRuntimeAdapter, allowHostProjectCodeExecution: false, discoverAll, isExtendedFSAdapter: noExtendedAdapters, @@ -68,6 +70,7 @@ describe("server/startup-discovery", () => { await runStartupDiscovery({ config: { baseDir: PROJECT_DIR, fsAdapter, verbose: true }, + runtimeAdapter: dedicatedRuntimeAdapter, allowHostProjectCodeExecution: false, discoverAll, isExtendedFSAdapter: noExtendedAdapters, @@ -91,6 +94,7 @@ describe("server/startup-discovery", () => { await runStartupDiscovery({ config: { baseDir: PROJECT_DIR }, + runtimeAdapter: dedicatedRuntimeAdapter, allowHostProjectCodeExecution: false, discoverAll, isExtendedFSAdapter: noExtendedAdapters, @@ -108,6 +112,7 @@ describe("server/startup-discovery", () => { const outcome = await runStartupDiscovery({ config: { baseDir: PROJECT_DIR, projectSlug: "p", apiToken: "t", fsAdapter }, + runtimeAdapter: dedicatedRuntimeAdapter, allowHostProjectCodeExecution: true, discoverAll, isExtendedFSAdapter: allExtendedAdapters, @@ -131,6 +136,7 @@ describe("server/startup-discovery", () => { await runStartupDiscovery({ config: { baseDir: PROJECT_DIR }, + runtimeAdapter: dedicatedRuntimeAdapter, allowHostProjectCodeExecution: true, discoverAll, isExtendedFSAdapter: noExtendedAdapters, @@ -139,6 +145,24 @@ describe("server/startup-discovery", () => { assertEquals(calls[0]?.allowHostProjectCodeExecution, true); }); + it("denies host execution for unscoped discovery in a shared runtime", async () => { + const { calls, discoverAll } = recorder(); + const runtimeAdapter = { + fs: { isMultiProjectMode: () => true }, + } as unknown as RuntimeAdapter; + + await runStartupDiscovery({ + config: { baseDir: PROJECT_DIR }, + runtimeAdapter, + allowHostProjectCodeExecution: true, + discoverAll, + isExtendedFSAdapter: noExtendedAdapters, + }); + + assertEquals(calls.length, 1); + assertEquals(calls[0]?.allowHostProjectCodeExecution, false); + }); + it("skips the scoped multi-project path rather than calling discovery ungranted", async () => { const { calls, discoverAll } = recorder(); const fsAdapter = { @@ -150,6 +174,7 @@ describe("server/startup-discovery", () => { // Even with the deployment granting, the scoped branch must not pass it: // that path evaluates tenant source under a project context. config: { baseDir: PROJECT_DIR, projectSlug: "p", apiToken: "t", fsAdapter }, + runtimeAdapter: dedicatedRuntimeAdapter, allowHostProjectCodeExecution: true, discoverAll, isExtendedFSAdapter: allExtendedAdapters, @@ -182,6 +207,7 @@ describe("server/startup-discovery", () => { assertEquals( await runStartupDiscovery({ config: { baseDir: PROJECT_DIR, projectSlug: "p", apiToken: "t", fsAdapter }, + runtimeAdapter: dedicatedRuntimeAdapter, allowHostProjectCodeExecution: true, discoverAll: enforcing, isExtendedFSAdapter: allExtendedAdapters, @@ -193,6 +219,7 @@ describe("server/startup-discovery", () => { assertEquals( await runStartupDiscovery({ config: { baseDir: PROJECT_DIR }, + runtimeAdapter: dedicatedRuntimeAdapter, allowHostProjectCodeExecution: true, discoverAll: enforcing, isExtendedFSAdapter: noExtendedAdapters, diff --git a/src/server/startup-discovery.ts b/src/server/startup-discovery.ts index 3fbf4888a3..ce58ffe41c 100644 --- a/src/server/startup-discovery.ts +++ b/src/server/startup-discovery.ts @@ -13,12 +13,15 @@ */ import type { DiscoveryConfig, DiscoveryResult } from "#veryfront/discovery/types.ts"; -import type { FileSystemAdapter } from "#veryfront/platform/adapters/base.ts"; +import type { FileSystemAdapter, RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; import type { ExtendedFileSystemAdapter } from "#veryfront/platform/adapters/fs/wrapper.ts"; +import { isHostProjectCodeExecutionAllowed } from "#veryfront/security/project-locality.ts"; import type { DiscoveryOptions } from "./production-server.ts"; export interface RunStartupDiscoveryInput { config: DiscoveryOptions; + /** Host-owned runtime topology used to constrain the deployment grant. */ + runtimeAdapter: RuntimeAdapter; /** * The deployment's posture, computed once by the host-owned entrypoint and * shared with the request handler. Never hardcoded here. @@ -68,7 +71,10 @@ export async function runStartupDiscovery( baseDir: config.baseDir, fsAdapter: config.fsAdapter, verbose: config.verbose ?? false, - allowHostProjectCodeExecution: input.allowHostProjectCodeExecution, + allowHostProjectCodeExecution: isHostProjectCodeExecutionAllowed({ + adapter: input.runtimeAdapter, + allowHostProjectCodeExecution: input.allowHostProjectCodeExecution, + }), }); return { ran: true }; } diff --git a/src/testing/bdd.ts b/src/testing/bdd.ts index a2314ab505..833d0a6487 100644 --- a/src/testing/bdd.ts +++ b/src/testing/bdd.ts @@ -14,6 +14,7 @@ import "./init.ts"; import { isBun, isDeno } from "#veryfront/platform/compat/runtime.ts"; import { getEnvOverlayStorage } from "#veryfront/platform/compat/process.ts"; +import { isTestOverlayAwareDenoEnvView } from "#veryfront/platform/compat/process/scoped-process-env.ts"; /** Test function that can be sync or async */ type TestFn = () => void | Promise; @@ -95,6 +96,11 @@ function installDenoEnvOverlayFacade(): void { if (globalAny["__vfTestDenoEnvOverlayFacadeInstalled"]) return; globalAny["__vfTestDenoEnvOverlayFacadeInstalled"] = true; + // The project environment facade composes this overlay itself. It is frozen + // so tenant code cannot replace its methods, and assigning here would make + // the public testing module depend on whether the server loaded first. + if (isTestOverlayAwareDenoEnvView(Deno.env)) return; + const originalDenoEnv = { get: Deno.env.get.bind(Deno.env), set: Deno.env.set.bind(Deno.env), diff --git a/src/transforms/mdx/esm-module-loader/module-fetcher/index.test.ts b/src/transforms/mdx/esm-module-loader/module-fetcher/index.test.ts index fb3062e7c0..a135452546 100644 --- a/src/transforms/mdx/esm-module-loader/module-fetcher/index.test.ts +++ b/src/transforms/mdx/esm-module-loader/module-fetcher/index.test.ts @@ -373,6 +373,85 @@ describe("module-fetcher", () => { assertEquals(resolved, false); }); + describe("privileged framework modules", () => { + const untouchableAdapter = { + env: { get: (_key: string) => undefined }, + fs: { + resolveFile: (_path: string) => { + throw new Error("resolveFile must not be called for a refused privileged module"); + }, + readFile: (_path: string) => { + throw new Error("readFile must not be called for a refused privileged module"); + }, + }, + } as any; + + it("refuses a tenant entry import of the host env implementation", async () => { + const ctx = createModuleFetcherContext( + "/cache", + untouchableAdapter, + "/project", + "proj-privileged", + { strictMissingModules: true }, + ); + + const result = await fetchAndCacheModule( + "/_vf_modules/_veryfront/platform/compat/process/env.js", + ctx, + ); + assertEquals(result, null); + }); + + it("refuses a privileged module imported from a tenant module", async () => { + const ctx = createModuleFetcherContext( + "/cache", + untouchableAdapter, + "/project", + "proj-privileged", + { strictMissingModules: true }, + ); + + const result = await fetchAndCacheModule( + "/_vf_modules/_veryfront/platform/compat/process/scoped-process-env.js", + ctx, + "_vf_modules/components/page.js", + ); + assertEquals(result, null); + }); + + it("refuses a tenant entry import of the host cloud bootstrap", async () => { + const ctx = createModuleFetcherContext( + "/cache", + untouchableAdapter, + "/project", + "proj-privileged", + { strictMissingModules: true }, + ); + + const result = await fetchAndCacheModule( + "/_vf_modules/_veryfront/platform/cloud/resolver.js", + ctx, + ); + assertEquals(result, null); + }); + + it("refuses an unexported host-environment wrapper as a tenant entry", async () => { + const ctx = createModuleFetcherContext( + "/cache", + untouchableAdapter, + "/project", + "proj-privileged", + { strictMissingModules: true }, + ); + + const result = await fetchAndCacheModule( + "/_vf_modules/_veryfront/observability/tracing/telemetry-env.js", + ctx, + ); + assertEquals(result, null); + }); + }); + describe("strictMissingModules", () => { it("throws when module cannot be resolved", async () => { const esmCacheDir = await makeTempDir({ prefix: "vf-mdx-strict-cache-" }); diff --git a/src/transforms/mdx/esm-module-loader/module-fetcher/index.ts b/src/transforms/mdx/esm-module-loader/module-fetcher/index.ts index 86d6362f59..5bef332587 100644 --- a/src/transforms/mdx/esm-module-loader/module-fetcher/index.ts +++ b/src/transforms/mdx/esm-module-loader/module-fetcher/index.ts @@ -21,6 +21,10 @@ import type { ModuleFetcherContext } from "../types.ts"; import { getModulePathCache } from "../cache/index.ts"; import { hashString } from "../utils/hash.ts"; import { resolveModuleFile } from "../resolution/file-finder.ts"; +import { canonicalizeContainedModulePath } from "../resolution/module-path.ts"; +import { + isPublicFrameworkSourceKey, +} from "#veryfront/platform/compat/framework-source-resolver.ts"; import { getTransformCacheKey, getVersionedPathCacheKey } from "./cache-keys.ts"; import { resolveNestedImportBase, resolveNestedModuleImports } from "./nested-imports.ts"; import { readDistributedCache } from "./distributed-cache.ts"; @@ -151,6 +155,47 @@ function unwrapDependencyPinningPath( return extracted.pathname; } +/** + * Return the framework-relative source key for a `_veryfront/` module path, + * or null when the path does not address framework source. + */ +function frameworkSourceKeyOf(modulePath: string): string | null { + const withoutVfModules = modulePath.startsWith("_vf_modules/") + ? modulePath.slice("_vf_modules/".length) + : modulePath; + if (!withoutVfModules.startsWith("_veryfront/")) return null; + return withoutVfModules.slice("_veryfront/".length); +} + +/** + * Return whether a tenant requested a framework module outside public exports. + * + * Privileged implementation modules (the host process env seam) may only be + * fetched as transitive dependencies of framework source — a fetch whose + * parent is itself a framework module. A fetch reached from tenant code + * (project module parent, or no parent at all) is refused before any cache + * lookup, so a copy cached for the framework graph is never handed to a + * tenant-requested import. + */ +function isRefusedTenantFrameworkModuleFetch( + normalizedPath: string, + parentModulePath: string | undefined, + expectedCacheKey: string | undefined, +): boolean { + const frameworkKey = frameworkSourceKeyOf(normalizedPath); + if (frameworkKey === null) return false; + + if (parentModulePath === undefined) { + return !isPublicFrameworkSourceKey(frameworkKey); + } + const normalizedParent = canonicalizeContainedModulePath( + unwrapDependencyPinningPath(parentModulePath, expectedCacheKey), + ); + if (normalizedParent === null) return true; + if (frameworkSourceKeyOf(normalizedParent) !== null) return false; + return !isPublicFrameworkSourceKey(frameworkKey); +} + /** * Fetch and cache a module. * This is the main entry point for module fetching operations. @@ -168,6 +213,16 @@ export async function fetchAndCacheModule( parentModulePath ? unwrapDependencyPinningPath(parentModulePath, expectedCacheKey) : undefined, ); const projectSlug = context.projectSlug || "unknown"; + + if (isRefusedTenantFrameworkModuleFetch(normalizedPath, parentModulePath, expectedCacheKey)) { + log.warn(`${LOG_PREFIX_MDX_LOADER} Refusing non-public framework module for tenant import`, { + projectSlug, + normalizedPath, + parentModulePath, + }); + return null; + } + const moduleGraph = context.moduleGraph ??= new Set(); if (!moduleGraph.has(normalizedPath)) { if (moduleGraph.size >= MAX_MDX_MODULE_GRAPH_ENTRIES) { diff --git a/src/transforms/pipeline/stages/ssr-vf-modules.test.ts b/src/transforms/pipeline/stages/ssr-vf-modules.test.ts index 0c45a3bd60..62962e9ef3 100644 --- a/src/transforms/pipeline/stages/ssr-vf-modules.test.ts +++ b/src/transforms/pipeline/stages/ssr-vf-modules.test.ts @@ -11,6 +11,7 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals, assertRejects, assertStringIncludes } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { createFileSystem } from "#veryfront/platform/compat/fs.ts"; +import { join } from "#veryfront/compat/path/index.ts"; import { REACT_DEFAULT_VERSION } from "#veryfront/utils/constants/cdn.ts"; import { buildReactUrl, getReactImportMap } from "../../import-rewriter/url-builder.ts"; import { @@ -547,6 +548,9 @@ describe("ssr-vf-modules relative import resolution", { code, target: "ssr", projectDir: "/tmp/test-project", + // components/Head is an internal dependency reached from framework + // source, not a tenant-selected entry point. + filePath: join(FRAMEWORK_ROOT, "src", "react", "public.ts"), reactVersion: REACT_DEFAULT_VERSION, } as TransformContext; @@ -570,6 +574,9 @@ describe("ssr-vf-modules relative import resolution", { code, target: "ssr", projectDir: "/tmp/test-project", + // components/index is an internal dependency reached from framework + // source, not a tenant-selected entry point. + filePath: join(FRAMEWORK_ROOT, "src", "react", "public.ts"), reactVersion: REACT_DEFAULT_VERSION, } as TransformContext; diff --git a/src/transforms/pipeline/stages/ssr-vf-modules/index.ts b/src/transforms/pipeline/stages/ssr-vf-modules/index.ts index 1b6d06c73b..d2120c6b0c 100644 --- a/src/transforms/pipeline/stages/ssr-vf-modules/index.ts +++ b/src/transforms/pipeline/stages/ssr-vf-modules/index.ts @@ -22,6 +22,7 @@ import { TransformStage } from "../../types.ts"; import { rendererLogger as logger } from "#veryfront/utils"; import { replaceSpecifiers } from "../../../esm/lexer.ts"; import { createFileSystem } from "#veryfront/platform/compat/fs.ts"; +import { isFrameworkSourcePath } from "#veryfront/platform/compat/framework-source-resolver.ts"; import { REACT_DEFAULT_VERSION } from "#veryfront/utils/constants/cdn.ts"; import { findRelativeImports, findVfModuleImports } from "./import-finder.ts"; import { @@ -240,7 +241,9 @@ export const ssrVfModulesPlugin: TransformPlugin = { embeddedSrcDir: EMBEDDED_SRC_DIR, }); - const resolved = await resolveFrameworkFile(vfModulePath, fs); + const resolved = await resolveFrameworkFile(vfModulePath, fs, undefined, { + trustedFrameworkParent: ctx.filePath !== undefined && isFrameworkSourcePath(ctx.filePath), + }); if (!resolved) { logger.warn(`${LOG_PREFIX} Could not resolve ${vfModulePath}`, { frameworkRoot: FRAMEWORK_ROOT, diff --git a/src/transforms/pipeline/stages/ssr-vf-modules/path-resolver.test.ts b/src/transforms/pipeline/stages/ssr-vf-modules/path-resolver.test.ts index a8fd0e4f0c..df79975e69 100644 --- a/src/transforms/pipeline/stages/ssr-vf-modules/path-resolver.test.ts +++ b/src/transforms/pipeline/stages/ssr-vf-modules/path-resolver.test.ts @@ -105,6 +105,91 @@ describe("resolveFrameworkFile", () => { assertEquals(result?.content, "export function usePageContext() {}"); }); + for ( + const privilegedPath of [ + "/_vf_modules/_veryfront/platform/compat/process/env.js", + "/_vf_modules/_veryfront/platform/compat/process/env.js?ssr=true", + "/_vf_modules/_veryfront/platform/compat/process/env.ts", + "/_vf_modules/_veryfront/platform/compat/process/runtime-process.js", + "/_vf_modules/_veryfront/platform/compat/process/scoped-process-env.js", + "/_vf_modules/_veryfront/platform/compat/process.js", + "/_vf_modules/_veryfront/platform/cloud/resolver.js", + "file:///_vf_modules/_veryfront/platform/compat/process/env.js?ssr=true", + ] + ) { + it(`refuses privileged framework module ${privilegedPath}`, async () => { + const fs = createMockFs( + new Proxy({}, { + has: () => true, + get: () => "export function getHostEnv() {}", + }) as Record, + ); + + const result = await resolveFrameworkFile(privilegedPath, fs, async () => true); + + assertEquals(result, null); + }); + } + + it("refuses an unexported host-environment wrapper as a tenant entry", async () => { + const fs = createMockFs( + new Proxy({}, { + has: () => true, + get: () => "export function getHostTelemetryEnv() {}", + }) as Record, + ); + + assertEquals( + await resolveFrameworkFile( + "/_vf_modules/_veryfront/observability/tracing/telemetry-env.js", + fs, + () => Promise.resolve(true), + ), + null, + ); + }); + + it("resolves a non-public dependency for a trusted framework parent", async () => { + const sourcePath = join( + FRAMEWORK_ROOT, + "src", + "html", + "managed-head-protocol.ts", + ); + const files: Record = { + [sourcePath]: "export const MANAGED_HEAD_ATTRIBUTE = 'data-vf-head';", + }; + const fs = createMockFs(files); + + const result = await resolveFrameworkFile( + "/_vf_modules/_veryfront/html/managed-head-protocol.js?ssr=true", + fs, + createExistsFn(files), + { trustedFrameworkParent: true }, + ); + + assertEquals(result?.sourcePath, sourcePath); + }); + + it("still resolves the public platform/env facade", async () => { + const sourcePath = join( + FRAMEWORK_ROOT, + "src", + "platform", + "env.ts", + ); + const files: Record = { + [sourcePath]: 'export { getEnv } from "./env.ts";', + }; + const fs = createMockFs(files); + const result = await resolveFrameworkFile( + "/_vf_modules/_veryfront/platform/env.js?ssr=true", + fs, + createExistsFn(files), + ); + assertEquals(result?.sourcePath, sourcePath); + }); + for ( const maliciousPath of [ "/_vf_modules/_veryfront/../../secret.js", diff --git a/src/transforms/pipeline/stages/ssr-vf-modules/path-resolver.ts b/src/transforms/pipeline/stages/ssr-vf-modules/path-resolver.ts index 3905f69bd6..49ed0410e5 100644 --- a/src/transforms/pipeline/stages/ssr-vf-modules/path-resolver.ts +++ b/src/transforms/pipeline/stages/ssr-vf-modules/path-resolver.ts @@ -9,6 +9,7 @@ import { createFileSystem, exists } from "#veryfront/platform/compat/fs.ts"; import { join } from "#veryfront/compat/path/index.ts"; import { rendererLogger as logger } from "#veryfront/utils"; import { + isPublicFrameworkSourceKey, isSafeFrameworkSourceKey, resolveRelativeFrameworkSourceImport, } from "#veryfront/platform/compat/framework-source-resolver.ts"; @@ -54,6 +55,7 @@ export async function resolveFrameworkFile( vfModulePath: string, fs: ReturnType, existsFn: (path: string) => Promise = exists, + options: { trustedFrameworkParent?: boolean } = {}, ): Promise<{ sourcePath: string; content: string } | null> { const normalizedVfModulePath = vfModulePath.replace(/^file:\/\/(?=\/_vf_modules\/)/, ""); @@ -66,7 +68,13 @@ export async function resolveFrameworkFile( ? pathWithoutPrefix.slice("_veryfront/".length) : pathWithoutPrefix; if (!isSafeFrameworkSourceKey(frameworkRelativePath)) return null; - + if (!options.trustedFrameworkParent && !isPublicFrameworkSourceKey(frameworkRelativePath)) { + logger.warn(`${LOG_PREFIX} Refusing non-public framework module for tenant import`, { + vfModulePath, + frameworkRelativePath, + }); + return null; + } logger.debug(`${LOG_PREFIX} resolveFrameworkFile`, { input: vfModulePath, normalizedVfModulePath, diff --git a/tsconfig.json b/tsconfig.json index 68d974ffe4..cd44612981 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -86,7 +86,7 @@ "veryfront/platform/esbuild-init": [ "./src/platform/compat/esbuild-init.ts" ], - "veryfront/platform/env": ["./src/platform/compat/process/env-public.ts"], + "veryfront/platform/env": ["./src/platform/env.ts"], "veryfront/platform/path": ["./src/platform/compat/path/index.ts"], "veryfront/platform/http": ["./src/platform/compat/http/index.ts"], "veryfront/errors/general": ["./src/errors/error-registry/general.ts"],