From 917434a548e103d7ec9bcb9afac208a28787f6f7 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Sun, 2 Aug 2026 21:54:58 +0200 Subject: [PATCH 01/26] feat(cli): publish dedicated proxy binary --- .github/workflows/cicd.yml | 39 + cli/commands/serve/command.ts | 38 +- .../serve/proxy-extension-composition.test.ts | 41 +- .../serve/proxy-extension-composition.ts | 86 +- cli/commands/serve/proxy-runtime.test.ts | 55 + cli/commands/serve/proxy-runtime.ts | 66 + cli/proxy-main.ts | 19 + deno.json | 5 +- scripts/build/build-all.js | 23 +- scripts/build/compile-binary.test.ts | 68 +- scripts/build/compile-binary.ts | 37 +- scripts/build/proxy-deno.lock | 1708 +++++++++++++++++ scripts/build/smoke-proxy-binary.sh | 30 + 13 files changed, 2134 insertions(+), 81 deletions(-) create mode 100644 cli/commands/serve/proxy-runtime.test.ts create mode 100644 cli/commands/serve/proxy-runtime.ts create mode 100644 cli/proxy-main.ts create mode 100644 scripts/build/proxy-deno.lock create mode 100644 scripts/build/smoke-proxy-binary.sh diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 33f6fa84af..6f6ecd08a9 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -407,6 +407,23 @@ jobs: # Version comes directly from deno.json # ============================================ + tests-proxy-binary: + if: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && github.event_name == 'pull_request' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ./.github/actions/setup-deno + - run: deno task build:prepare + - name: Compile proxy binary + run: | + deno run -A scripts/build/compile-binary.ts \ + --entrypoint cli/proxy-main.ts \ + --profile proxy \ + --target x86_64-unknown-linux-gnu \ + --output veryfront-proxy-linux-x64 + - name: Smoke test proxy binary + run: bash scripts/build/smoke-proxy-binary.sh ./veryfront-proxy-linux-x64 + build-binaries: if: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && github.ref == 'refs/heads/main' }} runs-on: ${{ matrix.os }} @@ -417,18 +434,33 @@ jobs: - os: macos-latest target: aarch64-apple-darwin name: veryfront-macos-arm64 + entrypoint: cli/main.ts + profile: full - os: macos-latest target: x86_64-apple-darwin name: veryfront-macos-x64 + entrypoint: cli/main.ts + profile: full - os: ubuntu-latest target: x86_64-unknown-linux-gnu name: veryfront-linux-x64 + entrypoint: cli/main.ts + profile: full - os: ubuntu-latest target: aarch64-unknown-linux-gnu name: veryfront-linux-arm64 + entrypoint: cli/main.ts + profile: full + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + name: veryfront-proxy-linux-x64 + entrypoint: cli/proxy-main.ts + profile: proxy - os: windows-2022 target: x86_64-pc-windows-msvc name: veryfront-windows-x64.exe + entrypoint: cli/main.ts + profile: full steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: ./.github/actions/setup-deno @@ -439,9 +471,16 @@ jobs: shell: bash run: | deno run -A scripts/build/compile-binary.ts \ + --entrypoint ${{ matrix.entrypoint }} \ + --profile ${{ matrix.profile }} \ --target ${{ matrix.target }} \ --output ${{ matrix.name }} + - name: Smoke test proxy binary + if: matrix.profile == 'proxy' + shell: bash + run: bash scripts/build/smoke-proxy-binary.sh ./${{ matrix.name }} + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ${{ matrix.name }} diff --git a/cli/commands/serve/command.ts b/cli/commands/serve/command.ts index 9e7a806e57..5e0465b1aa 100644 --- a/cli/commands/serve/command.ts +++ b/cli/commands/serve/command.ts @@ -5,6 +5,7 @@ import { exitProcess, registerTerminationSignals, showHeader } from "#cli/utils" import { generateDefaultProjectId } from "../../utils/project.ts"; import { startCliProductionServer } from "#cli/shared/server-startup"; import { ensureCliBundlerContracts } from "#cli/shared/default-contracts"; +import { runStandaloneProxyRuntime } from "./proxy-runtime.ts"; const STARTUP_ERROR_FLUSH_TIMEOUT_MS = 2_000; @@ -141,39 +142,10 @@ async function runSplit(options: ServeOptions): Promise { } async function runProxy(options: ServeOptions): Promise { - showHeader(); - cliLogger.info(`Starting proxy server on ${options.bindAddress}:${options.port}`); - - const { setEnv } = await import("veryfront/platform"); - setEnv("PORT", String(options.port)); - setEnv("HOST", options.bindAddress); - - const { - activateStandaloneProxyCacheExtension, - registerStandaloneProxyCacheExtensionTeardown, - } = await import( - "./proxy-extension-composition.ts" - ); - const extensionLoader = await activateStandaloneProxyCacheExtension(); - const teardownCacheExtension = await registerStandaloneProxyCacheExtensionTeardown( - extensionLoader, - ); - - // DenoHttpServer.serve() blocks until the server stops, - // so this import keeps the process alive. - try { - await import("veryfront/proxy/main"); - } catch (error) { - try { - await teardownCacheExtension(); - } catch (cleanupError) { - cliLogger.error("Failed to clean up proxy extensions after startup failure", cleanupError); - } - throw error; - } - - // Keep the process alive (Deno.serve returns immediately in compiled binaries) - await new Promise(() => {}); + await runStandaloneProxyRuntime({ + bindAddress: options.bindAddress, + port: options.port, + }); } function createDeferredProductionStartupErrorReporter(): { diff --git a/cli/commands/serve/proxy-extension-composition.test.ts b/cli/commands/serve/proxy-extension-composition.test.ts index fa65cf7dce..7066aa4f6b 100644 --- a/cli/commands/serve/proxy-extension-composition.test.ts +++ b/cli/commands/serve/proxy-extension-composition.test.ts @@ -4,12 +4,13 @@ import { assertEquals, assertRejects, assertStrictEquals } from "#veryfront/test import { afterEach, describe, it } from "#veryfront/testing/bdd.ts"; import { type ExtensionLoader, tryResolve } from "veryfront/extensions"; import type { TokenCacheStore } from "#veryfront/extensions/cache/index.ts"; +import { RedisRuntimeProviderName } from "#veryfront/extensions/distributed/index.ts"; import { createCacheFromEnv, TracingTokenCache } from "#veryfront/proxy/cache/index.ts"; import { acquireExtensionTokenCacheStoreFromEnv } from "#veryfront/proxy/cache/extension-store.ts"; import { createProxyShutdownHooks } from "#veryfront/proxy/shutdown-hooks.ts"; import { - activateStandaloneProxyCacheExtension, - registerStandaloneProxyCacheExtensionTeardown, + activateStandaloneProxyExtensions, + registerStandaloneProxyExtensionTeardown, } from "./proxy-extension-composition.ts"; describe("standalone proxy extension composition", () => { @@ -28,8 +29,9 @@ describe("standalone proxy extension composition", () => { it("does not import or activate a cache provider for the memory backend", async () => { Deno.env.set("CACHE_TYPE", "memory"); + Deno.env.delete("REDIS_URL"); - loader = await activateStandaloneProxyCacheExtension(); + loader = await activateStandaloneProxyExtensions(); assertEquals(loader, null); }); @@ -38,12 +40,13 @@ describe("standalone proxy extension composition", () => { Deno.env.set("CACHE_TYPE", "extension"); Deno.env.set("REDIS_URL", "redis://127.0.0.1:6379"); - loader = await activateStandaloneProxyCacheExtension(); + loader = await activateStandaloneProxyExtensions(); const shutdownHooks = createProxyShutdownHooks(); - await registerStandaloneProxyCacheExtensionTeardown(loader, shutdownHooks.register); + await registerStandaloneProxyExtensionTeardown(loader, shutdownHooks.register); const acquisition = await acquireExtensionTokenCacheStoreFromEnv(); assertEquals(loader !== null, true); + assertEquals(tryResolve(RedisRuntimeProviderName) !== undefined, true); assertEquals(acquisition.kind, "borrowed"); assertStrictEquals( acquisition.store, @@ -60,17 +63,33 @@ describe("standalone proxy extension composition", () => { assertEquals(await shutdownHooks.settle(), []); assertEquals(tryResolve("TokenCacheStore"), undefined); + assertEquals(tryResolve(RedisRuntimeProviderName), undefined); loader = null; }); + it("activates the Redis runtime for routing invalidation in memory-cache mode", async () => { + Deno.env.set("CACHE_TYPE", "memory"); + Deno.env.set("REDIS_URL", "redis://127.0.0.1:6379"); + + loader = await activateStandaloneProxyExtensions(); + + assertEquals(loader !== null, true); + assertEquals(tryResolve("TokenCacheStore"), undefined); + assertEquals(tryResolve(RedisRuntimeProviderName) !== undefined, true); + + await loader?.teardownAll(); + loader = null; + assertEquals(tryResolve(RedisRuntimeProviderName), undefined); + }); + it("tears down the provider when shutdown registration fails", async () => { Deno.env.set("CACHE_TYPE", "extension"); Deno.env.set("REDIS_URL", "redis://127.0.0.1:6379"); - loader = await activateStandaloneProxyCacheExtension(); + loader = await activateStandaloneProxyExtensions(); await assertRejects( () => - registerStandaloneProxyCacheExtensionTeardown(loader, () => { + registerStandaloneProxyExtensionTeardown(loader, () => { throw new Error("shutdown registration failed"); }), Error, @@ -83,8 +102,8 @@ describe("standalone proxy extension composition", () => { it("tears down the provider when shutdown-hook disposal fails", async () => { Deno.env.set("CACHE_TYPE", "extension"); Deno.env.set("REDIS_URL", "redis://127.0.0.1:6379"); - loader = await activateStandaloneProxyCacheExtension(); - const teardown = await registerStandaloneProxyCacheExtensionTeardown( + loader = await activateStandaloneProxyExtensions(); + const teardown = await registerStandaloneProxyExtensionTeardown( loader, () => () => { throw new Error("shutdown-hook disposal failed"); @@ -103,7 +122,7 @@ describe("standalone proxy extension composition", () => { it("uses Promise intrinsics captured before extension-owned mutation", async () => { Deno.env.set("CACHE_TYPE", "extension"); Deno.env.set("REDIS_URL", "redis://127.0.0.1:6379"); - loader = await activateStandaloneProxyCacheExtension(); + loader = await activateStandaloneProxyExtensions(); const resolveDescriptor = Object.getOwnPropertyDescriptor(Promise, "resolve")!; let registration: Promise<() => Promise> | undefined; @@ -114,7 +133,7 @@ describe("standalone proxy extension composition", () => { throw new Error("poisoned Promise.resolve"); }, }); - registration = registerStandaloneProxyCacheExtensionTeardown( + registration = registerStandaloneProxyExtensionTeardown( loader, () => () => undefined, ); diff --git a/cli/commands/serve/proxy-extension-composition.ts b/cli/commands/serve/proxy-extension-composition.ts index bab7a657a3..e3eff889b9 100644 --- a/cli/commands/serve/proxy-extension-composition.ts +++ b/cli/commands/serve/proxy-extension-composition.ts @@ -6,17 +6,18 @@ * `TokenCacheStore` contract published by the extension loader. */ -import { cliLogger } from "#cli/utils"; -import { type ExtensionFactory, ExtensionLoader } from "veryfront/extensions"; +import { cliLogger } from "veryfront/utils/logger"; +import { ExtensionLoader } from "veryfront/extensions/loader"; +import type { ExtensionFactory } from "veryfront/extensions/types"; import { importFirstPartyExtensionModule } from "veryfront/extensions/first-party-import"; -import { getEnv } from "veryfront/platform"; +import { getEnv } from "veryfront/platform/env"; import { createProxyShutdownAggregateError, type RegisterProxyShutdownHook, registerProxyShutdownHook, } from "veryfront/proxy/shutdown-hooks"; -type CacheExtensionModule = Readonly<{ default: ExtensionFactory }>; +type ProxyExtensionModule = Readonly<{ default: ExtensionFactory }>; // This module is evaluated before extension activation. Pin promises created // by the later teardown path so extension code cannot replace Promise species @@ -42,54 +43,75 @@ const resolvedBeforeExtensionActivation = pinCompositionPromise( const CACHE_EXTENSION_SOURCE_DIRECTORY = "ext-cache-redis"; const CACHE_EXTENSION_PACKAGE_NAME = "@veryfront/ext-cache-redis"; +const REDIS_EXTENSION_SOURCE_DIRECTORY = "ext-redis"; +const REDIS_EXTENSION_PACKAGE_NAME = "@veryfront/ext-redis"; /** - * Activate the standalone proxy's explicitly selected cache extension. - * Memory mode performs no extension import. The returned loader owns provider - * teardown; the proxy borrows its registered `TokenCacheStore`. + * Activate the standalone proxy's explicitly selected cache and Redis runtime + * providers. The returned loader owns provider teardown. */ -async function activateStandaloneProxyCacheExtensionInternal(): Promise { +async function activateStandaloneProxyExtensionsInternal(): Promise { const cacheType = getEnv("CACHE_TYPE") || "memory"; if (cacheType !== "memory" && cacheType !== "extension") { throw new NativeTypeError("CACHE_TYPE must be memory or extension"); } - if (cacheType === "memory") return null; - const module = await importFirstPartyExtensionModule( - CACHE_EXTENSION_SOURCE_DIRECTORY, - CACHE_EXTENSION_PACKAGE_NAME, - ); - if (typeof module.default !== "function") { - throw new NativeTypeError(`${CACHE_EXTENSION_PACKAGE_NAME} must export an ExtensionFactory`); + const selected: Array<{ + origin: string; + packageName: string; + sourceDirectory: string; + }> = []; + if (cacheType === "extension") { + selected.push({ + origin: "standalone proxy cache selection", + packageName: CACHE_EXTENSION_PACKAGE_NAME, + sourceDirectory: CACHE_EXTENSION_SOURCE_DIRECTORY, + }); + } + if (getEnv("REDIS_URL")) { + selected.push({ + origin: "standalone proxy routing invalidation", + packageName: REDIS_EXTENSION_PACKAGE_NAME, + sourceDirectory: REDIS_EXTENSION_SOURCE_DIRECTORY, + }); } + if (selected.length === 0) return null; + + const extensions = await NativePromise.all(selected.map(async (definition) => { + const module = await importFirstPartyExtensionModule( + definition.sourceDirectory, + definition.packageName, + ); + if (typeof module.default !== "function") { + throw new NativeTypeError(`${definition.packageName} must export an ExtensionFactory`); + } + return { + extension: module.default(), + source: "config" as const, + origin: definition.origin, + }; + })); const loader = new ExtensionLoader(cliLogger); try { - await loader.setupAll( - [{ - extension: module.default(), - source: "config", - origin: "standalone proxy cache selection", - }], - {}, - ); + await loader.setupAll(extensions, {}); return loader; } catch (error) { try { await loader.teardownAll(); } catch (cleanupError) { - cliLogger.error("Failed to clean up standalone proxy cache extension", cleanupError); + cliLogger.error("Failed to clean up standalone proxy extensions", cleanupError); } throw error; } } -/** Start explicit standalone cache composition with a pinned lifecycle promise. */ -export function activateStandaloneProxyCacheExtension(): Promise { - return pinCompositionPromise(activateStandaloneProxyCacheExtensionInternal()); +/** Start explicit standalone proxy extension composition. */ +export function activateStandaloneProxyExtensions(): Promise { + return pinCompositionPromise(activateStandaloneProxyExtensionsInternal()); } -async function registerStandaloneProxyCacheExtensionTeardownInternal( +async function registerStandaloneProxyExtensionTeardownInternal( loader: ExtensionLoader | null, registerHook: RegisterProxyShutdownHook, ): Promise<() => Promise> { @@ -114,7 +136,7 @@ async function registerStandaloneProxyCacheExtensionTeardownInternal( } catch (cleanupError) { throw createProxyShutdownAggregateError( [error, cleanupError], - "Failed to register and clean up standalone proxy cache extension teardown", + "Failed to register and clean up standalone proxy extension teardown", ); } throw error; @@ -137,7 +159,7 @@ async function registerStandaloneProxyCacheExtensionTeardownInternal( if (disposalFailed) { throw createProxyShutdownAggregateError( [disposalError, teardownError], - "Failed to unregister and tear down standalone proxy cache extension", + "Failed to unregister and tear down standalone proxy extensions", ); } throw teardownError; @@ -147,11 +169,11 @@ async function registerStandaloneProxyCacheExtensionTeardownInternal( } /** Register exactly-once provider teardown with the proxy's shutdown owner. */ -export function registerStandaloneProxyCacheExtensionTeardown( +export function registerStandaloneProxyExtensionTeardown( loader: ExtensionLoader | null, registerHook: RegisterProxyShutdownHook = registerProxyShutdownHook, ): Promise<() => Promise> { return pinCompositionPromise( - registerStandaloneProxyCacheExtensionTeardownInternal(loader, registerHook), + registerStandaloneProxyExtensionTeardownInternal(loader, registerHook), ); } diff --git a/cli/commands/serve/proxy-runtime.test.ts b/cli/commands/serve/proxy-runtime.test.ts new file mode 100644 index 0000000000..6fa5c15822 --- /dev/null +++ b/cli/commands/serve/proxy-runtime.test.ts @@ -0,0 +1,55 @@ +import "#veryfront/schemas/_test-setup.ts"; + +import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; +import { afterEach, describe, it } from "#veryfront/testing/bdd.ts"; +import { runStandaloneProxyRuntime } from "./proxy-runtime.ts"; + +describe("standalone proxy runtime", () => { + const originalHost = Deno.env.get("HOST"); + const originalPort = Deno.env.get("PORT"); + + afterEach(() => { + if (originalHost === undefined) Deno.env.delete("HOST"); + else Deno.env.set("HOST", originalHost); + if (originalPort === undefined) Deno.env.delete("PORT"); + else Deno.env.set("PORT", originalPort); + }); + + it("shares CLI bind options with the proxy entrypoint", async () => { + const observed: string[] = []; + + await runStandaloneProxyRuntime( + { bindAddress: "127.0.0.2", port: 4321 }, + { + activateExtensions: async () => null, + registerTeardown: async () => async () => undefined, + loadProxy: async () => { + observed.push(`${Deno.env.get("HOST")}:${Deno.env.get("PORT")}`); + }, + keepAlive: async () => undefined, + }, + ); + + assertEquals(observed, ["127.0.0.2:4321"]); + }); + + it("tears down activated extensions when proxy startup fails", async () => { + let teardownCount = 0; + + await assertRejects( + () => + runStandaloneProxyRuntime({}, { + activateExtensions: async () => null, + registerTeardown: async () => async () => { + teardownCount++; + }, + loadProxy: () => Promise.reject(new Error("proxy startup failed")), + keepAlive: async () => undefined, + }), + Error, + "proxy startup failed", + ); + + assertEquals(teardownCount, 1); + }); +}); diff --git a/cli/commands/serve/proxy-runtime.ts b/cli/commands/serve/proxy-runtime.ts new file mode 100644 index 0000000000..56b74a074c --- /dev/null +++ b/cli/commands/serve/proxy-runtime.ts @@ -0,0 +1,66 @@ +import denoConfig from "../../../deno.json" with { type: "json" }; +import { isJsonMode } from "../../shared/json-output.ts"; +import { bold, brand, dim } from "../../ui/colors.ts"; +import { getEnv, setEnv } from "veryfront/platform/env"; +import { cliLogger } from "veryfront/utils/logger"; +import { + activateStandaloneProxyExtensions, + registerStandaloneProxyExtensionTeardown, +} from "./proxy-extension-composition.ts"; + +export interface StandaloneProxyRuntimeOptions { + bindAddress?: string; + port?: number; +} + +interface StandaloneProxyRuntimeDependencies { + activateExtensions?: typeof activateStandaloneProxyExtensions; + keepAlive?: () => Promise; + loadProxy?: () => Promise; + registerTeardown?: typeof registerStandaloneProxyExtensionTeardown; +} + +const keepAliveForever = (): Promise => new Promise(() => {}); + +function showProxyHeader(): void { + if (isJsonMode()) return; + const version = typeof denoConfig.version === "string" ? denoConfig.version : "0.0.0"; + console.log(`${bold(brand("Veryfront"))} ${dim(`(v${version})`)}`); + console.log(); +} + +/** Start the standalone proxy with the same lifecycle in CLI and dedicated binaries. */ +export async function runStandaloneProxyRuntime( + options: StandaloneProxyRuntimeOptions = {}, + dependencies: StandaloneProxyRuntimeDependencies = {}, +): Promise { + if (options.port !== undefined) setEnv("PORT", String(options.port)); + if (options.bindAddress !== undefined) setEnv("HOST", options.bindAddress); + + const port = getEnv("PORT") || "8080"; + const bindAddress = getEnv("HOST") || "0.0.0.0"; + showProxyHeader(); + cliLogger.info(`Starting proxy server on ${bindAddress}:${port}`); + + const activateExtensions = dependencies.activateExtensions ?? + activateStandaloneProxyExtensions; + const registerTeardown = dependencies.registerTeardown ?? + registerStandaloneProxyExtensionTeardown; + const extensionLoader = await activateExtensions(); + const teardownExtensions = await registerTeardown(extensionLoader); + + try { + await (dependencies.loadProxy ?? (() => import("veryfront/proxy/main")))(); + } catch (error) { + try { + await teardownExtensions(); + } catch (cleanupError) { + cliLogger.error("Failed to clean up proxy extensions after startup failure", cleanupError); + } + throw error; + } + + // Deno.serve returns after binding in compiled binaries, while the proxy's + // signal handlers own shutdown and extension teardown. + await (dependencies.keepAlive ?? keepAliveForever)(); +} diff --git a/cli/proxy-main.ts b/cli/proxy-main.ts new file mode 100644 index 0000000000..ce2ae4d424 --- /dev/null +++ b/cli/proxy-main.ts @@ -0,0 +1,19 @@ +/** Dedicated compiled proxy entrypoint. Optional CLI arguments are ignored. */ + +// Keep the proxy's runtime-selected providers in the compile graph. Using +// `deno compile --include` for these modules embeds the workspace file tree; +// static references embed only each provider and its real dependencies. +import "../extensions/ext-auth-jwt/src/index.ts"; +import "../extensions/ext-cache-redis/src/index.ts"; +import "../extensions/ext-observability-opentelemetry/src/index.ts"; +import "../extensions/ext-observability-sentry/src/index.ts"; +import "../extensions/ext-redis/src/index.ts"; + +import { setLoggerPreset } from "veryfront/utils/logger"; + +setLoggerPreset("cli"); + +const { runStandaloneProxyRuntime } = await import( + "./commands/serve/proxy-runtime.ts" +); +await runStandaloneProxyRuntime(); diff --git a/deno.json b/deno.json index 0ba5b1fa32..ee33884786 100644 --- a/deno.json +++ b/deno.json @@ -141,6 +141,7 @@ "./embedding": "./src/embedding/index.ts", "./knowledge": "./src/knowledge/index.ts", "./extensions": "./src/extensions/index.ts", + "./extensions/loader": "./src/extensions/loader.ts", "./extensions/types": "./src/extensions/types.ts", "./extensions/llm": "./src/extensions/llm/index.ts", "./extensions/auth": "./src/extensions/auth/index.ts", @@ -266,6 +267,7 @@ "veryfront/tool/schema": "./src/tool/schema/index.ts", "veryfront/agent/composition": "./src/agent/composition/index.ts", "veryfront/extensions": "./src/extensions/index.ts", + "veryfront/extensions/loader": "./src/extensions/loader.ts", "veryfront/extensions/types": "./src/extensions/types.ts", "veryfront/testing": "./src/testing/index.ts", "veryfront/testing/assert": "./src/testing/assert.ts", @@ -454,6 +456,7 @@ "production": "deno task generate && deno run --allow-read --allow-write --allow-net --allow-env --allow-run --allow-sys --unstable-worker-options --unstable-net cli/main.ts serve --mode=production", "build:prepare": "deno run -A scripts/build/generate-integrations-module.ts && deno task generate && deno run -A scripts/build/prepare-framework-sources.ts", "build": "deno task build:prepare && deno run -A scripts/build/compile-binary.ts --output ./bin/veryfront", + "build:proxy-lock": "deno cache --node-modules-dir=none --lock scripts/build/proxy-deno.lock --frozen=false cli/proxy-main.ts", "build:npm": "deno run -A scripts/build/generate-integrations-module.ts && deno task generate && deno run --config=scripts/test.deno.json --frozen -A scripts/build/build-npm-dnt.ts", "release": "deno run -A scripts/release.ts", "test": "deno task generate && DENO_TESTING=1 VF_DISABLE_LRU_INTERVAL=1 SSR_TRANSFORM_PER_PROJECT_LIMIT=0 REVALIDATION_PER_PROJECT_LIMIT=0 NODE_ENV=production LOG_FORMAT=text deno test --preload=src/schemas/_test-setup.ts --no-check --parallel --allow-all '--ignore=tests/e2e,tests/integration/compiled-binary-e2e.test.ts' --unstable-worker-options --unstable-net", @@ -516,7 +519,7 @@ "lint:ban-test-only": "deno run --allow-read scripts/lint/ban-test-only.ts", "lint:sanitizer-baseline": "deno run --allow-read scripts/lint/check-sanitizer-baseline.ts", "lint:skipped-tests": "deno run --allow-read scripts/lint/check-skipped-tests-baseline.ts", - "test:scripts": "deno test --config=scripts/test.deno.json --no-check --allow-read --allow-write --allow-run scripts/ci/publish-npm-packages.test.ts scripts/build/dnt-polyfill.test.ts scripts/build/generate-sbom.test.ts scripts/build/npm-dependency-sources.test.ts scripts/build/npm-extension-package-metadata.test.ts scripts/build/npm-package-metadata.test.ts scripts/build/npm-react-shims.test.ts scripts/docs/docs-coverage.test.ts scripts/docs/generate-api-reference.test.ts scripts/docs/guide-validation.test.ts scripts/lint/audit-core-deps.test.ts scripts/lint/audit-dependency-boundaries.test.ts scripts/lint/audit-extension-capabilities.test.ts scripts/lint/audit-extension-contracts.test.ts scripts/lint/audit-deps.test.ts scripts/lint/check-module-boundaries.test.ts scripts/lint/ban-test-only.test.ts scripts/lint/check-sanitizer-baseline.test.ts scripts/lint/check-skipped-tests-baseline.test.ts scripts/lint/check-coverage.test.ts scripts/security/audit-npm.test.ts scripts/security/submit-dependency-snapshot.test.ts && deno task test:tool-search-live", + "test:scripts": "deno test --config=scripts/test.deno.json --no-check --allow-read --allow-write --allow-run scripts/ci/publish-npm-packages.test.ts scripts/build/compile-binary.test.ts scripts/build/dnt-polyfill.test.ts scripts/build/generate-sbom.test.ts scripts/build/npm-dependency-sources.test.ts scripts/build/npm-extension-package-metadata.test.ts scripts/build/npm-package-metadata.test.ts scripts/build/npm-react-shims.test.ts scripts/docs/docs-coverage.test.ts scripts/docs/generate-api-reference.test.ts scripts/docs/guide-validation.test.ts scripts/lint/audit-core-deps.test.ts scripts/lint/audit-dependency-boundaries.test.ts scripts/lint/audit-extension-capabilities.test.ts scripts/lint/audit-extension-contracts.test.ts scripts/lint/audit-deps.test.ts scripts/lint/check-module-boundaries.test.ts scripts/lint/ban-test-only.test.ts scripts/lint/check-sanitizer-baseline.test.ts scripts/lint/check-skipped-tests-baseline.test.ts scripts/lint/check-coverage.test.ts scripts/security/audit-npm.test.ts scripts/security/submit-dependency-snapshot.test.ts && deno task test:tool-search-live", "test:sentry-runtime-packages": "deno test --config=scripts/test.deno.json --no-check --no-lock --allow-read --allow-write --allow-run --allow-env=DENO_DIR,HOME,XDG_CACHE_HOME,LOCALAPPDATA,USERPROFILE scripts/build/sentry-runtime-packages.test.ts", "test:tool-search-live": "VF_DISABLE_LRU_INTERVAL=1 deno test --no-check -A tests/agent/verify-tool-search-live.test.ts", "test:cross-runtime": "deno run --allow-all src/platform/compat/cross-runtime.test.ts", diff --git a/scripts/build/build-all.js b/scripts/build/build-all.js index 524b2601e1..9174feeba3 100644 --- a/scripts/build/build-all.js +++ b/scripts/build/build-all.js @@ -33,33 +33,50 @@ const targets = [ name: "macOS (Intel)", target: "x86_64-apple-darwin", output: "veryfront-macos-x64", + entrypoint: "cli/main.ts", + profile: "full", }, { name: "macOS (Apple Silicon)", target: "aarch64-apple-darwin", output: "veryfront-macos-arm64", + entrypoint: "cli/main.ts", + profile: "full", }, { name: "Linux (x64)", target: "x86_64-unknown-linux-gnu", output: "veryfront-linux-x64", + entrypoint: "cli/main.ts", + profile: "full", }, { name: "Linux (ARM64)", target: "aarch64-unknown-linux-gnu", output: "veryfront-linux-arm64", + entrypoint: "cli/main.ts", + profile: "full", + }, + { + name: "Linux proxy (x64)", + target: "x86_64-unknown-linux-gnu", + output: "veryfront-proxy-linux-x64", + entrypoint: "cli/proxy-main.ts", + profile: "proxy", }, { name: "Windows (x64)", target: "x86_64-pc-windows-msvc", output: "veryfront-windows-x64.exe", + entrypoint: "cli/main.ts", + profile: "full", }, ]; let succeeded = 0; let failed = 0; -for (const { name, target, output } of targets) { +for (const { name, target, output, entrypoint, profile } of targets) { const outputPath = join(distDir, output); try { @@ -70,6 +87,10 @@ for (const { name, target, output } of targets) { "run", "-A", "scripts/build/compile-binary.ts", + "--entrypoint", + entrypoint, + "--profile", + profile, "--target", target, "--output", diff --git a/scripts/build/compile-binary.test.ts b/scripts/build/compile-binary.test.ts index 1d9e0dcb9a..6c7b547d95 100644 --- a/scripts/build/compile-binary.test.ts +++ b/scripts/build/compile-binary.test.ts @@ -1,6 +1,10 @@ import { assertEquals } from "#std/assert"; import { walk } from "#std/fs/walk"; -import { createCompileArgs, DEFAULT_INCLUDES } from "./compile-binary.ts"; +import { + createCompileArgs, + DEFAULT_INCLUDES, + PROXY_INCLUDES, +} from "./compile-binary.ts"; Deno.test("compiled CLI embeds the explicit Node WebSocket extension for opt-in activation", () => { const args = createCompileArgs({ @@ -102,3 +106,65 @@ Deno.test("compiled CLI embeds the auto-loaded Sentry reporter", () => { true, ); }); + +Deno.test("proxy binary embeds only the runtime-resolved proxy entrypoint", async () => { + const args = createCompileArgs({ + entrypoint: "cli/proxy-main.ts", + extraIncludes: [], + output: "/tmp/veryfront-proxy", + profile: "proxy", + }); + + for (const include of PROXY_INCLUDES) { + assertEquals(args.includes(include), true, `missing proxy include ${include}`); + } + + assertEquals(args.includes("--node-modules-dir=none"), true); + assertEquals(args.includes("scripts/build/proxy-deno.lock"), true); + assertEquals(args.includes("--frozen"), true); + assertEquals(args.includes("extensions/ext-image-sharp/src/index.ts"), false); + assertEquals(args.includes("dist/framework-src"), false); + assertEquals(args.at(-1), "cli/proxy-main.ts"); + + const entrypoint = await Deno.readTextFile("cli/proxy-main.ts"); + for ( + const extension of [ + "ext-auth-jwt", + "ext-cache-redis", + "ext-redis", + "ext-observability-opentelemetry", + "ext-observability-sentry", + ] + ) { + assertEquals( + entrypoint.includes(`../extensions/${extension}/src/index.ts`), + true, + `proxy entrypoint must statically embed ${extension}`, + ); + } + + const lock = JSON.parse( + await Deno.readTextFile("scripts/build/proxy-deno.lock"), + ) as { npm?: Record }; + const packages = Object.keys(lock.npm ?? {}); + for (const unrelated of ["@huggingface/transformers", "esbuild", "sharp"]) { + assertEquals( + packages.some((name) => name === unrelated || name.startsWith(`${unrelated}@`)), + false, + `proxy lock must not contain ${unrelated}`, + ); + } + +}); + +Deno.test("full binary remains the default compile profile", () => { + const args = createCompileArgs({ + entrypoint: "cli/main.ts", + extraIncludes: [], + output: "/tmp/veryfront", + }); + + assertEquals(args.includes("extensions/ext-image-sharp/src/index.ts"), true); + assertEquals(args.includes("dist/framework-src"), true); + assertEquals(args.includes("scripts/build/proxy-deno.lock"), false); +}); diff --git a/scripts/build/compile-binary.ts b/scripts/build/compile-binary.ts index 2e87811bc6..424535f582 100644 --- a/scripts/build/compile-binary.ts +++ b/scripts/build/compile-binary.ts @@ -47,13 +47,28 @@ export const DEFAULT_INCLUDES = [ "src/utils/clsx.ts", "dist/framework-src", ]; + +export const PROXY_INCLUDES = [ + // The proxy runtime is loaded after provider activation. Providers are + // statically referenced by cli/proxy-main.ts so --include does not embed the + // workspace file tree for each extension. + "src/proxy/main.ts", +]; + +export type CompileBinaryProfile = "full" | "proxy"; + interface CompileBinaryOptions { entrypoint: string; extraIncludes: string[]; output: string; + profile?: CompileBinaryProfile; target?: string; } +function includesForProfile(profile: CompileBinaryProfile): string[] { + return profile === "proxy" ? PROXY_INCLUDES : DEFAULT_INCLUDES; +} + export function createCompileArgs(options: CompileBinaryOptions): string[] { const args = [ "compile", @@ -62,8 +77,21 @@ export function createCompileArgs(options: CompileBinaryOptions): string[] { "--unstable-worker-options", ]; + if (options.profile === "proxy") { + // The workspace lock contains every framework dependency, and Deno embeds + // every locked npm package in a compiled binary. Use the graph-specific + // frozen lock so the proxy carries only its statically anchored providers. + // Refresh it with `deno task build:proxy-lock` after provider changes. + args.push( + "--node-modules-dir=none", + "--lock", + "scripts/build/proxy-deno.lock", + "--frozen", + ); + } + for (const include of [ - ...DEFAULT_INCLUDES, + ...includesForProfile(options.profile ?? "full"), ...options.extraIncludes, ]) { args.push("--include", include); @@ -96,7 +124,7 @@ function normalizeOutputPath(path: string): string { if (import.meta.main) { const args = parseArgs(Deno.args, { - string: ["entrypoint", "include", "output", "target"], + string: ["entrypoint", "include", "output", "profile", "target"], collect: ["include"], default: { entrypoint: "cli/main.ts" }, }); @@ -106,12 +134,17 @@ if (import.meta.main) { } const extraIncludes = (args.include as string[]).map(String); + const profile = args.profile ?? "full"; + if (profile !== "full" && profile !== "proxy") { + throw new Error(`Invalid --profile ${profile}; expected full or proxy`); + } try { await compileBinary({ entrypoint: String(args.entrypoint), extraIncludes, output: normalizeOutputPath(args.output), + profile, target: typeof args.target === "string" ? args.target : undefined, }); } catch (error) { diff --git a/scripts/build/proxy-deno.lock b/scripts/build/proxy-deno.lock new file mode 100644 index 0000000000..9e68029f2f --- /dev/null +++ b/scripts/build/proxy-deno.lock @@ -0,0 +1,1708 @@ +{ + "version": "5", + "specifiers": { + "jsr:@std/yaml@1.1.0": "1.1.0", + "npm:@opentelemetry/api-logs@0.220.0": "0.220.0", + "npm:@opentelemetry/api@1.9.1": "1.9.1", + "npm:@opentelemetry/auto-instrumentations-node@0.78.0": "0.78.0_@opentelemetry+api@1.9.1_@opentelemetry+core@2.9.0__@opentelemetry+api@1.9.1", + "npm:@opentelemetry/context-async-hooks@2.9.0": "2.9.0_@opentelemetry+api@1.9.1", + "npm:@opentelemetry/core@2.9.0": "2.9.0_@opentelemetry+api@1.9.1", + "npm:@opentelemetry/exporter-logs-otlp-http@0.220.0": "0.220.0_@opentelemetry+api@1.9.1", + "npm:@opentelemetry/exporter-metrics-otlp-http@0.220.0": "0.220.0_@opentelemetry+api@1.9.1", + "npm:@opentelemetry/exporter-trace-otlp-http@0.220.0": "0.220.0_@opentelemetry+api@1.9.1", + "npm:@opentelemetry/resources@2.9.0": "2.9.0_@opentelemetry+api@1.9.1", + "npm:@opentelemetry/sdk-logs@0.220.0": "0.220.0_@opentelemetry+api@1.9.1", + "npm:@opentelemetry/sdk-metrics@2.9.0": "2.9.0_@opentelemetry+api@1.9.1", + "npm:@opentelemetry/sdk-node@0.220.0": "0.220.0_@opentelemetry+api@1.9.1", + "npm:@opentelemetry/sdk-trace-base@2.9.0": "2.9.0_@opentelemetry+api@1.9.1", + "npm:@opentelemetry/semantic-conventions@1.43.0": "1.43.0", + "npm:@redis/client@1.5.8": "1.5.8", + "npm:@sentry/deno@10.68.0": "10.68.0", + "npm:jose@5.9.6": "5.9.6", + "npm:redis@5.11.0": "5.11.0" + }, + "jsr": { + "@std/yaml@1.1.0": { + "integrity": "fc1c5c63e05c4c5eb6118355f557958035d41940d6c29d35b306ef7155d6edb0" + } + }, + "npm": { + "@apm-js-collab/code-transformer-bundler-plugins@0.7.1": { + "integrity": "sha512-Yidf5GOl60db80UxUtNdKK3pnY7obU/gs0xOfA0SCdnvVLMCvfYIer/egC3TqpPiT0Jg22eg3RlzcO+zKfPMcA==", + "dependencies": [ + "@apm-js-collab/code-transformer", + "es-module-lexer", + "magic-string", + "module-details-from-path" + ] + }, + "@apm-js-collab/code-transformer@0.18.1": { + "integrity": "sha512-u1Hb6bHjWtkSpiprwVP6YaHC1DTN4RAU3zYkUDUe7WMnJwdyU1pwTL9dFKiSJB9IiLue/EQovmyx6xhU7FFtAQ==", + "dependencies": [ + "@types/estree", + "astring", + "esquery", + "meriyah", + "semifies", + "source-map" + ], + "bin": true + }, + "@apm-js-collab/tracing-hooks@0.13.0": { + "integrity": "sha512-mTvWz9rnQwx1U3h0XPTHaX7bgfkpipLLTQyjlC2cdhQpQEuoLT0AGzoydeoq2NxfEVv6fWOOETcSbb2nptleyw==", + "dependencies": [ + "@apm-js-collab/code-transformer", + "debug", + "module-details-from-path" + ] + }, + "@grpc/grpc-js@1.14.4": { + "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", + "dependencies": [ + "@grpc/proto-loader", + "@js-sdsl/ordered-map" + ] + }, + "@grpc/proto-loader@0.8.1": { + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", + "dependencies": [ + "lodash.camelcase", + "long", + "protobufjs", + "yargs" + ], + "bin": true + }, + "@isaacs/cliui@8.0.2": { + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dependencies": [ + "string-width@5.1.2", + "string-width-cjs@npm:string-width@4.2.3", + "strip-ansi@7.2.0", + "strip-ansi-cjs@npm:strip-ansi@6.0.1", + "wrap-ansi@8.1.0", + "wrap-ansi-cjs@npm:wrap-ansi@7.0.0" + ] + }, + "@jridgewell/sourcemap-codec@1.5.5": { + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" + }, + "@js-sdsl/ordered-map@4.4.2": { + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==" + }, + "@opentelemetry/api-logs@0.220.0": { + "integrity": "sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==", + "dependencies": [ + "@opentelemetry/api" + ] + }, + "@opentelemetry/api@1.9.1": { + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==" + }, + "@opentelemetry/auto-instrumentations-node@0.78.0_@opentelemetry+api@1.9.1_@opentelemetry+core@2.9.0__@opentelemetry+api@1.9.1": { + "integrity": "sha512-xbfBSlToc6Svrl1rnFdqU990XeUWZJ2IfcCXMRzGtcWpy8h19NoO9EFpXn9lB3NWtJchPb7BVeEhY4+b+fUuFg==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core", + "@opentelemetry/instrumentation", + "@opentelemetry/instrumentation-amqplib", + "@opentelemetry/instrumentation-aws-lambda", + "@opentelemetry/instrumentation-aws-sdk", + "@opentelemetry/instrumentation-bunyan", + "@opentelemetry/instrumentation-cassandra-driver", + "@opentelemetry/instrumentation-connect", + "@opentelemetry/instrumentation-cucumber", + "@opentelemetry/instrumentation-dataloader", + "@opentelemetry/instrumentation-dns", + "@opentelemetry/instrumentation-express", + "@opentelemetry/instrumentation-fs", + "@opentelemetry/instrumentation-generic-pool", + "@opentelemetry/instrumentation-graphql", + "@opentelemetry/instrumentation-grpc", + "@opentelemetry/instrumentation-hapi", + "@opentelemetry/instrumentation-host-metrics", + "@opentelemetry/instrumentation-http", + "@opentelemetry/instrumentation-ioredis", + "@opentelemetry/instrumentation-kafkajs", + "@opentelemetry/instrumentation-knex", + "@opentelemetry/instrumentation-koa", + "@opentelemetry/instrumentation-lru-memoizer", + "@opentelemetry/instrumentation-memcached", + "@opentelemetry/instrumentation-mongodb", + "@opentelemetry/instrumentation-mongoose", + "@opentelemetry/instrumentation-mysql", + "@opentelemetry/instrumentation-mysql2", + "@opentelemetry/instrumentation-nestjs-core", + "@opentelemetry/instrumentation-net", + "@opentelemetry/instrumentation-openai", + "@opentelemetry/instrumentation-oracledb", + "@opentelemetry/instrumentation-pg", + "@opentelemetry/instrumentation-pino", + "@opentelemetry/instrumentation-redis", + "@opentelemetry/instrumentation-restify", + "@opentelemetry/instrumentation-router", + "@opentelemetry/instrumentation-runtime-node", + "@opentelemetry/instrumentation-socket.io", + "@opentelemetry/instrumentation-tedious", + "@opentelemetry/instrumentation-undici", + "@opentelemetry/instrumentation-winston", + "@opentelemetry/resource-detector-alibaba-cloud", + "@opentelemetry/resource-detector-aws", + "@opentelemetry/resource-detector-azure", + "@opentelemetry/resource-detector-container", + "@opentelemetry/resource-detector-gcp", + "@opentelemetry/resources", + "@opentelemetry/sdk-node" + ] + }, + "@opentelemetry/configuration@0.220.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-glfIVKnZevRin8fY/9uES/mhRtMT1lGINLHc9MIo5fTQZXswEEHamJtgjv4MTtzgnhHGC92mIS/0lzAUZMyE0w==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core", + "yaml" + ] + }, + "@opentelemetry/context-async-hooks@2.9.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-OQ0vzvbZBiUhjqLnUaoNfYmP8553Crr3aggB4y0ZUi815mZ7idpdJXQmoKdeBKJelYttoBlLSSHubmyw3wvX4w==", + "dependencies": [ + "@opentelemetry/api" + ] + }, + "@opentelemetry/core@2.9.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/semantic-conventions" + ] + }, + "@opentelemetry/exporter-logs-otlp-grpc@0.220.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-s0sRPCSlXYqlgObOpCftomJllp3LfUL9FobQ5csg2172ydVhSEnu1ptpsVBJadazs5nUNp7vDuLE03FAFWTLOQ==", + "dependencies": [ + "@grpc/grpc-js", + "@opentelemetry/api", + "@opentelemetry/core", + "@opentelemetry/otlp-exporter-base", + "@opentelemetry/otlp-grpc-exporter-base", + "@opentelemetry/otlp-transformer", + "@opentelemetry/sdk-logs" + ] + }, + "@opentelemetry/exporter-logs-otlp-http@0.220.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-8186thl+pTw64iz/qEEen5oJZoZ/gO73XruChdaGlYdWOdBIQ42r+vHLf6a7vIDqTD4b8ZOoMlyxptanECaI9A==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/api-logs", + "@opentelemetry/core", + "@opentelemetry/otlp-exporter-base", + "@opentelemetry/otlp-transformer", + "@opentelemetry/sdk-logs" + ] + }, + "@opentelemetry/exporter-logs-otlp-proto@0.220.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-8LZAxdJ0ENDAFwr4j0oY35mHBltiSzvlhdQAPGiC7p9VnxtuSq4SW1gfBAdW6t6hiQG6OwUl8w7KHaOdJPKHWg==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/otlp-exporter-base", + "@opentelemetry/otlp-transformer", + "@opentelemetry/sdk-logs" + ] + }, + "@opentelemetry/exporter-metrics-otlp-grpc@0.220.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-U128izvJfX/dW9jRGP0gIfadR1Hg7ft3UEGIeRxLFK70m2BWw6AtNCOnsUygpw2zCgR/ygdWbGpcL6TmhW0ZGw==", + "dependencies": [ + "@grpc/grpc-js", + "@opentelemetry/api", + "@opentelemetry/core", + "@opentelemetry/exporter-metrics-otlp-http", + "@opentelemetry/otlp-exporter-base", + "@opentelemetry/otlp-grpc-exporter-base", + "@opentelemetry/otlp-transformer", + "@opentelemetry/resources", + "@opentelemetry/sdk-metrics" + ] + }, + "@opentelemetry/exporter-metrics-otlp-http@0.220.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-Yqt3RBw/bRVncaE9qIIhk4WfjbAQqXuP9FgAaU+IKPndnLEp/cUqZlSC324+bpmduRz7DoTjig8Ub0PeILWXUA==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core", + "@opentelemetry/otlp-exporter-base", + "@opentelemetry/otlp-transformer", + "@opentelemetry/resources", + "@opentelemetry/sdk-metrics" + ] + }, + "@opentelemetry/exporter-metrics-otlp-proto@0.220.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-lyO+IQBdSvqHN/ZOW/OzrSWemtfD+HgWngn+HBNLhjy0YrCQQTz0OE/kSekH2Pl340dn9DWzhqHdz5Eftr+HLA==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core", + "@opentelemetry/exporter-metrics-otlp-http", + "@opentelemetry/otlp-exporter-base", + "@opentelemetry/otlp-transformer", + "@opentelemetry/resources", + "@opentelemetry/sdk-metrics" + ] + }, + "@opentelemetry/exporter-prometheus@0.220.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-JZD5DL/NBpVd2BHefvYosm3G40UZ/KzExLv5tc0eZe0CtrsHHtcOk3YPUxR2EINmUeBf8+w5UReTV8fFPn95lA==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core", + "@opentelemetry/resources", + "@opentelemetry/sdk-metrics", + "@opentelemetry/semantic-conventions" + ] + }, + "@opentelemetry/exporter-trace-otlp-grpc@0.220.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-bv1xmNhmNwIM6MdUBw4yYuJeVcEViVLk3uD69vOQMwueHBnfyl/u0HnBlB1FNY/Te0UOzJzvcbyR8wN6b+iGbA==", + "dependencies": [ + "@grpc/grpc-js", + "@opentelemetry/api", + "@opentelemetry/otlp-exporter-base", + "@opentelemetry/otlp-grpc-exporter-base", + "@opentelemetry/otlp-transformer", + "@opentelemetry/sdk-trace" + ] + }, + "@opentelemetry/exporter-trace-otlp-http@0.220.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-/+ExB3lRkf+erv4PnoywyL7RHKITidxtUpUTS55k7OQ0dB42S7gEF1gry7swb9MSm1hYLUhJg4QQh9W8SpwwqA==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core", + "@opentelemetry/otlp-exporter-base", + "@opentelemetry/otlp-transformer", + "@opentelemetry/resources", + "@opentelemetry/sdk-trace" + ] + }, + "@opentelemetry/exporter-trace-otlp-proto@0.220.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-voTAD8XgJxlK7zLkXh8EzMB09zrQr3tyY/BsnDTlDiQU/UdK58MZ63A3mUjdEDrxMjCVmBHU3WQJhRmQe+Dvzg==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core", + "@opentelemetry/otlp-exporter-base", + "@opentelemetry/otlp-transformer", + "@opentelemetry/resources", + "@opentelemetry/sdk-trace" + ] + }, + "@opentelemetry/exporter-zipkin@2.9.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-RwINoce2BH8T4obT5pMcAla2sWma1YZvYuaktWmTluQ0PkQdvv5D060rWI1+kawX+J2qBRcMbwrZJJNcMJUauQ==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core", + "@opentelemetry/resources", + "@opentelemetry/sdk-trace", + "@opentelemetry/semantic-conventions" + ] + }, + "@opentelemetry/instrumentation-amqplib@0.67.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-e67iWHIDEJ34eO8Dm11fZ8vhELWeLtW09ghV76dnFSN02QiuxjzP9PJO7+ZPnmqbVps7wxIwdEhQaf75wOR2kQ==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core", + "@opentelemetry/instrumentation", + "@opentelemetry/semantic-conventions" + ] + }, + "@opentelemetry/instrumentation-aws-lambda@0.72.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-KE1LBGM9NteXuvE/8Vaol7peQAre8i0TSUgLG5WysdYg+ovb+lPuvgwlHKYWLJuNpsRPsoOmmfKo9+YTJUWGFA==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/instrumentation", + "@opentelemetry/propagator-aws-xray", + "@opentelemetry/semantic-conventions", + "@types/aws-lambda" + ] + }, + "@opentelemetry/instrumentation-aws-sdk@0.75.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-RLosRcyIojBDzX6uPcooDlpJH5UFzbOZXwLp4NKl2FHy0UgmMfQU+mmul12wEohKTDiGumWouw43yRF69NAfbQ==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core", + "@opentelemetry/instrumentation", + "@opentelemetry/semantic-conventions" + ] + }, + "@opentelemetry/instrumentation-bunyan@0.65.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-VNqQfK2DY8P5iZTIo/2qS72/fY3DSfUGyRqsfJi8HbQ3WTeWwucKcBUWFF3WMvtt4gNyRpZIk/5qKpBLoDKWZw==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/api-logs", + "@opentelemetry/instrumentation", + "@opentelemetry/semantic-conventions", + "@types/bunyan" + ] + }, + "@opentelemetry/instrumentation-cassandra-driver@0.65.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-WarpdvKpBzvPHPY9a7f0NJ2JSobnVdy+X4thmtvJ0K8XfsMvrrwYMy1FWe+5K03LPZtmfIQwoerEtC6ly5gsMw==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/instrumentation", + "@opentelemetry/semantic-conventions" + ] + }, + "@opentelemetry/instrumentation-connect@0.63.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-Lg13vVEtZe2yvOyLr61qJHSiD1p0+CTMZhV7mlcRuVABPrfrWuqeEKamsZW0r04DP6LOoVZAvIb3iU7DV4/nEA==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core", + "@opentelemetry/instrumentation", + "@opentelemetry/semantic-conventions", + "@types/connect" + ] + }, + "@opentelemetry/instrumentation-cucumber@0.36.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-QqG1j6E3tvUs+1ryUD9o/K3EDCxdffAmtMEzspzCYbC/fawJBCpGaLWbFaA7i90r26qTKwfWDoElCh51lMS7IQ==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/instrumentation", + "@opentelemetry/semantic-conventions" + ] + }, + "@opentelemetry/instrumentation-dataloader@0.37.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-9w0yRC6nyYYQkxwf3vOEBfxiGjyJaQDhOjpusZFmgOxW2bArtSrV8t2hdeLhU6dXy1Kn/N+yocnhWin2B/xEWQ==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/instrumentation" + ] + }, + "@opentelemetry/instrumentation-dns@0.63.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-sq7GI18PzmCBA5ATfT6I9KFiMBneEy2mjB7oKh46ATVbX2rx+kI2QQruJrGE8SYAIcT8uiF2fm0p1HwA06X7og==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/instrumentation" + ] + }, + "@opentelemetry/instrumentation-express@0.68.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-3ffjIQUFNVP94lLLHlBEgNaomCoP0BLH36Gxmkk3/WKX+1530QKVAnZTleYdM3RVU2EeQFtWSmFMAyCLglqO3w==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core", + "@opentelemetry/instrumentation", + "@opentelemetry/semantic-conventions" + ] + }, + "@opentelemetry/instrumentation-fs@0.39.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-y7xVCHIy1xDIx5X3N1jr/JLHNw57aa3pLAWwmYbvyFJGtQeac/GP7ykwY10QwCFukXvrrxyOYPpMXeICduZzgw==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core", + "@opentelemetry/instrumentation" + ] + }, + "@opentelemetry/instrumentation-generic-pool@0.63.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-8750xK6KABe1tQ3sWBfFdenXUaUaa+Qvxztrr/mg7nuNTPbttVDVRzmh6aes2TFDuj+iK232wz9FIETxzVAXLw==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/instrumentation" + ] + }, + "@opentelemetry/instrumentation-graphql@0.68.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-ZpL6FYk6NZTuBO5Dh8G7SNBANswTIxCI5qod2pQjF3fsKpxDRHA4FJ6yYK3TdJhFLloMdyRVmE1gMCxG7q6hYQ==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/instrumentation" + ] + }, + "@opentelemetry/instrumentation-grpc@0.220.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-U1EF8KKu52XwH2ybUkjVDmaVQZGf3mXirRSw1KJQrOV5aymgJgkPJV7+kRPqawZe0rpVc/BK+pPSyMWuQoyJJQ==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/instrumentation", + "@opentelemetry/semantic-conventions" + ] + }, + "@opentelemetry/instrumentation-hapi@0.66.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-M3ZTzsFwPcb2mL+skodr94WJ0hMmpkqCb9k3kvZ2THzf+cxBsWYl/gjHZhjfuminKSh5x5gX2A+IeA3lJP4NVA==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core", + "@opentelemetry/instrumentation", + "@opentelemetry/semantic-conventions" + ] + }, + "@opentelemetry/instrumentation-host-metrics@0.3.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-6Z7xjnOd8xpwFRv85AcsXid7RwjyMohu1XJ8xoduMjbOvXjTSENWm3G279dCY/nJQfO2JKo+ZpG3v0WW+JhNOA==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/instrumentation", + "systeminformation" + ] + }, + "@opentelemetry/instrumentation-http@0.220.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-Szt4dO2Boz2CDr38DaSw/lnqwhwKl+IAdgNGEGgSm2Anb+fwPtIAGmIwkhsLLN69QQZQE96JxjMKYY4rlRkYKw==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core", + "@opentelemetry/instrumentation", + "@opentelemetry/semantic-conventions", + "forwarded-parse" + ] + }, + "@opentelemetry/instrumentation-ioredis@0.68.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-M2MWPoKiMlNWzmW7+AwEwiFpTJQ7bhKpZuo9L3MS/z/KFm2yXY6B43IprAVyiszLFg8/JsF39t8b8wkGpxip2g==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/instrumentation", + "@opentelemetry/redis-common", + "@opentelemetry/semantic-conventions" + ] + }, + "@opentelemetry/instrumentation-kafkajs@0.29.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-rNCDUxtvPRRiRAL9Bn5zPWosZ7uE5RS7NDhxIa6DzaUs54GfhqP1DP7l/5jgilTMw8uoAK0/Hq+O2xmBKny8Hw==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/instrumentation", + "@opentelemetry/semantic-conventions" + ] + }, + "@opentelemetry/instrumentation-knex@0.64.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-tKTneLpKFZVz8TOSPM+Iho9XGkm95klIHS5oxjzBfsh0nXS6GBi9pzix86eyYINSpfQBMj4Piie6yC1wsH/QfA==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/instrumentation", + "@opentelemetry/semantic-conventions" + ] + }, + "@opentelemetry/instrumentation-koa@0.68.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-qem1LDEnrIq8BV+NwxTMy/AGZ4d4kT8Y5xQzJ56ogkhbChzdRKG+hof8taW7bOncNdbu26E17nh2RYuRvpI8sQ==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core", + "@opentelemetry/instrumentation", + "@opentelemetry/semantic-conventions" + ] + }, + "@opentelemetry/instrumentation-lru-memoizer@0.64.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-80aCN6z54VFl+xvqe6+GevSteBKN1pmMEM0kW6I0pojFZcyzzyk1CDVVcKwVG/+6uLm8P2pDpLm9gBH+1XwyPw==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/instrumentation" + ] + }, + "@opentelemetry/instrumentation-memcached@0.63.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-W1LdUV/+W1MNk9vkLwZrla1bFcjh81t7QQmeaxCPXFXX6VHgzwFp9Wj4atiD4Qch51OVNN988tmPayf49oNM7w==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/instrumentation", + "@opentelemetry/semantic-conventions", + "@types/memcached" + ] + }, + "@opentelemetry/instrumentation-mongodb@0.73.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-M61+VpaXaLj7euluV+jARBo62tBWrpubSUPIZMpUnjOM90nqCQCj8gpyhDP1rVgzQt2LoTIzcdWnKq/DEkzMog==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/instrumentation", + "@opentelemetry/semantic-conventions" + ] + }, + "@opentelemetry/instrumentation-mongoose@0.66.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-CxzRijJCexHnucPDuKSz1PTJf6+t0VJxFDNQtoCwSPo8eGXKoEuJ/I4V2kMQjMbcY4qISN3Efa+7TL5Zy6zqTA==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core", + "@opentelemetry/instrumentation", + "@opentelemetry/semantic-conventions" + ] + }, + "@opentelemetry/instrumentation-mysql2@0.66.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-rlGII5qTWklt9oGdmQzMDGjEpcQ3wf+rD6JCmPTe7nXJZSxibxgWvmFGGTZjq3Tu0wqHGJ9GWM1A5PphM2NTRA==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/instrumentation", + "@opentelemetry/semantic-conventions", + "@opentelemetry/sql-common" + ] + }, + "@opentelemetry/instrumentation-mysql@0.66.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-kfbyFeHOV/RzmRifWJflpBTCrYz4vD5j8IVqjSreaAPGOvKHj/fflwqNwdi7cy4QRmm7vVkxqTvM7q0+ZF58CA==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/instrumentation", + "@opentelemetry/semantic-conventions", + "@types/mysql" + ] + }, + "@opentelemetry/instrumentation-nestjs-core@0.66.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-ZCzcTWXwlmQsLWGARbUz5fCLpYABoo5A/3PuV5+iICV3pmKWT0rRdKDevkRo0prbzJVh9oEyuT1idxI8ipDqXg==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/instrumentation", + "@opentelemetry/semantic-conventions" + ] + }, + "@opentelemetry/instrumentation-net@0.64.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-AHIZAC0M969fRsFvYkpxbTYiNxyeyMA069uvwTtcUrkwZpE6BW/45Ap+YGMnIoLNdRCPKbsphoW9xXT4VpLJBw==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/instrumentation", + "@opentelemetry/semantic-conventions" + ] + }, + "@opentelemetry/instrumentation-openai@0.18.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-hk/AXskFOOGlZx7X6pewnyJLSTvW4DbXL/EOoxPS8xHY63B6hg4HVAmE4bdI48/qG6mM4jVod7/0XROghgPT5Q==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/api-logs", + "@opentelemetry/instrumentation", + "@opentelemetry/semantic-conventions" + ] + }, + "@opentelemetry/instrumentation-oracledb@0.45.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-Zhyxfzuh2oUktjGfwuq2gSGieMr3x1NDnjVYpdlEsTBD8fA9aCvjQQHrjp/vSPOEk3YOD9fZ2HnxsgX63/MBmg==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/instrumentation", + "@opentelemetry/semantic-conventions", + "@types/oracledb" + ] + }, + "@opentelemetry/instrumentation-pg@0.72.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-p9xrFc/6R8t6Y293sTYLZ83LnzZo/qY0bBPA4xabdQt0Qjt8i1SlYFsIeGY2Jmf5WcESNUdjQB3NxWnt5Ox7zw==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core", + "@opentelemetry/instrumentation", + "@opentelemetry/semantic-conventions", + "@opentelemetry/sql-common", + "@types/pg", + "@types/pg-pool" + ] + }, + "@opentelemetry/instrumentation-pino@0.66.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-RMAtAYuyouaMbHkQG8E97nJfwHftxmCbOURdD3n5s2Yd5zctLNudXB5hAfW18lsfUbePwQCND6QUXZixFN92Pw==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/api-logs", + "@opentelemetry/core", + "@opentelemetry/instrumentation", + "@opentelemetry/semantic-conventions" + ] + }, + "@opentelemetry/instrumentation-redis@0.68.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-D5N4CLWLjBRFa1Ee8D1U1tWCkED+Ob0AMzQlYJjlnnqfw0ofRMaJmoUrdI5ASKEcfDezzhELT1Slo4VesDpA/w==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/instrumentation", + "@opentelemetry/redis-common", + "@opentelemetry/semantic-conventions" + ] + }, + "@opentelemetry/instrumentation-restify@0.65.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-vdgtqK+uVf66FTjR6eVrveORtG7Jz5+Tlc4SvWCmtc1/2DYZ+IQuWQ9HPMJcFpcPkRXfiN1QNvZDPIe1Mlvopw==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core", + "@opentelemetry/instrumentation", + "@opentelemetry/semantic-conventions" + ] + }, + "@opentelemetry/instrumentation-router@0.64.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-C6PCzQYXYbmhhIjZQaAPCWchOe7Y/JLX9usj80xHEcEniOx2hFE1pUXneYZKcnFf8vuXjbYOUSlKkMd2WctirA==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/instrumentation", + "@opentelemetry/semantic-conventions" + ] + }, + "@opentelemetry/instrumentation-runtime-node@0.33.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-P4PFhufcbAeeZNNl5e4xDoWs7GieSebPuiWhe6V60yoSzL8OO4EkQU61g29O/PiypGQ/ay89n13htkMH2nxocg==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/api-logs", + "@opentelemetry/core", + "@opentelemetry/instrumentation" + ] + }, + "@opentelemetry/instrumentation-socket.io@0.67.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-fFJAh42goV9iOehmG97ycC+hPdW3d2HkoK2/ybD/OOuQYUsJ3JxwD5owtS71lQckZeDYF7NEb6hAZM+JS3iwCg==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/instrumentation" + ] + }, + "@opentelemetry/instrumentation-tedious@0.39.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-CMIg+CASssmkQWL+Ep+SSjstxr8blJeRL6RjLxlcEejBZeEj/0450pkSrZ7NtFcXpVqSLMd3+gEzkN55jWgP2A==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/instrumentation", + "@opentelemetry/semantic-conventions", + "@types/tedious" + ] + }, + "@opentelemetry/instrumentation-undici@0.30.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-VgxMzeR14uPEVqEC5b55m4KijEe+gQAgJ4jjWCE7h5i2Q76nS4y7OWDk8V+XkD4zK9bbJyWEK+a2DqtGP/fCuw==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core", + "@opentelemetry/instrumentation", + "@opentelemetry/semantic-conventions" + ] + }, + "@opentelemetry/instrumentation-winston@0.64.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-N4dJ0Var+deL2FKQn2TNPKLma8c/vgR0dL89I57eNBcV+5rGj3tk4glSDJqd9BQqkKuZ8+C4bAlCtVHHBsqdKw==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/api-logs", + "@opentelemetry/instrumentation" + ] + }, + "@opentelemetry/instrumentation@0.220.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/api-logs", + "import-in-the-middle", + "require-in-the-middle" + ] + }, + "@opentelemetry/otlp-exporter-base@0.220.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-CXYo8UD5Mn9YbgebO2EL4wejtA+gxLmLiu6HCk2KH2BR7XhFN6/6p1UlCb23DYCjeYkndevLHuejCCN1yx4+OQ==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core", + "@opentelemetry/otlp-transformer" + ] + }, + "@opentelemetry/otlp-grpc-exporter-base@0.220.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-/eIkBPMBTIvM3x/0mDX4aJeSkYifYClnBPr68PL1h5LV4VQv4+SV6CGrpiZ4fIWDnobVmhTWCm1J/QRdAWUfvA==", + "dependencies": [ + "@grpc/grpc-js", + "@opentelemetry/api", + "@opentelemetry/core", + "@opentelemetry/otlp-exporter-base", + "@opentelemetry/otlp-transformer" + ] + }, + "@opentelemetry/otlp-transformer@0.220.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-lXGrv7KXZ0gNH9SVNUaa6vv6phVYGvJxfXAlMbzbakiXru75f5MZl8Z7oqiMMQD77riVHJCFlQvbZs/VVN2/4A==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/api-logs", + "@opentelemetry/core", + "@opentelemetry/resources", + "@opentelemetry/sdk-logs", + "@opentelemetry/sdk-metrics", + "@opentelemetry/sdk-trace" + ] + }, + "@opentelemetry/propagator-aws-xray@2.2.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-Yjvt2EjL+tfpkVOdKbhTPgpM4SIAez9nG6Q/QjQ3yfcJcjIWp59ph70SLfvmkSL6++3DCnuBG3iWcB18PwWavQ==", + "dependencies": [ + "@opentelemetry/api" + ] + }, + "@opentelemetry/propagator-b3@2.9.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-WrOT1WsOUG+B7hstD2RYoMPIOK76G8E9AQHhMjUvrQaGx/oA7rPWQvvr1Rqv7+yy4R0ZMVwWLC4vW2xnkgWPAQ==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core" + ] + }, + "@opentelemetry/propagator-jaeger@2.9.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-4mYGty27rYvSM0jtp1ZUOqd3LfVRCYg9H5G9OFzSx5HViYToU21MFhWfco7x1HwXr7ER8yGOiCIHZUwjPksc0Q==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core" + ] + }, + "@opentelemetry/redis-common@0.38.3": { + "integrity": "sha512-VCghU1JYs/4gP6Gqf/xro9MEsZ7LrMv2uONVsaESKL38ZOB9BqnI98FfS23wjMnHlpuE+TTaWSoAVNpTwYXzjw==" + }, + "@opentelemetry/resource-detector-alibaba-cloud@0.35.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-IACBakM0z30CsASN6VSrpZi89+Ot4ZUerW8+6CdBFhdAZO+XGh0m4LigN6lh4yzUaqqva/SmH9yHeFfuctIY/A==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core", + "@opentelemetry/resources" + ] + }, + "@opentelemetry/resource-detector-aws@2.21.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-Veavy+khoywR+Hv065SU5jucFTGTiW1KXo39CsJ+8wqdYYz8jiRJPnQ20Kd+X9HbV2+Abb0l5CrJIdxK1ZOqBg==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core", + "@opentelemetry/resources", + "@opentelemetry/semantic-conventions" + ] + }, + "@opentelemetry/resource-detector-azure@0.28.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-YMMgH/ZIiZgy2CHTHjOBNEYhcsi/l67Q272vAgwMqegRFIME4KljDhmTmjLGzjE3b0sErLtXBqF8Y/3bKn89+Q==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core", + "@opentelemetry/resources", + "@opentelemetry/semantic-conventions" + ] + }, + "@opentelemetry/resource-detector-container@0.8.12_@opentelemetry+api@1.9.1": { + "integrity": "sha512-EJRFfIY26whY0w5RDxMRXlfBDgDS001JYMHuOVuDBBsRrV4MBqoVajR9B0L9Vy728+w/HNVnSQkpJFacFr+klg==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core", + "@opentelemetry/resources" + ] + }, + "@opentelemetry/resource-detector-gcp@0.55.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-uWU27lJcTbeXDY+uWEapsIyMx8mKi14/IGvUY1DkmMmLQnKRibnbpZEsRVeWBPdjOWSjH9LfWTXp9yDcBHZOeg==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core", + "@opentelemetry/resources", + "gcp-metadata" + ] + }, + "@opentelemetry/resources@2.9.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core", + "@opentelemetry/semantic-conventions" + ] + }, + "@opentelemetry/sdk-logs@0.220.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-WywcTkQtv2iNmt+6y5Kcd4rzvx9bLVsBa2Nwcmg01IUaBTkTow3W4d9KE5vNBpEDtb9tp21WcRBY/lANRrApYA==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/api-logs", + "@opentelemetry/core", + "@opentelemetry/resources", + "@opentelemetry/semantic-conventions" + ] + }, + "@opentelemetry/sdk-metrics@2.9.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-Xx8RGS4H5XEBl01WuCreMIpiah9cCXMbSkeuIePPdD2cUpq/vUzYmj8E/MK1OsbOc93FuAD4jfn2WOacKwLn7Q==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core", + "@opentelemetry/resources" + ] + }, + "@opentelemetry/sdk-node@0.220.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-wHtGyHhSKHNH3fym33xRu4Ef/HXTFvX8eQ42xdQdEO9LYx9Y2qNyBDJytyqVlvmo6abWZlNYTUthuAGUMYqYnQ==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/api-logs", + "@opentelemetry/configuration", + "@opentelemetry/context-async-hooks", + "@opentelemetry/core", + "@opentelemetry/exporter-logs-otlp-grpc", + "@opentelemetry/exporter-logs-otlp-http", + "@opentelemetry/exporter-logs-otlp-proto", + "@opentelemetry/exporter-metrics-otlp-grpc", + "@opentelemetry/exporter-metrics-otlp-http", + "@opentelemetry/exporter-metrics-otlp-proto", + "@opentelemetry/exporter-prometheus", + "@opentelemetry/exporter-trace-otlp-grpc", + "@opentelemetry/exporter-trace-otlp-http", + "@opentelemetry/exporter-trace-otlp-proto", + "@opentelemetry/exporter-zipkin", + "@opentelemetry/instrumentation", + "@opentelemetry/otlp-exporter-base", + "@opentelemetry/otlp-grpc-exporter-base", + "@opentelemetry/propagator-b3", + "@opentelemetry/propagator-jaeger", + "@opentelemetry/resources", + "@opentelemetry/sdk-logs", + "@opentelemetry/sdk-metrics", + "@opentelemetry/sdk-trace", + "@opentelemetry/sdk-trace-base", + "@opentelemetry/sdk-trace-node", + "@opentelemetry/semantic-conventions" + ] + }, + "@opentelemetry/sdk-trace-base@2.9.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-cp9zmTl62R8PJrpvFcmc8N2JQU/xfa0S+61q511Nji+QxCfZ8Ifvg7H27G8cANe4crg4RTrWsVvanHiXjSp6ag==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core", + "@opentelemetry/resources", + "@opentelemetry/sdk-trace", + "@opentelemetry/semantic-conventions" + ] + }, + "@opentelemetry/sdk-trace-node@2.9.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-ec9a7ps37huy5itYk0MalaZdSLlM6AXWp/FhtEjgMpp5leEGojBDvAl/UWttQnkMZOvFHKzRESn8TD3yKTF5nQ==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/context-async-hooks", + "@opentelemetry/core", + "@opentelemetry/sdk-trace-base" + ] + }, + "@opentelemetry/sdk-trace@2.9.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-sGA19HvtrrSKYsseHphluH6j3p6Xa3fqc7c7y8f/7mYWejc1lyDFcpSdD1kYa50HCLUeEo4zA5bW0pniaPszuw==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core", + "@opentelemetry/resources", + "@opentelemetry/semantic-conventions" + ] + }, + "@opentelemetry/semantic-conventions@1.43.0": { + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==" + }, + "@opentelemetry/sql-common@0.42.0_@opentelemetry+api@1.9.1": { + "integrity": "sha512-nwUwUU+8O8a4bnLqk6CodWeegGMEANgC94KTAhXcpGWLrW/2/hek/0ajNbjXnSOoNuCX+nteUPs46HFHhou9Xw==", + "dependencies": [ + "@opentelemetry/api", + "@opentelemetry/core" + ] + }, + "@pkgjs/parseargs@0.11.0": { + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==" + }, + "@protobufjs/aspromise@1.1.2": { + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==" + }, + "@protobufjs/base64@1.1.2": { + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" + }, + "@protobufjs/codegen@2.0.5": { + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==" + }, + "@protobufjs/eventemitter@1.1.1": { + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==" + }, + "@protobufjs/fetch@1.1.1": { + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "dependencies": [ + "@protobufjs/aspromise" + ] + }, + "@protobufjs/float@1.0.2": { + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==" + }, + "@protobufjs/path@1.1.2": { + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==" + }, + "@protobufjs/pool@1.1.0": { + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==" + }, + "@protobufjs/utf8@1.1.2": { + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==" + }, + "@redis/bloom@5.11.0_@redis+client@5.11.0": { + "integrity": "sha512-KYiVilAhAFN3057afUb/tfYJpsEyTkQB+tQcn5gVVA7DgcNOAj8lLxe4j8ov8BF6I9C1Fe/kwlbuAICcTMX8Lw==", + "dependencies": [ + "@redis/client@5.11.0" + ] + }, + "@redis/client@1.5.8": { + "integrity": "sha512-xzElwHIO6rBAqzPeVnCzgvrnBEcFL1P0w8P65VNLRkdVW8rOE58f52hdj0BDgmsdOm4f1EoXPZtH4Fh7M/qUpw==", + "dependencies": [ + "cluster-key-slot", + "generic-pool", + "yallist" + ] + }, + "@redis/client@5.11.0": { + "integrity": "sha512-GHoprlNQD51Xq2Ztd94HHV94MdFZQ3CVrpA04Fz8MVoHM0B7SlbmPEVIjwTbcv58z8QyjnrOuikS0rWF03k5dQ==", + "dependencies": [ + "cluster-key-slot" + ] + }, + "@redis/json@5.11.0_@redis+client@5.11.0": { + "integrity": "sha512-1iAy9kAtcD0quB21RbPTbUqqy+T2Uu2JxucwE+B4A+VaDbIRvpZR6DMqV8Iqaws2YxJYB3GC5JVNzPYio2ErUg==", + "dependencies": [ + "@redis/client@5.11.0" + ] + }, + "@redis/search@5.11.0_@redis+client@5.11.0": { + "integrity": "sha512-g1l7f3Rnyk/xI99oGHIgWHSKFl45Re5YTIcO8j/JE8olz389yUFyz2+A6nqVy/Zi031VgPDWscbbgOk8hlhZ3g==", + "dependencies": [ + "@redis/client@5.11.0" + ] + }, + "@redis/time-series@5.11.0_@redis+client@5.11.0": { + "integrity": "sha512-TWFeOcU4xkj0DkndnOyhtxvX1KWD+78UHT3XX3x3XRBUGWeQrKo3jqzDsZwxbggUgf9yLJr/akFHXru66X5UQA==", + "dependencies": [ + "@redis/client@5.11.0" + ] + }, + "@sentry/conventions@0.16.0": { + "integrity": "sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==" + }, + "@sentry/core@10.68.0": { + "integrity": "sha512-5Amhx8ltVz7vb1bRGyf3c4J69/iHW8R/H+SJxTRILHlsSOBrnVVc/IQEYDC6PTRdRdZ3x2u7RVjxZi2Mhe525g==", + "dependencies": [ + "@sentry/conventions" + ] + }, + "@sentry/deno@10.68.0": { + "integrity": "sha512-5RJZeXE/vrjt7YeiSkSNX2jNu4omNkPq3XCfs5k7LeyLNbu+7NoC96Dwo0uubni4a3cqULbhAwuDQXKk2M/PbQ==", + "dependencies": [ + "@opentelemetry/api", + "@sentry/core", + "@sentry/server-utils" + ] + }, + "@sentry/server-utils@10.68.0": { + "integrity": "sha512-lp1ZSs1auw7HrCESSYt/n4dOUaKPVUIAKyVYRk6xVr4bMIN3RPub/H5Wm7QPj9CpXVC5bQFvB6+dHZXz809oMg==", + "dependencies": [ + "@apm-js-collab/code-transformer-bundler-plugins", + "@apm-js-collab/tracing-hooks", + "@sentry/conventions", + "@sentry/core", + "meriyah" + ] + }, + "@types/aws-lambda@8.10.162": { + "integrity": "sha512-Fn658grtLOci1oxi1391vvDWJRKNGWRSqfxRkmN/Iy3c0tQH1USMKEXcPYHLvope+ZgTFocx9FRQJx1muBL6qw==" + }, + "@types/bunyan@1.8.11": { + "integrity": "sha512-758fRH7umIMk5qt5ELmRMff4mLDlN+xyYzC+dkPTdKwbSkJFvz6xwyScrytPU0QIBbRRwbiE8/BIg8bpajerNQ==", + "dependencies": [ + "@types/node" + ] + }, + "@types/connect@3.4.38": { + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dependencies": [ + "@types/node" + ] + }, + "@types/estree@1.0.9": { + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==" + }, + "@types/memcached@2.2.10": { + "integrity": "sha512-AM9smvZN55Gzs2wRrqeMHVP7KE8KWgCJO/XL5yCly2xF6EKa4YlbpK+cLSAH4NG/Ah64HrlegmGqW8kYws7Vxg==", + "dependencies": [ + "@types/node" + ] + }, + "@types/mysql@2.15.27": { + "integrity": "sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==", + "dependencies": [ + "@types/node" + ] + }, + "@types/node@26.1.2": { + "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", + "dependencies": [ + "undici-types" + ] + }, + "@types/oracledb@6.5.2": { + "integrity": "sha512-kK1eBS/Adeyis+3OlBDMeQQuasIDLUYXsi2T15ccNJ0iyUpQ4xDF7svFu3+bGVrI0CMBUclPciz+lsQR3JX3TQ==", + "dependencies": [ + "@types/node" + ] + }, + "@types/pg-pool@2.0.7": { + "integrity": "sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng==", + "dependencies": [ + "@types/pg" + ] + }, + "@types/pg@8.15.6": { + "integrity": "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==", + "dependencies": [ + "@types/node", + "pg-protocol", + "pg-types" + ] + }, + "@types/tedious@4.0.14": { + "integrity": "sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==", + "dependencies": [ + "@types/node" + ] + }, + "agent-base@7.1.4": { + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==" + }, + "ansi-regex@5.0.1": { + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" + }, + "ansi-regex@6.2.2": { + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==" + }, + "ansi-styles@4.3.0": { + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": [ + "color-convert" + ] + }, + "ansi-styles@6.2.3": { + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==" + }, + "astring@1.9.0": { + "integrity": "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==", + "bin": true + }, + "balanced-match@1.0.2": { + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "bignumber.js@9.3.1": { + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==" + }, + "brace-expansion@2.1.4": { + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dependencies": [ + "balanced-match" + ] + }, + "cjs-module-lexer@2.2.0": { + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==" + }, + "cliui@8.0.1": { + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dependencies": [ + "string-width@4.2.3", + "strip-ansi@6.0.1", + "wrap-ansi@7.0.0" + ] + }, + "cluster-key-slot@1.1.2": { + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==" + }, + "color-convert@2.0.1": { + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": [ + "color-name" + ] + }, + "color-name@1.1.4": { + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "cross-spawn@7.0.6": { + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dependencies": [ + "path-key", + "shebang-command", + "which" + ] + }, + "data-uri-to-buffer@4.0.1": { + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==" + }, + "debug@4.4.3": { + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": [ + "ms" + ] + }, + "eastasianwidth@0.2.0": { + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" + }, + "emoji-regex@8.0.0": { + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "emoji-regex@9.2.2": { + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" + }, + "es-module-lexer@2.3.1": { + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==" + }, + "escalade@3.2.0": { + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==" + }, + "esquery@1.7.0": { + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dependencies": [ + "estraverse" + ] + }, + "estraverse@5.3.0": { + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==" + }, + "extend@3.0.2": { + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, + "fetch-blob@3.2.0": { + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "dependencies": [ + "node-domexception", + "web-streams-polyfill" + ] + }, + "foreground-child@3.3.1": { + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dependencies": [ + "cross-spawn", + "signal-exit" + ] + }, + "formdata-polyfill@4.0.10": { + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dependencies": [ + "fetch-blob" + ] + }, + "forwarded-parse@2.1.2": { + "integrity": "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==" + }, + "gaxios@7.1.3": { + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "dependencies": [ + "extend", + "https-proxy-agent", + "node-fetch", + "rimraf" + ] + }, + "gcp-metadata@8.1.4": { + "integrity": "sha512-iJ9KMsiu+xKtNRX0PmGLSaIU3bUBAyzWTyqKemKPzNPsmmsBCQYmlNg+brEbES7IHSXtdVwzBPzx1vz3FAaipw==", + "dependencies": [ + "gaxios", + "google-logging-utils", + "json-bigint" + ] + }, + "generic-pool@3.9.0": { + "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==" + }, + "get-caller-file@2.0.5": { + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" + }, + "glob@10.5.0": { + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "dependencies": [ + "foreground-child", + "jackspeak", + "minimatch", + "minipass", + "package-json-from-dist", + "path-scurry" + ], + "deprecated": true, + "bin": true + }, + "google-logging-utils@1.1.3": { + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==" + }, + "https-proxy-agent@7.0.6": { + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dependencies": [ + "agent-base", + "debug" + ] + }, + "import-in-the-middle@3.3.2": { + "integrity": "sha512-jTd2FfOgOWOdgjkHuk/1Ms8VKFXkPs15ymYBETw1sAOrO/dY3XeGVRWir9qBbw7pXr0T2eTFwfCZ+N02HmiNGA==", + "dependencies": [ + "cjs-module-lexer", + "es-module-lexer", + "module-details-from-path" + ] + }, + "is-fullwidth-code-point@3.0.0": { + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" + }, + "isexe@2.0.0": { + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + }, + "jackspeak@3.4.3": { + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dependencies": [ + "@isaacs/cliui" + ], + "optionalDependencies": [ + "@pkgjs/parseargs" + ] + }, + "jose@5.9.6": { + "integrity": "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==" + }, + "json-bigint@1.0.0": { + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "dependencies": [ + "bignumber.js" + ] + }, + "lodash.camelcase@4.3.0": { + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==" + }, + "long@5.3.2": { + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==" + }, + "lru-cache@10.4.3": { + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" + }, + "magic-string@0.30.21": { + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dependencies": [ + "@jridgewell/sourcemap-codec" + ] + }, + "meriyah@6.1.4": { + "integrity": "sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ==" + }, + "minimatch@9.0.9": { + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dependencies": [ + "brace-expansion" + ] + }, + "minipass@7.1.3": { + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==" + }, + "module-details-from-path@1.0.4": { + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==" + }, + "ms@2.1.3": { + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node-domexception@1.0.0": { + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": true + }, + "node-fetch@3.3.2": { + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dependencies": [ + "data-uri-to-buffer", + "fetch-blob", + "formdata-polyfill" + ] + }, + "package-json-from-dist@1.0.1": { + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==" + }, + "path-key@3.1.1": { + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==" + }, + "path-scurry@1.11.1": { + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dependencies": [ + "lru-cache", + "minipass" + ] + }, + "pg-int8@1.0.1": { + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==" + }, + "pg-protocol@1.15.0": { + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==" + }, + "pg-types@2.2.0": { + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dependencies": [ + "pg-int8", + "postgres-array", + "postgres-bytea", + "postgres-date", + "postgres-interval" + ] + }, + "postgres-array@2.0.0": { + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==" + }, + "postgres-bytea@1.0.1": { + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==" + }, + "postgres-date@1.0.7": { + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==" + }, + "postgres-interval@1.2.0": { + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dependencies": [ + "xtend" + ] + }, + "protobufjs@7.6.5": { + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "dependencies": [ + "@protobufjs/aspromise", + "@protobufjs/base64", + "@protobufjs/codegen", + "@protobufjs/eventemitter", + "@protobufjs/fetch", + "@protobufjs/float", + "@protobufjs/path", + "@protobufjs/pool", + "@protobufjs/utf8", + "@types/node", + "long" + ], + "scripts": true + }, + "redis@5.11.0": { + "integrity": "sha512-YwXjATVDT+AuxcyfOwZn046aml9jMlQPvU1VXIlLDVAExe0u93aTfPYSeRgG4p9Q/Jlkj+LXJ1XEoFV+j2JKcQ==", + "dependencies": [ + "@redis/bloom", + "@redis/client@5.11.0", + "@redis/json", + "@redis/search", + "@redis/time-series" + ] + }, + "require-directory@2.1.1": { + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==" + }, + "require-in-the-middle@8.0.1": { + "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", + "dependencies": [ + "debug", + "module-details-from-path" + ] + }, + "rimraf@5.0.10": { + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "dependencies": [ + "glob" + ], + "bin": true + }, + "semifies@1.0.0": { + "integrity": "sha512-xXR3KGeoxTNWPD4aBvL5NUpMTT7WMANr3EWnaS190QVkY52lqqcVRD7Q05UVbBhiWDGWMlJEUam9m7uFFGVScw==" + }, + "shebang-command@2.0.0": { + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dependencies": [ + "shebang-regex" + ] + }, + "shebang-regex@3.0.0": { + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==" + }, + "signal-exit@4.1.0": { + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==" + }, + "source-map@0.6.1": { + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" + }, + "string-width@4.2.3": { + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": [ + "emoji-regex@8.0.0", + "is-fullwidth-code-point", + "strip-ansi@6.0.1" + ] + }, + "string-width@5.1.2": { + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dependencies": [ + "eastasianwidth", + "emoji-regex@9.2.2", + "strip-ansi@7.2.0" + ] + }, + "strip-ansi@6.0.1": { + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": [ + "ansi-regex@5.0.1" + ] + }, + "strip-ansi@7.2.0": { + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dependencies": [ + "ansi-regex@6.2.2" + ] + }, + "systeminformation@5.33.1": { + "integrity": "sha512-DEN6ICHk3Tk0Uf/hrAHh7xlt7iL5CJFBtPZinA0H62DrGG/KPKqq/Nzj6lCXPS4Ay/sf/14zNnk9LpqKzBIc+w==", + "os": ["darwin", "linux", "win32", "freebsd", "openbsd", "netbsd", "sunos", "android"], + "bin": true + }, + "undici-types@8.3.0": { + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==" + }, + "web-streams-polyfill@3.3.3": { + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==" + }, + "which@2.0.2": { + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dependencies": [ + "isexe" + ], + "bin": true + }, + "wrap-ansi@7.0.0": { + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dependencies": [ + "ansi-styles@4.3.0", + "string-width@4.2.3", + "strip-ansi@6.0.1" + ] + }, + "wrap-ansi@8.1.0": { + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dependencies": [ + "ansi-styles@6.2.3", + "string-width@5.1.2", + "strip-ansi@7.2.0" + ] + }, + "xtend@4.0.2": { + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==" + }, + "y18n@5.0.8": { + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==" + }, + "yallist@4.0.0": { + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, + "yaml@2.9.0": { + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "bin": true + }, + "yargs-parser@21.1.1": { + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==" + }, + "yargs@17.7.3": { + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dependencies": [ + "cliui", + "escalade", + "get-caller-file", + "require-directory", + "string-width@4.2.3", + "y18n", + "yargs-parser" + ] + } + }, + "redirects": { + "https://esm.sh/@types/react@~19.2.14/X-ZGNzc3R5cGVAMy4yLjM/index.d.ts": "https://esm.sh/@types/react@19.2.14/index.d.ts" + }, + "remote": { + "https://esm.sh/react@19.2.4/X-ZGNzc3R5cGVAMy4yLjM/es2022/react.mjs": "387b27232ebb1126bb803e2c4ac049332ce63a046354e188fc665dfd111c35fd", + "https://esm.sh/react@19.2.4?target=es2022&deps=csstype@3.2.3": "e7a88d3bb6a2bfaa898e95e17df49684c55f6a136c03d5df21a50f6782fd1209" + }, + "workspace": { + "dependencies": [ + "jsr:@std/assert@1.0.19", + "jsr:@std/async@1.2.0", + "jsr:@std/cli@1.0.28", + "jsr:@std/dotenv@0.225.6", + "jsr:@std/expect@1.0.18", + "jsr:@std/fmt@1.0.9", + "jsr:@std/fs@1.0.23", + "jsr:@std/path@1.1.4", + "jsr:@std/testing@1.0.17", + "jsr:@std/yaml@1.1.0" + ], + "members": { + "extensions/ext-auth-jwt": { + "dependencies": [ + "jsr:@std/assert@1.0.19", + "jsr:@std/testing@1.0.17", + "npm:jose@5.9.6" + ] + }, + "extensions/ext-blob-gcs": { + "dependencies": [ + "jsr:@std/assert@1.0.19", + "jsr:@std/testing@1.0.17" + ] + }, + "extensions/ext-blob-s3": { + "dependencies": [ + "jsr:@std/assert@1.0.19", + "jsr:@std/testing@1.0.17", + "npm:@aws-sdk/client-s3@3.980.0", + "npm:@aws-sdk/lib-storage@3.980.0" + ] + }, + "extensions/ext-bundler-esbuild": { + "dependencies": [ + "jsr:@std/assert@1.0.19", + "jsr:@std/testing@1.0.17", + "npm:es-module-lexer@2.3.1", + "npm:esbuild@0.28.1" + ] + }, + "extensions/ext-cache-redis": { + "dependencies": [ + "npm:redis@5.11.0" + ] + }, + "extensions/ext-content-mdx": { + "dependencies": [ + "jsr:@std/assert@1.0.19", + "jsr:@std/testing@1.0.17", + "npm:@mdx-js/mdx@3.1.1", + "npm:@mdx-js/react@3.1.1", + "npm:@types/hast@3.0.3", + "npm:@types/mdast@4.0.3", + "npm:@types/unist@3.0.2", + "npm:github-slugger@2.0.0", + "npm:mdast-util-to-string@4.0.0", + "npm:rehype-highlight@7.0.2", + "npm:rehype-raw@7.0.0", + "npm:rehype-sanitize@6.0.0", + "npm:rehype-slug@6.0.0", + "npm:rehype-starry-night@2.2.0", + "npm:rehype-stringify@10.0.1", + "npm:remark-frontmatter@5.0.0", + "npm:remark-gfm@4.0.1", + "npm:remark-parse@11.0.0", + "npm:remark-rehype@11.1.2", + "npm:unified@11.0.5", + "npm:unist-util-visit@5.1.0", + "npm:vfile@6.0.3" + ] + }, + "extensions/ext-css-lightning": { + "dependencies": [ + "jsr:@std/assert@1.0.19", + "jsr:@std/testing@1.0.17", + "npm:browserslist@4.28.7", + "npm:lightningcss@1.29.2" + ] + }, + "extensions/ext-css-purgecss": { + "dependencies": [ + "jsr:@std/assert@1.0.19", + "jsr:@std/testing@1.0.17", + "npm:purgecss@8.0.0" + ] + }, + "extensions/ext-css-tailwind": { + "dependencies": [ + "jsr:@std/assert@1.0.19", + "jsr:@std/testing@1.0.17", + "npm:@tailwindcss/forms@0.5.11", + "npm:@tailwindcss/typography@0.5.19", + "npm:daisyui@5.5.14", + "npm:tailwind-scrollbar-hide@2.0.0", + "npm:tailwindcss-animate@1.0.7", + "npm:tailwindcss@4.2.2" + ] + }, + "extensions/ext-db-sqlite": { + "dependencies": [ + "npm:@types/better-sqlite3@7.6.13", + "npm:better-sqlite3@9.6.0" + ] + }, + "extensions/ext-dev-ui-react": { + "dependencies": [ + "jsr:@std/assert@1.0.19", + "jsr:@std/fs@1.0.23", + "jsr:@std/path@1.1.4", + "jsr:@std/testing@1.0.17", + "npm:react-dom@19.2.4", + "npm:react@19.2.4" + ] + }, + "extensions/ext-document-kreuzberg": { + "dependencies": [ + "npm:@kreuzberg/node@4.4.2", + "npm:@kreuzberg/wasm@4.5.2", + "npm:jszip@3.10.1", + "npm:pdf-lib@1.17.1" + ] + }, + "extensions/ext-eval-report-http": { + "dependencies": [ + "jsr:@std/assert@1.0.19", + "jsr:@std/testing@1.0.17" + ] + }, + "extensions/ext-eval-report-mlflow": { + "dependencies": [ + "jsr:@std/assert@1.0.19", + "jsr:@std/testing@1.0.17" + ] + }, + "extensions/ext-image-sharp": { + "dependencies": [ + "jsr:@std/assert@1.0.19", + "jsr:@std/testing@1.0.17", + "npm:sharp@0.35.3" + ] + }, + "extensions/ext-llm-anthropic": { + "dependencies": [ + "jsr:@std/assert@1.0.19", + "jsr:@std/testing@1.0.17" + ] + }, + "extensions/ext-llm-google": { + "dependencies": [ + "jsr:@std/assert@1.0.19", + "jsr:@std/testing@1.0.17" + ] + }, + "extensions/ext-llm-openai": { + "dependencies": [ + "jsr:@std/assert@1.0.19", + "jsr:@std/testing@1.0.17" + ] + }, + "extensions/ext-node-websocket-ws": { + "dependencies": [ + "jsr:@std/assert@1.0.19", + "npm:@types/ws@8.18.1", + "npm:ws@8.21.1" + ] + }, + "extensions/ext-observability-opentelemetry": { + "dependencies": [ + "jsr:@std/assert@1.0.19", + "jsr:@std/testing@1.0.17", + "npm:@opentelemetry/api-logs@0.220.0", + "npm:@opentelemetry/api@1.9.1", + "npm:@opentelemetry/auto-instrumentations-node@0.78.0", + "npm:@opentelemetry/context-async-hooks@2.9.0", + "npm:@opentelemetry/core@2.9.0", + "npm:@opentelemetry/exporter-logs-otlp-http@0.220.0", + "npm:@opentelemetry/exporter-metrics-otlp-http@0.220.0", + "npm:@opentelemetry/exporter-trace-otlp-http@0.220.0", + "npm:@opentelemetry/resources@2.9.0", + "npm:@opentelemetry/sdk-logs@0.220.0", + "npm:@opentelemetry/sdk-metrics@2.9.0", + "npm:@opentelemetry/sdk-node@0.220.0", + "npm:@opentelemetry/sdk-trace-base@2.9.0", + "npm:@opentelemetry/semantic-conventions@1.43.0", + "npm:gaxios@7.2.0", + "npm:gcp-metadata@8.1.2", + "npm:protobufjs@7.6.5" + ] + }, + "extensions/ext-observability-sentry": { + "dependencies": [ + "jsr:@std/assert@1.0.19", + "npm:@sentry/deno@10.68.0", + "npm:@sentry/node@10.68.0" + ] + }, + "extensions/ext-parser-babel": { + "dependencies": [ + "jsr:@std/assert@1.0.19", + "jsr:@std/testing@1.0.17", + "npm:@babel/generator@7.29.1", + "npm:@babel/parser@7.29.2", + "npm:@babel/traverse@7.29.0", + "npm:@babel/types@7.29.0" + ] + }, + "extensions/ext-react-ssr": { + "dependencies": [ + "jsr:@std/assert@1.0.19" + ] + }, + "extensions/ext-redis": { + "dependencies": [ + "npm:@redis/client@1.5.8", + "npm:redis@5.11.0" + ] + }, + "extensions/ext-sandbox-shell-tools": { + "dependencies": [ + "jsr:@std/assert@1.0.19", + "jsr:@std/testing@1.0.17", + "npm:ai@7.0.41", + "npm:bash-tool@1.3.18", + "npm:brace-expansion@5.0.8", + "npm:just-bash@3.0.1" + ] + }, + "extensions/ext-schema-zod": { + "dependencies": [ + "jsr:@std/assert@1.0.19", + "jsr:@std/testing@1.0.17", + "npm:ajv-formats@3.0.1", + "npm:ajv@8.18.0", + "npm:zod@4.3.6" + ] + } + } + } +} diff --git a/scripts/build/smoke-proxy-binary.sh b/scripts/build/smoke-proxy-binary.sh new file mode 100644 index 0000000000..9a95b74ba3 --- /dev/null +++ b/scripts/build/smoke-proxy-binary.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +binary="${1:?usage: smoke-proxy-binary.sh [port]}" +port="${2:-18080}" +log_file="proxy-smoke.log" +proxy_pid="" + +cleanup() { + if [ -n "$proxy_pid" ]; then + kill "$proxy_pid" 2>/dev/null || true + wait "$proxy_pid" 2>/dev/null || true + fi +} +trap cleanup EXIT + +PORT="$port" HOST=127.0.0.1 NODE_ENV=development CACHE_TYPE=memory \ + "$binary" >"$log_file" 2>&1 & +proxy_pid=$! + +for _ in {1..30}; do + if curl -fsS "http://127.0.0.1:${port}/_proxy/health" 2>/dev/null \ + | grep -Fq '"status":"ok"'; then + exit 0 + fi + sleep 1 +done + +cat "$log_file" +exit 1 From 59fbf934b7b0e54edbfa33b927437940d22987a0 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Sun, 2 Aug 2026 22:10:59 +0200 Subject: [PATCH 02/26] fix(cli): harden proxy release contract --- .github/workflows/cicd.yml | 19 +++++- .../serve/proxy-extension-composition.test.ts | 59 ++++++++++--------- .../serve/proxy-extension-composition.ts | 15 +++-- deno.json | 2 - scripts/build/compile-binary.test.ts | 28 +++++++++ scripts/build/generate-sbom.ts | 12 ++-- scripts/build/smoke-proxy-binary.sh | 54 ++++++++++++----- 7 files changed, 133 insertions(+), 56 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 6f6ecd08a9..eb587fd6d6 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -414,6 +414,10 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: ./.github/actions/setup-deno - run: deno task build:prepare + - name: Verify proxy dependency lock is current + run: | + deno task build:proxy-lock + git diff --exit-code -- scripts/build/proxy-deno.lock - name: Compile proxy binary run: | deno run -A scripts/build/compile-binary.ts \ @@ -467,6 +471,13 @@ jobs: - run: deno task build:prepare + - name: Verify proxy dependency lock is current + if: matrix.profile == 'proxy' + shell: bash + run: | + deno task build:proxy-lock + git diff --exit-code -- scripts/build/proxy-deno.lock + - name: Compile binary shell: bash run: | @@ -534,7 +545,9 @@ jobs: - name: Generate SBOM env: VERSION: ${{ steps.version.outputs.version }} - run: deno task sbom:all --output-dir "dist/sbom-${VERSION}" + run: | + deno task sbom:all --output-dir "dist/sbom-${VERSION}" + deno task sbom --lock scripts/build/proxy-deno.lock --output "dist/sbom-${VERSION}/proxy.json" - name: Create release GitHub App token id: release-app-token @@ -686,7 +699,9 @@ jobs: - name: Generate SBOM env: VERSION: ${{ steps.version.outputs.version }} - run: deno task sbom:all --output-dir "dist/sbom-${VERSION}" + run: | + deno task sbom:all --output-dir "dist/sbom-${VERSION}" + deno task sbom --lock scripts/build/proxy-deno.lock --output "dist/sbom-${VERSION}/proxy.json" - name: Create git tag env: diff --git a/cli/commands/serve/proxy-extension-composition.test.ts b/cli/commands/serve/proxy-extension-composition.test.ts index 7066aa4f6b..ac61da617b 100644 --- a/cli/commands/serve/proxy-extension-composition.test.ts +++ b/cli/commands/serve/proxy-extension-composition.test.ts @@ -36,36 +36,39 @@ describe("standalone proxy extension composition", () => { assertEquals(loader, null); }); - it("activates ext-cache-redis before standalone cache acquisition", async () => { - Deno.env.set("CACHE_TYPE", "extension"); - Deno.env.set("REDIS_URL", "redis://127.0.0.1:6379"); - - loader = await activateStandaloneProxyExtensions(); - const shutdownHooks = createProxyShutdownHooks(); - await registerStandaloneProxyExtensionTeardown(loader, shutdownHooks.register); - const acquisition = await acquireExtensionTokenCacheStoreFromEnv(); - - assertEquals(loader !== null, true); - assertEquals(tryResolve(RedisRuntimeProviderName) !== undefined, true); - assertEquals(acquisition.kind, "borrowed"); - assertStrictEquals( - acquisition.store, - tryResolve("TokenCacheStore"), - ); + for (const cacheType of ["extension", "redis"] as const) { + it(`activates ext-cache-redis before ${cacheType} cache acquisition`, async () => { + Deno.env.set("CACHE_TYPE", cacheType); + Deno.env.set("REDIS_URL", "redis://127.0.0.1:6379"); + + loader = await activateStandaloneProxyExtensions(); + const shutdownHooks = createProxyShutdownHooks(); + await registerStandaloneProxyExtensionTeardown(loader, shutdownHooks.register); + const acquisition = await acquireExtensionTokenCacheStoreFromEnv(); + + assertEquals(Deno.env.get("CACHE_TYPE"), "extension"); + assertEquals(loader !== null, true); + assertEquals(tryResolve(RedisRuntimeProviderName) !== undefined, true); + assertEquals(acquisition.kind, "borrowed"); + assertStrictEquals( + acquisition.store, + tryResolve("TokenCacheStore"), + ); - const cache = await createCacheFromEnv({ extensionStore: acquisition }); - assertEquals(cache instanceof TracingTokenCache, true); - await cache.close(); - assertStrictEquals( - tryResolve("TokenCacheStore"), - acquisition.store, - ); + const cache = await createCacheFromEnv({ extensionStore: acquisition }); + assertEquals(cache instanceof TracingTokenCache, true); + await cache.close(); + assertStrictEquals( + tryResolve("TokenCacheStore"), + acquisition.store, + ); - assertEquals(await shutdownHooks.settle(), []); - assertEquals(tryResolve("TokenCacheStore"), undefined); - assertEquals(tryResolve(RedisRuntimeProviderName), undefined); - loader = null; - }); + assertEquals(await shutdownHooks.settle(), []); + assertEquals(tryResolve("TokenCacheStore"), undefined); + assertEquals(tryResolve(RedisRuntimeProviderName), undefined); + loader = null; + }); + } it("activates the Redis runtime for routing invalidation in memory-cache mode", async () => { Deno.env.set("CACHE_TYPE", "memory"); diff --git a/cli/commands/serve/proxy-extension-composition.ts b/cli/commands/serve/proxy-extension-composition.ts index e3eff889b9..8ebe4064d3 100644 --- a/cli/commands/serve/proxy-extension-composition.ts +++ b/cli/commands/serve/proxy-extension-composition.ts @@ -7,10 +7,9 @@ */ import { cliLogger } from "veryfront/utils/logger"; -import { ExtensionLoader } from "veryfront/extensions/loader"; -import type { ExtensionFactory } from "veryfront/extensions/types"; +import { type ExtensionFactory, ExtensionLoader } from "veryfront/extensions"; import { importFirstPartyExtensionModule } from "veryfront/extensions/first-party-import"; -import { getEnv } from "veryfront/platform/env"; +import { getEnv, setEnv } from "veryfront/platform/env"; import { createProxyShutdownAggregateError, type RegisterProxyShutdownHook, @@ -52,8 +51,8 @@ const REDIS_EXTENSION_PACKAGE_NAME = "@veryfront/ext-redis"; */ async function activateStandaloneProxyExtensionsInternal(): Promise { const cacheType = getEnv("CACHE_TYPE") || "memory"; - if (cacheType !== "memory" && cacheType !== "extension") { - throw new NativeTypeError("CACHE_TYPE must be memory or extension"); + if (cacheType !== "memory" && cacheType !== "extension" && cacheType !== "redis") { + throw new NativeTypeError("CACHE_TYPE must be memory, extension, or redis"); } const selected: Array<{ @@ -61,7 +60,7 @@ async function activateStandaloneProxyExtensionsInternal(): Promise = []; - if (cacheType === "extension") { + if (cacheType === "extension" || cacheType === "redis") { selected.push({ origin: "standalone proxy cache selection", packageName: CACHE_EXTENSION_PACKAGE_NAME, @@ -95,6 +94,10 @@ async function activateStandaloneProxyExtensionsInternal(): Promise { + const workflow = await Deno.readTextFile(".github/workflows/cicd.yml"); + assertEquals(workflow.includes("deno task build:proxy-lock"), true); + assertEquals( + workflow.includes("git diff --exit-code -- scripts/build/proxy-deno.lock"), + true, + ); + assertEquals( + workflow.includes("deno task sbom --lock scripts/build/proxy-deno.lock"), + true, + ); +}); + +Deno.test("compiled proxy smoke covers cache and observability providers", async () => { + const smoke = await Deno.readTextFile("scripts/build/smoke-proxy-binary.sh"); + + for (const contract of [ + "CACHE_TYPE=memory", + "CACHE_TYPE=redis", + "TokenCacheStore registered", + "OTEL_TRACES_EXPORTER=otlp", + "[otel] Initialized", + "SENTRY_DSN=https://public@example.com/1", + ]) { + assertEquals(smoke.includes(contract), true, `missing smoke contract ${contract}`); + } }); Deno.test("full binary remains the default compile profile", () => { diff --git a/scripts/build/generate-sbom.ts b/scripts/build/generate-sbom.ts index b4a9893f23..1074b82215 100644 --- a/scripts/build/generate-sbom.ts +++ b/scripts/build/generate-sbom.ts @@ -1,7 +1,7 @@ /** * Generate a CycloneDX 1.5 SBOM from deno.lock. * - * Usage: deno run --allow-read --allow-write scripts/build/generate-sbom.ts [--output path] + * Usage: deno run --allow-read --allow-write scripts/build/generate-sbom.ts [--lock path] [--output path] * deno run --allow-read --allow-write scripts/build/generate-sbom.ts \ * --all-manifests --output-dir dist/sbom * deno run --allow-read --allow-write scripts/build/generate-sbom.ts \ @@ -614,12 +614,16 @@ async function writeTextOutput( if (import.meta.main) { const args = parseArgs(Deno.args, { boolean: ["all-manifests"], - string: ["manifest", "output", "output-dir"], - default: { output: "dist/sbom.json", "output-dir": "dist/sbom" }, + string: ["lock", "manifest", "output", "output-dir"], + default: { + lock: "deno.lock", + output: "dist/sbom.json", + "output-dir": "dist/sbom", + }, }); const denoConfig = JSON.parse(await Deno.readTextFile("deno.json")); - const lockText = await Deno.readTextFile("deno.lock"); + const lockText = await Deno.readTextFile(args.lock); if (args["all-manifests"]) { const workspaceMembers = workspaceMembersFromDenoConfig(denoConfig); diff --git a/scripts/build/smoke-proxy-binary.sh b/scripts/build/smoke-proxy-binary.sh index 9a95b74ba3..509af79363 100644 --- a/scripts/build/smoke-proxy-binary.sh +++ b/scripts/build/smoke-proxy-binary.sh @@ -2,8 +2,8 @@ set -euo pipefail binary="${1:?usage: smoke-proxy-binary.sh [port]}" -port="${2:-18080}" -log_file="proxy-smoke.log" +base_port="${2:-18080}" +tmp_dir="$(mktemp -d)" proxy_pid="" cleanup() { @@ -11,20 +11,46 @@ cleanup() { kill "$proxy_pid" 2>/dev/null || true wait "$proxy_pid" 2>/dev/null || true fi + rm -rf "$tmp_dir" } trap cleanup EXIT -PORT="$port" HOST=127.0.0.1 NODE_ENV=development CACHE_TYPE=memory \ - "$binary" >"$log_file" 2>&1 & -proxy_pid=$! +run_smoke() { + local name="$1" + local port="$2" + local expected_log="$3" + shift 3 + local log_file="${tmp_dir}/${name}.log" -for _ in {1..30}; do - if curl -fsS "http://127.0.0.1:${port}/_proxy/health" 2>/dev/null \ - | grep -Fq '"status":"ok"'; then - exit 0 - fi - sleep 1 -done + env PORT="$port" HOST=127.0.0.1 NODE_ENV=development "$@" \ + "$binary" >"$log_file" 2>&1 & + proxy_pid=$! + + for _ in {1..30}; do + if curl -fsS "http://127.0.0.1:${port}/_proxy/health" 2>/dev/null \ + | grep -Fq '"status":"ok"'; then + if [ -n "$expected_log" ]; then + grep -Fq "$expected_log" "$log_file" + fi + kill "$proxy_pid" 2>/dev/null || true + wait "$proxy_pid" 2>/dev/null || true + proxy_pid="" + return 0 + fi + sleep 1 + done + + cat "$log_file" + return 1 +} -cat "$log_file" -exit 1 +run_smoke memory "$base_port" "" CACHE_TYPE=memory +run_smoke redis "$((base_port + 1))" "TokenCacheStore registered" \ + CACHE_TYPE=redis REDIS_URL=redis://127.0.0.1:1 +run_smoke observability "$((base_port + 2))" "[otel] Initialized" \ + CACHE_TYPE=memory \ + OTEL_TRACES_ENABLED=true \ + OTEL_TRACES_EXPORTER=otlp \ + OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318 \ + SENTRY_ENABLED=true \ + SENTRY_DSN=https://public@example.com/1 From dcf242aca04cfd506c784ad17f6daf087d153a67 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 10:10:27 +0200 Subject: [PATCH 03/26] Keep proxy release checks robust under extension mutation The dedicated proxy activates third-party extension code before entering its process-lifetime wait, so the runtime must retain the native Promise constructor. The binary smoke now preserves diagnostic logs when provider markers are absent, and its workflow guard directly expresses the same-repository PR boundary. Constraint: The proxy runtime must remain safe after extension-owned global mutation. Rejected: Restore the extensions/loader public subpath | the PR intentionally removed that extra public export in its hardening follow-up. Confidence: high Scope-risk: narrow Reversibility: clean Tested: Focused proxy runtime and compile-binary tests with Deno 2.7.7, formatting, bash syntax, and git diff validation. Not-tested: Compiled Linux proxy smoke, delegated to CI after the branch is reconciled with main. --- .github/workflows/cicd.yml | 2 +- .../serve/proxy-extension-composition.ts | 2 +- cli/commands/serve/proxy-runtime.test.ts | 24 ++++++++++++++++++- cli/commands/serve/proxy-runtime.ts | 11 +++++++-- scripts/build/compile-binary.test.ts | 17 +++++++++++++ scripts/build/smoke-proxy-binary.sh | 5 +++- 6 files changed, 55 insertions(+), 6 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index eb587fd6d6..7785fad3db 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -408,7 +408,7 @@ jobs: # ============================================ tests-proxy-binary: - if: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && github.event_name == 'pull_request' }} + if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }} runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/cli/commands/serve/proxy-extension-composition.ts b/cli/commands/serve/proxy-extension-composition.ts index 8ebe4064d3..7a5e064760 100644 --- a/cli/commands/serve/proxy-extension-composition.ts +++ b/cli/commands/serve/proxy-extension-composition.ts @@ -95,7 +95,7 @@ async function activateStandaloneProxyExtensionsInternal(): Promise { const originalHost = Deno.env.get("HOST"); @@ -52,4 +55,23 @@ describe("standalone proxy runtime", () => { assertEquals(teardownCount, 1); }); + + it("uses the Promise constructor captured before extension activation", () => { + const NativePromise = Promise; + const descriptor = Object.getOwnPropertyDescriptor(globalThis, "Promise"); + if (!descriptor) throw new Error("Promise descriptor is unavailable"); + + Object.defineProperty(globalThis, "Promise", { + ...descriptor, + value: function PoisonedPromise(): never { + throw new Error("extension replaced Promise"); + }, + }); + try { + const pending = createStandaloneProxyKeepAlivePromise(); + assertEquals(pending instanceof NativePromise, true); + } finally { + Object.defineProperty(globalThis, "Promise", descriptor); + } + }); }); diff --git a/cli/commands/serve/proxy-runtime.ts b/cli/commands/serve/proxy-runtime.ts index 56b74a074c..1e643869cd 100644 --- a/cli/commands/serve/proxy-runtime.ts +++ b/cli/commands/serve/proxy-runtime.ts @@ -20,7 +20,14 @@ interface StandaloneProxyRuntimeDependencies { registerTeardown?: typeof registerStandaloneProxyExtensionTeardown; } -const keepAliveForever = (): Promise => new Promise(() => {}); +// Capture the constructor before extension activation so extension-owned +// global mutations cannot break the CLI-owned process lifetime promise. +const NativePromise = Promise; + +/** Create the never-settling promise that owns the standalone process lifetime. */ +export function createStandaloneProxyKeepAlivePromise(): Promise { + return new NativePromise(() => {}); +} function showProxyHeader(): void { if (isJsonMode()) return; @@ -62,5 +69,5 @@ export async function runStandaloneProxyRuntime( // Deno.serve returns after binding in compiled binaries, while the proxy's // signal handlers own shutdown and extension teardown. - await (dependencies.keepAlive ?? keepAliveForever)(); + await (dependencies.keepAlive ?? createStandaloneProxyKeepAlivePromise)(); } diff --git a/scripts/build/compile-binary.test.ts b/scripts/build/compile-binary.test.ts index 1feca4a02f..4d32874a13 100644 --- a/scripts/build/compile-binary.test.ts +++ b/scripts/build/compile-binary.test.ts @@ -183,6 +183,23 @@ Deno.test("compiled proxy smoke covers cache and observability providers", async ]) { assertEquals(smoke.includes(contract), true, `missing smoke contract ${contract}`); } + + assertEquals( + smoke.includes('if ! grep -Fq "$expected_log" "$log_file"; then'), + true, + "missing proxy log markers must print diagnostics before failing", + ); +}); + +Deno.test("proxy binary smoke runs only for same-repository pull requests", async () => { + const workflow = await Deno.readTextFile(".github/workflows/cicd.yml"); + + assertEquals( + workflow.includes( + "if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }}", + ), + true, + ); }); Deno.test("full binary remains the default compile profile", () => { diff --git a/scripts/build/smoke-proxy-binary.sh b/scripts/build/smoke-proxy-binary.sh index 509af79363..0dbb304e6f 100644 --- a/scripts/build/smoke-proxy-binary.sh +++ b/scripts/build/smoke-proxy-binary.sh @@ -30,7 +30,10 @@ run_smoke() { if curl -fsS "http://127.0.0.1:${port}/_proxy/health" 2>/dev/null \ | grep -Fq '"status":"ok"'; then if [ -n "$expected_log" ]; then - grep -Fq "$expected_log" "$log_file" + if ! grep -Fq "$expected_log" "$log_file"; then + cat "$log_file" + return 1 + fi fi kill "$proxy_pid" 2>/dev/null || true wait "$proxy_pid" 2>/dev/null || true From 4ac1e19e518a4db46f9502a558618c90637247f8 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 10:23:21 +0200 Subject: [PATCH 04/26] Preserve fork safety in the proxy smoke gate The proxy smoke job must keep the repository-wide fork guard in its literal condition so the hardening test can prove every code-executing job is protected. The condition remains pull-request-only while retaining the auditable guard contract. Constraint: External fork pull requests must not execute repository code. Rejected: Keep only the logically equivalent direct repository comparison | it bypasses the shared hardening invariant and failed the full pre-push suite. Confidence: high Scope-risk: narrow Reversibility: clean Directive: Preserve WORKFLOW_PR_GUARD verbatim in every CI job condition. Tested: compile-binary tests, repository-hardening tests, Deno formatting, and git diff validation with Deno 2.7.7. Not-tested: Compiled Linux proxy smoke, delegated to CI. --- .github/workflows/cicd.yml | 2 +- scripts/build/compile-binary.test.ts | 36 ++++++++++++++++++---------- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 7785fad3db..eb587fd6d6 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -408,7 +408,7 @@ jobs: # ============================================ tests-proxy-binary: - if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }} + if: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && github.event_name == 'pull_request' }} runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/scripts/build/compile-binary.test.ts b/scripts/build/compile-binary.test.ts index 4d32874a13..19553369ad 100644 --- a/scripts/build/compile-binary.test.ts +++ b/scripts/build/compile-binary.test.ts @@ -116,7 +116,11 @@ Deno.test("proxy binary embeds only the runtime-resolved proxy entrypoint", asyn }); for (const include of PROXY_INCLUDES) { - assertEquals(args.includes(include), true, `missing proxy include ${include}`); + assertEquals( + args.includes(include), + true, + `missing proxy include ${include}`, + ); } assertEquals(args.includes("--node-modules-dir=none"), true); @@ -149,7 +153,9 @@ Deno.test("proxy binary embeds only the runtime-resolved proxy entrypoint", asyn const packages = Object.keys(lock.npm ?? {}); for (const unrelated of ["@huggingface/transformers", "esbuild", "sharp"]) { assertEquals( - packages.some((name) => name === unrelated || name.startsWith(`${unrelated}@`)), + packages.some((name) => + name === unrelated || name.startsWith(`${unrelated}@`) + ), false, `proxy lock must not contain ${unrelated}`, ); @@ -173,15 +179,21 @@ Deno.test("proxy release verifies lock freshness and publishes an exact SBOM", a Deno.test("compiled proxy smoke covers cache and observability providers", async () => { const smoke = await Deno.readTextFile("scripts/build/smoke-proxy-binary.sh"); - for (const contract of [ - "CACHE_TYPE=memory", - "CACHE_TYPE=redis", - "TokenCacheStore registered", - "OTEL_TRACES_EXPORTER=otlp", - "[otel] Initialized", - "SENTRY_DSN=https://public@example.com/1", - ]) { - assertEquals(smoke.includes(contract), true, `missing smoke contract ${contract}`); + for ( + const contract of [ + "CACHE_TYPE=memory", + "CACHE_TYPE=redis", + "TokenCacheStore registered", + "OTEL_TRACES_EXPORTER=otlp", + "[otel] Initialized", + "SENTRY_DSN=https://public@example.com/1", + ] + ) { + assertEquals( + smoke.includes(contract), + true, + `missing smoke contract ${contract}`, + ); } assertEquals( @@ -196,7 +208,7 @@ Deno.test("proxy binary smoke runs only for same-repository pull requests", asyn assertEquals( workflow.includes( - "if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }}", + "if: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && github.event_name == 'pull_request' }}", ), true, ); From 48298573caabf40a5567482c2e8aec0dbcb777ba Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 10:30:35 +0200 Subject: [PATCH 05/26] Keep the proxy dependency lock aligned with current main Merging current main added the YAML extension workspace to the lockfile workspace graph. Regenerating the dedicated proxy lock makes the exact CI freshness check deterministic again without changing the proxy runtime dependency set. Constraint: Proxy compilation uses a frozen graph-specific lock and CI rejects any generated delta. Confidence: high Scope-risk: narrow Reversibility: clean Directive: Run build:proxy-lock after workspace membership or dependency changes reach this branch. Tested: Deno 2.7.7 build:proxy-lock regeneration and repeat run, compile-binary suite (11 passed), and git diff validation. Not-tested: Linux proxy compilation, delegated to fresh exact-head CI. --- scripts/build/proxy-deno.lock | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/scripts/build/proxy-deno.lock b/scripts/build/proxy-deno.lock index 9e68029f2f..e9d40ce6b6 100644 --- a/scripts/build/proxy-deno.lock +++ b/scripts/build/proxy-deno.lock @@ -1702,6 +1702,13 @@ "npm:ajv@8.18.0", "npm:zod@4.3.6" ] + }, + "extensions/ext-yaml": { + "dependencies": [ + "jsr:@std/assert@1.0.19", + "jsr:@std/testing@1.0.17", + "jsr:@std/yaml@1.1.0" + ] } } } From c8b8761fa2fcabc5e178a2ddecd01d0293a212d7 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 11:58:28 +0200 Subject: [PATCH 06/26] Keep proxy smoke checks stable during asynchronous startup Provider logs can flush just after the health endpoint becomes ready. Retry the marker check within the existing bounded smoke loop, keep lock regeneration on Deno's default mutable mode, and describe SBOM input as the selected lockfile. Constraint: Preserve the bounded 30-attempt proxy smoke deadline and graph-specific lock workflow. Rejected: Fail immediately after health readiness | asynchronous logger flush can make a healthy proxy fail CI. Confidence: high Scope-risk: narrow Tested: Focused compile-binary tests, Bash syntax, format, lint, typecheck, and git diff check. --- deno.json | 2 +- scripts/build/compile-binary.test.ts | 14 ++++++++++++++ scripts/build/generate-sbom.ts | 6 +++--- scripts/build/smoke-proxy-binary.sh | 4 ++-- 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/deno.json b/deno.json index c4720127e5..5eee58a419 100644 --- a/deno.json +++ b/deno.json @@ -457,7 +457,7 @@ "production": "deno task generate && deno run --allow-read --allow-write --allow-net --allow-env --allow-run --allow-sys --unstable-worker-options --unstable-net cli/main.ts serve --mode=production", "build:prepare": "deno run -A scripts/build/generate-integrations-module.ts && deno task generate && deno run -A scripts/build/prepare-framework-sources.ts", "build": "deno task build:prepare && deno run -A scripts/build/compile-binary.ts --output ./bin/veryfront", - "build:proxy-lock": "deno cache --node-modules-dir=none --lock scripts/build/proxy-deno.lock --frozen=false cli/proxy-main.ts", + "build:proxy-lock": "deno cache --node-modules-dir=none --lock scripts/build/proxy-deno.lock cli/proxy-main.ts", "build:npm": "deno run -A scripts/build/generate-integrations-module.ts && deno task generate && deno run --config=scripts/test.deno.json --frozen -A scripts/build/build-npm-dnt.ts", "release": "deno run -A scripts/release.ts", "test": "deno task generate && DENO_TESTING=1 VF_DISABLE_LRU_INTERVAL=1 SSR_TRANSFORM_PER_PROJECT_LIMIT=0 REVALIDATION_PER_PROJECT_LIMIT=0 NODE_ENV=production LOG_FORMAT=text deno test --preload=src/schemas/_test-setup.ts --no-check --parallel --allow-all '--ignore=tests/e2e,tests/integration/compiled-binary-e2e.test.ts' --unstable-worker-options --unstable-net", diff --git a/scripts/build/compile-binary.test.ts b/scripts/build/compile-binary.test.ts index 19553369ad..22c45a2989 100644 --- a/scripts/build/compile-binary.test.ts +++ b/scripts/build/compile-binary.test.ts @@ -164,6 +164,9 @@ Deno.test("proxy binary embeds only the runtime-resolved proxy entrypoint", asyn Deno.test("proxy release verifies lock freshness and publishes an exact SBOM", async () => { const workflow = await Deno.readTextFile(".github/workflows/cicd.yml"); + const denoConfig = JSON.parse(await Deno.readTextFile("deno.json")) as { + tasks?: Record; + }; assertEquals(workflow.includes("deno task build:proxy-lock"), true); assertEquals( @@ -174,6 +177,11 @@ Deno.test("proxy release verifies lock freshness and publishes an exact SBOM", a workflow.includes("deno task sbom --lock scripts/build/proxy-deno.lock"), true, ); + assertEquals( + denoConfig.tasks?.["build:proxy-lock"]?.includes("--frozen=false"), + false, + "proxy lock refresh must use Deno's default mutable lock mode", + ); }); Deno.test("compiled proxy smoke covers cache and observability providers", async () => { @@ -201,6 +209,12 @@ Deno.test("compiled proxy smoke covers cache and observability providers", async true, "missing proxy log markers must print diagnostics before failing", ); + assertEquals( + /if ! grep -Fq "\$expected_log" "\$log_file"; then\s+sleep 1\s+continue/ + .test(smoke), + true, + "healthy proxies must retry briefly while asynchronous provider logs flush", + ); }); Deno.test("proxy binary smoke runs only for same-repository pull requests", async () => { diff --git a/scripts/build/generate-sbom.ts b/scripts/build/generate-sbom.ts index 1074b82215..4181192331 100644 --- a/scripts/build/generate-sbom.ts +++ b/scripts/build/generate-sbom.ts @@ -1,5 +1,5 @@ /** - * Generate a CycloneDX 1.5 SBOM from deno.lock. + * Generate a CycloneDX 1.5 SBOM from a Deno lockfile. * * Usage: deno run --allow-read --allow-write scripts/build/generate-sbom.ts [--lock path] [--output path] * deno run --allow-read --allow-write scripts/build/generate-sbom.ts \ @@ -8,8 +8,8 @@ * --manifest extensions/ext-sandbox-shell-tools/deno.json \ * --output dist/sbom-ext-sandbox-shell-tools.json * - * Walks deno.lock so the SBOM lists the transitive npm graph that ships in - * the binary, not just the top-level import map. + * Walks the selected lockfile so the SBOM lists the transitive npm graph that + * ships in the binary, not just the top-level import map. */ import { parseArgs } from "#std/flags"; diff --git a/scripts/build/smoke-proxy-binary.sh b/scripts/build/smoke-proxy-binary.sh index 0dbb304e6f..01148b123b 100644 --- a/scripts/build/smoke-proxy-binary.sh +++ b/scripts/build/smoke-proxy-binary.sh @@ -31,8 +31,8 @@ run_smoke() { | grep -Fq '"status":"ok"'; then if [ -n "$expected_log" ]; then if ! grep -Fq "$expected_log" "$log_file"; then - cat "$log_file" - return 1 + sleep 1 + continue fi fi kill "$proxy_pid" 2>/dev/null || true From e99381dda58592dc097cd00ae5e52a48dc5c07b9 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 12:02:21 +0200 Subject: [PATCH 07/26] Keep proxy script tests frozen after baseline reconciliation The reconciled compile-binary test imports the shared first-party extension policy, which adds the pinned module lexer to the scripts graph. Record that resolved dependency so the focused script suite remains reproducible under frozen lock enforcement. Constraint: Script tests use scripts/test.deno.json and its dedicated lockfile. Confidence: high Scope-risk: narrow Tested: Focused compile-binary suite with Deno 2.7.7 and --frozen; git diff check. --- scripts/deno.lock | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/deno.lock b/scripts/deno.lock index 4111744f8d..7f18e98b97 100644 --- a/scripts/deno.lock +++ b/scripts/deno.lock @@ -18,6 +18,7 @@ "jsr:@ts-morph/common@0.27": "0.27.0", "npm:@babel/parser@7.29.2": "7.29.2", "npm:@mdx-js/mdx@3.1.1": "3.1.1", + "npm:es-module-lexer@2.3.1": "2.3.1", "npm:esbuild@0.28.1": "0.28.1" }, "jsr": { @@ -365,6 +366,9 @@ "dequal" ] }, + "es-module-lexer@2.3.1": { + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==" + }, "esast-util-from-estree@2.0.0": { "integrity": "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==", "dependencies": [ From 0a98e7addf2984b48e167dc94b945595295d52c1 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 13:21:22 +0200 Subject: [PATCH 08/26] Clarify standalone proxy provider activation The draft proxy-binary PR can activate both the cache token store provider and the Redis runtime provider before importing the proxy runtime. The module header now matches the actual boundary so future changes do not miss the Redis routing-invalidation dependency. Constraint: Review comment requested documentation for the second provider contract without changing runtime behavior. Confidence: high Scope-risk: narrow Tested: npx --yes deno@2.7.7 fmt --check cli/commands/serve/proxy-extension-composition.ts Tested: git diff --check --- cli/commands/serve/proxy-extension-composition.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cli/commands/serve/proxy-extension-composition.ts b/cli/commands/serve/proxy-extension-composition.ts index 7a5e064760..135a11cb38 100644 --- a/cli/commands/serve/proxy-extension-composition.ts +++ b/cli/commands/serve/proxy-extension-composition.ts @@ -2,8 +2,9 @@ * Standalone proxy extension composition. * * This CLI/deployment boundary activates provider implementations before the - * provider-neutral proxy runtime is imported. Core consumes only the - * `TokenCacheStore` contract published by the extension loader. + * provider-neutral proxy runtime is imported. Core consumes the + * `TokenCacheStore` contract and the Redis runtime provider published by the + * extension loader. */ import { cliLogger } from "veryfront/utils/logger"; From 6811e51ed609d6742b4ef5d8d8cc299100e7d1bd Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 13:48:33 +0200 Subject: [PATCH 09/26] fix(platform): close filesystem hardening review gaps (#3323) * fix(platform): close filesystem hardening review gaps * Keep authenticated GitHub requests inside the configured repository Repository identity and dynamic API path values were accepted as raw URL segments. Validate repository identity at the client boundary and encode every path segment so malformed configuration cannot redirect an authenticated request through URL normalization. Constraint: Content paths remain hierarchical, but each individual segment must be encoded and dot traversal must fail closed. Rejected: Encode the complete endpoint string | would also encode required API separators Confidence: high Scope-risk: narrow Tested: GitHub adapter suite (118 steps), targeted fmt, lint, check, diff check * fix(platform): bind GitHub API repository segments * Reject non-printable C1 bytes at GitHub path boundaries The URL-facing path normalizer already rejected C0 and DEL controls, but the adjacent C1 range remained admissible. Treat the complete C0, DEL, and C1 control ranges consistently before URL construction. Constraint: Preserve existing path normalization and public error wording. Rejected: Match only ASCII C0 controls | leaves U+0080 through U+009F available as non-printable path input. Confidence: high Scope-risk: narrow Tested: Focused GitHub path and API client suites, 36 steps Tested: Touched-file format, lint, typecheck, and git diff checks --- .../adapters/fs/cache/file-cache.test.ts | 40 +++++ src/platform/adapters/fs/cache/file-cache.ts | 11 +- .../fs/github/github-api-client.test.ts | 154 +++++++++++++++++- .../adapters/fs/github/github-api-client.ts | 98 ++++++++++- .../adapters/fs/github/path-utils.test.ts | 21 +++ src/platform/adapters/fs/github/path-utils.ts | 27 ++- src/platform/adapters/fs/integration.test.ts | 24 +++ .../fs/veryfront/path-normalizer.test.ts | 14 +- .../adapters/fs/veryfront/path-normalizer.ts | 12 +- 9 files changed, 385 insertions(+), 16 deletions(-) diff --git a/src/platform/adapters/fs/cache/file-cache.test.ts b/src/platform/adapters/fs/cache/file-cache.test.ts index 0037a21527..b421f21ef5 100644 --- a/src/platform/adapters/fs/cache/file-cache.test.ts +++ b/src/platform/adapters/fs/cache/file-cache.test.ts @@ -6,6 +6,7 @@ import { initializeFileCacheBackend, isFileCacheDistributedEnabled, } from "./file-cache.ts"; +import { CacheBackends } from "#veryfront/cache/backend.ts"; describe("FileCache", () => { let cache: FileCache; @@ -329,6 +330,45 @@ describe("Distributed cache functions", () => { assertEquals(typeof initializeFileCacheBackend, "function"); }); + it("skips non-serializable synchronous writes to a distributed backend", async () => { + // A query-qualified import gives this regression its own module-scoped + // backend state, so the fake distributed backend cannot leak into other + // file-cache tests in the same Deno process. + const distributedModule = await import( + "./file-cache.ts?distributed-serialization-regression" + ); + const descriptor = Object.getOwnPropertyDescriptor(CacheBackends, "file"); + assertExists(descriptor); + let backendWrites = 0; + Object.defineProperty(CacheBackends, "file", { + ...descriptor, + value: () => + Promise.resolve({ + type: "redis", + size: 0, + get: () => Promise.resolve(null), + set: () => { + backendWrites += 1; + return Promise.resolve(); + }, + del: () => Promise.resolve(false), + clear: () => Promise.resolve(), + } as never), + }); + + try { + assertEquals(await distributedModule.initializeFileCacheBackend(), true); + } finally { + Object.defineProperty(CacheBackends, "file", descriptor); + } + + const distributedCache = new distributedModule.FileCache(); + const circular: Record = {}; + circular.self = circular; + distributedCache.set("cyclic", circular); + assertEquals(backendWrites, 0); + }); + it("should return boolean", async () => { assertEquals(typeof (await initializeFileCacheBackend()), "boolean"); }); diff --git a/src/platform/adapters/fs/cache/file-cache.ts b/src/platform/adapters/fs/cache/file-cache.ts index b36afba165..85c03eac1b 100644 --- a/src/platform/adapters/fs/cache/file-cache.ts +++ b/src/platform/adapters/fs/cache/file-cache.ts @@ -205,7 +205,16 @@ export class FileCache { // Note: key already includes the full prefix from buildFileCacheKeyPrefix (e.g., "file:env:project:...") const backend = this.getBackend(); if (backend) { - const serialized = JSON.stringify(entry); + let serialized: string; + try { + serialized = JSON.stringify(entry); + } catch (error) { + logger.debug("Backend set skipped because the cache entry is not serializable", { + key, + error, + }); + return; + } // Update request-scoped cache so subsequent reads in same request see the new value setInRequestCache(key, serialized); backend.set(key, serialized, this.backendTtlSeconds).catch((error) => { diff --git a/src/platform/adapters/fs/github/github-api-client.test.ts b/src/platform/adapters/fs/github/github-api-client.test.ts index c912490df8..691e719271 100644 --- a/src/platform/adapters/fs/github/github-api-client.test.ts +++ b/src/platform/adapters/fs/github/github-api-client.test.ts @@ -1,6 +1,12 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals, assertExists } from "#veryfront/testing/assert.ts"; +import { + assertEquals, + assertExists, + assertRejects, + assertThrows, +} from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; +import { withMockFetch } from "#veryfront/testing/mock-fetch.ts"; import { GitHubApiClient } from "./github-api-client.ts"; const mockConfig = { @@ -37,6 +43,29 @@ describe("GitHubApiClient", () => { it("should be instantiable with config", () => { assertExists(createClient()); }); + + it("rejects repository identity that could escape its URL segments", () => { + for ( + const [field, value] of [ + ["owner", ".."], + ["owner", "%2e%2e"], + ["owner", "%25252e%25252e"], + ["owner", "team/other"], + ["owner", "team%2Fother"], + ["repo", "."], + ["repo", "..\\other"], + ["repo", "repo%5Cother"], + ["repo", "repo\u0000name"], + ["repo", "r".repeat(257)], + ] as const + ) { + assertThrows( + () => new GitHubApiClient({ ...mockConfig, [field]: value }), + TypeError, + "GitHub", + ); + } + }); }); describe("repoId", () => { @@ -70,4 +99,127 @@ describe("GitHubApiClient", () => { assertEquals(createClient().getRateLimitInfo(), null); }); }); + + describe("getContents", () => { + it("encodes path segments and refs before URL construction", async () => { + const requestedUrls: string[] = []; + await withMockFetch( + (input) => { + requestedUrls.push(String(input)); + return Promise.resolve(Response.json({ + type: "file", + name: "file.ts", + path: "file.ts", + sha: "sha-1", + size: 0, + content: "", + encoding: "base64", + })); + }, + async () => { + const client = createClient(); + for ( + const path of [ + "%2e%2e/%2E%2E/user/repos", + "..\\..\\user/repos", + "docs/read me#draft?.md", + ] + ) { + await client.getContents(path, "feature/secure-paths"); + } + }, + ); + + assertEquals(requestedUrls.length, 3); + for (const requestedUrl of requestedUrls) { + const url = new URL(requestedUrl); + assertEquals( + url.pathname.startsWith("/repos/test-owner/test-repo/contents/"), + true, + ); + assertEquals(url.searchParams.get("ref"), "feature/secure-paths"); + } + assertEquals( + new URL(requestedUrls[0]!).pathname, + "/repos/test-owner/test-repo/contents/%252e%252e/%252E%252E/user/repos", + ); + assertEquals( + new URL(requestedUrls[1]!).pathname, + "/repos/test-owner/test-repo/contents/..%5C..%5Cuser/repos", + ); + assertEquals( + new URL(requestedUrls[2]!).pathname, + "/repos/test-owner/test-repo/contents/docs/read%20me%23draft%3F.md", + ); + }); + + it("rejects literal traversal segments before fetching", async () => { + let fetchCalls = 0; + await withMockFetch( + () => { + fetchCalls++; + return Promise.resolve(Response.json({})); + }, + async () => { + await assertRejects( + () => createClient().getContents("../secrets"), + TypeError, + "traversal", + ); + }, + ); + assertEquals(fetchCalls, 0); + }); + }); + + describe("endpoint construction", () => { + it("rejects dot-only endpoint values before fetching", async () => { + let fetchCalls = 0; + await withMockFetch( + () => { + fetchCalls++; + return Promise.resolve(Response.json({})); + }, + async () => { + await assertRejects(() => createClient().getTree(".."), TypeError); + await assertRejects(() => createClient().getBlob("."), TypeError); + }, + ); + assertEquals(fetchCalls, 0); + }); + + it("encodes repository identity, refs, and blob identifiers as path segments", async () => { + const requestedUrls: string[] = []; + const client = new GitHubApiClient({ + ...mockConfig, + owner: "test owner", + repo: "repo#name", + }); + + await withMockFetch( + (input) => { + const url = String(input); + requestedUrls.push(url); + return Promise.resolve( + url.includes("/git/trees/") + ? Response.json({ sha: "tree", tree: [], truncated: false }) + : Response.json({ sha: "blob", size: 0, content: "", encoding: "base64" }), + ); + }, + async () => { + await client.getTree("feature/secure?recursive=0"); + await client.getBlob("sha/../other"); + }, + ); + + assertEquals( + new URL(requestedUrls[0]!).pathname, + "/repos/test%20owner/repo%23name/git/trees/feature%2Fsecure%3Frecursive%3D0", + ); + assertEquals( + new URL(requestedUrls[1]!).pathname, + "/repos/test%20owner/repo%23name/git/blobs/sha%2F..%2Fother", + ); + }); + }); }); diff --git a/src/platform/adapters/fs/github/github-api-client.ts b/src/platform/adapters/fs/github/github-api-client.ts index ead85cb9ea..27f1a05cd0 100644 --- a/src/platform/adapters/fs/github/github-api-client.ts +++ b/src/platform/adapters/fs/github/github-api-client.ts @@ -14,6 +14,79 @@ const LOG_PREFIX = "[GitHubApiClient]"; const RATE_LIMIT_WARNING_THRESHOLD = 100; const RETRY_JITTER_MAX_MS = 1_000; +const MAX_REPOSITORY_SEGMENT_LENGTH = 256; +const MAX_ENDPOINT_VALUE_LENGTH = 4_096; + +function encodeRepositorySegment(value: string, label: string): string { + if ( + typeof value !== "string" || + value.length === 0 || + value.length > MAX_REPOSITORY_SEGMENT_LENGTH || + value.trim() !== value || + value.normalize("NFC") !== value || + /\p{Cc}/u.test(value) + ) { + throw new TypeError(`GitHub ${label} must be a bounded canonical path segment`); + } + + let decoded = value; + for (let depth = 0; depth <= value.length; depth++) { + if ( + decoded === "." || + decoded === ".." || + decoded.includes("/") || + decoded.includes("\\") || + decoded.trim() !== decoded || + /\p{Cc}/u.test(decoded) + ) { + throw new TypeError(`GitHub ${label} must be a single non-traversal path segment`); + } + + let next: string; + try { + next = decodeURIComponent(decoded); + } catch { + throw new TypeError(`GitHub ${label} contains malformed percent-encoding`); + } + if (next === decoded) return encodeURIComponent(value); + decoded = next; + } + + throw new TypeError(`GitHub ${label} contains excessive percent-encoding`); +} + +function encodeEndpointValue(value: string, label: string): string { + if ( + typeof value !== "string" || + value.length === 0 || + value.length > MAX_ENDPOINT_VALUE_LENGTH || + value === "." || + value === ".." || + /\p{Cc}/u.test(value) + ) { + throw new TypeError(`GitHub ${label} must be bounded non-empty text`); + } + return encodeURIComponent(value); +} + +function encodeContentsPath(path: string): { normalized: string; encoded: string } { + if ( + typeof path !== "string" || + path.length > MAX_ENDPOINT_VALUE_LENGTH || + /\p{Cc}/u.test(path) + ) { + throw new TypeError("GitHub contents path must be bounded text without control characters"); + } + const normalized = path.replace(/^\/+/, ""); + const segments = normalized.split("/"); + if (segments.some((segment) => segment === "." || segment === "..")) { + throw new TypeError("GitHub contents path must not contain traversal segments"); + } + return { + normalized, + encoded: segments.map(encodeURIComponent).join("/"), + }; +} class GitHubBlobIntegrityError extends Error {} @@ -28,9 +101,14 @@ type APIError = Error & { statusCode?: number; endpoint?: string; repo?: string export class GitHubApiClient { private readonly baseUrl = "https://api.github.com"; + private readonly repositoryEndpoint: string; private rateLimitInfo: RateLimitInfo | null = null; - constructor(private readonly config: ResolvedGitHubConfig) {} + constructor(private readonly config: ResolvedGitHubConfig) { + const owner = encodeRepositorySegment(config.owner, "owner"); + const repo = encodeRepositorySegment(config.repo, "repository"); + this.repositoryEndpoint = `/repos/${owner}/${repo}`; + } get repoId(): string { return `${this.config.owner}/${this.config.repo}`; @@ -38,8 +116,9 @@ export class GitHubApiClient { async getTree(ref?: string): Promise { const treeRef = ref ?? this.config.ref; - const endpoint = - `/repos/${this.config.owner}/${this.config.repo}/git/trees/${treeRef}?recursive=1`; + const endpoint = `${this.repositoryEndpoint}/git/trees/${ + encodeEndpointValue(treeRef, "tree ref") + }?recursive=1`; logger.debug(`${LOG_PREFIX} Fetching tree`, { ref: treeRef }); @@ -60,18 +139,19 @@ export class GitHubApiClient { ref?: string, ): Promise { const contentRef = ref ?? this.config.ref; - const normalizedPath = path.replace(/^\/+/, ""); - const endpoint = - `/repos/${this.config.owner}/${this.config.repo}/contents/${normalizedPath}?ref=${contentRef}`; + const { normalized, encoded } = encodeContentsPath(path); + const endpoint = `${this.repositoryEndpoint}/contents/${encoded}?ref=${ + encodeEndpointValue(contentRef, "contents ref") + }`; - logger.debug(`${LOG_PREFIX} Fetching contents`, { path: normalizedPath }); + logger.debug(`${LOG_PREFIX} Fetching contents`, { path: normalized }); const raw = await this.request(endpoint); return getGitHubContentsResponseSchema().parse(raw); } async getBlob(sha: string): Promise { - const endpoint = `/repos/${this.config.owner}/${this.config.repo}/git/blobs/${sha}`; + const endpoint = `${this.repositoryEndpoint}/git/blobs/${encodeEndpointValue(sha, "blob SHA")}`; logger.debug(`${LOG_PREFIX} Fetching blob`, { sha }); @@ -95,7 +175,7 @@ export class GitHubApiClient { if (expectedSize > byteLimit) { throw new RangeError(`GitHub blob exceeds ${byteLimit} bytes`); } - const endpoint = `/repos/${this.config.owner}/${this.config.repo}/git/blobs/${sha}`; + const endpoint = `${this.repositoryEndpoint}/git/blobs/${encodeEndpointValue(sha, "blob SHA")}`; logger.debug(`${LOG_PREFIX} Fetching bounded raw blob`, { sha, expectedSize }); diff --git a/src/platform/adapters/fs/github/path-utils.test.ts b/src/platform/adapters/fs/github/path-utils.test.ts index f69a511130..b5fa6b093b 100644 --- a/src/platform/adapters/fs/github/path-utils.test.ts +++ b/src/platform/adapters/fs/github/path-utils.test.ts @@ -54,6 +54,9 @@ describe("platform/adapters/fs/github/path-utils", () => { "src/../secret.ts", "/project/../../secret.ts", "../../../../user/repos", + "%2e%2e/%2e%2e/user/repos", + "%2E%2E/%2E%2E/user/repos", + ".%2e/.%2e/user/repos", ] ) { assertThrows( @@ -64,6 +67,24 @@ describe("platform/adapters/fs/github/path-utils", () => { } }); + it("rejects backslashes, control characters, and unbounded paths", () => { + for ( + const [path, message] of [ + ["..\\..\\user/repos", "forward slashes"], + ["src/\u0000secret.ts", "control characters"], + ["src/\u0080secret.ts", "control characters"], + ["src/\u009fsecret.ts", "control characters"], + ["a".repeat(4_097), "4096-character limit"], + ] as const + ) { + assertThrows( + () => normalizeGitHubPath(path), + TypeError, + message, + ); + } + }); + it("rejects traversal segments in projectDir", () => { assertThrows( () => normalizeGitHubPath("src/file.ts", "/project/../other"), diff --git a/src/platform/adapters/fs/github/path-utils.ts b/src/platform/adapters/fs/github/path-utils.ts index 175b136207..cb9a8c3f8b 100644 --- a/src/platform/adapters/fs/github/path-utils.ts +++ b/src/platform/adapters/fs/github/path-utils.ts @@ -1,3 +1,17 @@ +const MAX_GITHUB_PATH_CODE_UNITS = 4_096; + +function hasControlCharacter(value: string): boolean { + for (let index = 0; index < value.length; index++) { + const codeUnit = value.charCodeAt(index); + if (codeUnit <= 0x1f || (codeUnit >= 0x7f && codeUnit <= 0x9f)) return true; + } + return false; +} + +function isUrlDoubleDotSegment(segment: string): boolean { + return /^(?:\.|%2e)(?:\.|%2e)$/i.test(segment); +} + export function normalizeGitHubPath(path: string, projectDir: string = ""): string { const normalizedPath = normalizePathSegments(path, "path"); const normalizedProjectDir = normalizePathSegments(projectDir, "projectDir"); @@ -17,6 +31,17 @@ function normalizePathSegments(value: string, label: string): string { if (typeof value !== "string") { throw new TypeError(`GitHub ${label} must be a string`); } + if (value.length > MAX_GITHUB_PATH_CODE_UNITS) { + throw new TypeError( + `GitHub ${label} exceeds the ${MAX_GITHUB_PATH_CODE_UNITS}-character limit`, + ); + } + if (hasControlCharacter(value)) { + throw new TypeError(`GitHub ${label} must not contain control characters`); + } + if (value.includes("\\")) { + throw new TypeError(`GitHub ${label} must use forward slashes`); + } const collapsed = value.replace(/^\/+|\/+$/g, "").replace(/\/+/g, "/"); const segments: string[] = []; @@ -26,7 +51,7 @@ function normalizePathSegments(value: string, label: string): string { if (segment === ".") continue; // ".." would escape the repository scope once the path is embedded in a // GitHub API URL (WHATWG URL resolution collapses dot segments): reject. - if (segment === "..") { + if (isUrlDoubleDotSegment(segment)) { throw new TypeError(`GitHub ${label} must not contain ".." traversal segments`); } segments.push(segment); diff --git a/src/platform/adapters/fs/integration.test.ts b/src/platform/adapters/fs/integration.test.ts index 781dfdaa5a..b679b6afdf 100644 --- a/src/platform/adapters/fs/integration.test.ts +++ b/src/platform/adapters/fs/integration.test.ts @@ -116,6 +116,30 @@ describe("integration.ts", () => { assertEquals(rejection.slug, "config-validation-failed"); }); + it("should preserve invalid project scoping instead of falling back to local files", async () => { + const error = await assertRejects( + () => + enhanceAdapterWithFS( + denoAdapter, + { + fs: { + type: "veryfront-api", + veryfront: { + apiBaseUrl: "https://api.example.com", + apiToken: "token", + projectSlug: "project", + }, + }, + }, + "/project/../etc", + ), + VeryfrontError, + "project directory must not contain", + ); + assertInstanceOf(error, VeryfrontError); + assertEquals(error.slug, "config-validation-failed"); + }); + it("should fall back to original adapter for unsupported type", async () => { const adapter = await enhanceAdapterWithFS(denoAdapter, { fs: { type: "unsupported-type" as any }, diff --git a/src/platform/adapters/fs/veryfront/path-normalizer.test.ts b/src/platform/adapters/fs/veryfront/path-normalizer.test.ts index 5765f82d9c..05b0d1dba9 100644 --- a/src/platform/adapters/fs/veryfront/path-normalizer.test.ts +++ b/src/platform/adapters/fs/veryfront/path-normalizer.test.ts @@ -1,7 +1,13 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals, assertExists, assertThrows } from "#veryfront/testing/assert.ts"; +import { + assertEquals, + assertExists, + assertInstanceOf, + assertThrows, +} from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { PathNormalizer } from "./path-normalizer.ts"; +import { VeryfrontError } from "#veryfront/errors/types.ts"; describe("PathNormalizer", () => { describe("class", () => { @@ -20,11 +26,13 @@ describe("PathNormalizer", () => { it("should reject traversal segments in projectDir", () => { for (const projectDir of ["/project/..", "../project", "/project//../root"]) { - assertThrows( + const error = assertThrows( () => new PathNormalizer(projectDir), - TypeError, + VeryfrontError, 'project directory must not contain ".." segments', ); + assertInstanceOf(error, VeryfrontError); + assertEquals(error.slug, "config-validation-failed"); } }); }); diff --git a/src/platform/adapters/fs/veryfront/path-normalizer.ts b/src/platform/adapters/fs/veryfront/path-normalizer.ts index ba334dac79..dbf07fe44f 100644 --- a/src/platform/adapters/fs/veryfront/path-normalizer.ts +++ b/src/platform/adapters/fs/veryfront/path-normalizer.ts @@ -1,4 +1,5 @@ import { logger as baseLogger } from "#veryfront/utils"; +import { CONFIG_VALIDATION_FAILED } from "#veryfront/errors/error-registry/config.ts"; const logger = baseLogger.component("path-normalizer"); const MAX_PATH_CODE_UNITS = 4_096; @@ -27,7 +28,16 @@ export class PathNormalizer { constructor(private readonly projectDir?: string) { if (projectDir !== undefined) { - this.assertSafePath(projectDir, "project directory"); + try { + this.assertSafePath(projectDir, "project directory"); + } catch (cause) { + throw CONFIG_VALIDATION_FAILED.create({ + detail: cause instanceof Error + ? cause.message + : "Filesystem project directory is invalid", + cause, + }); + } this.projectDirPrefix = normalizeForComparison(projectDir); } } From 07cd1f49e01b81a7ad4d69bcb8924e0f4061c5ff Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 13:55:40 +0200 Subject: [PATCH 10/26] Diagnose invalid CACHE_TYPE values and mark the rollout shim for removal --- cli/commands/serve/proxy-extension-composition.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/cli/commands/serve/proxy-extension-composition.ts b/cli/commands/serve/proxy-extension-composition.ts index 135a11cb38..c970a260d6 100644 --- a/cli/commands/serve/proxy-extension-composition.ts +++ b/cli/commands/serve/proxy-extension-composition.ts @@ -53,7 +53,9 @@ const REDIS_EXTENSION_PACKAGE_NAME = "@veryfront/ext-redis"; async function activateStandaloneProxyExtensionsInternal(): Promise { const cacheType = getEnv("CACHE_TYPE") || "memory"; if (cacheType !== "memory" && cacheType !== "extension" && cacheType !== "redis") { - throw new NativeTypeError("CACHE_TYPE must be memory, extension, or redis"); + throw new NativeTypeError( + `CACHE_TYPE must be memory, extension, or redis (received "${cacheType}")`, + ); } const selected: Array<{ @@ -97,7 +99,11 @@ async function activateStandaloneProxyExtensionsInternal(): Promise Date: Mon, 3 Aug 2026 14:14:04 +0200 Subject: [PATCH 11/26] fix(security): bind hosted source and environment identity (#3290) * fix(server): harden hosted request and module boundaries * security: bind hosted environment cache identity * security: harden project environment fetch lifecycle * docs(security): define shared environment trust boundary * fix(server): fail closed across hosted env admission * fix(security): bind production modules and hosted environments * fix(security): bind agent runs to exact environments * fix(security): bind module admission to resolved source * fix(security): bind hosted source authority * fix(security): bind hosted agent branch targets * test(server): narrow project env timeout errors * fix(security): preserve standalone module admission * fix(security): reject untrusted default branch identity * fix(server): detach environment response cleanup * fix(security): close browser module admission gaps * test(cache): synchronize inflight admission deterministically * test(server): type branch identity fixtures explicitly * fix(security): close browser module admission bypasses * fix(security): close hosted runtime admission gaps * fix(modules): bound request-triggered browser bundles * fix(server): harden browser module resource admission * test(security): preserve trusted proxy middleware isolation * test(rsc): preserve exact filesystem and proxy boundaries * fix(server): close browser module admission gaps * fix(server): admit dependency snapshot reads with browser builds * Preserve canonical signed stream identity through the proxy The proxy now reads signed runtime target identity from the same nested run.project envelope consumed by runtime handlers. Contract tests carry one body through proxy admission and runtime parsing while covering legacy main-branch defaults and fail-closed target mismatches. Constraint: Signed stream bodies must retain the canonical runtime invocation envelope end to end Rejected: Accept both flat and nested target fields | dual wire shapes would preserve the incompatible test-only contract Confidence: high Scope-risk: narrow Directive: Keep proxy target extraction aligned with RuntimeAgentRunInvocationSchema Tested: Focused proxy/runtime tests, format, lint, typecheck, and repository pre-push checks Not-tested: External control-plane producer deployment; pre-push unit suite has one unrelated path-normalization assertion failure in the temporary worktree * Prevent credential correlation through proxy diagnostics Proxy adapter cache isolation still uses the full token digest internally, while logs, invariant errors, and public stats now use a credential-redacted diagnostic identity. Duplicate credential partitions receive process-local ordinal stats suffixes so redaction does not collapse observable adapter counts. Constraint: Credential rotation must continue to partition internal adapters without exposing a token-derived stable identifier Rejected: Truncate the credential digest | a truncated digest remains correlatable and weakens the diagnostic boundary Confidence: high Scope-risk: narrow Directive: Never pass the internal proxy adapter cache key or credentialPrincipal to logs, errors, telemetry, or public stats Tested: TDD red-green proxy diagnostics regression; 10 focused tests with 207 steps; format, lint, typecheck, and repository pre-push checks Not-tested: Repository pre-push unit suite has one unrelated path-normalization assertion failure in the temporary worktree * Prevent module origin secrets in fetch diagnostics The HTTP module fallback accepted a full module server URL, then reused it for request construction and diagnostics. Normalizing the configured server to its origin before adding the module path keeps credentials and unrelated URL components out of fetch inputs, span attributes, and warning messages. Constraint: Copilot review flagged credential leakage through moduleServerOrigin-derived http.url attributes and warnings Rejected: Redact only warning output | span attributes and fetch inputs would still receive the unsanitized URL Confidence: high Scope-risk: narrow Directive: Keep moduleServerOrigin as an origin identity before deriving request URLs or diagnostic attributes Tested: deno test --no-check --allow-all src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.test.ts Tested: deno fmt --check src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.ts src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.test.ts Tested: deno lint src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.ts src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.test.ts Tested: deno check src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.ts src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.test.ts Tested: git diff --check * fix(platform): reject credentialed redirects by default * Lock credentialed API redirect opt-in coverage The remote PR head already changed the transport default to fail closed. This follow-up keeps the requested regression coverage focused by separating the default rejection case from the explicit caller opt-in case while retaining manual-policy coverage. Constraint: PR #3290 review thread 3702907390 requires proof that credentialed transport redirects default to error and follow only occurs by explicit opt-in Rejected: Duplicate the remote redirect-default implementation | remote head 5831f204e already contains the production policy change Confidence: high Scope-risk: narrow Directive: Keep redirect follow/manual coverage at the fetch boundary when changing Veryfront API transport credential policy Tested: deno test --no-check --allow-all src/platform/adapters/veryfront-api-transport.test.ts Tested: deno test --no-check --allow-all src/platform/adapters/veryfront-api-client/operations.test.ts src/platform/adapters/token/veryfront/api-client.test.ts Tested: deno fmt --check src/platform/adapters/veryfront-api-transport.ts src/platform/adapters/veryfront-api-transport.test.ts Tested: deno lint src/platform/adapters/veryfront-api-transport.ts src/platform/adapters/veryfront-api-transport.test.ts Tested: deno check src/platform/adapters/veryfront-api-transport.ts src/platform/adapters/veryfront-api-transport.test.ts Tested: git diff --check Not-tested: full repository test suite * Preserve signed branch identity across hosted runtime boundaries Control-plane branch names can be valid Unicode, but Fetch Headers only accepts ByteString values. The proxy now percent-encodes only identity header values that cannot be represented directly, and the trusted runtime boundary decodes the explicit prefix before project resolution consumes the identity. The explicit MDX module server origin path now validates only the origin supplied by the caller, so unrelated fallback PORT and project slug values cannot reject an already-authoritative origin. Constraint: Deno Headers values must be ByteString-compatible Constraint: Explicit moduleServerOrigin must not depend on fallback local host inputs Rejected: Reject Unicode branch names | signed control-plane branch identity already accepts valid Unicode and both sides of this proxy/runtime boundary are owned Confidence: high Scope-risk: narrow Tested: deno test --no-check --allow-all src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.test.ts src/proxy/handler.test.ts src/utils/header-identity.test.ts Tested: deno test --no-check --allow-all src/proxy/control-plane-signature.test.ts src/proxy/mode-parity.test.ts src/server/runtime-handler/project-resolution.test.ts Tested: deno fmt --check, deno lint, and deno check on touched files Tested: deno task lint:test-typecheck Tested: deno task verify:quick Not-tested: Full pre-push unit suite has an unrelated repeatable worker-pool path assertion failure in src/security/sandbox/worker-pool.test.ts * Fail closed before named agent env lookup without run credentials Signed agent stream requests that select an exact named environment now require a request-scoped credential from the signed payload or trusted proxy context before any source-context or environment secret lookup runs. This keeps the hosted identity hardening contract aligned with the shared proxy helper: the process host token is not combined with request-selected tenant identity. Constraint: Hosted environment secret lookup must not fall back to VERYFRONT_API_TOKEN when the run supplies no credential. Rejected: Document signed-only host fallback as intentional | resolveNamedForActiveRelease authorization is ineffective with a platform-scoped host credential. Confidence: high Scope-risk: narrow Directive: Do not restore host-token fallback on this path without proving tenant authorization remains request-scoped before secrets are read. Tested: RED/green agent-stream regression; DENO_TESTING=1 deno test --no-check --allow-all --unstable-worker-options src/server/handlers/request/agent-stream.handler.test.ts; related auth/env suites; deno fmt --check; deno lint; deno check; deno task verify:quick; deno task lint:test-typecheck; git diff --check Not-tested: Repo unit-only command still fails before executing tests on pre-existing scripts/build/build-npm-extension-packages.ts #dnt import-map resolution. * Stabilize worker permission root test under temp symlinks The SSR worker permission contract test used /tmp as the project read root. On macOS /tmp canonicalizes to /private/tmp, which can be an ancestor of the PR worktree and causes permission normalization to prune the extension read root that the test is meant to observe. Use a fresh temp project root and point the SSR page module inside that root so the extension read root remains independent of the project read scope. Constraint: Normal push hooks run the branch unit suite before accepting the security fix. Rejected: Bypass the pre-push hook | the user requested a normal push and the failure reproduced in isolation. Confidence: high Scope-risk: narrow Directive: Keep this test's project read root independent of the repository path so canonicalization cannot collapse the extension root into a broader project root. Tested: DENO_TESTING=1 deno test --no-check --allow-all --unstable-worker-options src/security/sandbox/worker-pool.test.ts; deno fmt --check; deno lint; deno check * Reject malformed encoded identity headers Malformed vf-utf8 identity header payloads previously surfaced as generic URIError failures during request header extraction. Treating them as absent lets the existing trusted-proxy guard reject the request through the normal identity validation path. Constraint: Hosted proxy identity headers are security-sensitive and must fail closed without exposing malformed input as unexpected 500s. Rejected: Let decodeURIComponent throw and rely on outer request containment | it bypasses the typed identity validation path and matches the review finding. Confidence: high Scope-risk: narrow Tested: npx --yes deno@2.7.7 test --no-check --allow-all src/utils/header-identity.test.ts src/server/runtime-handler/project-resolution.test.ts src/proxy/handler.test.ts Tested: npx --yes deno@2.7.7 fmt --check src/utils/header-identity.ts src/utils/header-identity.test.ts Tested: npx --yes deno@2.7.7 lint src/utils/header-identity.ts src/utils/header-identity.test.ts Tested: npx --yes deno@2.7.7 check src/utils/header-identity.ts src/utils/header-identity.test.ts Tested: git diff --check * fix(modules): admit RSC entry reads before filesystem work * Keep trusted branch identity canonical after decoding Encoded branch identity can reintroduce surrounding whitespace after the Fetch headers layer has already normalized raw header OWS. Normalize the decoded preview branch name the same way as the default branch name so signed source matching and cache identity use the canonical branch string. Constraint: Latest exact-head Copilot review found branchName/defaultBranchName normalization drift in hosted identity extraction Rejected: Change header encoding globally | ByteString preservation is intentional for existing ASCII branch names Confidence: high Scope-risk: narrow Tested: npx --yes deno@2.7.7 test --no-check --allow-all src/server/runtime-handler/project-resolution.test.ts src/proxy/handler.test.ts src/utils/header-identity.test.ts Tested: npx --yes deno@2.7.7 fmt --check src/server/runtime-handler/project-resolution.ts src/server/runtime-handler/project-resolution.test.ts Tested: npx --yes deno@2.7.7 lint src/server/runtime-handler/project-resolution.ts src/server/runtime-handler/project-resolution.test.ts Tested: npx --yes deno@2.7.7 check src/server/runtime-handler/project-resolution.ts src/server/runtime-handler/project-resolution.test.ts Tested: git diff --check * fix(modules): preserve browser boundary response contracts --- .env.example | 11 +- .../architecture/11-control-plane-channels.md | 10 + .../src/context-build-lifecycle.ts | 57 + .../src/esbuild-bundler.test.ts | 77 +- .../src/esbuild-bundler.ts | 30 +- src/agent/hosted/chat-request.test.ts | 23 +- .../runtime/agent-invocation-contract.test.ts | 40 + .../runtime/agent-invocation-contract.ts | 54 +- src/cache/keys.test.ts | 30 + src/cache/keys/builders/render.ts | 18 +- src/channels/control-plane.test.ts | 23 + src/channels/control-plane.ts | 54 + src/extensions/bundler/bundler.ts | 8 + src/internal-agents/schema.test.ts | 52 +- src/internal-agents/schema.ts | 7 + src/modules/README.md | 7 + .../server/browser-module-admission.test.ts | 134 ++ .../server/browser-module-admission.ts | 214 +++ src/modules/server/classify.test.ts | 23 + src/modules/server/classify.ts | 21 +- src/modules/server/module-server.test.ts | 1260 ++++++++++++++++- src/modules/server/module-server.ts | 769 ++++++++-- .../fs/veryfront/proxy-manager.test.ts | 417 ++++++ .../adapters/fs/veryfront/proxy-manager.ts | 350 +++-- .../veryfront/schemas/proxy-manager.schema.ts | 2 +- .../adapters/veryfront-api-transport.test.ts | 45 + .../adapters/veryfront-api-transport.ts | 30 +- src/proxy/control-plane-signature.test.ts | 96 +- src/proxy/control-plane-signature.ts | 159 +++ src/proxy/handler.test.ts | 270 +++- src/proxy/handler.ts | 65 +- src/proxy/mode-parity.test.ts | 35 + src/proxy/split-forward-request.test.ts | 2 + src/release-assets/manifest-cache.ts | 29 +- src/security/README.md | 28 + src/security/http/base-handler.test.ts | 7 +- src/security/http/base-handler.ts | 8 +- src/security/sandbox/worker-pool.test.ts | 47 +- src/server/bootstrap.test.ts | 17 + src/server/bootstrap.ts | 12 + src/server/context/request-context.test.ts | 14 + src/server/context/request-context.ts | 9 +- .../handlers/dev/files/esbuild-plugins.ts | 29 +- .../handlers/preview/hmr.handler.test.ts | 25 +- src/server/handlers/preview/hmr.handler.ts | 29 +- .../agent-stream.handler.test-helpers.ts | 5 + .../request/agent-stream.handler.test.ts | 546 ++++++- .../handlers/request/agent-stream.handler.ts | 177 ++- .../internal-agents-list.handler.test.ts | 8 +- .../request/module/module-server-handler.ts | 2 + src/server/handlers/request/rsc/index.test.ts | 14 +- src/server/project-env/cache.test.ts | 424 ++++-- src/server/project-env/cache.ts | 352 ++++- src/server/project-env/fetcher.test.ts | 371 ++++- src/server/project-env/fetcher.ts | 210 ++- src/server/project-env/index.ts | 12 +- .../production-environment-resolver.test.ts | 229 +++ .../production-environment-resolver.ts | 327 +++++ .../runtime-handler/adapter-factory.test.ts | 9 +- src/server/runtime-handler/adapter-factory.ts | 13 +- .../environment-resolution.test.ts | 27 + .../runtime-handler/environment-resolution.ts | 9 +- .../handler-context-builder.test.ts | 2 + .../handler-context-builder.ts | 14 +- src/server/runtime-handler/index.test.ts | 83 +- src/server/runtime-handler/index.ts | 50 +- .../project-middleware.test.ts | 52 +- .../runtime-handler/project-middleware.ts | 21 +- .../project-resolution.test.ts | 78 +- .../runtime-handler/project-resolution.ts | 42 +- .../project-runtime-context.test.ts | 503 ++++++- .../project-runtime-context.ts | 131 +- .../runtime-handler/timeout-manager.test.ts | 29 + src/server/runtime-handler/timeout-manager.ts | 15 +- .../endpoints/endpoint-router.test-helpers.ts | 18 +- .../rsc/endpoints/endpoint-router.test.ts | 216 ++- .../services/rsc/endpoints/endpoint-router.ts | 203 ++- .../shared/browser-module-bundler.test.ts | 777 +++++++++- src/server/shared/browser-module-bundler.ts | 1025 ++++++++++++-- src/server/utils/proxy-trust.test.ts | 8 +- src/server/utils/proxy-trust.ts | 46 +- src/transforms/esm/http-cache-helpers.ts | 4 + src/transforms/esm/http-cache.test.ts | 38 + src/transforms/esm/package-registry.test.ts | 29 + src/transforms/esm/package-registry.ts | 13 + src/transforms/esm/specifier-resolver.ts | 10 +- .../module-fetcher/http-fallback.ts | 1 + .../module-fetcher/http-fetcher.test.ts | 116 ++ .../module-fetcher/http-fetcher.ts | 21 +- .../pipeline/stages/ssr-http-cache.ts | 2 + src/types/server.ts | 8 + src/utils/header-identity.test.ts | 28 + src/utils/header-identity.ts | 23 + .../regressions/rsc-proxy-hydration.test.ts | 71 +- .../adapters/proxy-fs-adapter-manager.test.ts | 47 +- tests/integration/compiled-binary-e2e.test.ts | 70 +- .../server/modules/hmr-handler.test.ts | 24 +- .../server/production-server.test.ts | 6 + tests/integration/vfs-proxy-mode-e2e.test.ts | 1 + 99 files changed, 9933 insertions(+), 1274 deletions(-) create mode 100644 extensions/ext-bundler-esbuild/src/context-build-lifecycle.ts create mode 100644 src/modules/server/browser-module-admission.test.ts create mode 100644 src/modules/server/browser-module-admission.ts create mode 100644 src/server/project-env/production-environment-resolver.test.ts create mode 100644 src/server/project-env/production-environment-resolver.ts create mode 100644 src/utils/header-identity.test.ts create mode 100644 src/utils/header-identity.ts diff --git a/.env.example b/.env.example index 02aa3405f6..18083c4884 100644 --- a/.env.example +++ b/.env.example @@ -17,10 +17,12 @@ REDIS_URL= # Proxy Mode (deployed pods) # Set PROXY_MODE=1 only for hosted/deployed runtimes. -# Requires NODE_ENV=production and a valid signing key. +# Requires NODE_ENV=production, a valid signing key, and a private runtime +# behind an edge that strips client-supplied forwarding/project headers. # PROXY_MODE=1 # NODE_ENV=production # CHANNEL_DISPATCH_SIGNING_PUBLIC_KEY= +# VERYFRONT_TRUST_FORWARDED_HEADERS=1 # Host outbound network policy # Remote modules and remote MCP calls may reach public HTTP(S) endpoints. The @@ -32,6 +34,13 @@ REDIS_URL= # this host-owned option. # VERYFRONT_HOST_ALLOW_INTERNAL_EGRESS=1 +# Optional privileged environment-value retrieval for shared runtimes. +# Configure both values only when VERYFRONT_API_BASE_URL exposes the canonical +# /internal/project-environment-variables endpoint. The runtime first verifies +# the request-scoped bearer token and never falls back if this endpoint fails. +# VERYFRONT_API_INTERNAL_USER= +# VERYFRONT_API_INTERNAL_PASS= + # Binary compilation # Set to 1 to always rebuild binary, even if source unchanged VERYFRONT_BINARY_FRESH=1 diff --git a/docs/architecture/11-control-plane-channels.md b/docs/architecture/11-control-plane-channels.md index 71b7c55b27..0a4ac879af 100644 --- a/docs/architecture/11-control-plane-channels.md +++ b/docs/architecture/11-control-plane-channels.md @@ -32,6 +32,16 @@ Primary source areas: 4. Invoke handlers execute project-scoped runtime work and return structured results. +Agent run signatures bind `agentSource` together with the runtime target kind, +environment ID, and branch ID. Shared runtimes revalidate a named source's +environment name/ID pair against project metadata before loading secrets. +The operator proxy verifies the body hash before reconstructing preview branch +ID/name pairs or a project's default branch name for the runtime; inbound +branch headers are discarded. The runtime compares the signed source with that +trusted context, so default branches are not assumed to be named `main`. +Branch and bare-release sources have no authoritative environment identity and +therefore receive no project environment variables. + ## Boundaries - Control-plane channels are signed management surfaces, not public app routes. diff --git a/extensions/ext-bundler-esbuild/src/context-build-lifecycle.ts b/extensions/ext-bundler-esbuild/src/context-build-lifecycle.ts new file mode 100644 index 0000000000..78f2a3b9af --- /dev/null +++ b/extensions/ext-bundler-esbuild/src/context-build-lifecycle.ts @@ -0,0 +1,57 @@ +export interface EsbuildBuildContextLike { + rebuild(): Promise; + cancel(): Promise; + dispose(): Promise; +} + +/** + * Run one active esbuild context build with abort-driven cancellation. + * Cleanup failures remain observable after success, but never replace the + * build or abort error that caused cleanup. + */ +export async function rebuildContextWithSignal( + context: EsbuildBuildContextLike, + signal: AbortSignal, +): Promise { + let cancellation: Promise | undefined; + let primaryError: unknown; + let cleanupError: unknown; + let result: T | undefined; + const cancel = (): void => { + if (cancellation) return; + cancellation = Promise.resolve().then(() => context.cancel()); + // Observe immediately; the primary flow awaits it during cleanup. + void cancellation.catch(() => undefined); + }; + + signal.addEventListener("abort", cancel, { once: true }); + if (signal.aborted) cancel(); + try { + signal.throwIfAborted(); + try { + result = await context.rebuild(); + signal.throwIfAborted(); + } catch (error) { + signal.throwIfAborted(); + throw error; + } + } catch (error) { + primaryError = error; + throw error; + } finally { + signal.removeEventListener("abort", cancel); + try { + await cancellation; + } catch (error) { + cleanupError = error; + } + try { + await context.dispose(); + } catch (error) { + cleanupError ??= error; + } + } + + if (primaryError === undefined && cleanupError !== undefined) throw cleanupError; + return result as T; +} diff --git a/extensions/ext-bundler-esbuild/src/esbuild-bundler.test.ts b/extensions/ext-bundler-esbuild/src/esbuild-bundler.test.ts index 0900e6f218..f97f01256a 100644 --- a/extensions/ext-bundler-esbuild/src/esbuild-bundler.test.ts +++ b/extensions/ext-bundler-esbuild/src/esbuild-bundler.test.ts @@ -5,11 +5,12 @@ * @module extensions/ext-bundler-esbuild/esbuild-bundler.test */ -import { assertEquals, assertExists, assertStringIncludes } from "@std/assert"; +import { assertEquals, assertExists, assertRejects, assertStringIncludes } from "@std/assert"; import { describe, it } from "@std/testing/bdd"; import { createRequire } from "node:module"; import { EsbuildBundler } from "./esbuild-bundler.ts"; +import { rebuildContextWithSignal } from "./context-build-lifecycle.ts"; const childProcess = createRequire(import.meta.url)("node:child_process") as { spawn: typeof import("node:child_process").spawn; @@ -89,6 +90,36 @@ describe("EsbuildBundler.transform", () => { }); }); +describe("abortable esbuild context lifecycle", () => { + it("cancels active work and preserves the primary abort over cleanup failures", async () => { + const controller = new AbortController(); + const rebuild = Promise.withResolvers(); + const cancelCalled = Promise.withResolvers(); + let disposed = false; + const abortReason = new DOMException("deadline exceeded", "AbortError"); + const building = rebuildContextWithSignal({ + rebuild: () => rebuild.promise, + cancel: () => { + cancelCalled.resolve(); + rebuild.reject(new Error("esbuild rebuild cancelled")); + return Promise.reject(new Error("cancel cleanup failed")); + }, + dispose: () => { + disposed = true; + return Promise.reject(new Error("dispose cleanup failed")); + }, + }, controller.signal); + void building.catch(() => undefined); + + controller.abort(abortReason); + await cancelCalled.promise; + const error = await assertRejects(() => building); + + assertEquals(error, abortReason); + assertEquals(disposed, true); + }); +}); + describe("EsbuildBundler.stop", () => { it("does not return until the service fully closes", async () => { const serviceClosed = Promise.withResolvers(); @@ -721,6 +752,50 @@ describe("EsbuildBundler.bundle", () => { await bundler.stop(); } }); + + it("cancels an active context build through the contract signal", async () => { + const bundler = new EsbuildBundler(); + const controller = new AbortController(); + const loadStarted = Promise.withResolvers(); + const releaseLoad = Promise.withResolvers(); + const abortReason = new DOMException("cancel requested", "AbortError"); + let bundling: Promise>> | undefined; + + try { + bundling = bundler.bundle({ + entryPoints: ["cancel:entry"], + bundle: true, + format: "esm", + write: false, + signal: controller.signal, + plugins: [{ + name: "cancel-active-build", + setup(build) { + build.onResolve({ filter: /^cancel:/ }, () => ({ + path: "entry", + namespace: "cancel", + })); + build.onLoad({ filter: /.*/, namespace: "cancel" }, async () => { + loadStarted.resolve(); + await releaseLoad.promise; + return { contents: "export default 1;", loader: "ts" }; + }); + }, + }], + }); + void bundling.catch(() => undefined); + await loadStarted.promise; + controller.abort(abortReason); + releaseLoad.resolve(); + + const error = await assertRejects(() => bundling!); + assertEquals(error, abortReason); + } finally { + releaseLoad.resolve(); + await bundling?.catch(() => undefined); + await bundler.stop(); + } + }); }); describe("EsbuildBundler unsupported lifecycle ownership", () => { diff --git a/extensions/ext-bundler-esbuild/src/esbuild-bundler.ts b/extensions/ext-bundler-esbuild/src/esbuild-bundler.ts index a8f0d57686..e4f56e90ea 100644 --- a/extensions/ext-bundler-esbuild/src/esbuild-bundler.ts +++ b/extensions/ext-bundler-esbuild/src/esbuild-bundler.ts @@ -21,6 +21,7 @@ import type { TransformOptions, TransformResult, } from "veryfront/extensions/bundler"; +import { rebuildContextWithSignal } from "./context-build-lifecycle.ts"; import { AsyncLocalStorage } from "node:async_hooks"; import type { ChildProcess } from "node:child_process"; import { createRequire } from "node:module"; @@ -317,7 +318,8 @@ function toOutput(f: any): BundleOutput { } function mapOptions(options: BundleOptions, scope: OperationScope): MappedBundleOptions { - const { plugins, ...rest } = options; + // `signal` belongs to the framework contract, not esbuild's BuildOptions. + const { plugins, signal: _signal, ...rest } = options; const mapped: Record = { ...rest }; const pluginDisposals = createPluginDisposalBarrier(scope); if (plugins && plugins.length > 0) { @@ -343,8 +345,28 @@ export class EsbuildBundler implements Bundler { return runBundlerOperation(async (scope) => { const esbuild = await getEsbuild(); const mapped = mapOptions(options, scope); + const signal = options.signal; try { - const result = await invokeEsbuild(() => esbuild.build(mapped.options)); + signal?.throwIfAborted(); + + let result: { + outputFiles?: unknown[]; + warnings?: unknown[]; + errors?: unknown[]; + metafile?: unknown; + }; + try { + if (signal) { + const buildContext = await invokeEsbuild(() => esbuild.context(mapped.options)); + result = await rebuildContextWithSignal(buildContext, signal); + } else { + result = await invokeEsbuild(() => esbuild.build(mapped.options)); + } + } catch (error) { + signal?.throwIfAborted(); + throw error; + } + signal?.throwIfAborted(); return { outputFiles: (result.outputFiles ?? []).map(toOutput), warnings: toMessages(result.warnings), @@ -391,6 +413,10 @@ export class EsbuildBundler implements Bundler { metafile: result.metafile as Metafile | undefined, }; }, contextScope), + cancel: () => + runBundlerOperation(async () => { + await ctx.cancel(); + }, contextScope), dispose: () => runBundlerOperation(async () => { try { diff --git a/src/agent/hosted/chat-request.test.ts b/src/agent/hosted/chat-request.test.ts index 3e7fdf1ca1..d5585b729a 100644 --- a/src/agent/hosted/chat-request.test.ts +++ b/src/agent/hosted/chat-request.test.ts @@ -208,8 +208,7 @@ function createRuntimeInvocation(): ReturnType { }); it("builds a hosted chat request from a runtime agent invocation", () => { - const invocation = createRuntimeInvocation(); + const baseInvocation = createRuntimeInvocation(); + const invocation = RuntimeAgentRunInvocationSchema.parse({ + ...baseInvocation, + run: { + ...baseInvocation.run, + project: { + projectId, + projectSlug: "demo-project", + runtimeTargetKind: "preview_branch", + runtimeTargetBranchId: branchId, + }, + }, + agentSource: { type: "branch", branch: "feature/runtime-preview" }, + }); const forwardedProps = buildHostedChatRequestForwardedPropsFromRuntimeAgentInvocation( invocation, ); @@ -1543,6 +1555,11 @@ describe("agent/hosted-chat-request", () => { runtimeTargetEnvironmentId: environmentId, }, }, + agentSource: { + type: "environment", + environmentName: "Production", + releaseId: "release-42", + }, }); const request = buildHostedChatRequestFromRuntimeAgentInvocation(invocation); diff --git a/src/agent/runtime/agent-invocation-contract.test.ts b/src/agent/runtime/agent-invocation-contract.test.ts index f52afae9cd..e138622f69 100644 --- a/src/agent/runtime/agent-invocation-contract.test.ts +++ b/src/agent/runtime/agent-invocation-contract.test.ts @@ -102,6 +102,10 @@ describe("agent/runtime-agent-invocation-contract", () => { })); assertEquals(parsed.run.project.runtimeTargetKind, "main_branch"); + const nonMainDefault = RuntimeAgentRunInvocationSchema.parse(createInvocation({ + agentSource: { type: "branch", branch: "trunk" }, + })); + assertEquals(nonMainDefault.agentSource, { type: "branch", branch: "trunk" }); assertThrows(() => RuntimeAgentRunInvocationSchema.parse(createInvocation({ run: { @@ -134,6 +138,15 @@ describe("agent/runtime-agent-invocation-contract", () => { ); const parsed = RuntimeAgentRunInvocationSchema.parse(createInvocation({ + run: { + ...createInvocation().run, + project: { + projectId, + projectSlug: "demo-project", + runtimeTargetKind: "environment", + runtimeTargetEnvironmentId: environmentId, + }, + }, agentSource: { type: "environment", environmentName: "Production", @@ -148,6 +161,26 @@ describe("agent/runtime-agent-invocation-contract", () => { }); }); + it("rejects agent sources that do not match the selected runtime target", () => { + assertThrows( + () => + RuntimeAgentRunInvocationSchema.parse(createInvocation({ + run: { + ...createInvocation().run, + project: { + projectId, + projectSlug: "demo-project", + runtimeTargetKind: "environment", + runtimeTargetEnvironmentId: environmentId, + }, + }, + agentSource: { type: "branch", branch: "main" }, + })), + Error, + "environment runtime target requires an environment agent source", + ); + }); + it("requires an exact source for every runtime invocation", () => { assertThrows(() => RuntimeAgentRunInvocationSchema.parse(createInvocation({ agentSource: undefined })) @@ -268,6 +301,8 @@ describe("agent/runtime-agent-invocation-contract", () => { messages: parsed.messages, tools: parsed.tools, context: parsed.context, + runtimeTargetKind: "preview_branch", + runtimeTargetEnvironmentId: null, runtimeTargetBranchId: branchId, credentials: parsed.credentials, agentSource: parsed.agentSource, @@ -292,6 +327,11 @@ describe("agent/runtime-agent-invocation-contract", () => { runtimeTargetEnvironmentId: environmentId, }, }, + agentSource: { + type: "environment", + environmentName: "Production", + releaseId: "release-1", + }, })); const request = buildRuntimeAgentControlPlaneStreamRequestFromInvocation(parsed); diff --git a/src/agent/runtime/agent-invocation-contract.ts b/src/agent/runtime/agent-invocation-contract.ts index 3f77e51675..39d9785ac0 100644 --- a/src/agent/runtime/agent-invocation-contract.ts +++ b/src/agent/runtime/agent-invocation-contract.ts @@ -156,7 +156,7 @@ export const getRuntimeAgentTargetKindSchema = defineSchema((v) => */ export const RuntimeAgentTargetKindSchema = lazySchema(getRuntimeAgentTargetKindSchema); -type RuntimeAgentTargetSelectionInput = { +export type RuntimeAgentTargetSelectionInput = { runtimeTargetKind?: InferSchema> | null; runtimeTargetEnvironmentId?: string | null; runtimeTargetBranchId?: string | null; @@ -201,6 +201,44 @@ export function validateRuntimeAgentTargetSelection( } } +/** + * Binds the selected source snapshot to the runtime target whose identifiers + * will be signed into the control-plane request. + */ +export function validateRuntimeAgentSourceTargetBinding( + input: RuntimeAgentTargetSelectionInput & { agentSource: RuntimeAgentSourceContext }, + ctx: RefinementCtx, +) { + const kind = input.runtimeTargetKind ?? "main_branch"; + const sourceType = input.agentSource.type; + + if (sourceType === "environment" && kind !== "environment") { + ctx.addIssue({ + code: "custom", + message: "environment agent source requires an environment runtime target", + path: ["agentSource", "type"], + }); + } else if (sourceType !== "environment" && kind === "environment") { + ctx.addIssue({ + code: "custom", + message: "environment runtime target requires an environment agent source", + path: ["agentSource", "type"], + }); + } + + if (sourceType === "release" && kind !== "main_branch") { + ctx.addIssue({ + code: "custom", + message: "release agent source requires a main-branch runtime target", + path: ["agentSource", "type"], + }); + } + + // A project's default branch is platform metadata, not a framework literal. + // Hosted runtimes compare branch sources with the trusted default branch + // supplied by the proxy after body-bound control-plane verification. +} + export const getRuntimeAgentProjectContextSchema = defineSchema((v) => v.object({ projectId: v.string().uuid(), @@ -332,6 +370,14 @@ export const getRuntimeAgentRunInvocationSchema = defineSchema((v) => path: ["agentConfig", "id"], }); } + + validateRuntimeAgentSourceTargetBinding( + { + ...input.run.project, + agentSource: input.agentSource, + }, + ctx, + ); }) ); @@ -380,6 +426,7 @@ export type RuntimeAgentControlPlaneStreamRequest = { messages: RuntimeAgentRunInvocation["messages"]; tools: RuntimeAgentRunInvocation["tools"]; context: RuntimeAgentRunInvocation["context"]; + runtimeTargetKind: NonNullable; runtimeTargetEnvironmentId?: RuntimeAgentProjectContext["runtimeTargetEnvironmentId"]; runtimeTargetBranchId?: RuntimeAgentProjectContext["runtimeTargetBranchId"]; credentials?: RuntimeAgentRunInvocation["credentials"]; @@ -400,9 +447,8 @@ export function buildRuntimeAgentControlPlaneStreamRequestFromInvocation( messages: input.messages, tools: input.tools, context: input.context, - ...(input.run.project.runtimeTargetEnvironmentId !== undefined - ? { runtimeTargetEnvironmentId: input.run.project.runtimeTargetEnvironmentId } - : {}), + runtimeTargetKind: input.run.project.runtimeTargetKind ?? "main_branch", + runtimeTargetEnvironmentId: input.run.project.runtimeTargetEnvironmentId ?? null, runtimeTargetBranchId: input.run.project.runtimeTargetBranchId ?? null, ...(input.credentials ? { credentials: input.credentials } : {}), agentSource: input.agentSource, diff --git a/src/cache/keys.test.ts b/src/cache/keys.test.ts index 7140b5df5e..e912659b4e 100644 --- a/src/cache/keys.test.ts +++ b/src/cache/keys.test.ts @@ -339,6 +339,36 @@ describe("cache/keys", () => { assertEquals(environment.includes("environment:Production:release-1"), true); assertEquals(release.includes("release:release-1"), true); }); + + it("separates canonical projects and credential principals", () => { + const first = buildProxyManagerCacheKey( + "reusable-slug", + false, + null, + "main", + null, + { projectId: "project-one", credentialPrincipal: "principal-one" }, + ); + const reassigned = buildProxyManagerCacheKey( + "reusable-slug", + false, + null, + "main", + null, + { projectId: "project-two", credentialPrincipal: "principal-one" }, + ); + const rotatedCredential = buildProxyManagerCacheKey( + "reusable-slug", + false, + null, + "main", + null, + { projectId: "project-one", credentialPrincipal: "principal-two" }, + ); + + assertNotEquals(first, reassigned); + assertNotEquals(first, rotatedCredential); + }); }); describe("computeContentSourceId", () => { diff --git a/src/cache/keys/builders/render.ts b/src/cache/keys/builders/render.ts index 4317c904a4..de22c2c753 100644 --- a/src/cache/keys/builders/render.ts +++ b/src/cache/keys/builders/render.ts @@ -14,6 +14,7 @@ import { sanitizeQueryParamsForCacheKey } from "../utils.ts"; import { CACHE_INVARIANT_VIOLATION } from "#veryfront/errors"; import { encodeCacheSourceIdentity } from "../source-identity.ts"; import { buildDependencyPinningCacheVariant } from "../dependency-pinning.ts"; +import { encodeCacheKeyLiteralSegment } from "../segment-codec.ts"; export function buildRenderCachePrefix( projectId: string, @@ -108,8 +109,21 @@ export function buildProxyManagerCacheKey( releaseId: string | null, branch: string | null, environmentName?: string | null, + authority?: { + projectId: string | null; + credentialPrincipal: string; + }, ): string { const mode = productionMode ? "production" : "preview"; + if (authority && !authority.credentialPrincipal) { + throw CACHE_INVARIANT_VIOLATION.create({ + detail: `Missing credential principal for proxy adapter ${projectSlug}`, + }); + } + const authorityKey = authority + ? `:project:${encodeCacheKeyLiteralSegment(authority.projectId ?? "")}` + + `:credential:${encodeCacheKeyLiteralSegment(authority.credentialPrincipal)}` + : ""; if (productionMode) { if (!releaseId) { @@ -120,11 +134,11 @@ export function buildProxyManagerCacheKey( const source = environmentName ? encodeCacheSourceIdentity({ type: "environment", environmentName, releaseId }) : encodeCacheSourceIdentity({ type: "release", releaseId }); - return `${CacheKeyPrefix.PROXY}:${projectSlug}:${mode}:${source.key}`; + return `${CacheKeyPrefix.PROXY}:${projectSlug}:${mode}:${source.key}${authorityKey}`; } const source = encodeCacheSourceIdentity({ type: "branch", branch: branch ?? "main" }); - return `${CacheKeyPrefix.PROXY}:${projectSlug}:${mode}:${source.qualifier}`; + return `${CacheKeyPrefix.PROXY}:${projectSlug}:${mode}:${source.qualifier}${authorityKey}`; } /** diff --git a/src/channels/control-plane.test.ts b/src/channels/control-plane.test.ts index ceea7284b8..267dfc047f 100644 --- a/src/channels/control-plane.test.ts +++ b/src/channels/control-plane.test.ts @@ -14,6 +14,7 @@ import { resolveAgentSkills, RuntimeAgentListResponseSchema, verifyControlPlaneJws, + verifyControlPlaneJwsRequestSignature, verifyControlPlaneJwsSignature, } from "./control-plane.ts"; @@ -404,6 +405,28 @@ describe("channels/control-plane", () => { false, ); }); + + it("binds the proxy-safe verifier to the exact request body", async () => { + const body = JSON.stringify({ runtimeTargetKind: "main_branch" }); + const { jws, publicKeyPem } = await createControlPlaneSignature(body); + const options = { + audience: "demo-project", + expectedProjectId: "proj-1", + publicKeyPem, + maxAgeSeconds: 60, + requestMethod: "POST", + requestPath: CONTROL_PLANE_AGENTS_LIST_PATH, + }; + + assertEquals( + await verifyControlPlaneJwsRequestSignature(jws, body, options), + true, + ); + assertEquals( + await verifyControlPlaneJwsRequestSignature(jws, `${body} `, options), + false, + ); + }); }); describe("listRuntimeAgents", () => { diff --git a/src/channels/control-plane.ts b/src/channels/control-plane.ts index 114dc21f48..0e54034637 100644 --- a/src/channels/control-plane.ts +++ b/src/channels/control-plane.ts @@ -748,6 +748,49 @@ export async function verifyControlPlaneJwsSignature( ); } +/** + * Verify a control-plane JWS against its request body without depending on the + * extension-backed schema registry. + * + * The split proxy uses this after it has resolved the project audience. It + * needs the signed body binding before it may turn target metadata in the body + * into trusted downstream headers, while the authoritative request handler + * still performs the full schema-backed verification. + */ +export async function verifyControlPlaneJwsRequestSignature( + jws: string, + body: string, + options: { + audience: string; + expectedProjectId?: string; + publicKeyPem: string; + maxAgeSeconds: number; + requestMethod: string; + requestPath: string; + }, +): Promise { + let requestBinding: { method: string; path: string }; + try { + requestBinding = readExpectedRequestBinding(options); + } catch { + return false; + } + + return await verifySignedRequestJwsSignature( + jws, + parseControlPlaneSignatureClaims, + { + audience: options.audience, + expectedProjectId: options.expectedProjectId, + maxAgeSeconds: options.maxAgeSeconds, + publicKeyPem: options.publicKeyPem, + requestBinding, + expectedRequestHash: await sha256Base64url(body), + requestHashClaimKey: "request_hash", + }, + ); +} + async function verifySignedRequestJwsSignature( jws: string, parseClaims: (encodedPayload: string) => SignedRequestClaims, @@ -756,6 +799,8 @@ async function verifySignedRequestJwsSignature( expectedProjectId?: string; publicKeyPem: string; maxAgeSeconds: number; + expectedRequestHash?: string; + requestHashClaimKey?: string; requestBinding?: { method: string; path: string; @@ -804,6 +849,15 @@ async function verifySignedRequestJwsSignature( options.requestBinding.path, ); } + if ( + options.expectedRequestHash !== undefined && + ( + options.requestHashClaimKey === undefined || + claims[options.requestHashClaimKey] !== options.expectedRequestHash + ) + ) { + return false; + } if ( !Number.isSafeInteger(claims.iat) || !Number.isSafeInteger(claims.exp) || diff --git a/src/extensions/bundler/bundler.ts b/src/extensions/bundler/bundler.ts index 5115994f69..527da761c3 100644 --- a/src/extensions/bundler/bundler.ts +++ b/src/extensions/bundler/bundler.ts @@ -98,6 +98,12 @@ export interface BundleOptions { logLevel?: "silent" | "error" | "warning" | "info" | "debug" | "verbose"; /** Emit a dependency-graph {@link Metafile} in the result. */ metafile?: boolean; + /** + * Cancels the bundle operation. Implementations must stop active work rather + * than only rejecting the caller while compilation continues in the + * background. + */ + signal?: AbortSignal; /** Extra implementation-specific options. */ [key: string]: unknown; @@ -261,6 +267,8 @@ export interface BundlerPlugin { export interface BuildContext { /** Re-run the build with cached state. */ rebuild(): Promise; + /** Cancel the active rebuild, when the implementation supports it. */ + cancel?(): Promise; /** Release context resources. */ dispose(): Promise; } diff --git a/src/internal-agents/schema.test.ts b/src/internal-agents/schema.test.ts index 68a237e46c..b3b707b98e 100644 --- a/src/internal-agents/schema.test.ts +++ b/src/internal-agents/schema.test.ts @@ -8,6 +8,17 @@ import { toRuntimeRunAgentInput, } from "./schema.ts"; +const MAIN_BRANCH_TARGET = { + runtimeTargetKind: "main_branch", + runtimeTargetEnvironmentId: null, + runtimeTargetBranchId: null, +} as const; +const ENVIRONMENT_TARGET = { + runtimeTargetKind: "environment", + runtimeTargetEnvironmentId: "10000000-1000-4000-8000-100000000009", + runtimeTargetBranchId: null, +} as const; + describe("internal-agents/schema", () => { it("applies defaults for optional runtime collections", () => { const parsed = getRuntimeRunAgentInputSchema().parse({ @@ -87,6 +98,7 @@ describe("internal-agents/schema", () => { agentId: "agent_1", threadId: "10000000-1000-4000-8000-100000000001", runId: "run_1", + ...MAIN_BRANCH_TARGET, agentSource: { type: "branch", branch: "main" }, messages: [], forwardedProps, @@ -101,7 +113,12 @@ describe("internal-agents/schema", () => { agentId: "agent_1", threadId: "10000000-1000-4000-8000-100000000001", runId: "run_1", - agentSource: { type: "branch", branch: "main" }, + runtimeTargetKind: "environment", + agentSource: { + type: "environment", + environmentName: "staging", + releaseId: "release_1", + }, messages: [], runtimeTargetEnvironmentId, runtimeTargetBranchId: null, @@ -118,6 +135,7 @@ describe("internal-agents/schema", () => { agentId: "agent_1", threadId: "10000000-1000-4000-8000-100000000001", runId: "run_1", + ...MAIN_BRANCH_TARGET, agentSource: { type: "branch", branch: "main" }, messages: [], forwardedProps: { @@ -143,6 +161,7 @@ describe("internal-agents/schema", () => { agentId: "agent_1", threadId: "10000000-1000-4000-8000-100000000001", runId: "run_1", + ...MAIN_BRANCH_TARGET, agentSource: { type: "branch", branch: "main" }, messages: [], forwardedProps: { maxOutputTokens }, @@ -160,6 +179,7 @@ describe("internal-agents/schema", () => { agentId: "agent_1", threadId: "10000000-1000-4000-8000-100000000001", runId: "run_1", + ...MAIN_BRANCH_TARGET, agentSource: { type: "branch", branch: "main" }, messages: [], tools: [ @@ -194,6 +214,7 @@ describe("internal-agents/schema", () => { agentId: "agent_1", threadId: "10000000-1000-4000-8000-100000000001", runId: "run_1", + ...MAIN_BRANCH_TARGET, messages: [], }) ); @@ -202,6 +223,7 @@ describe("internal-agents/schema", () => { agentId: "agent_1", threadId: "10000000-1000-4000-8000-100000000001", runId: "run_1", + ...ENVIRONMENT_TARGET, messages: [], agentSource: { type: "environment", @@ -215,12 +237,14 @@ describe("internal-agents/schema", () => { environmentName: "staging", releaseId: "release_1", }); + assertEquals(parsed.runtimeTargetEnvironmentId, ENVIRONMENT_TARGET.runtimeTargetEnvironmentId); assertThrows( () => getInternalAgentStreamRequestSchema().parse({ agentId: "agent_1", threadId: "10000000-1000-4000-8000-100000000001", runId: "run_1", + ...MAIN_BRANCH_TARGET, messages: [], agentSource: { type: "branch", branch: "" }, }), @@ -229,6 +253,22 @@ describe("internal-agents/schema", () => { ); }); + it("rejects a signed target whose kind does not match the exact source", () => { + assertThrows( + () => + getInternalAgentStreamRequestSchema().parse({ + agentId: "agent_1", + threadId: "10000000-1000-4000-8000-100000000001", + runId: "run_1", + ...ENVIRONMENT_TARGET, + agentSource: { type: "branch", branch: "main" }, + messages: [], + }), + Error, + "environment runtime target requires an environment agent source", + ); + }); + it("accepts a canonical AG-UI-aligned runtime payload", () => { const parsed = getRuntimeRunAgentInputSchema().parse({ threadId: crypto.randomUUID(), @@ -341,6 +381,7 @@ describe("internal-agents/schema", () => { agentId: "agent_1", threadId: "10000000-1000-4000-8000-100000000001", runId: "run_1", + ...MAIN_BRANCH_TARGET, agentSource: { type: "branch", branch: "main" }, messages: [], agentConfig: { @@ -360,6 +401,7 @@ describe("internal-agents/schema", () => { agentId: "agent_1", threadId: "10000000-1000-4000-8000-100000000001", runId: "run_1", + ...MAIN_BRANCH_TARGET, agentSource: { type: "branch", branch: "main" }, messages: [ { @@ -417,6 +459,7 @@ describe("internal-agents/schema", () => { agentId: "agent_1", threadId: "10000000-1000-4000-8000-100000000001", runId: "run_1", + ...MAIN_BRANCH_TARGET, agentSource: { type: "branch", branch: "main" }, messages: [ { @@ -462,6 +505,7 @@ describe("internal-agents/schema", () => { agentId: "agent_1", threadId: "10000000-1000-4000-8000-100000000001", runId: "run_1", + ...MAIN_BRANCH_TARGET, agentSource: { type: "branch", branch: "main" }, endUserId: "10000000-1000-4000-8000-100000000004", messages: [], @@ -508,6 +552,7 @@ describe("internal-agents/schema", () => { agentId: "agent_1", threadId: "10000000-1000-4000-8000-100000000001", runId: "run_1", + ...MAIN_BRANCH_TARGET, agentSource: { type: "branch", branch: "main" }, messages: [ { @@ -556,6 +601,7 @@ describe("internal-agents/schema", () => { agentId: "agent_1", threadId: "10000000-1000-4000-8000-100000000001", runId: "run_1", + ...MAIN_BRANCH_TARGET, agentSource: { type: "branch", branch: "main" }, messages: [ { @@ -603,6 +649,7 @@ describe("internal-agents/schema", () => { agentId: "agent_1", threadId: "10000000-1000-4000-8000-100000000001", runId: "run_1", + ...MAIN_BRANCH_TARGET, agentSource: { type: "branch", branch: "main" }, messages: [ { @@ -653,6 +700,7 @@ describe("internal-agents/schema", () => { agentId: "agent_1", threadId: "10000000-1000-4000-8000-100000000001", runId: "run_1", + ...MAIN_BRANCH_TARGET, agentSource: { type: "branch", branch: "main" }, messages: [ { @@ -697,6 +745,7 @@ describe("internal-agents/schema", () => { agentId: "agent_1", threadId: "10000000-1000-4000-8000-100000000001", runId: "run_1", + ...MAIN_BRANCH_TARGET, agentSource: { type: "branch", branch: "main" }, messages: [ { @@ -753,6 +802,7 @@ describe("internal-agents/schema", () => { agentId: "agent_1", threadId: "10000000-1000-4000-8000-100000000001", runId: "run_1", + ...MAIN_BRANCH_TARGET, agentSource: { type: "branch", branch: "main" }, messages: [ { diff --git a/src/internal-agents/schema.ts b/src/internal-agents/schema.ts index efb21ab18c..a5dbe93410 100644 --- a/src/internal-agents/schema.ts +++ b/src/internal-agents/schema.ts @@ -17,7 +17,10 @@ import { getRuntimeAgentMarkdownDefinitionSchema } from "#veryfront/agent/runtim import { getRuntimeAgentCredentialsSchema, getRuntimeAgentSourceContextSchema, + getRuntimeAgentTargetKindSchema, type RuntimeAgentSourceContext, + validateRuntimeAgentSourceTargetBinding, + validateRuntimeAgentTargetSelection, } from "#veryfront/agent/runtime/agent-invocation-contract.ts"; const AGENT_ID_PATTERN = /^[a-zA-Z0-9_-]+$/; @@ -73,6 +76,7 @@ export const getInternalAgentControlPlaneStreamRequestSchema = defineSchema((v) (value) => isWithinJsonSizeLimit(value, 65_536), { message: "context must be less than 64 KB total" }, ), + runtimeTargetKind: getRuntimeAgentTargetKindSchema(), runtimeTargetEnvironmentId: v.string().uuid().nullable().optional(), runtimeTargetBranchId: v.string().uuid().nullable().optional(), agentSource: getRuntimeAgentSourceContextSchema(), @@ -86,6 +90,9 @@ export const getInternalAgentControlPlaneStreamRequestSchema = defineSchema((v) { message: "forwardedProps must be less than 192 KB" }, ), }).strict().superRefine((input, ctx) => { + validateRuntimeAgentTargetSelection(input, ctx); + validateRuntimeAgentSourceTargetBinding(input, ctx); + if (input.agentConfig && input.agentConfig.id !== input.agentId) { ctx.addIssue({ code: "custom", diff --git a/src/modules/README.md b/src/modules/README.md index bd92f3c815..c6c7b63461 100644 --- a/src/modules/README.md +++ b/src/modules/README.md @@ -172,6 +172,13 @@ then enforces the framework React mappings. - Hosted module requests should carry the request-bound import-map identity. Cache entries are scoped by the identities that can change transformed output. +- Request-triggered browser graphs require either a root-bound stable snapshot + reader or an own `symlinkSemantics: "none"` declaration paired with a genuine + exact bounded byte reader. Browser compilation fails closed when an adapter + cannot provide either authority; raw text reads are never a fallback. +- Browser graph compilation has fixed per-project and isolate-wide admission + ceilings, bounded queues, dependency/probe/input/output limits, and a request + deadline. Operator overrides may only tighten resource and duration limits. - Component, manifest, lookup, response, and transform caches are bounded. Use the exported project-specific invalidation functions when project content changes. diff --git a/src/modules/server/browser-module-admission.test.ts b/src/modules/server/browser-module-admission.test.ts new file mode 100644 index 0000000000..2894df1f94 --- /dev/null +++ b/src/modules/server/browser-module-admission.test.ts @@ -0,0 +1,134 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { + classifyBrowserModuleAbsoluteSourcePath, + isProtectedBrowserModulePath, +} from "./browser-module-admission.ts"; + +describe("browser module admission", () => { + it("protects project metadata and environment files", () => { + for ( + const path of [ + "veryfront.config.js", + "deno.json", + "deno.json.js", + "package.json", + "package.json.js", + "veryfront.lock", + ".env.production", + ] + ) { + assertEquals(isProtectedBrowserModulePath(path), true, path); + } + }); + + it("protects default and configured server roots", () => { + assertEquals(isProtectedBrowserModulePath("app/actions/update.ts"), true); + assertEquals(isProtectedBrowserModulePath("app/api/users/route.ts"), true); + assertEquals(isProtectedBrowserModulePath("pages/api/users.ts"), true); + assertEquals( + isProtectedBrowserModulePath("source/server/actions/update.ts", { + directories: { app: "source/server", pages: "source/pages" }, + }), + true, + ); + assertEquals( + isProtectedBrowserModulePath("source/pages/api/users.ts", { + directories: { app: "source/server", pages: "source/pages" }, + }), + true, + ); + assertEquals( + isProtectedBrowserModulePath("actions/update.ts", { + directories: { app: ".", pages: "source/pages" }, + }), + true, + ); + assertEquals( + isProtectedBrowserModulePath("server/actions/update.ts", { + directories: { app: "source/../server" }, + }), + true, + ); + }); + + it("protects every framework discovery root and configured replacement", () => { + for ( + const root of [ + "tools", + "agents", + "skills", + "resources", + "prompts", + "workflows", + "tasks", + "schedules", + "webhooks", + "evals", + ] + ) { + assertEquals(isProtectedBrowserModulePath(`${root}/private.ts`), true, root); + } + + assertEquals( + isProtectedBrowserModulePath("source/private-tools/private.ts", { + ai: { + tools: { + discovery: { paths: ["source/./internal/../private-tools"] }, + }, + }, + }), + true, + ); + }); + + it("protects app route handlers and root middleware", () => { + for ( + const path of [ + "app/route.ts", + "app/account/route.tsx", + "source/server/account/route.js", + "middleware.ts", + "middleware.js", + "middleware.mjs", + ] + ) { + assertEquals( + isProtectedBrowserModulePath( + path, + path.startsWith("source/") ? { directories: { app: "source/server" } } : undefined, + ), + true, + path, + ); + } + }); + + it("keeps ordinary browser candidates eligible for deeper checks", () => { + assertEquals(isProtectedBrowserModulePath("app/page.tsx"), false); + assertEquals(isProtectedBrowserModulePath("components/Button.tsx"), false); + assertEquals(isProtectedBrowserModulePath("src/client.ts"), false); + }); + + it("applies the canonical policy to resolved absolute project paths", () => { + assertEquals( + classifyBrowserModuleAbsoluteSourcePath( + "/tenant/project/app/actions/private.ts", + "/tenant/project", + ), + { + canonicalPath: "app/actions/private.ts", + protectionReason: "server-route", + requiresClientBoundary: false, + }, + ); + assertEquals( + classifyBrowserModuleAbsoluteSourcePath( + "/tenant/other/private.ts", + "/tenant/project", + ).protectionReason, + "invalid-path", + ); + }); +}); diff --git a/src/modules/server/browser-module-admission.ts b/src/modules/server/browser-module-admission.ts new file mode 100644 index 0000000000..40692a4c71 --- /dev/null +++ b/src/modules/server/browser-module-admission.ts @@ -0,0 +1,214 @@ +import type { VeryfrontConfig } from "#veryfront/config"; +import { isAbsolute, relative } from "#veryfront/compat/path/index.ts"; +import { + createProjectDiscoveryConfig, + DEFAULT_PROJECT_DISCOVERY_DIRS, +} from "#veryfront/discovery/project-discovery-config.ts"; + +const PROJECT_METADATA_FILE = + /^(?:veryfront\.config\.(?:ts|js|mjs)|deno\.jsonc?|import_map\.json|package\.json|package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?|veryfront\.lock|tsconfig(?:\.[a-z0-9_-]+)?\.json|\.env(?:\..+)?)$/i; +const APP_ROUTE_MODULE = /(?:^|\/)route\.(?:tsx?|jsx?)$/i; +const ROOT_MIDDLEWARE_MODULE = /^middleware\.(?:ts|js|mjs)$/i; +const WINDOWS_ABSOLUTE_PATH = /^[a-z]:\//i; + +export type BrowserModuleProtectionReason = + | "invalid-path" + | "invalid-configured-root" + | "metadata" + | "hidden-path" + | "middleware" + | "discovery" + | "server-route"; + +export interface BrowserModuleSourcePolicy { + canonicalPath: string | null; + protectionReason: BrowserModuleProtectionReason | null; + requiresClientBoundary: boolean; +} + +export interface BrowserModuleSourcePolicyOptions { + config?: VeryfrontConfig; + rscEnabled?: boolean; +} + +/** + * Classify an adapter-resolved absolute source path with the same canonical + * browser policy used for logical module requests. Containment is established + * before the relative path reaches the policy grammar, so aliases and resolved + * dependencies cannot bypass protected project roots. + */ +export function classifyBrowserModuleAbsoluteSourcePath( + sourcePath: string, + projectDir: string, + options: BrowserModuleSourcePolicyOptions = {}, +): BrowserModuleSourcePolicy { + const relativePath = relative(projectDir, sourcePath).replaceAll("\\", "/"); + if ( + relativePath.length === 0 || + relativePath === "." || + relativePath === ".." || + relativePath.startsWith("../") || + isAbsolute(relativePath) + ) { + return { + canonicalPath: null, + protectionReason: "invalid-path", + requiresClientBoundary: false, + }; + } + return classifyBrowserModuleSourcePath(relativePath, options); +} + +function containsControlCharacter(value: string): boolean { + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index); + if (code <= 0x1f || code === 0x7f) return true; + } + return false; +} + +function normalizeRelativePath(value: string, allowLeadingSlash: boolean): string | null { + if (containsControlCharacter(value)) return null; + + const separated = value.replaceAll("\\", "/"); + if ( + (!allowLeadingSlash && separated.startsWith("/")) || + WINDOWS_ABSOLUTE_PATH.test(separated) + ) { + return null; + } + + const parts: string[] = []; + for (const part of separated.replace(/^\/+/, "").split("/")) { + if (!part || part === ".") continue; + if (part === "..") { + if (parts.length === 0) return null; + parts.pop(); + continue; + } + parts.push(part); + } + return parts.join("/"); +} + +function configuredRoot(value: unknown, fallback: string): string | null { + if (value === undefined) return fallback; + if (typeof value !== "string") return null; + return normalizeRelativePath(value, false); +} + +function relativeToRoot(path: string, root: string): string | null { + if (root === "") return path; + if (path === root) return ""; + const prefix = `${root}/`; + return path.startsWith(prefix) ? path.slice(prefix.length) : null; +} + +function belowRoot(root: string, child: string): string { + return root ? `${root}/${child}` : child; +} + +function isAtOrBelow(path: string, root: string): boolean { + return relativeToRoot(path, root) !== null; +} + +function discoveryRoots(config: VeryfrontConfig | undefined): string[] | null { + const resolved = createProjectDiscoveryConfig({ projectDir: "", config }); + const roots = [ + ...Object.values(DEFAULT_PROJECT_DISCOVERY_DIRS).flat(), + ...resolved.toolDirs, + ...resolved.agentDirs, + ...resolved.skillDirs, + ...resolved.resourceDirs, + ...resolved.promptDirs, + ...resolved.workflowDirs, + ...resolved.taskDirs, + ...resolved.scheduleDirs, + ...resolved.webhookDirs, + ...resolved.evalDirs, + ]; + + const canonicalRoots = new Set(); + for (const root of roots) { + const canonical = configuredRoot(root, ""); + if (canonical === null) return null; + canonicalRoots.add(canonical); + } + return [...canonicalRoots]; +} + +/** + * Classify one project-relative source path for the public browser module + * transport. The same policy is applied before remote fetches and again after + * local resolution so aliases cannot bypass a server-source boundary. + */ +export function classifyBrowserModuleSourcePath( + logicalPath: string, + options: BrowserModuleSourcePolicyOptions = {}, +): BrowserModuleSourcePolicy { + const path = normalizeRelativePath(logicalPath.replace(/[?#].*$/, ""), true); + if (path === null || path === "") { + return { + canonicalPath: path, + protectionReason: "invalid-path", + requiresClientBoundary: false, + }; + } + + const appRoot = configuredRoot(options.config?.directories?.app, "app"); + const pagesRoot = configuredRoot(options.config?.directories?.pages, "pages"); + const primitiveRoots = discoveryRoots(options.config); + if (appRoot === null || pagesRoot === null || primitiveRoots === null) { + return { + canonicalPath: path, + protectionReason: "invalid-configured-root", + requiresClientBoundary: false, + }; + } + + const parts = path.split("/"); + const metadataCandidate = parts.length === 1 ? parts[0]!.replace(/\.(?:mjs|js)$/i, "") : ""; + if ( + parts.length === 1 && + (PROJECT_METADATA_FILE.test(parts[0]!) || PROJECT_METADATA_FILE.test(metadataCandidate)) + ) { + return { canonicalPath: path, protectionReason: "metadata", requiresClientBoundary: false }; + } + if (parts.some((part) => part.startsWith("."))) { + return { canonicalPath: path, protectionReason: "hidden-path", requiresClientBoundary: false }; + } + if (ROOT_MIDDLEWARE_MODULE.test(path)) { + return { canonicalPath: path, protectionReason: "middleware", requiresClientBoundary: false }; + } + if (primitiveRoots.some((root) => isAtOrBelow(path, root))) { + return { canonicalPath: path, protectionReason: "discovery", requiresClientBoundary: false }; + } + + const appRelative = relativeToRoot(path, appRoot); + const protectedServerRoute = isAtOrBelow(path, belowRoot(appRoot, "actions")) || + isAtOrBelow(path, belowRoot(appRoot, "api")) || + isAtOrBelow(path, belowRoot(pagesRoot, "api")) || + isAtOrBelow(path, "api") || + (appRelative !== null && APP_ROUTE_MODULE.test(appRelative)); + if (protectedServerRoute) { + return { + canonicalPath: path, + protectionReason: "server-route", + requiresClientBoundary: false, + }; + } + + return { + canonicalPath: path, + protectionReason: null, + requiresClientBoundary: options.rscEnabled === true && appRelative !== null, + }; +} + +/** Framework-owned project surfaces that are never browser module entrypoints. */ +export function isProtectedBrowserModulePath( + logicalPath: string, + config?: VeryfrontConfig, +): boolean { + return classifyBrowserModuleSourcePath(logicalPath, { config }).protectionReason !== null; +} diff --git a/src/modules/server/classify.test.ts b/src/modules/server/classify.test.ts index e88abab192..2c85dc184d 100644 --- a/src/modules/server/classify.test.ts +++ b/src/modules/server/classify.test.ts @@ -147,6 +147,29 @@ describe("classifyModuleRequest", () => { }); } }); + + it("rejects percent-encoded cross-project paths as ambiguous", () => { + for ( + const path of [ + "app%2factions%2fsecret.js", + "app%2Factions%2Fsecret.js", + "app%5cactions%5csecret.js", + "app%5Cactions%5Csecret.js", + "app%252factions%252fsecret.js", + "app%252Factions%252Fsecret.js", + "app%255cactions%255csecret.js", + "app%255Cactions%255Csecret.js", + "components/encoded%20name.js", + "components/incomplete%.js", + ] + ) { + assertEquals( + classifyModuleRequest(url(`/_vf_modules/_cross/demo/@/${path}`)), + { kind: "invalid-module", namespace: "cross-project" }, + path, + ); + } + }); }); describe("dev-module", () => { diff --git a/src/modules/server/classify.ts b/src/modules/server/classify.ts index a66009b2ba..11f4152a4b 100644 --- a/src/modules/server/classify.ts +++ b/src/modules/server/classify.ts @@ -23,6 +23,15 @@ function isInNamespace(pathname: string, namespace: string): boolean { return pathname === namespace || pathname.startsWith(`${namespace}/`); } +/** + * Cross-project source keys use literal URL path segments. The registry route + * does not define a percent-decoding contract, so forwarding a percent sign + * would make admission depend on how many times an intermediary decodes it. + */ +function hasAmbiguousCrossProjectPath(path: string): boolean { + return path.includes("%"); +} + /** URL does not start with any module prefix — not a module request. */ export interface NotModuleKind { kind: "not-module"; @@ -94,20 +103,28 @@ export function classifyModuleRequest(url: URL): ModuleRequestKind { const versionedMatch = url.pathname.match(CROSS_PROJECT_VERSIONED_PREFIX); if (versionedMatch) { + const path = versionedMatch[3] ?? ""; + if (hasAmbiguousCrossProjectPath(path)) { + return { kind: "invalid-module", namespace: "cross-project" }; + } return { kind: "cross-project-versioned", slug: versionedMatch[1] ?? "", version: versionedMatch[2] ?? "", - path: versionedMatch[3] ?? "", + path, }; } const latestMatch = url.pathname.match(CROSS_PROJECT_LATEST_PREFIX); if (latestMatch) { + const path = latestMatch[2] ?? ""; + if (hasAmbiguousCrossProjectPath(path)) { + return { kind: "invalid-module", namespace: "cross-project" }; + } return { kind: "cross-project-latest", slug: latestMatch[1] ?? "", - path: latestMatch[2] ?? "", + path, }; } diff --git a/src/modules/server/module-server.test.ts b/src/modules/server/module-server.test.ts index 7eaa8bedb2..6da456268b 100644 --- a/src/modules/server/module-server.test.ts +++ b/src/modules/server/module-server.test.ts @@ -42,6 +42,10 @@ import { getDependencyPinningSnapshot, } from "#veryfront/transforms/esm/package-registry.ts"; import { buildImportMapJson, clearImportMapCache } from "../../html/utils.ts"; +import { hashString } from "#veryfront/cache/hash.ts"; +import { register, tryResolve, unregister } from "#veryfront/extensions/contracts.ts"; +import type { Bundler } from "#veryfront/extensions/bundler/bundler.ts"; +import { bundleBrowserModuleWithMetadata } from "#veryfront/server/shared/browser-module-bundler.ts"; describe("isModuleRequest", () => { it("should return true for /_vf_modules/ path", () => { @@ -105,6 +109,8 @@ describe({ name: "serveModule", sanitizeResources: false, sanitizeOps: false }, projectId: "test", projectDir, adapter: denoAdapter, + isLocalProject: true, + allowSSRModuleMode: true, }); } @@ -132,6 +138,7 @@ describe({ name: "serveModule", sanitizeResources: false, sanitizeOps: false }, dependencies: ReleaseAssetManifest["dependencies"], releaseId = "release-id", dependencyMode: ReleaseAssetManifest["dependencyMode"] = "immutable", + modules: ReleaseAssetManifest["modules"] = {}, ): ReleaseAssetManifest { return { schemaVersion: RELEASE_ASSET_MANIFEST_SCHEMA_VERSION, @@ -143,7 +150,7 @@ describe({ name: "serveModule", sanitizeResources: false, sanitizeOps: false }, sourceContentHash: "a".repeat(64), createdAt: new Date(0).toISOString(), assetBasePath: "/_vf/assets", - modules: {}, + modules, css: [], routes: {}, dependencyMode, @@ -263,6 +270,1208 @@ describe({ name: "serveModule", sanitizeResources: false, sanitizeOps: false }, assertStringIncludes(await developmentResponse.text(), secret); }); + it("does not expose project metadata or server route roots as browser modules", async () => { + const projectDir = await Deno.makeTempDir({ prefix: "vf-browser-module-private-" }); + + try { + await Deno.mkdir(`${projectDir}/app/actions`, { recursive: true }); + await Deno.writeTextFile( + `${projectDir}/veryfront.config.ts`, + `export default { secret: "not-browser-data" };`, + ); + await Deno.writeTextFile(`${projectDir}/package.json`, `{"secret":"not-browser-data"}`); + await Deno.writeTextFile( + `${projectDir}/app/actions/save.ts`, + `export const save = () => "not-browser-code";`, + ); + + for (const path of ["veryfront.config.js", "package.json.js", "app/actions/save.js"]) { + const response = await serve( + new Request(`http://localhost:3000/_vf_modules/${path}`), + projectDir, + ); + assertEquals(response.status, 404); + assertEquals((await response.text()).includes("not-browser"), false); + } + + const headResponse = await serve( + new Request("http://localhost:3000/_vf_modules/app/actions/save.js", { method: "HEAD" }), + projectDir, + ); + assertEquals(headResponse.status, 404); + assertEquals(await headResponse.text(), ""); + + const claimedSsrResponse = await serve( + new Request("http://localhost:3000/_vf_modules/package.json.js?ssr=true"), + projectDir, + ); + assertEquals(claimedSsrResponse.status, 404); + } finally { + await Deno.remove(projectDir, { recursive: true }); + } + }); + + it("only serves module endpoints over GET and HEAD", async () => { + for (const prefix of ["/_vf_modules", "/_veryfront/modules"]) { + for (const method of ["POST", "PUT", "PATCH", "DELETE"]) { + const response = await serve( + new Request(`http://localhost:3000${prefix}/app/actions/save.js`, { method }), + ); + assertEquals(response.status, 405, `${method} ${prefix}`); + assertEquals(response.headers.get("allow"), "GET, HEAD", `${method} ${prefix}`); + } + } + }); + + it("enforces server-source policy on exact resolved aliases in preview and standalone modes", async () => { + const projectDir = await Deno.makeTempDir({ prefix: "vf-browser-module-resolved-" }); + + try { + await Deno.mkdir(`${projectDir}/app/actions`, { recursive: true }); + await Deno.writeTextFile( + `${projectDir}/app/actions/private.ts`, + `export const marker = "resolved-server-source";`, + ); + const { serveModule } = await import("./module-server.ts"); + + for (const prefix of ["/_vf_modules", "/_veryfront/modules"]) { + for ( + const runtime of [ + { isLocalProject: false, isProxyMode: true, mode: "preview" }, + { isLocalProject: true, isProxyMode: false, mode: "production" }, + ] + ) { + const response = await serveModule( + new Request(`http://localhost:3000${prefix}/actions/private.js`), + { + projectId: "test", + projectDir, + adapter: denoAdapter, + ...runtime, + }, + ); + assertEquals(response.status, 404, `${prefix} ${JSON.stringify(runtime)}`); + assertEquals((await response.text()).includes("resolved-server-source"), false); + } + } + } finally { + await Deno.remove(projectDir, { recursive: true }); + } + }); + + it("normalizes configured roots and protects discovery, routes, and middleware", async () => { + const projectDir = await Deno.makeTempDir({ prefix: "vf-browser-module-policy-" }); + + try { + const protectedFiles = [ + "server/actions/private.ts", + "server/account/route.ts", + "middleware.ts", + "tools/private.ts", + "agents/private.ts", + "skills/private.ts", + "resources/private.ts", + "prompts/private.ts", + "workflows/private.ts", + "tasks/private.ts", + "schedules/private.ts", + "webhooks/private.ts", + "evals/private.ts", + "source/private-tools/private.ts", + ]; + for (const path of protectedFiles) { + await Deno.mkdir(`${projectDir}/${path.slice(0, path.lastIndexOf("/")) || "."}`, { + recursive: true, + }); + await Deno.writeTextFile( + `${projectDir}/${path}`, + `export const marker = ${JSON.stringify(path)};`, + ); + } + + const config = { + directories: { app: "source/../server" }, + ai: { + tools: { + discovery: { paths: ["source/./internal/../private-tools"] }, + }, + }, + }; + const { serveModule } = await import("./module-server.ts"); + + for (const prefix of ["/_vf_modules", "/_veryfront/modules"]) { + for (const path of protectedFiles) { + const response = await serveModule( + new Request(`http://localhost:3000${prefix}/${path.replace(/\.ts$/, ".js")}`), + { + projectId: "test", + projectDir, + adapter: denoAdapter, + isLocalProject: false, + isProxyMode: true, + mode: "preview", + config, + }, + ); + assertEquals(response.status, 404, `${prefix}/${path}`); + assertEquals((await response.text()).includes(path), false, `${prefix}/${path}`); + } + } + } finally { + await Deno.remove(projectDir, { recursive: true }); + } + }); + + it("requires an explicit client boundary for RSC app modules", async () => { + const projectDir = await Deno.makeTempDir({ prefix: "vf-browser-module-rsc-" }); + + try { + await Deno.mkdir(`${projectDir}/app`, { recursive: true }); + for (const name of ["page", "layout", "template", "error", "loading", "not-found"]) { + await Deno.writeTextFile( + `${projectDir}/app/${name}.tsx`, + `export const marker = "server-${name}"; export default function View() { return null; }`, + ); + } + await Deno.writeTextFile( + `${projectDir}/app/client.tsx`, + [ + `"use client";`, + `import { helper } from "./helper.ts";`, + `export function client() { return "browser-client:" + helper; }`, + ].join("\n"), + ); + await Deno.writeTextFile( + `${projectDir}/app/helper.ts`, + `export const helper = "transitive-client-helper";`, + ); + await Deno.writeTextFile( + `${projectDir}/app/server-helper.ts`, + `"use server"; export const secret = "server-only-helper";`, + ); + await Deno.writeTextFile( + `${projectDir}/app/leaky-client.tsx`, + [ + `"use client";`, + `import { secret } from "./server-helper.ts";`, + `export const marker = secret;`, + ].join("\n"), + ); + await Deno.writeTextFile( + `${projectDir}/app/action-client.tsx`, + [ + `"use client";`, + `export async function save() { "use server"; return "entry-server-action"; }`, + ].join("\n"), + ); + const { serveModule } = await import("./module-server.ts"); + const options = { + projectId: "test", + projectDir, + adapter: denoAdapter, + isLocalProject: false, + isProxyMode: true, + mode: "preview", + config: { experimental: { rsc: true } }, + } as const; + + for (const prefix of ["/_vf_modules", "/_veryfront/modules"]) { + for (const name of ["page", "layout", "template", "error", "loading", "not-found"]) { + const response = await serveModule( + new Request(`http://localhost:3000${prefix}/app/${name}.js`), + options, + ); + assertEquals(response.status, 404, `${prefix}/app/${name}.js`); + assertEquals((await response.text()).includes(`server-${name}`), false); + } + + const clientGet = await serveModule( + new Request(`http://localhost:3000${prefix}/app/client.js`), + options, + ); + assertEquals(clientGet.status, 200, `${prefix} client GET`); + const clientCode = await clientGet.text(); + assertStringIncludes(clientCode, "browser-client"); + assertStringIncludes(clientCode, "transitive-client-helper"); + assertStringIncludes(clientCode, "client as default"); + + const clientHead = await serveModule( + new Request(`http://localhost:3000${prefix}/app/client.js`, { method: "HEAD" }), + options, + ); + assertEquals(clientHead.status, 200, `${prefix} client HEAD`); + assertEquals(await clientHead.text(), ""); + + const helperEntry = await serveModule( + new Request(`http://localhost:3000${prefix}/app/helper.js`), + options, + ); + assertEquals(helperEntry.status, 404, `${prefix} helper entry`); + + const actionEntry = await serveModule( + new Request(`http://localhost:3000${prefix}/app/action-client.js`), + options, + ); + assertEquals(actionEntry.status, 404, `${prefix} server-directed client entry`); + assertEquals((await actionEntry.text()).includes("entry-server-action"), false); + + const leakyBoundary = await serveModule( + new Request(`http://localhost:3000${prefix}/app/leaky-client.js`), + options, + ); + assertEquals(leakyBoundary.status, 500, `${prefix} server-only dependency`); + assertEquals((await leakyBoundary.text()).includes("server-only-helper"), false); + } + } finally { + await Deno.remove(projectDir, { recursive: true }); + } + }); + + it("rejects server-directed source from browser module requests", async () => { + const projectDir = await Deno.makeTempDir({ prefix: "vf-browser-module-boundary-" }); + + try { + await Deno.mkdir(`${projectDir}/components`, { recursive: true }); + await Deno.writeTextFile( + `${projectDir}/components/server.ts`, + `"use server"; export const secret = "private";`, + ); + await Deno.writeTextFile( + `${projectDir}/components/function-action.ts`, + `export async function save() { "use server"; return "private"; }`, + ); + + for (const path of ["components/server.js", "components/function-action.js"]) { + const response = await serve( + new Request(`http://localhost:3000/_vf_modules/${path}`), + projectDir, + ); + assertEquals(response.status, 404); + assertEquals((await response.text()).includes("private"), false); + } + + const ssrResponse = await serve( + new Request("http://localhost:3000/_vf_modules/components/server.js?ssr=true"), + projectDir, + ); + assertEquals(ssrResponse.status, 200); + assertStringIncludes(await ssrResponse.text(), "secret"); + } finally { + await Deno.remove(projectDir, { recursive: true }); + } + }); + + it("bounds request-triggered RSC client graphs for GET and HEAD", async () => { + const projectDir = "/bounded-rsc-project"; + const adapter = createMockAdapter(); + const imports: string[] = ['"use client";']; + for (let index = 0; index < 4; index++) { + imports.push(`import "./dependency-${index}.ts";`); + adapter.fs.files.set( + `${projectDir}/app/dependency-${index}.ts`, + `export const value${index} = ${index};`, + ); + } + imports.push("export default function Client() { return null; }"); + adapter.fs.files.set(`${projectDir}/app/client.tsx`, imports.join("\n")); + + const { serveModule } = await import("./module-server.ts"); + const options = { + projectId: "bounded-rsc-project", + projectDir, + adapter, + dev: false, + isLocalProject: false, + isProxyMode: true, + mode: "preview", + config: { experimental: { rsc: true } }, + browserModuleBundleLimits: { maxDependencies: 3 }, + } as const; + const url = "http://localhost:3000/_vf_modules/app/client.js"; + const [getResponse, headResponse] = await Promise.all([ + serveModule(new Request(url), options), + serveModule(new Request(url, { method: "HEAD" }), options), + ]); + + assertEquals(getResponse.status, 413); + assertEquals(headResponse.status, 413); + assertEquals((await getResponse.text()).includes("value3"), false); + assertEquals(await headResponse.text(), ""); + }); + + it("defers client-boundary dependency metadata until browser admission", async () => { + const projectDir = "/module-server-snapshot-admission"; + const packagePath = `${projectDir}/package.json`; + const clientPath = `${projectDir}/app/client.tsx`; + const dependencies = { react: "19.2.4" }; + const requestedCacheKey = `on:${hashString(JSON.stringify(Object.entries(dependencies)))}`; + const adapter = createMockAdapter(); + adapter.fs.files.set(clientPath, '"use client"; export default function Client() {}'); + adapter.fs.files.set(packagePath, JSON.stringify({ dependencies })); + const occupyingPaths = [0, 1].map( + (index) => `${projectDir}/app/occupying-${index}.ts`, + ); + for (const path of occupyingPaths) adapter.fs.files.set(path, "export default 1;"); + const snapshotRead = adapter.fs.readFileSnapshotWithinLimit!; + const stat = adapter.fs.stat; + let packageReads = 0; + let packageStats = 0; + adapter.fs.readFileSnapshotWithinLimit = (path, root, limit) => { + if (path === packagePath) packageReads += 1; + return snapshotRead(path, root, limit); + }; + adapter.fs.stat = (path) => { + if (path === packagePath) packageStats += 1; + return stat(path); + }; + + const release = Promise.withResolvers(); + const twoStarted = Promise.withResolvers(); + let buildCalls = 0; + const previous = tryResolve("Bundler"); + register("Bundler", { + bundle: async () => { + buildCalls += 1; + if (buildCalls === 2) twoStarted.resolve(); + await release.promise; + return { + outputFiles: [{ + path: "out.js", + contents: new TextEncoder().encode("export default 1;"), + text: "export default 1;", + }], + warnings: [], + errors: [], + }; + }, + transform: () => Promise.resolve({ code: "", warnings: [] }), + }); + setEnv(DEPENDENCY_PINNING_ENV_FLAG, "1"); + clearReactVersionCache(); + + try { + const occupying = occupyingPaths.map((entryPath, index) => + bundleBrowserModuleWithMetadata(entryPath, { + adapter, + projectDir, + projectId: "test", + dependencyPinningCacheKey: "off", + importMapJson: "{}", + singleflightKey: `module-server-occupying-${index}`, + }) + ); + occupying.forEach((promise) => void promise.catch(() => undefined)); + await twoStarted.promise; + + const { serveModule } = await import("./module-server.ts"); + const responsePromise = serveModule( + new Request( + `http://localhost:3000/_vf_modules/app/client.js?pins=${requestedCacheKey}`, + ), + { + projectId: "test", + projectDir, + adapter, + isLocalProject: false, + isProxyMode: true, + mode: "preview", + config: { experimental: { rsc: true } }, + dependencyPinningSource: { + projectDir, + fs: adapter.fs, + cacheNamespace: "module-server-snapshot-admission", + }, + }, + ); + void responsePromise.catch(() => undefined); + await new Promise((resolve) => setTimeout(resolve, 25)); + assertEquals(packageStats, 0); + assertEquals(packageReads, 0); + + release.resolve(); + const [response] = await Promise.all([responsePromise, ...occupying]); + assertEquals(response.status, 200); + assertEquals(packageStats, 1); + assertEquals(packageReads, 1); + } finally { + release.resolve(); + clearReactVersionCache(); + if (previous) register("Bundler", previous); + else unregister("Bundler"); + } + }); + + it({ + name: "admits RSC entry reads before starting project filesystem work", + timeout: 5_000, + }, async () => { + const projectDir = "/module-server-entry-admission"; + const projectId = "module-server-entry-admission"; + const adapter = createMockAdapter(); + const entryPaths = Array.from( + { length: 11 }, + (_, index) => `${projectDir}/app/client-${index}.tsx`, + ); + const entryPathSet = new Set(entryPaths); + for (const [index, path] of entryPaths.entries()) { + adapter.fs.files.set( + path, + `"use client"; export default function Client${index}() { return null; }`, + ); + } + + const exactRead = adapter.fs.readFileBytesWithinLimit!; + const snapshotRead = adapter.fs.readFileSnapshotWithinLimit!; + const bypassDetected = Promise.withResolvers(); + const twoSnapshotReadsStarted = Promise.withResolvers(); + const releaseSnapshotReads = Promise.withResolvers(); + let exactEntryReads = 0; + let activeSnapshotReads = 0; + let maximumActiveSnapshotReads = 0; + let snapshotEntryReads = 0; + adapter.fs.readFileBytesWithinLimit = (path, limit) => { + if (entryPathSet.has(path)) { + exactEntryReads += 1; + bypassDetected.resolve(); + } + return exactRead(path, limit); + }; + adapter.fs.readFileSnapshotWithinLimit = async (path, root, limit) => { + if (entryPathSet.has(path)) { + snapshotEntryReads += 1; + activeSnapshotReads += 1; + maximumActiveSnapshotReads = Math.max( + maximumActiveSnapshotReads, + activeSnapshotReads, + ); + if (snapshotEntryReads === 2) twoSnapshotReadsStarted.resolve(); + try { + await releaseSnapshotReads.promise; + } finally { + activeSnapshotReads -= 1; + } + } + return await snapshotRead(path, root, limit); + }; + + const previous = tryResolve("Bundler"); + register("Bundler", { + bundle: (options) => + Promise.resolve({ + outputFiles: [{ + path: "out.js", + contents: new TextEncoder().encode(options.stdin?.contents ?? ""), + text: options.stdin?.contents ?? "", + }], + warnings: [], + errors: [], + }), + transform: () => Promise.resolve({ code: "", warnings: [] }), + }); + + try { + const { serveModule } = await import("./module-server.ts"); + const serveEntry = (index: number, signal?: AbortSignal) => + serveModule( + new Request(`http://localhost:3000/_vf_modules/app/client-${index}.js`, { signal }), + { + projectId, + projectDir, + adapter, + dev: false, + isLocalProject: false, + isProxyMode: true, + mode: "preview", + config: { experimental: { rsc: true } }, + }, + ); + const controller = new AbortController(); + const cancelled = serveEntry(0, controller.signal); + const admitted = entryPaths.slice(1, 10).map((_, index) => serveEntry(index + 1)); + admitted.forEach((response) => void response.catch(() => undefined)); + + const firstEntryRead = await Promise.race([ + twoSnapshotReadsStarted.promise.then(() => "admitted" as const), + bypassDetected.promise.then(() => "bypassed" as const), + ]); + assertEquals(firstEntryRead, "admitted"); + assertEquals(exactEntryReads, 0); + assertEquals(snapshotEntryReads, 2); + assertEquals(maximumActiveSnapshotReads, 2); + + controller.abort(new DOMException("request cancelled", "AbortError")); + const abortTimeout = Promise.withResolvers<"timeout">(); + const abortTimeoutId = setTimeout(() => abortTimeout.resolve("timeout"), 500); + const cancelledResult = await Promise.race([ + cancelled, + abortTimeout.promise, + ]); + clearTimeout(abortTimeoutId); + if (cancelledResult === "timeout") { + throw new Error("Cancelled entry request did not return promptly"); + } + assertEquals(cancelledResult.status, 500); + assertEquals(activeSnapshotReads, 2); + + const overflow = await serveEntry(10); + assertEquals(overflow.status, 503); + assertEquals(snapshotEntryReads, 2); + + releaseSnapshotReads.resolve(); + const responses = await Promise.all(admitted); + assertEquals(responses.every((response) => response.status === 200), true); + assertEquals(maximumActiveSnapshotReads, 2); + } finally { + releaseSnapshotReads.resolve(); + if (previous) register("Bundler", previous); + else unregister("Bundler"); + } + }); + + it("does not let remote requests spoof the local SSR module capability", async () => { + const projectDir = await Deno.makeTempDir({ prefix: "vf-remote-ssr-spoof-" }); + + try { + await Deno.mkdir(`${projectDir}/components`, { recursive: true }); + await Deno.writeTextFile( + `${projectDir}/components/server.ts`, + `"use server"; export const secret = "private";`, + ); + + const { serveModule } = await import("./module-server.ts"); + const options = { + projectId: "remote-project", + projectDir, + adapter: denoAdapter, + isLocalProject: false, + // Even a mistakenly broad in-process capability is insufficient unless + // the project itself was explicitly classified as local. + allowSSRModuleMode: true, + } as const; + + for ( + const request of [ + new Request("http://localhost:3000/_vf_modules/components/server.js?ssr=true"), + new Request("http://localhost:3000/_vf_modules/components/server.js", { + headers: { "user-agent": "Deno/2.4.0" }, + }), + ] + ) { + const response = await serveModule(request, options); + assertEquals(response.status, 404); + assertEquals((await response.text()).includes("private"), false); + } + } finally { + await Deno.remove(projectDir, { recursive: true }); + } + }); + + it("rejects private and server-only cross-project browser modules", async () => { + const projectDir = await Deno.makeTempDir({ prefix: "vf-cross-project-private-" }); + const originalFetch = globalThis.fetch; + let fetchCalls = 0; + + try { + await Deno.writeTextFile(`${projectDir}/package.json`, `{"name":"local"}`); + globalThis.fetch = (_input: string | URL | Request) => { + fetchCalls++; + return Promise.resolve( + new Response(`"use server"; export const secret = "private";`, { + status: 200, + }), + ); + }; + + const { serveModule } = await import("./module-server.ts"); + const options = { + projectId: "local-project", + projectDir, + adapter: denoAdapter, + isLocalProject: false, + allowSSRModuleMode: true, + } as const; + + for ( + const path of [ + "package.json.js", + "app/actions/save.js", + "app/api/private.js", + "app/account/route.js", + "middleware.js", + "tools/private.js", + "agents/private.js", + "skills/private.js", + "resources/private.js", + "prompts/private.js", + "workflows/private.js", + "tasks/private.js", + "schedules/private.js", + "webhooks/private.js", + "evals/private.js", + ".env.js", + ] + ) { + const response = await serveModule( + new Request( + `http://localhost:3000/_vf_modules/_cross/remote@1.0.0/@/${path}?ssr=true`, + ), + options, + ); + assertEquals(response.status, 404); + } + assertEquals(fetchCalls, 0); + + const serverOnlyResponse = await serveModule( + new Request( + "http://localhost:3000/_vf_modules/_cross/remote@1.0.0/@/components/server.js?ssr=true", + { headers: { "user-agent": "Deno/2.4.0" } }, + ), + options, + ); + assertEquals(serverOnlyResponse.status, 404); + assertEquals((await serverOnlyResponse.text()).includes("private"), false); + assertEquals(fetchCalls, 1); + } finally { + globalThis.fetch = originalFetch; + await Deno.remove(projectDir, { recursive: true }); + } + }); + + it("rejects encoded cross-project paths before registry access", async () => { + const projectDir = await Deno.makeTempDir({ prefix: "vf-cross-project-encoded-" }); + const originalFetch = globalThis.fetch; + let fetchCalls = 0; + + try { + globalThis.fetch = () => { + fetchCalls++; + return Promise.resolve( + new Response(`export const secret = "cross-project-private";`, { status: 200 }), + ); + }; + + const { serveModule } = await import("./module-server.ts"); + for ( + const path of [ + "app%2factions%2fsecret.js", + "app%2Factions%2Fsecret.js", + "app%5cactions%5csecret.js", + "app%5Cactions%5Csecret.js", + "app%252factions%252fsecret.js", + "app%252Factions%252Fsecret.js", + "app%255cactions%255csecret.js", + "app%255Cactions%255Csecret.js", + "components/encoded%20name.js", + ] + ) { + for (const method of ["GET", "HEAD"]) { + const response = await serveModule( + new Request(`http://localhost:3000/_vf_modules/_cross/remote@1.0.0/@/${path}`, { + method, + }), + { + projectId: "local-project", + projectDir, + adapter: denoAdapter, + isLocalProject: false, + }, + ); + assertEquals(response.status, 400, `${path} ${method}`); + assertEquals( + (await response.text()).includes("cross-project-private"), + false, + `${path} ${method}`, + ); + } + } + assertEquals(fetchCalls, 0); + } finally { + globalThis.fetch = originalFetch; + await Deno.remove(projectDir, { recursive: true }); + } + }); + + it("admits production browser modules only from a ready release manifest", async () => { + // The rollout flag may disable manifest-based rendering optimizations, but + // it must never disable the production browser-module security boundary. + setEnv(RELEASE_ASSET_MANIFEST_ENV_FLAG, "0"); + const projectDir = await Deno.makeTempDir({ prefix: "vf-browser-module-manifest-" }); + const releaseId = `rel-browser-admission-${crypto.randomUUID()}`; + const hash = "b".repeat(64); + + try { + await Deno.mkdir(`${projectDir}/components`, { recursive: true }); + await Deno.writeTextFile( + `${projectDir}/components/App.tsx`, + `export default function App() { return "safe"; }`, + ); + await Deno.writeTextFile( + `${projectDir}/components/Secret.ts`, + `export const secret = "not-listed";`, + ); + registerManifestFetcherForRelease(releaseId, () => + Promise.resolve({ + state: "ready", + manifest_version: 1, + manifest: manifest({}, releaseId, "source", { + "components/App.tsx": { + contentHash: hash, + size: 1, + contentType: "text/javascript", + }, + }), + })); + + const { serveModule } = await import("./module-server.ts"); + const options = { + projectId: "test", + projectDir, + adapter: denoAdapter, + dev: false, + mode: "production", + releaseId, + } as const; + + const admitted = await serveModule( + new Request("http://localhost:3000/_vf_modules/components/App.js"), + options, + ); + assertEquals(admitted.status, 200); + + const rejected = await serveModule( + new Request("http://localhost:3000/_vf_modules/components/Secret.js"), + options, + ); + assertEquals(rejected.status, 404); + assertEquals((await rejected.text()).includes("not-listed"), false); + + for ( + const spoofedRequest of [ + new Request( + "http://localhost:3000/_vf_modules/components/Secret.js?ssr=true", + ), + new Request("http://localhost:3000/_vf_modules/components/Secret.js", { + headers: { "user-agent": "Deno/2.4.0" }, + }), + ] + ) { + const spoofed = await serveModule(spoofedRequest, { + ...options, + allowSSRModuleMode: true, + isLocalProject: false, + }); + assertEquals(spoofed.status, 404); + assertEquals((await spoofed.text()).includes("not-listed"), false); + } + } finally { + await Deno.remove(projectDir, { recursive: true }); + } + }); + + it("requires client boundaries for manifested production RSC app modules", async () => { + const projectDir = await Deno.makeTempDir({ prefix: "vf-browser-module-rsc-production-" }); + const releaseId = `rel-browser-rsc-${crypto.randomUUID()}`; + const hash = "d".repeat(64); + + try { + await Deno.mkdir(`${projectDir}/app`, { recursive: true }); + await Deno.writeTextFile( + `${projectDir}/app/page.tsx`, + `export const marker = "server-page"; export default function Page() { return null; }`, + ); + await Deno.writeTextFile( + `${projectDir}/app/layout.tsx`, + `export const marker = "server-layout"; export default function Layout() { return null; }`, + ); + await Deno.writeTextFile( + `${projectDir}/app/client.tsx`, + [ + `"use client";`, + `import { helper } from "./helper.ts";`, + `export const marker = "browser-client:" + helper;`, + ].join("\n"), + ); + await Deno.writeTextFile( + `${projectDir}/app/helper.ts`, + `export const helper = "manifested-client-helper";`, + ); + await Deno.writeTextFile( + `${projectDir}/app/unlisted-client.tsx`, + [ + `"use client";`, + `import { helper } from "./unlisted-helper.ts";`, + `export const marker = helper;`, + ].join("\n"), + ); + await Deno.writeTextFile( + `${projectDir}/app/unlisted-helper.ts`, + `export const helper = "unmanifested-client-helper";`, + ); + registerManifestFetcherForRelease(releaseId, () => + Promise.resolve({ + state: "ready", + manifest_version: 1, + manifest: manifest({}, releaseId, "source", { + "app/page.tsx": { + contentHash: hash, + size: 1, + contentType: "text/javascript", + }, + "app/layout.tsx": { + contentHash: hash, + size: 1, + contentType: "text/javascript", + }, + "app/client.tsx": { + contentHash: hash, + size: 1, + contentType: "text/javascript", + }, + "app/helper.ts": { + contentHash: hash, + size: 1, + contentType: "text/javascript", + }, + "app/unlisted-client.tsx": { + contentHash: hash, + size: 1, + contentType: "text/javascript", + }, + }), + })); + + const { serveModule } = await import("./module-server.ts"); + const options = { + projectId: "test", + projectDir, + adapter: denoAdapter, + dev: false, + mode: "production", + releaseId, + config: { experimental: { rsc: true } }, + } as const; + + for (const prefix of ["/_vf_modules", "/_veryfront/modules"]) { + for (const name of ["page", "layout"]) { + for (const method of ["GET", "HEAD"]) { + const response = await serveModule( + new Request(`http://localhost:3000${prefix}/app/${name}.js`, { method }), + options, + ); + assertEquals(response.status, 404, `${prefix}/app/${name}.js ${method}`); + assertEquals( + (await response.text()).includes(`server-${name}`), + false, + `${prefix}/app/${name}.js ${method}`, + ); + } + } + + const clientGet = await serveModule( + new Request(`http://localhost:3000${prefix}/app/client.js`), + options, + ); + assertEquals(clientGet.status, 200, `${prefix}/app/client.js GET`); + const clientCode = await clientGet.text(); + assertStringIncludes(clientCode, "browser-client"); + assertStringIncludes(clientCode, "manifested-client-helper"); + + const clientHead = await serveModule( + new Request(`http://localhost:3000${prefix}/app/client.js`, { method: "HEAD" }), + options, + ); + assertEquals(clientHead.status, 200, `${prefix}/app/client.js HEAD`); + assertEquals(await clientHead.text(), ""); + + const helperEntry = await serveModule( + new Request(`http://localhost:3000${prefix}/app/helper.js`), + options, + ); + assertEquals(helperEntry.status, 404, `${prefix}/app/helper.js GET`); + + const unlistedDependency = await serveModule( + new Request(`http://localhost:3000${prefix}/app/unlisted-client.js`), + options, + ); + assertEquals(unlistedDependency.status, 404, `${prefix}/app/unlisted-client.js GET`); + assertEquals( + (await unlistedDependency.text()).includes("unmanifested-client-helper"), + false, + `${prefix}/app/unlisted-client.js GET`, + ); + } + } finally { + await Deno.remove(projectDir, { recursive: true }); + } + }); + + it("fails closed while a production browser manifest is unavailable", async () => { + setEnv(RELEASE_ASSET_MANIFEST_ENV_FLAG, "0"); + const projectDir = await Deno.makeTempDir({ prefix: "vf-browser-module-manifest-wait-" }); + const releaseId = `rel-browser-wait-${crypto.randomUUID()}`; + + try { + await Deno.mkdir(`${projectDir}/components`, { recursive: true }); + await Deno.writeTextFile(`${projectDir}/components/App.ts`, `export const app = true;`); + registerManifestFetcherForRelease( + releaseId, + () => Promise.resolve({ state: "building", manifest_version: 1, manifest: null }), + ); + + const { serveModule } = await import("./module-server.ts"); + const response = await serveModule( + new Request("http://localhost:3000/_vf_modules/components/App.js"), + { + projectId: "test", + projectDir, + adapter: denoAdapter, + dev: false, + mode: "production", + releaseId, + }, + ); + assertEquals(response.status, 503); + assertEquals(response.headers.get("cache-control"), "no-store"); + } finally { + await Deno.remove(projectDir, { recursive: true }); + } + }); + + it("serves standalone production browser modules without a hosted release manifest", async () => { + const projectDir = await Deno.makeTempDir({ prefix: "vf-standalone-production-module-" }); + + try { + await Deno.mkdir(`${projectDir}/pages`, { recursive: true }); + await Deno.writeTextFile( + `${projectDir}/pages/index.tsx`, + `export default function Page() { return "local-production"; }`, + ); + + const { serveModule } = await import("./module-server.ts"); + const response = await serveModule( + new Request("http://localhost:3000/_vf_modules/pages/index.js"), + { + projectId: "test", + projectDir, + adapter: denoAdapter, + dev: false, + mode: "production", + releaseId: "standalone-dev", + isLocalProject: false, + isProxyMode: false, + }, + ); + + assertEquals(response.status, 200); + assertStringIncludes(await response.text(), "local-production"); + } finally { + await Deno.remove(projectDir, { recursive: true }); + } + }); + + it("rejects hosted production project modules without a release identity", async () => { + const projectDir = await Deno.makeTempDir({ prefix: "vf-hosted-production-module-" }); + + try { + await Deno.mkdir(`${projectDir}/components`, { recursive: true }); + await Deno.writeTextFile( + `${projectDir}/components/Secret.ts`, + `export const secret = "hosted-source-without-release";`, + ); + + const { serveModule } = await import("./module-server.ts"); + const options = { + projectId: "test", + projectDir, + adapter: denoAdapter, + dev: false, + mode: "production", + isLocalProject: false, + isProxyMode: true, + } as const; + + for (const prefix of ["/_vf_modules", "/_veryfront/modules"]) { + for (const method of ["GET", "HEAD"]) { + const response = await serveModule( + new Request(`http://localhost:3000${prefix}/components/Secret.js`, { method }), + options, + ); + assertEquals(response.status, 404, `${prefix} ${method}`); + assertEquals(response.headers.get("cache-control"), "no-store", `${prefix} ${method}`); + assertEquals( + (await response.text()).includes("hosted-source-without-release"), + false, + `${prefix} ${method}`, + ); + } + } + } finally { + await Deno.remove(projectDir, { recursive: true }); + } + }); + + it("admits the exact resolved source instead of a same-stem manifest entry", async () => { + const projectDir = await Deno.makeTempDir({ prefix: "vf-browser-module-exact-source-" }); + const releaseId = `rel-browser-exact-${crypto.randomUUID()}`; + const hash = "c".repeat(64); + + try { + await Deno.mkdir(`${projectDir}/components`, { recursive: true }); + await Deno.writeTextFile( + `${projectDir}/components/Collision.ts`, + `export const source = "manifested-ts";`, + ); + await Deno.writeTextFile( + `${projectDir}/components/Collision.tsx`, + `export const source = "unmanifested-tsx";`, + ); + registerManifestFetcherForRelease(releaseId, () => + Promise.resolve({ + state: "ready", + manifest_version: 1, + manifest: manifest({}, releaseId, "source", { + "components/Collision.ts": { + contentHash: hash, + size: 1, + contentType: "text/javascript", + }, + }), + })); + + const { serveModule } = await import("./module-server.ts"); + const options = { + projectId: "test", + projectDir, + adapter: denoAdapter, + dev: false, + mode: "production", + releaseId, + } as const; + + const ambiguous = await serveModule( + new Request("http://localhost:3000/_vf_modules/components/Collision.js"), + options, + ); + assertEquals(ambiguous.status, 404); + assertEquals(ambiguous.headers.get("cache-control"), "no-store"); + assertEquals((await ambiguous.text()).includes("unmanifested-tsx"), false); + + const exact = await serveModule( + new Request("http://localhost:3000/_vf_modules/components/Collision.ts"), + options, + ); + assertEquals(exact.status, 200); + assertStringIncludes(await exact.text(), "manifested-ts"); + } finally { + await Deno.remove(projectDir, { recursive: true }); + } + }); + + it("does not resolve tenant source through reserved framework namespaces in production", async () => { + const projectDir = await Deno.makeTempDir({ prefix: "vf-browser-module-reserved-" }); + const releaseId = `rel-browser-reserved-${crypto.randomUUID()}`; + const privateSource = `export const secret = "tenant-private-source";`; + const reservedPaths = [ + "deps/security-review-private.ts", + "react/security-review-private.ts", + "_veryfront/security-review-private.ts", + "_dnt.security-review-private.ts", + ]; + + try { + for (const path of reservedPaths) { + const slash = path.lastIndexOf("/"); + if (slash >= 0) { + await Deno.mkdir(`${projectDir}/${path.slice(0, slash)}`, { recursive: true }); + } + await Deno.writeTextFile(`${projectDir}/${path}`, privateSource); + } + // This was previously considered an extra framework lookup directory, + // which could misclassify tenant source as framework-owned. + await Deno.mkdir(`${projectDir}/src`, { recursive: true }); + await Deno.writeTextFile( + `${projectDir}/src/security-review-src-alias.ts`, + privateSource, + ); + + registerManifestFetcherForRelease(releaseId, () => + Promise.resolve({ + state: "ready", + manifest_version: 1, + manifest: manifest({}, releaseId, "source"), + })); + + const { serveModule } = await import("./module-server.ts"); + const options = { + projectId: "test", + projectDir, + adapter: denoAdapter, + dev: false, + mode: "production", + releaseId, + } as const; + + for ( + const path of [ + "deps/security-review-private.js", + "react/security-review-private.js", + "_veryfront/security-review-private.js", + "_veryfront/security-review-src-alias.js", + "_dnt.security-review-private.js", + ] + ) { + const response = await serveModule( + new Request(`http://localhost:3000/_vf_modules/${path}`), + options, + ); + assertEquals(response.status, 404, path); + assertEquals(response.headers.get("cache-control"), "no-store", path); + assertEquals((await response.text()).includes("tenant-private-source"), false, path); + } + } finally { + await Deno.remove(projectDir, { recursive: true }); + } + }); + + it("keeps known framework assets available without a production manifest", async () => { + const projectDir = await Deno.makeTempDir({ prefix: "vf-browser-framework-assets-" }); + const releaseId = `rel-browser-framework-${crypto.randomUUID()}`; + + try { + registerManifestFetcherForRelease( + releaseId, + () => Promise.resolve({ state: "building", manifest_version: 1, manifest: null }), + ); + + const { serveModule } = await import("./module-server.ts"); + const options = { + projectId: "test", + projectDir, + adapter: denoAdapter, + dev: false, + mode: "production", + releaseId, + } as const; + + for ( + const path of [ + "_veryfront/_dnt.shims.js", + "_dnt.polyfills.js", + "react/react.js", + "deno.js", + ] + ) { + const response = await serveModule( + new Request(`http://localhost:3000/_vf_modules/${path}`), + options, + ); + assertEquals(response.status, 200, path); + } + } finally { + await Deno.remove(projectDir, { recursive: true }); + } + }); + it("should serve _dnt.shims.js with _veryfront/ prefix", async () => { const response = await serve( new Request("http://localhost:3000/_vf_modules/_veryfront/_dnt.shims.js"), @@ -1881,6 +3090,55 @@ describe({ name: "serveModule", sanitizeResources: false, sanitizeOps: false }, } }); + it("rejects missing and malformed dependency snapshots before metadata I/O", async () => { + setEnv(DEPENDENCY_PINNING_ENV_FLAG, "1"); + clearReactVersionCache(); + let metadataOperations = 0; + const adapter = createMockAdapter(); + const dependencyPinningSource = { + projectDir: "/pre-admission-pin-rejection", + cacheNamespace: "module-server-pre-admission-pin-rejection", + fs: { + readFile: () => { + metadataOperations += 1; + return Promise.resolve("{}"); + }, + stat: () => { + metadataOperations += 1; + return Promise.resolve({ + size: 2, + isFile: true, + isDirectory: false, + isSymlink: false, + mtime: new Date(1), + }); + }, + }, + }; + const { serveModule } = await import("./module-server.ts"); + + for ( + const pathAndQuery of [ + "/_vf_modules/app/client.js", + "/_vf_modules/app/client.js?pins=on%3A", + "/_vf_modules/app/client.js?pins=on%3A1&pins=on%3A1", + ] + ) { + const response = await serveModule( + new Request(`http://localhost:3000${pathAndQuery}`), + { + projectId: "pre-admission-pin-rejection", + projectDir: "/pre-admission-pin-rejection", + adapter, + config: { experimental: { rsc: true } }, + dependencyPinningSource, + }, + ); + assertEquals(response.status, 409); + } + assertEquals(metadataOperations, 0); + }); + it("rejects missing, duplicate, malformed, and unknown dependency snapshots", async () => { const projectDir = await Deno.makeTempDir({ prefix: "vf-module-invalid-pins-" }); try { diff --git a/src/modules/server/module-server.ts b/src/modules/server/module-server.ts index f8a5c6ee54..aa7a8c176b 100644 --- a/src/modules/server/module-server.ts +++ b/src/modules/server/module-server.ts @@ -39,6 +39,9 @@ import { hasReleaseDependencyImportSpecifiers, } from "#veryfront/release-assets/module-consumption.ts"; import type { ReleaseAssetManifest } from "#veryfront/release-assets/manifest-schema.ts"; +import { + getReadyManifestForBrowserModuleAdmission, +} from "#veryfront/release-assets/manifest-cache.ts"; import { DEPENDENCY_PINNING_ENV_FLAG, RELEASE_ASSET_IMMUTABLE_MAX_AGE_SECONDS, @@ -62,7 +65,9 @@ import { classifyModuleRequest, DEV_MODULE_PREFIX } from "./classify.ts"; import { transformModuleToServable } from "./module-transform.ts"; import { createDependencyPinningSource, + type DependencyPinningSnapshot, type DependencyPinningSourceInput, + getRememberedDependencyPinningSnapshot, resolveProjectReactVersion, resolveRequestedDependencyPinningSnapshot, } from "#veryfront/transforms/esm/package-registry.ts"; @@ -72,10 +77,30 @@ import { } from "#veryfront/transforms/import-rewriter/url-builder.ts"; import type { VeryfrontConfig } from "#veryfront/config"; import { getHttpBundleCacheDir } from "#veryfront/utils/cache-dir.ts"; +import { HttpStatus } from "#veryfront/http/responses"; +import { + describeBrowserModuleBoundaryViolation, + inspectBrowserModuleBoundary, +} from "#veryfront/server/shared/browser-module-boundary.ts"; +import { + BrowserModuleBoundaryError, + type BrowserModuleBundle, + BrowserModuleBundleError, + type BrowserModuleBundleLimitOverrides, + BrowserModuleDependencySnapshotError, + BrowserModuleEntryRejectedError, + bundleBrowserModuleWithMetadata, +} from "#veryfront/server/shared/browser-module-bundler.ts"; +import { ensureDefaultParserContracts } from "#veryfront/extensions/parser/defaults.ts"; +import { + classifyBrowserModuleAbsoluteSourcePath, + isProtectedBrowserModulePath, +} from "./browser-module-admission.ts"; +import { isRSCEnabled } from "#veryfront/utils/feature-flags.ts"; +import { isCanonicalDependencyPinningCacheKey } from "#veryfront/cache/keys/dependency-pinning.ts"; const logger = serverLogger.component("module-server"); const PROJECT_FALLBACK_EMBEDDED_POLYFILLS = new Set(["deno"]); -const DEPENDENCY_PIN_PATTERN = /^on:[A-Za-z0-9._-]+$/; /** * Embedded polyfills for compiled Deno binaries. @@ -173,11 +198,76 @@ function shouldCacheReleaseVersionedModule( url.searchParams.get(RELEASE_MODULE_RUNTIME_VERSION_PARAM) === VERSION; } -function isSSRModuleRequest(req: Request, url: URL): boolean { +function isSSRModuleRequest( + req: Request, + url: URL, + options: ModuleServerOptions, +): boolean { + // Query parameters and user-agent strings are attacker-controlled. Only an + // in-process caller that has explicitly admitted a local project may enable + // the legacy SSR module transport. + if (options.allowSSRModuleMode !== true || options.isLocalProject !== true) return false; const userAgent = req.headers.get("user-agent") ?? ""; return url.searchParams.get("ssr") === "true" || userAgent.startsWith("Deno/"); } +function isReservedFrameworkModulePath(modulePathWithoutJsExtension: string): boolean { + return modulePathWithoutJsExtension.startsWith("_veryfront/") || + modulePathWithoutJsExtension.startsWith("react/") || + modulePathWithoutJsExtension.startsWith("deps/") || + modulePathWithoutJsExtension.startsWith("_dnt.") || + modulePathWithoutJsExtension === "deno"; +} + +function validateBundledClientDependencies( + bundle: BrowserModuleBundle, + options: { + projectDir: string; + config?: VeryfrontConfig; + admissionManifest?: ReleaseAssetManifest | null; + }, +): { valid: true } | { valid: false; path: string | null; reason: string } { + for (const dependency of bundle.dependencies) { + const policy = classifyBrowserModuleAbsoluteSourcePath( + dependency.path, + options.projectDir, + { + config: options.config, + rscEnabled: true, + }, + ); + const path = policy.canonicalPath; + if (!path) { + return { valid: false, path: null, reason: "outside-project" }; + } + if (policy.protectionReason) { + return { valid: false, path, reason: policy.protectionReason }; + } + + if ( + options.admissionManifest && + !Object.hasOwn(options.admissionManifest.modules, path) + ) { + return { valid: false, path, reason: "absent-from-release-manifest" }; + } + } + + return { valid: true }; +} + +function isScriptModuleSource(path: string): boolean { + return /\.(?:[cm]?[jt]sx?)$/i.test(path); +} + +async function inspectBrowserSourceBoundary( + source: string, + sourceFile: string, +): Promise { + await ensureDefaultParserContracts(); + const violation = await inspectBrowserModuleBoundary(source, sourceFile); + return violation ? describeBrowserModuleBoundaryViolation(violation) : null; +} + async function addReleaseVersionToFallbackImports( code: string, modulePath: string, @@ -215,6 +305,12 @@ interface SourceLookupContext { branch?: string | null; releaseId?: string | null; reactVersion?: string; + /** + * Whether a reserved framework request may fall back to project-owned source. + * Production browser requests disable this so a missing framework asset + * cannot silently change provenance to tenant code. + */ + allowReservedProjectFallback?: boolean; } export interface ModuleServerOptions { @@ -238,6 +334,17 @@ export interface ModuleServerOptions { contentSourceId?: string; /** Explicitly selects host FS for local projects and adapter FS for proxy projects. */ isLocalProject?: boolean; + /** + * Whether modules are being served by the shared multi-project runtime. + * Production release admission fails closed when this is omitted; only an + * explicitly standalone runtime may bypass the hosted release manifest. + */ + isProxyMode?: boolean; + /** + * Enables the legacy SSR transform only for an explicitly admitted local + * project. Never derive this capability from request headers or query data. + */ + allowSSRModuleMode?: boolean; /** * Restrict module imports to specific directories (opt-in security). * When not set, users can import from any directory in the project. @@ -251,6 +358,14 @@ export interface ModuleServerOptions { dependencyPinningSource?: DependencyPinningSourceInput; /** Request mode ("preview" | "production") for studio features like node positions */ mode?: string; + /** Optional operator tightening for request-triggered browser graph compilation. */ + browserModuleBundleLimits?: BrowserModuleBundleLimitOverrides; +} + +interface ModuleDependencyState { + dependencyPinningCacheKey: string; + dependencyPinningDependencies?: Readonly>; + reactVersion: string; } /** Serve transformed module at /_vf_modules/* path */ @@ -282,10 +397,27 @@ export function serveModule(req: Request, options: ModuleServerOptions): Promise const effectiveProjectId = projectUUID ?? projectId; const method = req.method.toUpperCase(); const isHeadRequest = method === "HEAD"; + if (method !== "GET" && method !== "HEAD") { + return createModuleResponse(method, "Method not allowed", HttpStatus.METHOD_NOT_ALLOWED, { + "Allow": "GET, HEAD", + "Cache-Control": "no-store", + "Content-Type": "text/plain; charset=utf-8", + }); + } const queryPinValues = url.searchParams.getAll("pins"); const requestedPinKey = pathPin.found ? pathPin.cacheKey : queryPinValues[0]; const requestedPinCount = queryPinValues.length + (pathPin.found ? 1 : 0); const hasRequestedPinKey = requestedPinCount > 0; + const dependencyPinningEnabled = getHostEnv(DEPENDENCY_PINNING_ENV_FLAG) === "1"; + if ( + pathPin.malformed || + requestedPinCount > 1 || + (hasRequestedPinKey && + (!requestedPinKey || !isCanonicalDependencyPinningCacheKey(requestedPinKey))) || + (!hasRequestedPinKey && dependencyPinningEnabled) + ) { + return unknownDependencySnapshotModuleResponse(method); + } const dependencySource = options.dependencyPinningSource ?? createDependencyPinningSource({ projectDir, @@ -298,35 +430,46 @@ export function serveModule(req: Request, options: ModuleServerOptions): Promise branch: options.branch, config, }); - const dependencySnapshot = pathPin.malformed || - requestedPinCount > 1 || - (hasRequestedPinKey && - (!requestedPinKey || !DEPENDENCY_PIN_PATTERN.test(requestedPinKey))) - ? undefined - : await resolveRequestedDependencyPinningSnapshot( - dependencySource, - requestedPinKey, - ); - if ( - !dependencySnapshot || - (!requestedPinKey && dependencySnapshot.cacheKey.startsWith("on:")) - ) { - return createModuleResponse(method, "Unknown dependency snapshot", 409, { - "Content-Type": "text/plain; charset=utf-8", - "Cache-Control": "no-store", + const rememberedDependencySnapshot = requestedPinKey + ? getRememberedDependencyPinningSnapshot(dependencySource, requestedPinKey) + : undefined; + let dependencyStatePromise: Promise | undefined; + const dependencyStateFromSnapshot = async ( + snapshot: DependencyPinningSnapshot, + ): Promise => { + const dependencyPinningCacheKey = snapshot.cacheKey; + const dependencyPinningDependencies = snapshot.dependencies; + const snapshotReactVersion = await resolveProjectReactVersion({ + projectDir, + config, + dependencyPinningCacheKey, + dependencyPinningDependencies, }); - } - const dependencyPinningCacheKey = dependencySnapshot.cacheKey; - const dependencyPinningDependencies = dependencySnapshot.dependencies; - const snapshotReactVersion = await resolveProjectReactVersion({ - projectDir, - config, - dependencyPinningCacheKey, - dependencyPinningDependencies, - }); - const reactVersion = dependencyPinningCacheKey.startsWith("on:") - ? snapshotReactVersion - : explicitReactVersion ?? snapshotReactVersion; + return { + dependencyPinningCacheKey, + dependencyPinningDependencies, + reactVersion: dependencyPinningCacheKey.startsWith("on:") + ? snapshotReactVersion + : explicitReactVersion ?? snapshotReactVersion, + }; + }; + const resolveDependencyState = (): Promise => { + dependencyStatePromise ??= (async () => { + const snapshot = rememberedDependencySnapshot ?? + await resolveRequestedDependencyPinningSnapshot( + dependencySource, + requestedPinKey, + ); + if ( + !snapshot || + (requestedPinKey + ? snapshot.cacheKey !== requestedPinKey + : snapshot.cacheKey.startsWith("on:")) + ) return undefined; + return await dependencyStateFromSnapshot(snapshot); + })(); + return dependencyStatePromise; + }; const secureFs = createSecureFs({ baseDir: projectDir, @@ -379,6 +522,13 @@ export function serveModule(req: Request, options: ModuleServerOptions): Promise "Cache-Control": "no-cache", }); } + const dependencyState = await resolveDependencyState(); + if (!dependencyState) return unknownDependencySnapshotModuleResponse(method); + const { + dependencyPinningCacheKey, + dependencyPinningDependencies, + reactVersion, + } = dependencyState; const { getCompiledSnippetAsync } = await import( "#veryfront/rendering/snippet-renderer.ts" @@ -395,7 +545,24 @@ export function serveModule(req: Request, options: ModuleServerOptions): Promise const { slug: snippetProjectSlug, branch: snippetBranch } = parseProjectDomain(url.host); - const isSSR = isSSRModuleRequest(req, url); + const isSSR = isSSRModuleRequest(req, url, options); + + if (!isSSR) { + const boundaryReason = await inspectBrowserSourceBoundary( + snippetCode, + `_snippets/${hash}.tsx`, + ); + if (boundaryReason) { + logger.warn("Rejected server-only snippet from browser module endpoint", { + hash, + reason: boundaryReason, + }); + return createModuleResponse(method, "Module not found", HTTP_NOT_FOUND, { + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": "no-store", + }); + } + } logger.debug("Transforming snippet", { hash, @@ -488,6 +655,26 @@ export function serveModule(req: Request, options: ModuleServerOptions): Promise }); } + // The remote project's configuration is unavailable here, so enforce + // the framework-owned default private roots before any registry fetch. + if (isProtectedBrowserModulePath(crossPath)) { + logger.warn("Rejected protected cross-project browser module path", { + project: crossProjectSlug, + path: crossPath, + }); + return createModuleResponse(method, "Module not found", HTTP_NOT_FOUND, { + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": "no-store", + }); + } + const dependencyState = await resolveDependencyState(); + if (!dependencyState) return unknownDependencySnapshotModuleResponse(method); + const { + dependencyPinningCacheKey, + dependencyPinningDependencies, + reactVersion, + } = dependencyState; + const projectRef = crossVersion === "latest" ? crossProjectSlug : `${crossProjectSlug}@${crossVersion}`; @@ -512,7 +699,21 @@ export function serveModule(req: Request, options: ModuleServerOptions): Promise ); } - const isSSR = isSSRModuleRequest(req, url); + const isSSR = isSSRModuleRequest(req, url, options); + if (!isSSR && isScriptModuleSource(crossPath)) { + const boundaryReason = await inspectBrowserSourceBoundary(source, crossPath); + if (boundaryReason) { + logger.warn("Rejected server-only cross-project source from browser endpoint", { + projectRef, + path: crossPath, + reason: boundaryReason, + }); + return createModuleResponse(method, "Module not found", HTTP_NOT_FOUND, { + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": "no-store", + }); + } + } const crossProjectModuleServerUrl = `/_vf_modules/_cross/${projectRef}/@`; const browserCrossProjectModuleServerUrl = dependencyPinningCacheKey.startsWith("on:") ? pathPin.found ? undefined : `/_vf_modules/_cross/${projectRef}/@` @@ -598,7 +799,44 @@ export function serveModule(req: Request, options: ModuleServerOptions): Promise branch ??= parsedHost.branch; } - const isSSR = isSSRModuleRequest(req, url); + const isSSR = isSSRModuleRequest(req, url, options); + if (isProtectedBrowserModulePath(modulePath, options.config)) { + logger.warn("Rejected protected project path from browser module endpoint", { + modulePath, + }); + return createModuleResponse(method, "Module not found", HTTP_NOT_FOUND, { + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": "no-store", + }); + } + + const requiresProductionManifestAdmission = !isSSR && + options.mode === "production" && + options.isLocalProject !== true && + options.isProxyMode !== false; + const resolveBeforeSourceLookup = !dependencyPinningEnabled || + rememberedDependencySnapshot !== undefined || + isSSR || + isReservedFrameworkModulePath(filePathWithoutExt); + let dependencyState = resolveBeforeSourceLookup ? await resolveDependencyState() : undefined; + if (resolveBeforeSourceLookup && !dependencyState) { + return unknownDependencySnapshotModuleResponse(method); + } + + if ( + requiresProductionManifestAdmission && + !options.releaseId && + !isReservedFrameworkModulePath(filePathWithoutExt) + ) { + logger.warn("Rejected hosted production browser module without a release", { + modulePath, + }); + return createModuleResponse(method, "Module not found", HTTP_NOT_FOUND, { + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": "no-store", + }); + } + const canUseReleaseModuleResponseCache = method === "GET" || method === "HEAD"; const canCacheReleaseVersionedModule = canUseReleaseModuleResponseCache && shouldCacheReleaseVersionedModule(url, options, isSSR); @@ -615,24 +853,35 @@ export function serveModule(req: Request, options: ModuleServerOptions): Promise releaseDependencyManifestVersion = manifestState.manifest?.manifestVersion ?? null; } } - const releaseModuleResponseCacheKey = canCacheReleaseVersionedModule - ? buildReleaseModuleResponseCacheKey({ - projectIdentity: effectiveProjectId, - projectDir, - projectSlug, - branch, - releaseId: options.releaseId!, - runtimeVersion: VERSION, - reactVersion, - dependencyPinningCacheKey, - moduleServerOrigin: url.origin, - releaseDependencyManifestVersion, - modulePath, - }) - : null; + let releaseModuleResponseCacheKey: string | null | undefined; + const getReleaseModuleResponseCacheKey = ( + state: ModuleDependencyState, + ): string | null => { + releaseModuleResponseCacheKey ??= canCacheReleaseVersionedModule + ? buildReleaseModuleResponseCacheKey({ + projectIdentity: effectiveProjectId, + projectDir, + projectSlug, + branch, + releaseId: options.releaseId!, + runtimeVersion: VERSION, + reactVersion: state.reactVersion, + dependencyPinningCacheKey: state.dependencyPinningCacheKey, + moduleServerOrigin: url.origin, + releaseDependencyManifestVersion, + modulePath, + }) + : null; + return releaseModuleResponseCacheKey; + }; + + const readCachedReleaseModule = async ( + state: ModuleDependencyState, + ): Promise => { + const cacheKey = getReleaseModuleResponseCacheKey(state); + if (!cacheKey) return null; - if (releaseModuleResponseCacheKey) { - const cachedResponse = await getReleaseModuleResponse(releaseModuleResponseCacheKey); + const cachedResponse = await getReleaseModuleResponse(cacheKey); if (cachedResponse?.entry) { const canUseCachedResponse = !releaseDependencyRewriteEnabled || !(await hasReleaseDependencyImportSpecifiers(cachedResponse.entry.body)); @@ -651,6 +900,12 @@ export function serveModule(req: Request, options: ModuleServerOptions): Promise markRequestProfilePhase("module.response_cache_dependency_blocked"); } markRequestProfilePhase("module.response_cache_miss"); + return null; + }; + + if (!requiresProductionManifestAdmission && dependencyState) { + const cachedResponse = await readCachedReleaseModule(dependencyState); + if (cachedResponse) return cachedResponse; } try { @@ -666,7 +921,9 @@ export function serveModule(req: Request, options: ModuleServerOptions): Promise projectSlug, branch, releaseId: options.releaseId, - reactVersion, + reactVersion: dependencyState?.reactVersion ?? + explicitReactVersion ?? REACT_DEFAULT_VERSION, + allowReservedProjectFallback: !requiresProductionManifestAdmission, }, modulePath, ), @@ -679,108 +936,298 @@ export function serveModule(req: Request, options: ModuleServerOptions): Promise projectDir, }); return createModuleResponse(method, "Module not found", HTTP_NOT_FOUND, { - "Content-Type": "text/plain", + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": "no-store", }); } const { path: sourceFile, isFrameworkFile, embeddedContent } = findResult; + const sourcePolicy = isFrameworkFile + ? null + : classifyBrowserModuleAbsoluteSourcePath(sourceFile, projectDir, { + config: options.config, + rscEnabled: isRSCEnabled(options.config), + }); + const exactSourceKey = sourcePolicy?.canonicalPath ?? null; - let code = ""; + if (!isFrameworkFile && (!exactSourceKey || sourcePolicy?.protectionReason)) { + logger.warn("Rejected protected resolved source from browser module endpoint", { + modulePath, + sourceFile, + exactSourceKey, + reason: sourcePolicy?.protectionReason ?? "outside-project", + }); + return createModuleResponse(method, "Module not found", HTTP_NOT_FOUND, { + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": "no-store", + }); + } - if (!isHeadRequest) { - // Use embedded content for compiled polyfills (no filesystem I/O needed) - let source: string; - if (embeddedContent) { - source = embeddedContent; - logger.debug("Using embedded polyfill content", { - path: sourceFile, - contentLength: embeddedContent.length, + let productionAdmissionManifest: ReleaseAssetManifest | null = null; + if (requiresProductionManifestAdmission && !isFrameworkFile) { + if (!options.releaseId) { + logger.warn("Rejected hosted production browser module without a release", { + modulePath, + sourceFile, + }); + return createModuleResponse(method, "Module not found", HTTP_NOT_FOUND, { + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": "no-store", }); - } else { - source = isFrameworkFile - ? await platformFs.readTextFile(sourceFile) - : await readBoundedModuleSource( - secureFs.readFileBytesWithinLimit, - sourceFile, - ); } - - const userAgent = req.headers.get("user-agent") ?? ""; - - const studioEmbed = url.searchParams.get("studio_embed") === "true"; - const shouldInjectPositions = dev || options.mode === "preview"; - const isJsxFile = /\.(tsx|jsx)$/i.test(sourceFile); - if (shouldInjectPositions && !isFrameworkFile && isJsxFile) { - const relativeFilePath = sourceFile.startsWith(projectDir) - ? sourceFile.slice(projectDir.length).replace(/^\/+/, "") - : sourceFile; - source = injectNodePositions(source, { filePath: relativeFilePath }); + const admissionManifest = await getReadyManifestForBrowserModuleAdmission( + options.releaseId, + { refreshCachedNull: true }, + ); + if (!admissionManifest) { + logger.error("Production browser module manifest is unavailable", { + modulePath, + sourceFile, + releaseId: options.releaseId, + }); + return createModuleResponse( + method, + "Browser module manifest unavailable", + HttpStatus.SERVICE_UNAVAILABLE, + { + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": "no-store", + }, + ); } + if (!exactSourceKey || !Object.hasOwn(admissionManifest.modules, exactSourceKey)) { + logger.warn("Rejected production browser source absent from release manifest", { + modulePath, + sourceFile, + exactSourceKey, + releaseId: options.releaseId, + manifestVersion: admissionManifest.manifestVersion, + }); + return createModuleResponse(method, "Module not found", HTTP_NOT_FOUND, { + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": "no-store", + }); + } + productionAdmissionManifest = admissionManifest; + } - logger.debug("SSR mode check", { - isSSR, - isDenoRequest: userAgent.startsWith("Deno/"), - hasSSRParam: url.searchParams.get("ssr") === "true", - userAgent: userAgent.slice(0, 30), - }); + if (requiresProductionManifestAdmission && dependencyState) { + const cachedResponse = await readCachedReleaseModule(dependencyState); + if (cachedResponse) return cachedResponse; + } - const transformOpts: TransformOptions = { - projectId: effectiveProjectId, - dev, - ssr: isSSR, - moduleServerUrl: !isSSR && - dependencyPinningCacheKey.startsWith("on:") && - !pathPin.found - ? "/_vf_modules" - : undefined, - moduleServerOrigin: url.origin, - studioEmbed, - reactVersion, - dependencyPinningCacheKey, - dependencyPinningDependencies, - dependencyPinningSource: dependencySource, - }; - - // The dev-module path has two post-steps that stay outside - // transformModuleToServable to keep its API small: - // - HMR timestamp injection: runs after the full shared sequence - // (originally between the SSR rewrite and the non-SSR release - // rewrite; reordering is safe because they touch disjoint - // specifiers) - // - addReleaseVersionToFallbackImports: runs after the release rewrite - code = await transformModuleToServable({ - source, - sourceFile, - projectDir, - adapter, - transformOpts, - isSSR, - postTransform: (c) => ensureFilenameDefaultExport(modulePath, c), - ssrRewriteOptions: { - projectSlug, - branch, + let code = ""; + let inspectedBrowserSource: string | undefined; + let bundledClientBoundary: BrowserModuleBundle | undefined; + + if (!isSSR && !isFrameworkFile && isScriptModuleSource(sourceFile)) { + // `use client` marks a graph boundary, not every file in that graph. + // Bundle the boundary so ordinary transitive helpers never become + // independently addressable browser entrypoints. The bundler applies + // admission before reading the entry, then applies server-only checks + // to every dependency. The post-build pass below additionally enforces + // project path policy and release membership. + if (sourcePolicy?.requiresClientBoundary === true) { + const dependencyPinningOptions = dependencyState + ? { + dependencyPinningCacheKey: dependencyState.dependencyPinningCacheKey, + dependencyPinningDependencies: dependencyState.dependencyPinningDependencies, + } + : requestedPinKey + ? { requestedDependencyPinningCacheKey: requestedPinKey } + : undefined; + if (!dependencyPinningOptions) { + return unknownDependencySnapshotModuleResponse(method); + } + await ensureDefaultParserContracts(); + bundledClientBoundary = await bundleBrowserModuleWithMetadata(sourceFile, { + adapter, projectDir, projectId: effectiveProjectId, - resolveCacheBuster: createSSRTargetCacheBusterResolver({ - secureFs, + projectSlug: projectSlug ?? undefined, + config: options.config, + moduleServerOrigin: url.origin, + ...dependencyPinningOptions, + dependencyPinningSource: dependencySource, + signal: req.signal, + requireClientBoundary: true, + limits: options.browserModuleBundleLimits, + ...(requiresProductionManifestAdmission && options.releaseId + ? { + singleflightKey: [ + effectiveProjectId, + options.releaseId, + dependencyState?.dependencyPinningCacheKey ?? requestedPinKey, + url.origin, + sourceFile, + ].join("\0"), + } + : {}), + }); + if (!dependencyState) { + const resolvedCacheKey = bundledClientBoundary.dependencyPinningCacheKey; + const resolvedDependencies = bundledClientBoundary.dependencyPinningDependencies; + if ( + typeof resolvedCacheKey !== "string" || + resolvedCacheKey !== requestedPinKey || + resolvedDependencies === undefined + ) { + throw new BrowserModuleDependencySnapshotError(); + } + dependencyState = await dependencyStateFromSnapshot( + Object.freeze({ + cacheKey: resolvedCacheKey, + dependencies: resolvedDependencies, + }), + ); + } + const dependencyAdmission = validateBundledClientDependencies( + bundledClientBoundary, + { projectDir, - currentModulePath: modulePath, - projectId: effectiveProjectId, + config: options.config, + admissionManifest: productionAdmissionManifest, + }, + ); + if (!dependencyAdmission.valid) { + logger.warn("Rejected protected RSC client dependency", { + modulePath, + dependencyPath: dependencyAdmission.path, + reason: dependencyAdmission.reason, + }); + return createModuleResponse(method, "Module not found", HTTP_NOT_FOUND, { + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": "no-store", + }); + } + } else { + inspectedBrowserSource = await readSourceFileForVersion(secureFs, findResult); + const boundaryReason = await inspectBrowserSourceBoundary( + inspectedBrowserSource, + sourceFile, + ); + if (boundaryReason) { + logger.warn("Rejected server-only source from browser module endpoint", { + modulePath, + reason: boundaryReason, + }); + return createModuleResponse(method, "Module not found", HTTP_NOT_FOUND, { + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": "no-store", + }); + } + } + } + + dependencyState ??= await resolveDependencyState(); + if (!dependencyState) return unknownDependencySnapshotModuleResponse(method); + const { + dependencyPinningCacheKey, + dependencyPinningDependencies, + reactVersion, + } = dependencyState; + releaseModuleResponseCacheKey = getReleaseModuleResponseCacheKey(dependencyState); + + if (!isHeadRequest) { + if (bundledClientBoundary) { + code = ensureFilenameDefaultExport(modulePath, bundledClientBoundary.source); + } else { + // Use embedded content for compiled polyfills (no filesystem I/O needed) + let source: string; + if (inspectedBrowserSource !== undefined) { + source = inspectedBrowserSource; + } else if (embeddedContent !== undefined) { + source = embeddedContent; + logger.debug("Using embedded polyfill content", { + path: sourceFile, + contentLength: embeddedContent.length, + }); + } else { + source = isFrameworkFile + ? await platformFs.readTextFile(sourceFile) + : await readBoundedModuleSource( + secureFs.readFileBytesWithinLimit, + sourceFile, + ); + } + + const userAgent = req.headers.get("user-agent") ?? ""; + + const studioEmbed = url.searchParams.get("studio_embed") === "true"; + const shouldInjectPositions = dev || options.mode === "preview"; + const isJsxFile = /\.(tsx|jsx)$/i.test(sourceFile); + if (shouldInjectPositions && !isFrameworkFile && isJsxFile) { + const relativeFilePath = sourceFile.startsWith(projectDir) + ? sourceFile.slice(projectDir.length).replace(/^\/+/, "") + : sourceFile; + source = injectNodePositions(source, { filePath: relativeFilePath }); + } + + logger.debug("SSR mode check", { + isSSR, + isDenoRequest: userAgent.startsWith("Deno/"), + hasSSRParam: url.searchParams.get("ssr") === "true", + userAgent: userAgent.slice(0, 30), + }); + + const transformOpts: TransformOptions = { + projectId: effectiveProjectId, + dev, + ssr: isSSR, + moduleServerUrl: !isSSR && + dependencyPinningCacheKey.startsWith("on:") && + !pathPin.found + ? "/_vf_modules" + : undefined, + moduleServerOrigin: url.origin, + studioEmbed, + reactVersion, + dependencyPinningCacheKey, + dependencyPinningDependencies, + dependencyPinningSource: dependencySource, + }; + + // The dev-module path has two post-steps that stay outside + // transformModuleToServable to keep its API small: + // - HMR timestamp injection: runs after the full shared sequence + // (originally between the SSR rewrite and the non-SSR release + // rewrite; reordering is safe because they touch disjoint + // specifiers) + // - addReleaseVersionToFallbackImports: runs after the release rewrite + code = await transformModuleToServable({ + source, + sourceFile, + projectDir, + adapter, + transformOpts, + isSSR, + postTransform: (c) => ensureFilenameDefaultExport(modulePath, c), + ssrRewriteOptions: { projectSlug, branch, + projectDir, + projectId: effectiveProjectId, + resolveCacheBuster: createSSRTargetCacheBusterResolver({ + secureFs, + projectDir, + currentModulePath: modulePath, + projectId: effectiveProjectId, + projectSlug, + branch, + releaseId: options.releaseId, + reactVersion, + }), + }, + releaseRewriteOptions: { releaseId: options.releaseId, - reactVersion, - }), - }, - releaseRewriteOptions: { - releaseId: options.releaseId, - manifest: releaseDependencyRewriteEnabled ? releaseDependencyManifest : undefined, - manifestReadOptions: { refreshCachedNull: true }, - dependencyCacheRoot, - readDependencySource: (path) => platformFs.readTextFile(path), - }, - profile: true, - }); + manifest: releaseDependencyRewriteEnabled ? releaseDependencyManifest : undefined, + manifestReadOptions: { refreshCachedNull: true }, + dependencyCacheRoot, + readDependencySource: (path) => platformFs.readTextFile(path), + }, + profile: true, + }); + } const hmrTimestamp = url.searchParams.get("t"); if (hmrTimestamp) { @@ -803,7 +1250,8 @@ export function serveModule(req: Request, options: ModuleServerOptions): Promise const hasUnrewrittenReleaseDependencyImports = releaseDependencyRewriteEnabled && await hasReleaseDependencyImportSpecifiers(code); - const canCacheModuleResponse = releaseModuleResponseCacheKey !== null && + const responseCacheKey = releaseModuleResponseCacheKey; + const canCacheModuleResponse = typeof responseCacheKey === "string" && !hasUnrewrittenReleaseDependencyImports; if (hasUnrewrittenReleaseDependencyImports) { markRequestProfilePhase("module.response_cache_dependency_blocked"); @@ -817,7 +1265,7 @@ export function serveModule(req: Request, options: ModuleServerOptions): Promise }); if (canCacheModuleResponse && method === "GET") { - void rememberReleaseModuleResponse(releaseModuleResponseCacheKey, { + void rememberReleaseModuleResponse(responseCacheKey, { body: code, status: HTTP_OK, headers: Object.entries(headers), @@ -827,16 +1275,35 @@ export function serveModule(req: Request, options: ModuleServerOptions): Promise return createModuleResponse(method, code, HTTP_OK, headers); } catch (error) { + if (error instanceof BrowserModuleDependencySnapshotError) { + return unknownDependencySnapshotModuleResponse(method); + } + if ( + error instanceof BrowserModuleEntryRejectedError || + error instanceof BrowserModuleBoundaryError + ) { + return createModuleResponse(method, "Module not found", HTTP_NOT_FOUND, { + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": "no-store", + }); + } const errorMsg = getErrorMessage(error); logger.error("Module transform error", { modulePath, error: errorMsg }); const headers = getModuleHeaders(modulePath); + const status = error instanceof BrowserModuleBundleError + ? error.kind === "limit" + ? HttpStatus.PAYLOAD_TOO_LARGE + : error.kind === "deadline" + ? HttpStatus.GATEWAY_TIMEOUT + : HttpStatus.SERVICE_UNAVAILABLE + : HTTP_SERVER_ERROR; const errorBody = createModuleErrorBody( modulePath, getClientModuleError(dev, errorMsg), ); - return createModuleResponse(method, errorBody, HTTP_SERVER_ERROR, headers); + return createModuleResponse(method, errorBody, status, headers); } }, { "modules.path": url.pathname, "modules.projectSlug": options.projectSlug || "unknown" }, @@ -967,6 +1434,7 @@ async function findSourceFile( requestedModulePath = basePath, ): Promise { const { reactVersion } = context; + const allowReservedProjectFallback = context.allowReservedProjectFallback !== false; // Extensions including .src for compiled binary embedded sources const extensions = [ ".json", @@ -1022,7 +1490,8 @@ async function findSourceFile( // Note: checked before isFrameworkPath guard because relative imports from // deeply nested modules (e.g. ../../../../_dnt.shims.js) resolve outside // the _veryfront/ prefix. - const embeddedContent = PROJECT_FALLBACK_EMBEDDED_POLYFILLS.has(basePathWithoutExt) + const embeddedContent = allowReservedProjectFallback && + PROJECT_FALLBACK_EMBEDDED_POLYFILLS.has(basePathWithoutExt) ? undefined : EMBEDDED_POLYFILLS[basePathWithoutExt]; if (embeddedContent) { @@ -1056,13 +1525,15 @@ async function findSourceFile( if (packageAssetPath) { return { path: packageAssetPath, isFrameworkFile: true }; } + + if (!allowReservedProjectFallback) return null; } if (isFrameworkPath) { const frameworkResult = await resolveFrameworkSourcePath( basePathWithoutExt.slice("_veryfront/".length), { - extraLookupDirs: [join(projectDir, "src")], + extraLookupDirs: allowReservedProjectFallback ? [join(projectDir, "src")] : [], extensions, }, ); @@ -1075,13 +1546,20 @@ async function findSourceFile( return { path: frameworkResult.path, isFrameworkFile: true }; } - // Framework path not found locally - log warning and fall back to project lookups + // A production browser request must not silently change provenance from + // a reserved framework namespace to tenant source. logger.warn("Framework file not found locally", { basePath: basePathWithoutExt, frameworkRoot: FRAMEWORK_ROOT, }); + if (!allowReservedProjectFallback) return null; } + if ( + !allowReservedProjectFallback && + isReservedFrameworkModulePath(basePathWithoutExt) + ) return null; + if (hasKnownExt) { const fullPath = join(projectDir, basePath); try { @@ -1262,6 +1740,13 @@ function createModuleErrorBody(modulePath: string, errorMessage: string): string return `// Transform Error\nthrow new Error(${JSON.stringify(errorMessage)});`; } +function unknownDependencySnapshotModuleResponse(method: string): Response { + return createModuleResponse(method, "Unknown dependency snapshot", HttpStatus.CONFLICT, { + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": "no-store", + }); +} + function classifyModuleServeStatus(status: number): ModuleServeStatus { if (status >= 200 && status < 300) return "ok"; if (status === HTTP_NOT_FOUND) return "not_found"; diff --git a/src/platform/adapters/fs/veryfront/proxy-manager.test.ts b/src/platform/adapters/fs/veryfront/proxy-manager.test.ts index 55c4b505f4..51353daecd 100644 --- a/src/platform/adapters/fs/veryfront/proxy-manager.test.ts +++ b/src/platform/adapters/fs/veryfront/proxy-manager.test.ts @@ -3,11 +3,18 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals, assertExists, + assertNotStrictEquals, assertRejects, assertThrows, } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { API_CLIENT_ERROR } from "#veryfront/errors"; +import { + __registerLogRecordEmitter, + __resetLogRecordEmitterForTests, + type LogEntry, + refreshLoggerConfig, +} from "#veryfront/utils/logger/index.ts"; import { VeryfrontFSAdapter } from "./adapter.ts"; import { ProxyFSAdapterManager } from "./proxy-manager.ts"; @@ -220,6 +227,16 @@ describe("ProxyFSAdapterManager", () => { manager.dispose(); }); + it("rejects non-positive or non-integral adapter limits", () => { + for (const maxAdapters of [0, -1, 1.5, Number.POSITIVE_INFINITY]) { + assertThrows( + () => createManager({ maxAdapters }), + RangeError, + "maxAdapters must be a positive safe integer", + ); + } + }); + it("should accept maxIdleMs option", () => { const manager = createManager({ maxIdleMs: 60000 }); assertExists(manager); @@ -331,6 +348,62 @@ describe("ProxyFSAdapterManager", () => { assertEquals(Object.keys(stats.stats).length, 0); manager.dispose(); }); + + it("does not expose credential principals in public adapter keys", async () => { + const credentialPrincipal = + "478bc71887c1235cd3040630d0f3e8eb1cabd4797951e480e90c006428962952"; + const manager = createManager({ + adapterFactory: (config) => { + const adapter = new VeryfrontFSAdapter(config); + adapter.initialize = () => Promise.resolve(); + return adapter; + }, + }); + + try { + await manager.getAdapter( + "diagnostic-project", + "vf_test_diagnostic_credential", + "project-one", + false, + null, + null, + "feature-branch", + ); + await manager.getAdapter( + "diagnostic-project", + "different-diagnostic-credential", + "project-one", + false, + null, + null, + "feature-branch", + ); + await manager.getAdapter( + "diagnostic-project", + "vf_test_diagnostic_credential", + "project-one", + true, + "release-one", + "Production", + null, + ); + + const statsKeys = Object.keys(manager.getStats().stats); + const serializedKeys = JSON.stringify(statsKeys); + assertEquals(statsKeys.length, 3); + assertEquals(serializedKeys.includes(credentialPrincipal), false); + assertEquals(/[a-f0-9]{64}/.test(serializedKeys), false); + assertEquals(serializedKeys.includes("diagnostic-project"), true); + assertEquals(serializedKeys.includes("project-one"), true); + assertEquals(serializedKeys.includes("feature-branch"), true); + assertEquals(serializedKeys.includes("release-one"), true); + assertEquals(serializedKeys.includes("Production"), true); + assertEquals(statsKeys.some((key) => key.endsWith(":instance:2")), true); + } finally { + manager.dispose(); + } + }); }); describe("dispose", () => { @@ -406,4 +479,348 @@ describe("ProxyFSAdapterManager", () => { assertEquals(manager.getStats().adapters, 0); }); }); + + describe("hosted tenant and credential isolation", () => { + it("fails closed when shared proxy mode has no canonical project ID", async () => { + const manager = createManager({ + baseConfig: { + ...baseConfig, + veryfront: { ...baseConfig.veryfront, proxyMode: true }, + }, + }); + try { + await assertGetAdapterRejects( + manager, + ["reusable-slug", "tenant-token", undefined, false, null, null, "main"], + "require a canonical project ID", + ); + } finally { + manager.dispose(); + } + }); + + it("does not reuse source adapters after a project slug is reassigned", async () => { + const observedProjectIds: Array = []; + const manager = createManager({ + baseConfig: { + ...baseConfig, + veryfront: { ...baseConfig.veryfront, proxyMode: true }, + }, + adapterFactory: (config) => { + observedProjectIds.push(config.veryfront?.projectId); + const adapter = new VeryfrontFSAdapter(config); + adapter.initialize = () => Promise.resolve(); + return adapter; + }, + }); + try { + const first = await manager.getAdapter( + "reusable-slug", + "same-token", + "project-one", + false, + null, + null, + "main", + ); + const reassigned = await manager.getAdapter( + "reusable-slug", + "same-token", + "project-two", + false, + null, + null, + "main", + ); + + assertNotStrictEquals(first, reassigned); + assertEquals(observedProjectIds, ["project-one", "project-two"]); + assertEquals(manager.getStats().adapters, 2); + } finally { + manager.dispose(); + } + }); + + it("partitions concurrent adapters by immutable credential principal", async () => { + const observedTokens: Array = []; + const manager = createManager({ + baseConfig: { + ...baseConfig, + veryfront: { ...baseConfig.veryfront, proxyMode: true }, + }, + adapterFactory: (config) => { + observedTokens.push(config.veryfront?.apiToken); + const adapter = new VeryfrontFSAdapter(config); + adapter.initialize = async () => await Promise.resolve(); + adapter.setRequestToken = () => { + throw new Error("cached adapter token must remain immutable"); + }; + return adapter; + }, + }); + try { + const [first, second] = await Promise.all([ + manager.getAdapter( + "tenant", + "credential-one", + "project-one", + false, + null, + null, + "main", + ), + manager.getAdapter( + "tenant", + "credential-two", + "project-one", + false, + null, + null, + "main", + ), + ]); + + assertNotStrictEquals(first, second); + assertEquals(observedTokens.toSorted(), ["credential-one", "credential-two"]); + } finally { + manager.dispose(); + } + }); + + it("does not expose credential principals in logs or cache invariant errors", async () => { + const token = "vf_test_diagnostic_credential"; + const credentialPrincipal = + "478bc71887c1235cd3040630d0f3e8eb1cabd4797951e480e90c006428962952"; + const entries: LogEntry[] = []; + const previousLogLevel = Deno.env.get("LOG_LEVEL"); + const originalConsoleDebug = console.debug; + const originalConsoleError = console.error; + const manager = createManager({ + adapterFactory: (config) => { + const adapter = new VeryfrontFSAdapter(config); + adapter.initialize = () => Promise.resolve(); + return adapter; + }, + }); + + try { + Deno.env.set("LOG_LEVEL", "DEBUG"); + refreshLoggerConfig(); + __registerLogRecordEmitter((entry) => entries.push(entry)); + console.debug = () => {}; + console.error = () => {}; + + const adapter = await manager.getAdapter( + "diagnostic-project", + token, + "project-one", + false, + null, + null, + "feature-branch", + ); + adapter.setContentContext({ + sourceType: "branch", + projectSlug: "diagnostic-project", + branch: "different-branch", + }); + + const error = await assertRejects(() => + manager.getAdapter( + "diagnostic-project", + token, + "project-one", + false, + null, + null, + "feature-branch", + ) + ); + const diagnostics = JSON.stringify({ + entries, + error: { + message: error instanceof Error ? error.message : String(error), + detail: (error as { detail?: unknown }).detail, + context: (error as { context?: unknown }).context, + }, + }); + + assertEquals(diagnostics.includes(token), false); + assertEquals(diagnostics.includes(credentialPrincipal), false); + assertEquals(diagnostics.includes("diagnostic-project"), true); + assertEquals(diagnostics.includes("project-one"), true); + assertEquals(diagnostics.includes("feature-branch"), true); + } finally { + manager.dispose(); + console.debug = originalConsoleDebug; + console.error = originalConsoleError; + __resetLogRecordEmitterForTests(); + if (previousLogLevel === undefined) Deno.env.delete("LOG_LEVEL"); + else Deno.env.set("LOG_LEVEL", previousLogLevel); + refreshLoggerConfig(); + } + }); + + it("reserves capacity for pending adapter initialization", async () => { + const initializationGate = Promise.withResolvers(); + const firstInitializationStarted = Promise.withResolvers(); + let factoryCalls = 0; + let firstRequest: Promise | undefined; + const manager = createManager({ + maxAdapters: 1, + adapterFactory: (config) => { + factoryCalls += 1; + const adapter = new VeryfrontFSAdapter(config); + adapter.initialize = factoryCalls === 1 + ? () => { + firstInitializationStarted.resolve(); + return initializationGate.promise; + } + : () => Promise.resolve(); + return adapter; + }, + }); + + try { + firstRequest = manager.getAdapter( + "tenant-one", + "credential-one", + undefined, + false, + null, + null, + "main", + ); + await firstInitializationStarted.promise; + + const overload = await assertRejects(() => + manager.getAdapter( + "tenant-two", + "credential-two", + undefined, + false, + null, + null, + "main", + ) + ); + assertEquals((overload as { slug?: string }).slug, "service-overloaded"); + assertEquals(factoryCalls, 1); + + initializationGate.resolve(); + const first = await firstRequest; + assertEquals(manager.getStats().adapters, 1); + + const second = await manager.getAdapter( + "tenant-two", + "credential-two", + undefined, + false, + null, + null, + "main", + ); + assertNotStrictEquals(first, second); + assertEquals(factoryCalls, 2); + assertEquals(manager.getStats().adapters, 1); + } finally { + initializationGate.resolve(); + await firstRequest?.catch(() => {}); + manager.dispose(); + } + }); + + it("releases a reserved slot after synchronous initialization failure", async () => { + let factoryCalls = 0; + const manager = createManager({ + maxAdapters: 1, + adapterFactory: (config) => { + factoryCalls += 1; + const adapter = new VeryfrontFSAdapter(config); + adapter.initialize = factoryCalls === 1 + ? () => { + throw new Error("synchronous initialization failure"); + } + : () => Promise.resolve(); + return adapter; + }, + }); + + try { + await assertRejects( + () => + manager.getAdapter( + "tenant-one", + "credential-one", + undefined, + false, + null, + null, + "main", + ), + Error, + "synchronous initialization failure", + ); + + const recovered = await manager.getAdapter( + "tenant-two", + "credential-two", + undefined, + false, + null, + null, + "main", + ); + assertExists(recovered); + assertEquals(factoryCalls, 2); + assertEquals(manager.getStats().adapters, 1); + } finally { + manager.dispose(); + } + }); + + it("evicts a cached adapter whose resolved source context is corrupted", async () => { + const manager = createManager({ + adapterFactory: (config) => { + const adapter = new VeryfrontFSAdapter(config); + adapter.initialize = () => Promise.resolve(); + return adapter; + }, + }); + try { + const adapter = await manager.getAdapter( + "tenant", + "credential", + "project-one", + false, + null, + null, + "main", + ); + adapter.setContentContext({ + sourceType: "branch", + projectSlug: "tenant", + branch: "other", + }); + + await assertRejects( + () => + manager.getAdapter( + "tenant", + "credential", + "project-one", + false, + null, + null, + "main", + ), + Error, + "Context mismatch", + ); + assertEquals(manager.hasAdapter("tenant", false, null, "main", null, "project-one"), false); + } finally { + manager.dispose(); + } + }); + }); }); diff --git a/src/platform/adapters/fs/veryfront/proxy-manager.ts b/src/platform/adapters/fs/veryfront/proxy-manager.ts index 7bbdfd1f8d..db50098e89 100644 --- a/src/platform/adapters/fs/veryfront/proxy-manager.ts +++ b/src/platform/adapters/fs/veryfront/proxy-manager.ts @@ -1,6 +1,7 @@ import { logger as baseLogger } from "#veryfront/utils/logger/logger.ts"; import { CACHE_INVARIANT_VIOLATION } from "#veryfront/errors/error-registry.ts"; import { INVALID_ARGUMENT } from "#veryfront/errors/error-registry/general.ts"; +import { SERVICE_OVERLOADED } from "#veryfront/errors/error-registry/server.ts"; import { buildProxyManagerCacheKey } from "#veryfront/cache/keys/index.ts"; import { VeryfrontFSAdapter } from "./adapter.ts"; import type { CacheStats, FSAdapterConfig, ResolvedContentContext } from "./types.ts"; @@ -12,10 +13,62 @@ const logger = baseLogger.component("proxy-fs-adapter-manager"); const DEFAULT_MAX_ADAPTERS = 100; const DEFAULT_MAX_IDLE_MS = 30 * 60 * 1_000; +function requirePositiveSafeInteger(value: number, name: string): number { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new RangeError(`${name} must be a positive safe integer`); + } + return value; +} + interface ProjectAdapter { adapter: VeryfrontFSAdapter; lastAccessed: number; initializing?: Promise; + identity: ProxyAdapterIdentity; +} + +interface ProxyAdapterIdentity { + projectSlug: string; + projectId: string | null; + credentialPrincipal: string; + productionMode: boolean; + releaseId: string | null; + environmentName: string | null; + branch: string | null; +} + +type ProxyAdapterDiagnosticIdentity = Omit; + +const encodeText = TextEncoder.prototype.encode; +const subtleDigest = crypto.subtle.digest.bind(crypto.subtle); +const textEncoder = new TextEncoder(); + +async function hashCredentialPrincipal(token: string): Promise { + const bytes = encodeText.call(textEncoder, token); + const digest = new Uint8Array(await subtleDigest("SHA-256", bytes)); + return Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +function buildDiagnosticCacheKey(identity: ProxyAdapterIdentity): string { + return buildProxyManagerCacheKey( + identity.projectSlug, + identity.productionMode, + identity.releaseId, + identity.branch, + identity.environmentName, + { projectId: identity.projectId, credentialPrincipal: "[redacted]" }, + ); +} + +function getDiagnosticIdentity(identity: ProxyAdapterIdentity): ProxyAdapterDiagnosticIdentity { + return { + projectSlug: identity.projectSlug, + projectId: identity.projectId, + productionMode: identity.productionMode, + releaseId: identity.releaseId, + environmentName: identity.environmentName, + branch: identity.branch, + }; } interface ProxyFSAdapterManagerConfig { @@ -39,7 +92,10 @@ export class ProxyFSAdapterManager { this.baseConfig = config.baseConfig; this.adapterFactory = config.adapterFactory ?? ((adapterConfig) => new VeryfrontFSAdapter(adapterConfig)); - this.maxAdapters = config.maxAdapters ?? DEFAULT_MAX_ADAPTERS; + this.maxAdapters = requirePositiveSafeInteger( + config.maxAdapters ?? DEFAULT_MAX_ADAPTERS, + "maxAdapters", + ); this.maxIdleMs = config.maxIdleMs ?? DEFAULT_MAX_IDLE_MS; if (config.cleanupIntervalMs) { @@ -71,6 +127,15 @@ export class ProxyFSAdapterManager { const effectiveEnvironmentName = environmentName ?? null; const effectiveBranch = branch ?? (effectiveProductionMode ? null : "main"); + if ( + this.baseConfig.veryfront?.proxyMode === true && + (!projectId?.trim() || projectId !== projectId.trim()) + ) { + throw INVALID_ARGUMENT.create({ + detail: "[ProxyFSAdapterManager] Hosted proxy adapters require a canonical project ID", + }); + } + logger.debug("getAdapter START", { projectSlug, productionMode: effectiveProductionMode, @@ -110,13 +175,26 @@ export class ProxyFSAdapterManager { }); } + const credentialPrincipal = await hashCredentialPrincipal(token); + const identity: ProxyAdapterIdentity = Object.freeze({ + projectSlug, + projectId: projectId ?? null, + credentialPrincipal, + productionMode: effectiveProductionMode, + releaseId: effectiveReleaseId, + environmentName: effectiveEnvironmentName, + branch: effectiveBranch, + }); + const cacheKey = buildProxyManagerCacheKey( projectSlug, effectiveProductionMode, effectiveReleaseId, effectiveBranch, effectiveEnvironmentName, + { projectId: identity.projectId, credentialPrincipal }, ); + const diagnosticCacheKey = buildDiagnosticCacheKey(identity); logger.debug("getAdapter called", { projectSlug, @@ -124,7 +202,7 @@ export class ProxyFSAdapterManager { releaseId: effectiveReleaseId, environmentName: effectiveEnvironmentName, branch: effectiveBranch, - cacheKey, + cacheKey: diagnosticCacheKey, hasExisting: this.adapters.has(cacheKey), totalCachedAdapters: this.adapters.size, }); @@ -132,22 +210,21 @@ export class ProxyFSAdapterManager { const existing = this.adapters.get(cacheKey); if (existing) { existing.lastAccessed = Date.now(); - existing.adapter.setRequestToken(token); const existingContext = existing.adapter.getContentContext(); logger.debug("REUSING_CACHED_ADAPTER", { - cacheKey, + cacheKey: diagnosticCacheKey, requestedReleaseId: effectiveReleaseId, cachedSourceType: existingContext?.sourceType, cachedReleaseId: existingContext?.releaseId, }); - this.assertContextMatches(cacheKey, existingContext, { - productionMode: effectiveProductionMode, - releaseId: effectiveReleaseId, - environmentName: effectiveEnvironmentName, - branch: effectiveBranch, - }); + try { + this.assertContextMatches(diagnosticCacheKey, existing, existingContext, identity); + } catch (error) { + this.evictAdapterByCacheKey(cacheKey); + throw error; + } return existing.adapter; } @@ -155,35 +232,64 @@ export class ProxyFSAdapterManager { const pending = this.pendingAdapters.get(cacheKey); if (pending) { logger.debug("Waiting for pending adapter creation", { - cacheKey, + cacheKey: diagnosticCacheKey, projectSlug, }); const waitStartTime = performance.now(); const adapter = await pending; + const initialized = this.adapters.get(cacheKey); + if (!initialized) { + adapter.dispose(); + throw CACHE_INVARIANT_VIOLATION.create({ + detail: `[ProxyFSAdapterManager] Pending adapter completed without a cache identity`, + }); + } + + try { + this.assertContextMatches( + diagnosticCacheKey, + initialized, + adapter.getContentContext(), + identity, + ); + } catch (error) { + this.evictAdapterByCacheKey(cacheKey); + throw error; + } logger.debug("Pending adapter ready", { - cacheKey, + cacheKey: diagnosticCacheKey, waitDuration: `${(performance.now() - waitStartTime).toFixed(2)}ms`, totalDuration: `${(performance.now() - getAdapterStartTime).toFixed(2)}ms`, }); - adapter.setRequestToken(token); return adapter; } - if (this.adapters.size >= this.maxAdapters) { - this.evictLeastRecentlyUsed(); + // A pending initialization already owns a cache slot. Counting only + // completed adapters lets a burst of distinct tenant/credential identities + // initialize without bound and then commit past maxAdapters. Reuse an LRU + // completed slot when possible; if every slot is initializing, fail fast + // without starting more work. + if (this.adapters.size + this.pendingAdapters.size >= this.maxAdapters) { + const evicted = this.evictLeastRecentlyUsed(); + if (!evicted) { + throw SERVICE_OVERLOADED.create({ + detail: "Proxy filesystem adapter initialization capacity is exhausted", + }); + } } logger.debug("Creating new adapter", { - cacheKey, + cacheKey: diagnosticCacheKey, projectSlug, elapsedBeforeCreate: `${(performance.now() - getAdapterStartTime).toFixed(2)}ms`, }); return this.createAdapter( cacheKey, + diagnosticCacheKey, projectSlug, token, projectId, @@ -191,25 +297,36 @@ export class ProxyFSAdapterManager { effectiveReleaseId, effectiveEnvironmentName, effectiveBranch, + identity, ); } private assertContextMatches( - cacheKey: string, + diagnosticCacheKey: string, + cached: ProjectAdapter, currentContext: ResolvedContentContext | null | undefined, - expected: { - productionMode: boolean; - releaseId: string | null; - environmentName: string | null; - branch: string | null; - }, + expected: ProxyAdapterIdentity, ): void { + const cachedIdentityMismatch = this.getIdentityMismatchReason(cached.identity, expected); + if (cachedIdentityMismatch) { + logger.error("Adapter identity mismatch detected", { + cacheKey: diagnosticCacheKey, + cachedIdentity: getDiagnosticIdentity(cached.identity), + expected: getDiagnosticIdentity(expected), + mismatchReason: cachedIdentityMismatch, + }); + throw CACHE_INVARIANT_VIOLATION.create({ + detail: `[ProxyFSAdapterManager] FATAL: Identity mismatch for cached adapter. ` + + `Reason: ${cachedIdentityMismatch}. CacheKey: ${diagnosticCacheKey}`, + }); + } + if (!currentContext) { - logger.error("Null context detected", { cacheKey }); + logger.error("Null context detected", { cacheKey: diagnosticCacheKey }); throw CACHE_INVARIANT_VIOLATION.create({ detail: `[ProxyFSAdapterManager] FATAL: Cached adapter has null context. ` + `This indicates a critical bug in adapter initialization. ` + - `CacheKey: ${cacheKey}`, + `CacheKey: ${diagnosticCacheKey}`, }); } @@ -217,9 +334,9 @@ export class ProxyFSAdapterManager { if (!mismatchReason) return; logger.error("Context mismatch detected", { - cacheKey, + cacheKey: diagnosticCacheKey, currentContext, - expected, + expected: getDiagnosticIdentity(expected), mismatchReason, }); @@ -227,12 +344,31 @@ export class ProxyFSAdapterManager { detail: `[ProxyFSAdapterManager] FATAL: Context mismatch for cached adapter. ` + `This indicates a critical bug in adapter caching. ` + `Reason: ${mismatchReason}. ` + - `Expected: ${JSON.stringify(expected)} ` + + `Expected: ${JSON.stringify(getDiagnosticIdentity(expected))} ` + `Got: ${JSON.stringify(currentContext)} ` + - `CacheKey: ${cacheKey}`, + `CacheKey: ${diagnosticCacheKey}`, }); } + private getIdentityMismatchReason( + actual: ProxyAdapterIdentity, + expected: ProxyAdapterIdentity, + ): string | null { + const fields: Array = [ + "projectSlug", + "projectId", + "credentialPrincipal", + "productionMode", + "releaseId", + "environmentName", + "branch", + ]; + for (const field of fields) { + if (actual[field] !== expected[field]) return `Cached ${field} does not match the request`; + } + return null; + } + private getContextMismatchReason( currentContext: ResolvedContentContext, expected: { @@ -275,6 +411,7 @@ export class ProxyFSAdapterManager { private createAdapter( cacheKey: string, + diagnosticCacheKey: string, projectSlug: string, token: string, projectId: string | undefined, @@ -282,11 +419,10 @@ export class ProxyFSAdapterManager { releaseId: string | null, environmentName: string | null, branch: string | null, + identity: ProxyAdapterIdentity, ): Promise { - const effectiveToken = token || this.baseConfig.veryfront?.apiToken; - logger.debug("Creating NEW adapter", { - cacheKey, + cacheKey: diagnosticCacheKey, projectSlug, productionMode, releaseId, @@ -301,12 +437,11 @@ export class ProxyFSAdapterManager { ...this.baseConfig.veryfront, projectSlug, projectId, - apiToken: effectiveToken, + apiToken: token, }, invalidationCallbacks: createDefaultInvalidationCallbacks({ ...this.baseConfig.invalidationCallbacks, - evictCurrentAdapter: () => - this.evictAdapter(projectSlug, productionMode, releaseId, branch, environmentName), + evictCurrentAdapter: () => this.evictAdapterByCacheKey(cacheKey), }), }; @@ -334,7 +469,7 @@ export class ProxyFSAdapterManager { } logger.debug("CONTENT_CONTEXT_SET", { - cacheKey, + cacheKey: diagnosticCacheKey, projectSlug, productionMode, releaseId, @@ -345,23 +480,24 @@ export class ProxyFSAdapterManager { adapter.setContentContext(context); - const projectAdapter: ProjectAdapter = { adapter, lastAccessed: Date.now() }; + const projectAdapter: ProjectAdapter = { adapter, lastAccessed: Date.now(), identity }; - const initPromise = (async (): Promise => { + // Defer initialization until after its promise is registered. This makes + // capacity admission atomic even when initialize() throws synchronously. + const initPromise = Promise.resolve().then(async (): Promise => { const initStartTime = performance.now(); logger.debug("Adapter initialization START", { - cacheKey, + cacheKey: diagnosticCacheKey, projectSlug, }); - projectAdapter.initializing = adapter.initialize(); - try { + projectAdapter.initializing = adapter.initialize(); await projectAdapter.initializing; logger.debug("Adapter initialization DONE", { - cacheKey, + cacheKey: diagnosticCacheKey, projectSlug, duration: `${(performance.now() - initStartTime).toFixed(2)}ms`, }); @@ -370,7 +506,7 @@ export class ProxyFSAdapterManager { return adapter; } catch (error) { logger.error("Adapter initialization failed", { - cacheKey, + cacheKey: diagnosticCacheKey, projectSlug, duration: `${(performance.now() - initStartTime).toFixed(2)}ms`, error: error instanceof Error ? error.message : String(error), @@ -381,13 +517,13 @@ export class ProxyFSAdapterManager { projectAdapter.initializing = undefined; this.pendingAdapters.delete(cacheKey); } - })(); + }); this.pendingAdapters.set(cacheKey, initPromise); return initPromise; } - private evictLeastRecentlyUsed(): void { + private evictLeastRecentlyUsed(): boolean { let oldestCacheKey: string | null = null; let oldestTime = Infinity; @@ -398,15 +534,17 @@ export class ProxyFSAdapterManager { } } - if (!oldestCacheKey) return; - - logger.debug("Evicting LRU adapter", { cacheKey: oldestCacheKey }); + if (!oldestCacheKey) return false; const adapter = this.adapters.get(oldestCacheKey); - if (!adapter) return; + if (!adapter) return false; + logger.debug("Evicting LRU adapter", { + cacheKey: buildDiagnosticCacheKey(adapter.identity), + }); adapter.adapter.dispose(); this.adapters.delete(oldestCacheKey); + return true; } private cleanupIdleAdapters(): void { @@ -415,7 +553,9 @@ export class ProxyFSAdapterManager { for (const [cacheKey, adapter] of this.adapters) { if (now - adapter.lastAccessed <= this.maxIdleMs) continue; - logger.debug("Removing idle adapter", { cacheKey }); + logger.debug("Removing idle adapter", { + cacheKey: buildDiagnosticCacheKey(adapter.identity), + }); adapter.adapter.dispose(); this.adapters.delete(cacheKey); } @@ -427,17 +567,20 @@ export class ProxyFSAdapterManager { releaseId?: string | null, branch?: string | null, environmentName?: string | null, + projectId?: string, ): boolean { - const effectiveProductionMode = productionMode ?? false; - const effectiveEnvironmentName = environmentName ?? null; - const cacheKey = buildProxyManagerCacheKey( - projectSlug, - effectiveProductionMode, - releaseId ?? null, - branch ?? null, - effectiveEnvironmentName, + this.assertValidSelection(projectSlug, productionMode, releaseId); + return Array.from(this.adapters.values()).some(({ identity }) => + this.matchesAdapterSelection( + identity, + projectSlug, + productionMode, + releaseId, + branch, + environmentName, + projectId, + ) ); - return this.adapters.has(cacheKey); } evictAdapter( @@ -446,33 +589,80 @@ export class ProxyFSAdapterManager { releaseId?: string | null, branch?: string | null, environmentName?: string | null, + projectId?: string, ): void { - const effectiveProductionMode = productionMode ?? false; - const effectiveEnvironmentName = environmentName ?? null; - const cacheKey = buildProxyManagerCacheKey( - projectSlug, - effectiveProductionMode, - releaseId ?? null, - branch ?? null, - effectiveEnvironmentName, - ); - - const adapter = this.adapters.get(cacheKey); - if (!adapter) { - logger.debug("No adapter to evict", { cacheKey }); - return; + this.assertValidSelection(projectSlug, productionMode, releaseId); + let evicted = false; + for (const [cacheKey, { identity }] of this.adapters) { + if ( + !this.matchesAdapterSelection( + identity, + projectSlug, + productionMode, + releaseId, + branch, + environmentName, + projectId, + ) + ) continue; + this.evictAdapterByCacheKey(cacheKey); + evicted = true; } + if (!evicted) logger.debug("No adapter to evict", { projectSlug }); + } - logger.debug("Evicting adapter", { cacheKey }); + private evictAdapterByCacheKey(cacheKey: string): void { + const adapter = this.adapters.get(cacheKey); + if (!adapter) return; + logger.debug("Evicting adapter", { + cacheKey: buildDiagnosticCacheKey(adapter.identity), + }); adapter.adapter.dispose(); this.adapters.delete(cacheKey); } + private matchesAdapterSelection( + identity: ProxyAdapterIdentity, + projectSlug: string, + productionMode = false, + releaseId: string | null = null, + branch: string | null = null, + environmentName: string | null = null, + projectId?: string, + ): boolean { + if (identity.projectSlug !== projectSlug || identity.productionMode !== productionMode) { + return false; + } + if (projectId !== undefined && identity.projectId !== projectId) return false; + if (productionMode) { + return identity.releaseId === releaseId && + identity.environmentName === environmentName; + } + return identity.branch === (branch ?? "main"); + } + + private assertValidSelection( + projectSlug: string, + productionMode = false, + releaseId: string | null = null, + ): void { + if (productionMode && !releaseId) { + throw CACHE_INVARIANT_VIOLATION.create({ + detail: `Missing releaseId in production for ${projectSlug}`, + }); + } + } + getStats(): { adapters: number; stats: Record } { const stats: Record = {}; - - for (const [cacheKey, adapter] of this.adapters) { - stats[cacheKey] = adapter.adapter.getCacheStats(); + const diagnosticKeyCounts = new Map(); + + for (const adapter of this.adapters.values()) { + const diagnosticKey = buildDiagnosticCacheKey(adapter.identity); + const keyCount = (diagnosticKeyCounts.get(diagnosticKey) ?? 0) + 1; + diagnosticKeyCounts.set(diagnosticKey, keyCount); + const statsKey = keyCount === 1 ? diagnosticKey : `${diagnosticKey}:instance:${keyCount}`; + stats[statsKey] = adapter.adapter.getCacheStats(); } return { adapters: this.adapters.size, stats }; @@ -484,8 +674,10 @@ export class ProxyFSAdapterManager { this.cleanupTimer = undefined; } - for (const [cacheKey, adapter] of this.adapters) { - logger.debug("Disposing adapter", { cacheKey }); + for (const adapter of this.adapters.values()) { + logger.debug("Disposing adapter", { + cacheKey: buildDiagnosticCacheKey(adapter.identity), + }); adapter.adapter.dispose(); } diff --git a/src/platform/adapters/fs/veryfront/schemas/proxy-manager.schema.ts b/src/platform/adapters/fs/veryfront/schemas/proxy-manager.schema.ts index 2d42f803d8..d73ab6863f 100644 --- a/src/platform/adapters/fs/veryfront/schemas/proxy-manager.schema.ts +++ b/src/platform/adapters/fs/veryfront/schemas/proxy-manager.schema.ts @@ -5,7 +5,7 @@ export const getGetAdapterParamsSchema = defineSchema((v) => v.object({ projectSlug: v.string().min(1, "projectSlug must be non-empty"), token: v.string().min(1, "token must be non-empty"), - projectId: v.string().optional(), + projectId: v.string().min(1, "projectId must be non-empty").optional(), productionMode: v.boolean(), releaseId: v.string().nullable().optional(), environmentName: v.string().nullable().optional(), diff --git a/src/platform/adapters/veryfront-api-transport.test.ts b/src/platform/adapters/veryfront-api-transport.test.ts index fd85a9f80e..b5fac39c6c 100644 --- a/src/platform/adapters/veryfront-api-transport.test.ts +++ b/src/platform/adapters/veryfront-api-transport.test.ts @@ -481,6 +481,51 @@ describe("Veryfront API transport authority and response boundaries", () => { } }); + it("rejects redirects by default for credentialed API requests", async () => { + const originalFetch = globalThis.fetch; + const redirectPolicies: Array = []; + + try { + globalThis.fetch = ((_input: RequestInfo | URL, init?: RequestInit) => { + redirectPolicies.push(init?.redirect); + return Promise.resolve(Response.json({ ok: true })); + }) as typeof fetch; + const transport = createVeryfrontApiTransport({ + ...baseConfig, + retry: { maxRetries: 0, initialDelay: 0, maxDelay: 0 }, + }); + + await transport.request("/files"); + + assertEquals(redirectPolicies, ["error"]); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("uses explicit redirect policies when callers opt in", async () => { + const originalFetch = globalThis.fetch; + const redirectPolicies: Array = []; + + try { + globalThis.fetch = ((_input: RequestInfo | URL, init?: RequestInit) => { + redirectPolicies.push(init?.redirect); + return Promise.resolve(Response.json({ ok: true })); + }) as typeof fetch; + const transport = createVeryfrontApiTransport({ + ...baseConfig, + retry: { maxRetries: 0, initialDelay: 0, maxDelay: 0 }, + }); + + await transport.request("/explicit-follow", { redirect: "follow" }); + await transport.request("/explicit-manual", { redirect: "manual" }); + + assertEquals(redirectPolicies, ["follow", "manual"]); + } finally { + globalThis.fetch = originalFetch; + } + }); + it("bounds and cancels upstream error bodies while redacting diagnostic URLs", async () => { const originalFetch = globalThis.fetch; let cancelled = false; diff --git a/src/platform/adapters/veryfront-api-transport.ts b/src/platform/adapters/veryfront-api-transport.ts index 36be8a055c..51d9552f6f 100644 --- a/src/platform/adapters/veryfront-api-transport.ts +++ b/src/platform/adapters/veryfront-api-transport.ts @@ -51,6 +51,10 @@ export interface TransportRequestInit { timeoutMs?: number; /** Caller-owned cancellation signal, composed with the per-attempt timeout. */ signal?: AbortSignal; + /** Redirect policy for requests carrying platform credentials. Defaults to `"error"`. */ + redirect?: RequestRedirect; + /** Allow bounded upstream error bodies in logs/error context. Defaults to true. */ + includeErrorBodyInDiagnostics?: boolean; } export interface VeryfrontApiTransportConfig { @@ -143,6 +147,7 @@ function createValidatedVeryfrontApiTransport( const timeoutMs = init.timeoutMs ?? cfgTimeout; const requestHeaders = new Headers(init.headers); const body = init.body; + const redirect = requireRedirectPolicy(init.redirect); const responseInit: TransportRequestInit = Object.freeze({ method, headers: new Headers(requestHeaders), @@ -153,6 +158,8 @@ function createValidatedVeryfrontApiTransport( expected404: init.expected404 === true, timeoutMs, signal: callerSignal, + redirect, + includeErrorBodyInDiagnostics: init.includeErrorBodyInDiagnostics !== false, }); // Capture the token once per request: retries of this request must not // pick up mid-flight token mutations (setRequestToken/clearRequestToken), @@ -173,6 +180,7 @@ function createValidatedVeryfrontApiTransport( headers, body, signal, + redirect, }; const res = config.outboundPolicy ? await guardedOutboundFetch(url, { ...requestInit, redirect: "error" }, { @@ -290,23 +298,25 @@ async function defaultOnResponse( const isExpected404 = init.expected404 === true && response.status === 404; const level = isExpected404 ? "debug" : response.status >= 500 ? "error" : "warn"; const redactedUrl = sanitizeUrlCredentials(url); + const includeErrorBody = init.includeErrorBodyInDiagnostics !== false; apiClientLog[level]("Request failed", { url: redactedUrl, status: response.status, statusText: response.statusText, - responseText: text.slice(0, 500), + responseText: includeErrorBody ? text.slice(0, 500) : undefined, responseTruncated: truncated, }); + const details: Record = { + url: redactedUrl, + responseTruncated: truncated, + }; + if (includeErrorBody) details.responseText = text; throw API_CLIENT_ERROR.create({ detail: `API request failed: ${response.status} ${response.statusText}`, status: response.status, // Redacted so error telemetry cannot leak token query params. context: { - details: { - url: redactedUrl, - responseText: text, - responseTruncated: truncated, - }, + details, }, }); } @@ -375,6 +385,14 @@ function requireSuccessResponseByteLimit(value: number | undefined): number { return limit; } +function requireRedirectPolicy(value: RequestRedirect | undefined): RequestRedirect { + const redirect = value ?? "error"; + if (redirect !== "error" && redirect !== "follow" && redirect !== "manual") { + throw new TypeError("redirect must be 'error', 'follow', or 'manual'"); + } + return redirect; +} + /** * Validate bounded JSON-field options before a request performs any I/O, so a * malformed selector can never reach the network or be retried. diff --git a/src/proxy/control-plane-signature.test.ts b/src/proxy/control-plane-signature.test.ts index 7051648aac..b3fa4fc149 100644 --- a/src/proxy/control-plane-signature.test.ts +++ b/src/proxy/control-plane-signature.test.ts @@ -1,9 +1,11 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals } from "#veryfront/testing/assert"; +import { assertEquals, assertRejects } from "#veryfront/testing/assert"; import { afterEach, beforeEach, describe, it } from "#veryfront/testing/bdd"; import { + ControlPlaneBranchBindingError, isAuthenticInternalControlPlaneCandidate, isVerifiedInternalControlPlaneRequest, + resolveVerifiedControlPlaneBranchBinding, } from "./control-plane-signature.ts"; const PUBLIC_KEY_ENV = "CHANNEL_DISPATCH_SIGNING_PUBLIC_KEY"; @@ -19,6 +21,11 @@ function base64urlBytes(bytes: Uint8Array): string { return base64url(String.fromCharCode(...bytes)); } +async function sha256Base64url(value: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", encoder.encode(value)); + return base64urlBytes(new Uint8Array(digest)); +} + function encodePem(label: string, der: ArrayBuffer): string { const base64 = btoa(String.fromCharCode(...new Uint8Array(der))); const lines = base64.match(/.{1,64}/g) ?? [base64]; @@ -38,6 +45,7 @@ async function mintJws( alg: string; requestMethod: string; requestPath: string; + body: string; signingKeyPair: CryptoKeyPair; advertisedKeyPair: CryptoKeyPair; }> = {}, @@ -62,7 +70,7 @@ async function mintJws( const claims = kind === "dispatch" ? { ...base, platform: "slack", body_sha256: "n/a" } : { ...base, surface: "channels", - request_hash: "n/a", + request_hash: await sha256Base64url(overrides.body ?? ""), request_method: overrides.requestMethod ?? "POST", request_path: overrides.requestPath ?? "/api/control-plane/runs/r_1/stream", @@ -83,11 +91,34 @@ function requestWith( headers: Record, url = CONTROL_PLANE_PATH, method = "POST", + body?: string, ): { req: Request; url: URL; } { - return { req: new Request(url, { method, headers }), url: new URL(url) }; + return { req: new Request(url, { method, headers, body }), url: new URL(url) }; +} + +function createNestedTargetBody( + project: Record, + agentSource: Record, +): string { + return JSON.stringify({ run: { project }, agentSource }); +} + +async function resolveNestedTarget(body: string) { + const { jws, publicKeyPem } = await mintJws("control-plane", { body }); + Deno.env.set(PUBLIC_KEY_ENV, publicKeyPem); + const { req, url } = requestWith( + { "x-token": "t", "x-veryfront-control-plane-jws": jws }, + CONTROL_PLANE_PATH, + "POST", + body, + ); + return await resolveVerifiedControlPlaneBranchBinding(req, url, { + audience: "protected", + expectedProjectId: "proj-1", + }); } function verifyRequest( @@ -114,6 +145,65 @@ describe("proxy/control-plane-signature", () => { else Deno.env.set(PUBLIC_KEY_ENV, previousKey); }); + it("defaults an omitted nested runtime target kind to the main branch", async () => { + assertEquals( + await resolveNestedTarget( + createNestedTargetBody({}, { type: "branch", branch: "trunk" }), + ), + { defaultBranchName: "trunk" }, + ); + }); + + it("validates nested environment target identity", async () => { + assertEquals( + await resolveNestedTarget( + createNestedTargetBody( + { + runtimeTargetKind: "environment", + runtimeTargetEnvironmentId: "10000000-1000-4000-8000-100000000006", + }, + { type: "environment", environmentName: "preview", releaseId: "release-1" }, + ), + ), + {}, + ); + }); + + it("rejects mismatched nested runtime targets", async () => { + const cases = [ + createNestedTargetBody( + { + runtimeTargetKind: "preview_branch", + runtimeTargetBranchId: "10000000-1000-4000-8000-100000000006", + runtimeTargetEnvironmentId: "10000000-1000-4000-8000-100000000007", + }, + { type: "branch", branch: "feature" }, + ), + createNestedTargetBody( + { runtimeTargetKind: "environment" }, + { type: "environment", environmentName: "preview", releaseId: "release-1" }, + ), + createNestedTargetBody( + { runtimeTargetEnvironmentId: "10000000-1000-4000-8000-100000000006" }, + { type: "branch", branch: "trunk" }, + ), + createNestedTargetBody( + { + runtimeTargetKind: "preview_branch", + runtimeTargetBranchId: "10000000-1000-4000-8000-100000000006", + }, + { type: "release", releaseId: "release-1" }, + ), + ]; + + for (const body of cases) { + await assertRejects( + () => resolveNestedTarget(body), + ControlPlaneBranchBindingError, + ); + } + }); + it("returns false for non-control-plane paths", async () => { const { jws, publicKeyPem } = await mintJws("dispatch"); Deno.env.set(PUBLIC_KEY_ENV, publicKeyPem); diff --git a/src/proxy/control-plane-signature.ts b/src/proxy/control-plane-signature.ts index cdebfdd628..f9d83c532a 100644 --- a/src/proxy/control-plane-signature.ts +++ b/src/proxy/control-plane-signature.ts @@ -24,9 +24,14 @@ import { getHostEnv } from "#veryfront/platform/compat/process.ts"; import { + verifyControlPlaneJwsRequestSignature, verifyControlPlaneJwsSignature, verifyDispatchJwsSignature, } from "#veryfront/channels/control-plane.ts"; +import { isRequestBodyTooLargeError, readBodyWithLimit } from "#veryfront/security/index.ts"; +import { DEFAULT_MAX_BODY_SIZE_BYTES } from "#veryfront/utils/constants/index.ts"; +import { isWellFormedString } from "#veryfront/utils/is-well-formed-string.ts"; +import { isCanonicalOpaqueProjectIdentifier } from "#veryfront/utils/project-identity.ts"; const CONTROL_PLANE_JWS_HEADER = "x-veryfront-control-plane-jws"; const DISPATCH_JWS_HEADER = "x-veryfront-dispatch-jws"; @@ -39,6 +44,7 @@ export const INTERNAL_CONTROL_PLANE_SIGNATURE_HEADERS = [ const PUBLIC_KEY_ENV_VAR = "CHANNEL_DISPATCH_SIGNING_PUBLIC_KEY"; const MAX_SIGNATURE_AGE_SECONDS = 60; +const MAX_BRANCH_NAME_CODE_UNITS = 255; export type InternalControlPlaneRouteKind = "dispatch" | "control-plane" | "reserved" | "public"; @@ -89,6 +95,159 @@ export interface InternalControlPlaneProjectBinding { expectedProjectId?: string; } +export interface VerifiedControlPlaneBranchBinding { + branchId?: string; + branchName?: string; + defaultBranchName?: string; +} + +export class ControlPlaneBranchBindingError extends Error { + constructor( + readonly status: 400 | 401 | 413, + message: string, + ) { + super(message); + this.name = "ControlPlaneBranchBindingError"; + } +} + +function requireBranchName(value: unknown): string { + if ( + typeof value !== "string" || + value.length === 0 || + value.length > MAX_BRANCH_NAME_CODE_UNITS || + !isWellFormedString(value) || + value !== value.trim() + ) { + throw new ControlPlaneBranchBindingError(400, "Invalid control-plane branch target"); + } + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index); + if (code <= 0x1f || (code >= 0x7f && code <= 0x9f)) { + throw new ControlPlaneBranchBindingError(400, "Invalid control-plane branch target"); + } + } + return value; +} + +function parseVerifiedBranchBinding(rawBody: string): VerifiedControlPlaneBranchBinding { + let value: unknown; + try { + value = JSON.parse(rawBody); + } catch { + throw new ControlPlaneBranchBindingError(400, "Invalid control-plane request body"); + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new ControlPlaneBranchBindingError(400, "Invalid control-plane request body"); + } + + const request = value as Record; + const run = request.run; + if (!run || typeof run !== "object" || Array.isArray(run)) { + throw new ControlPlaneBranchBindingError(400, "Invalid control-plane request body"); + } + const project = (run as Record).project; + if (!project || typeof project !== "object" || Array.isArray(project)) { + throw new ControlPlaneBranchBindingError(400, "Invalid control-plane runtime target"); + } + const target = project as Record; + const source = request.agentSource; + if (!source || typeof source !== "object" || Array.isArray(source)) { + throw new ControlPlaneBranchBindingError(400, "Invalid control-plane source target"); + } + const sourceRecord = source as Record; + + switch (target.runtimeTargetKind ?? "main_branch") { + case "preview_branch": { + if ( + sourceRecord.type !== "branch" || + !isCanonicalOpaqueProjectIdentifier(target.runtimeTargetBranchId) || + target.runtimeTargetEnvironmentId !== null && + target.runtimeTargetEnvironmentId !== undefined + ) { + throw new ControlPlaneBranchBindingError(400, "Invalid control-plane preview target"); + } + return Object.freeze({ + branchId: target.runtimeTargetBranchId, + branchName: requireBranchName(sourceRecord.branch), + }); + } + case "main_branch": + if ( + target.runtimeTargetBranchId !== null && target.runtimeTargetBranchId !== undefined || + target.runtimeTargetEnvironmentId !== null && + target.runtimeTargetEnvironmentId !== undefined + ) { + throw new ControlPlaneBranchBindingError( + 400, + "Invalid control-plane default branch target", + ); + } + if (sourceRecord.type === "branch") { + return Object.freeze({ defaultBranchName: requireBranchName(sourceRecord.branch) }); + } + if (sourceRecord.type === "release") return Object.freeze({}); + throw new ControlPlaneBranchBindingError(400, "Invalid control-plane default branch source"); + case "environment": + if ( + sourceRecord.type !== "environment" || + !isCanonicalOpaqueProjectIdentifier(target.runtimeTargetEnvironmentId) || + target.runtimeTargetBranchId !== null && target.runtimeTargetBranchId !== undefined + ) { + throw new ControlPlaneBranchBindingError(400, "Invalid control-plane environment source"); + } + return Object.freeze({}); + default: + throw new ControlPlaneBranchBindingError(400, "Invalid control-plane runtime target"); + } +} + +/** + * Resolve branch identity only from a body-bound control-plane signature. + * Caller-provided branch headers are never consulted. + */ +export async function resolveVerifiedControlPlaneBranchBinding( + req: Request, + url: URL, + binding: InternalControlPlaneProjectBinding, +): Promise { + if ( + req.method.toUpperCase() !== "POST" || + !/^\/api\/control-plane\/runs\/[^/]+\/stream$/u.test(url.pathname) + ) { + return undefined; + } + + const jws = req.headers.get(CONTROL_PLANE_JWS_HEADER); + const publicKeyPem = getHostEnv(PUBLIC_KEY_ENV_VAR); + if (!jws || !publicKeyPem) { + throw new ControlPlaneBranchBindingError(401, "Invalid control-plane signature"); + } + + let rawBody: string; + try { + rawBody = await readBodyWithLimit(req.clone(), DEFAULT_MAX_BODY_SIZE_BYTES); + } catch (error) { + if (isRequestBodyTooLargeError(error)) { + throw new ControlPlaneBranchBindingError(413, "Control-plane request body is too large"); + } + throw error; + } + + const verified = await verifyControlPlaneJwsRequestSignature(jws, rawBody, { + publicKeyPem, + maxAgeSeconds: MAX_SIGNATURE_AGE_SECONDS, + audience: binding.audience, + expectedProjectId: binding.expectedProjectId, + requestMethod: req.method, + requestPath: url.pathname, + }); + if (!verified) { + throw new ControlPlaneBranchBindingError(401, "Invalid control-plane signature"); + } + return parseVerifiedBranchBinding(rawBody); +} + async function verifyInternalControlPlaneSignature( req: Request, url: URL, diff --git a/src/proxy/handler.test.ts b/src/proxy/handler.test.ts index c7c5d72f85..946e12b6a4 100644 --- a/src/proxy/handler.test.ts +++ b/src/proxy/handler.test.ts @@ -1,4 +1,4 @@ -import "#veryfront/schemas/_test-setup.ts"; +import { ensureTestSchemaValidator } from "#veryfront/schemas/_test-setup.ts"; import { assertEquals, assertNotEquals, @@ -8,6 +8,8 @@ import { import { afterEach, beforeEach, describe, it } from "#veryfront/testing/bdd"; import { createMockServer } from "../../tests/_helpers/utils.ts"; import { createProxyHandler, injectContextHeaders, type ProxyContext } from "./handler.ts"; +import { extractRequestHeaders } from "#veryfront/server/runtime-handler/project-resolution.ts"; +import { parseRuntimeAgentRunInvocation } from "#veryfront/agent/runtime/agent-invocation-contract.ts"; import { register, reset } from "../extensions/contracts.ts"; import type { AuthProvider, TokenHeader, TokenPayload } from "../extensions/auth/index.ts"; @@ -94,6 +96,7 @@ function base64urlBytes(bytes: Uint8Array): string { */ async function mintControlPlaneJws( overrides: Partial<{ + body: string; iss: string; aud: string; projectId: string; @@ -111,6 +114,10 @@ async function mintControlPlaneJws( const publicKeyPem = encodePem("PUBLIC KEY", der); const now = Math.floor(Date.now() / 1000); + const body = overrides.body ?? ""; + const requestHash = base64urlBytes( + new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(body))), + ); const header = { alg: "EdDSA", typ: "JWT" }; const claims = { iss: overrides.iss ?? "veryfront-api", @@ -118,7 +125,7 @@ async function mintControlPlaneJws( sub: "control-plane", surface: "channels", project_id: overrides.projectId ?? "proj-123", - request_hash: "n/a", + request_hash: requestHash, request_method: overrides.requestMethod ?? "POST", request_path: overrides.requestPath ?? "/api/control-plane/runs/run_1/stream", @@ -135,6 +142,49 @@ async function mintControlPlaneJws( }; } +function createSignedPreviewTargetBody( + branchId = "10000000-1000-4000-8000-100000000006", + branchName = "feature-branch", +): string { + return JSON.stringify({ + run: { + agentServiceId: "veryfront-platform-agent", + agentId: "assistant-1", + conversationId: "10000000-1000-4000-8000-100000000001", + runId: "run_1", + messageId: "10000000-1000-4000-8000-100000000002", + inputAnchorMessageId: "10000000-1000-4000-8000-100000000003", + requestedByUserId: "10000000-1000-4000-8000-100000000004", + project: { + projectId: "10000000-1000-4000-8000-100000000005", + projectSlug: "protected-project", + runtimeTargetKind: "preview_branch", + runtimeTargetBranchId: branchId, + }, + }, + agentSource: { type: "branch", branch: branchName }, + }); +} + +function createSignedDefaultBranchTargetBody(branchName = "trunk"): string { + return JSON.stringify({ + run: { + agentServiceId: "veryfront-platform-agent", + agentId: "assistant-1", + conversationId: "10000000-1000-4000-8000-100000000001", + runId: "run_1", + messageId: "10000000-1000-4000-8000-100000000002", + inputAnchorMessageId: "10000000-1000-4000-8000-100000000003", + requestedByUserId: "10000000-1000-4000-8000-100000000004", + project: { + projectId: "10000000-1000-4000-8000-100000000005", + projectSlug: "protected-project", + }, + }, + agentSource: { type: "branch", branch: branchName }, + }); +} + function createMockAuthProvider(options: MockAuthOptions = {}): AuthProvider { const secret = options.secret ?? TEST_JWT_SECRET; const jwksVerifiers = options.jwksVerifiers ?? new Map(); @@ -631,6 +681,8 @@ describe("Proxy Handler", () => { assertEquals(beforeActivation.error?.status, 404); assertEquals(afterActivation.error, undefined); assertEquals(afterActivation.releaseId, "rel-456"); + assertEquals(afterActivation.environmentId, "env-1"); + assertEquals(afterActivation.environmentName, "staging"); assertEquals(routingLookups, 2); await handler.close(); @@ -2176,27 +2228,39 @@ describe("Proxy Handler", () => { const previousKey = Deno.env.get("CHANNEL_DISPATCH_SIGNING_PUBLIC_KEY"); let handler: ReturnType | undefined; try { - const { jws, publicKeyPem } = await mintControlPlaneJws(); + const body = createSignedDefaultBranchTargetBody(); + const { jws, publicKeyPem } = await mintControlPlaneJws({ body }); Deno.env.set("CHANNEL_DISPATCH_SIGNING_PUBLIC_KEY", publicKeyPem); handler = createHandler(port); - const ctx = await handler.processRequest( - new Request( - "http://protected.example.com/api/control-plane/runs/run_1/stream", - { - method: "POST", - headers: { - host: "protected.example.com", - "x-token": "project-agent-token", - "x-veryfront-control-plane-jws": jws, - }, + const req = new Request( + "http://protected.example.com/api/control-plane/runs/run_1/stream", + { + method: "POST", + headers: { + host: "protected.example.com", + "x-token": "project-agent-token", + "x-veryfront-control-plane-jws": jws, }, - ), + body, + }, ); + const ctx = await handler.processRequest(req); assertEquals(ctx.error, undefined); assertEquals(ctx.projectSlug, "protected-project"); assertEquals(ctx.projectId, "proj-123"); assertEquals(ctx.releaseId, "rel-123"); + assertEquals(ctx.defaultBranchName, "trunk"); + const forwarded = injectContextHeaders(req, ctx); + const runtimeHeaders = extractRequestHeaders( + forwarded, + new URL(forwarded.url), + true, + true, + ); + assertEquals(runtimeHeaders.defaultBranchName, "trunk"); + assertEquals(runtimeHeaders.branchId, undefined); + assertEquals(runtimeHeaders.branchName, undefined); assertEquals(ctx.token, "project-agent-token"); assertEquals(metadataAuthorization, new Set(["Bearer project-agent-token"])); assertEquals(tokenEndpointHits, 0); @@ -2985,14 +3049,14 @@ describe("Proxy Handler", () => { assertEquals(ctx.error, undefined); assertEquals(ctx.projectSlug, "protected-project"); assertEquals(ctx.environmentId, "env-1"); - + assertEquals(ctx.environmentName, "preview"); await handler.close(); } finally { await server.shutdown(); } }); - it("allows cryptographically signed control-plane run stream requests through protected preview using inbound token", async () => { + it("forwards one canonical signed preview invocation through proxy and runtime parsing", async () => { let tokenEndpointHits = 0; const { server, port } = createMockServer((req: Request) => { const { pathname } = new URL(req.url); @@ -3021,7 +3085,8 @@ describe("Proxy Handler", () => { const previousKey = Deno.env.get("CHANNEL_DISPATCH_SIGNING_PUBLIC_KEY"); try { - const { jws, publicKeyPem } = await mintControlPlaneJws(); + const body = createSignedPreviewTargetBody(); + const { jws, publicKeyPem } = await mintControlPlaneJws({ body }); Deno.env.set("CHANNEL_DISPATCH_SIGNING_PUBLIC_KEY", publicKeyPem); const handler = createHandler(port); @@ -3034,6 +3099,7 @@ describe("Proxy Handler", () => { "x-token": "project-agent-token", "x-veryfront-control-plane-jws": jws, }, + body, }, ); @@ -3042,6 +3108,30 @@ describe("Proxy Handler", () => { assertEquals(ctx.error, undefined); assertEquals(ctx.projectSlug, "protected-project"); assertEquals(ctx.environmentId, "env-1"); + assertEquals(ctx.environmentName, "preview"); + assertEquals(ctx.branchId, "10000000-1000-4000-8000-100000000006"); + assertEquals(ctx.branchName, "feature-branch"); + const forwarded = injectContextHeaders(req, ctx); + ensureTestSchemaValidator(); + const invocation = await parseRuntimeAgentRunInvocation(forwarded.clone()); + assertEquals(invocation.run.project.runtimeTargetKind, "preview_branch"); + assertEquals( + invocation.run.project.runtimeTargetBranchId, + "10000000-1000-4000-8000-100000000006", + ); + assertEquals(invocation.agentSource, { + type: "branch", + branch: "feature-branch", + }); + const runtimeHeaders = extractRequestHeaders( + forwarded, + new URL(forwarded.url), + true, + true, + ); + assertEquals(runtimeHeaders.branchId, ctx.branchId); + assertEquals(runtimeHeaders.branchName, ctx.branchName); + assertEquals(runtimeHeaders.defaultBranchName, undefined); assertEquals(ctx.token, "project-agent-token"); assertEquals(tokenEndpointHits, 0); @@ -3056,6 +3146,78 @@ describe("Proxy Handler", () => { } }); + it("forwards signed unicode preview branch names through proxy and runtime parsing", async () => { + const { server, port } = createMockServer((req: Request) => { + const { pathname } = new URL(req.url); + + if (pathname === "/auth/token") return createTokenResponse(); + + if (pathname.startsWith("/projects/")) { + return Response.json({ + id: "proj-123", + slug: "protected-project", + name: "Protected Project", + environments: [{ + id: "env-1", + name: "preview", + active_release_id: "rel-123", + protected: true, + }], + }); + } + + return createNotFoundResponse(); + }); + + const previousKey = Deno.env.get("CHANNEL_DISPATCH_SIGNING_PUBLIC_KEY"); + try { + const branchName = "功能/新"; + const body = createSignedPreviewTargetBody( + "10000000-1000-4000-8000-100000000006", + branchName, + ); + const { jws, publicKeyPem } = await mintControlPlaneJws({ body }); + Deno.env.set("CHANNEL_DISPATCH_SIGNING_PUBLIC_KEY", publicKeyPem); + const handler = createHandler(port); + + const req = new Request( + "http://protected-project.preview.veryfront.com/api/control-plane/runs/run_1/stream", + { + method: "POST", + headers: { + host: "protected-project.preview.veryfront.com", + "x-token": "project-agent-token", + "x-veryfront-control-plane-jws": jws, + }, + body, + }, + ); + + const ctx = await handler.processRequest(req); + + assertEquals(ctx.error, undefined); + assertEquals(ctx.branchName, branchName); + const forwarded = injectContextHeaders(req, ctx); + const runtimeHeaders = extractRequestHeaders( + forwarded, + new URL(forwarded.url), + true, + true, + ); + assertEquals(runtimeHeaders.branchName, branchName); + assertEquals(runtimeHeaders.defaultBranchName, undefined); + + await handler.close(); + } finally { + if (previousKey === undefined) { + Deno.env.delete("CHANNEL_DISPATCH_SIGNING_PUBLIC_KEY"); + } else { + Deno.env.set("CHANNEL_DISPATCH_SIGNING_PUBLIC_KEY", previousKey); + } + await server.shutdown(); + } + }); + it("fails closed when an authentic control-plane signature names another project id", async () => { const { server, port } = createMockServer((req: Request) => { const { pathname } = new URL(req.url); @@ -3678,14 +3840,20 @@ describe("Proxy Handler", () => { }); describe("injectContextHeaders", () => { - it("includes x-environment-id when environmentId is present", () => { - const req = new Request("http://example.com/api/test"); + it("replaces client environment identity with the canonical proxy pair", () => { + const req = new Request("http://example.com/api/test", { + headers: { + "x-environment-id": "attacker-environment", + "x-environment-name": "attacker-name", + }, + }); const ctx: ProxyContext = { token: "test-token", projectSlug: "my-project", projectId: "proj-123", releaseId: "rel-456", environmentId: "env-789", + environmentName: "staging", environment: "production", contentSourceId: "cs-123", host: "example.com", @@ -3702,6 +3870,7 @@ describe("Proxy Handler", () => { const injected = injectContextHeaders(req, ctx); assertEquals(injected.headers.get("x-environment-id"), "env-789"); + assertEquals(injected.headers.get("x-environment-name"), "staging"); assertEquals(injected.headers.get("x-project-id"), "proj-123"); assertEquals(injected.headers.get("x-release-id"), "rel-456"); }); @@ -3727,6 +3896,69 @@ describe("Proxy Handler", () => { const injected = injectContextHeaders(req, ctx); assertEquals(injected.headers.get("x-environment-id"), null); + assertEquals(injected.headers.get("x-environment-name"), null); + }); + + it("refuses to forward a partial environment identity", () => { + const req = new Request("http://example.com/api/test"); + const ctx: ProxyContext = { + projectSlug: "my-project", + environmentId: "env-789", + environment: "production", + contentSourceId: "cs-123", + host: "example.com", + parsedDomain: { + slug: "my-project", + branch: null, + environment: "production", + isVeryfrontDomain: false, + isDraft: false, + allowIframeEmbed: false, + }, + isLocalProject: false, + }; + + assertThrows( + () => injectContextHeaders(req, ctx), + TypeError, + "requires both environmentId and environmentName", + ); + }); + + it("refuses partial or ambiguous branch identity", () => { + const req = new Request("http://example.com/api/test"); + const base: ProxyContext = { + projectSlug: "my-project", + environment: "preview", + contentSourceId: "cs-123", + host: "example.com", + parsedDomain: { + slug: "my-project", + branch: null, + environment: "preview", + isVeryfrontDomain: true, + isDraft: true, + allowIframeEmbed: true, + }, + isLocalProject: false, + }; + + assertThrows( + () => injectContextHeaders(req, { ...base, branchId: "branch-1" }), + TypeError, + "requires both branchId and branchName", + ); + assertThrows( + () => + injectContextHeaders(req, { + ...base, + branchId: "branch-1", + branchName: "feature", + defaultBranchName: "trunk", + }), + TypeError, + "cannot be both preview and default", + ); }); }); }); diff --git a/src/proxy/handler.ts b/src/proxy/handler.ts index 03496d00eb..edcd7babbe 100644 --- a/src/proxy/handler.ts +++ b/src/proxy/handler.ts @@ -14,9 +14,12 @@ import { import { profileProxyServerTimingPhase, type ProxyServerTiming } from "./server-timing.ts"; import { classifyInternalControlPlaneRequest, + ControlPlaneBranchBindingError, isAuthenticInternalControlPlaneCandidate, isVerifiedInternalControlPlaneRequest, + resolveVerifiedControlPlaneBranchBinding, } from "./control-plane-signature.ts"; +import { encodeIdentityHeaderValue } from "#veryfront/utils/header-identity.ts"; import { createProjectMetadataClient, type DomainLookupResult, @@ -36,6 +39,7 @@ export const INTERNAL_PROXY_HEADERS = [ "x-project-slug", "x-environment", "x-environment-id", + "x-environment-name", "x-content-source-id", "x-forwarded-host", "x-project-path", @@ -43,6 +47,7 @@ export const INTERNAL_PROXY_HEADERS = [ "x-release-id", "x-branch-id", "x-branch-name", + "x-default-branch-name", ] as const; interface ProjectRoutingCacheEntry { @@ -106,7 +111,9 @@ export interface ProxyContext { releaseId?: string; branchId?: string; branchName?: string; + defaultBranchName?: string; environmentId?: string; + environmentName?: string; environment: "preview" | "production"; contentSourceId: string; localPath?: string; @@ -127,6 +134,7 @@ type ResolvedProjectMetadata = projectSlug?: string; releaseId?: string; environmentId?: string; + environmentName?: string; signedInternalControlPlaneRequest?: boolean; } | { @@ -619,6 +627,7 @@ export function createProxyHandler(options: ProxyHandlerOptions) { projectSlug: lookupResult.slug, releaseId: matchingEnv?.active_release_id ?? undefined, environmentId: matchingEnv?.id, + environmentName: matchingEnv?.name, signedInternalControlPlaneRequest, }; } @@ -693,7 +702,12 @@ export function createProxyHandler(options: ProxyHandlerOptions) { const routingEnv = routingResult.environments.find(envMatcher); const accessEnv = accessResult.environments.find(envMatcher); - if (!routingEnv || !accessEnv || routingEnv.id !== accessEnv.id) { + if ( + !routingEnv || + !accessEnv || + routingEnv.id !== accessEnv.id || + routingEnv.name !== accessEnv.name + ) { routingLookupCache.delete(normalizeProjectLookupKey(lookupKey)); return await resolveFullProjectLookupAndProtection( req, @@ -738,6 +752,7 @@ export function createProxyHandler(options: ProxyHandlerOptions) { projectSlug: routingResult.slug, releaseId: routingEnv?.active_release_id ?? undefined, environmentId: routingEnv?.id, + environmentName: routingEnv?.name, signedInternalControlPlaneRequest, }; }, @@ -763,6 +778,10 @@ export function createProxyHandler(options: ProxyHandlerOptions) { let projectId: string | undefined; let releaseId: string | undefined; let environmentId: string | undefined; + let environmentName: string | undefined; + let branchId: string | undefined; + let branchName: string | undefined; + let defaultBranchName: string | undefined; const isCustomDomain = !projectSlug && !parsedDomain.isVeryfrontDomain; // The first pass authenticates the candidate so its x-token can perform the @@ -1083,6 +1102,7 @@ export function createProxyHandler(options: ProxyHandlerOptions) { projectId = resolved.projectId; releaseId = resolved.releaseId; environmentId = resolved.environmentId; + environmentName = resolved.environmentName; signedInternalControlPlaneRequest = resolved.signedInternalControlPlaneRequest ?? false; logger?.info("Resolved custom domain to project", { @@ -1125,6 +1145,7 @@ export function createProxyHandler(options: ProxyHandlerOptions) { projectId = resolved.projectId; releaseId = resolved.releaseId; environmentId = resolved.environmentId; + environmentName = resolved.environmentName; signedInternalControlPlaneRequest = resolved.signedInternalControlPlaneRequest ?? false; logger?.info("Resolved veryfront domain to project", { @@ -1161,6 +1182,7 @@ export function createProxyHandler(options: ProxyHandlerOptions) { projectId = resolved.projectId; environmentId = resolved.environmentId; + environmentName = resolved.environmentName; signedInternalControlPlaneRequest = resolved.signedInternalControlPlaneRequest ?? false; if (projectId) { @@ -1180,6 +1202,26 @@ export function createProxyHandler(options: ProxyHandlerOptions) { }); } + if (signedInternalControlPlaneRequest && projectSlug && projectId) { + try { + const branchBinding = await resolveVerifiedControlPlaneBranchBinding(req, url, { + audience: projectSlug, + expectedProjectId: projectId, + }); + branchId = branchBinding?.branchId; + branchName = branchBinding?.branchName; + defaultBranchName = branchBinding?.defaultBranchName; + } catch (error) { + if (error instanceof ControlPlaneBranchBindingError) { + return createProxyErrorContext(base, { + status: error.status, + message: error.message, + }); + } + throw error; + } + } + if (scope === "production" && projectSlug && !releaseId && !isLocalProject) { logger?.warn("No active release found", { projectSlug, @@ -1202,7 +1244,11 @@ export function createProxyHandler(options: ProxyHandlerOptions) { projectSlug, projectId, releaseId, + branchId, + branchName, + defaultBranchName, environmentId, + environmentName, contentSourceId, environment: scope, localPath, @@ -1265,6 +1311,17 @@ export function createProxyContextHeaders( sourceHeaders: Headers, ctx: ProxyContext, ): Headers { + if (Boolean(ctx.environmentId) !== Boolean(ctx.environmentName)) { + throw new TypeError( + "Proxy environment identity requires both environmentId and environmentName", + ); + } + if (Boolean(ctx.branchId) !== Boolean(ctx.branchName)) { + throw new TypeError("Proxy preview branch identity requires both branchId and branchName"); + } + if (ctx.branchId && ctx.defaultBranchName) { + throw new TypeError("Proxy branch identity cannot be both preview and default"); + } const headers = createProxyEndToEndHeaders(sourceHeaders); for (const header of INTERNAL_PROXY_HEADERS) headers.delete(header); @@ -1285,9 +1342,13 @@ export function createProxyContextHeaders( if (ctx.projectId) headers.set("x-project-id", ctx.projectId); if (ctx.releaseId) headers.set("x-release-id", ctx.releaseId); if (ctx.environmentId) headers.set("x-environment-id", ctx.environmentId); + if (ctx.environmentName) headers.set("x-environment-name", ctx.environmentName); if (ctx.branchId) headers.set("x-branch-id", ctx.branchId); - if (ctx.branchName) headers.set("x-branch-name", ctx.branchName); + if (ctx.branchName) headers.set("x-branch-name", encodeIdentityHeaderValue(ctx.branchName)); + if (ctx.defaultBranchName) { + headers.set("x-default-branch-name", encodeIdentityHeaderValue(ctx.defaultBranchName)); + } headers.delete("host"); return headers; diff --git a/src/proxy/mode-parity.test.ts b/src/proxy/mode-parity.test.ts index 56c1b0df82..307d3e57cc 100644 --- a/src/proxy/mode-parity.test.ts +++ b/src/proxy/mode-parity.test.ts @@ -22,6 +22,7 @@ function extractProxyHeaders(req: Request): Record { "x-project-slug": req.headers.get("x-project-slug"), "x-environment": req.headers.get("x-environment"), "x-environment-id": req.headers.get("x-environment-id"), + "x-environment-name": req.headers.get("x-environment-name"), "x-content-source-id": req.headers.get("x-content-source-id"), "x-forwarded-host": req.headers.get("x-forwarded-host"), "x-project-path": req.headers.get("x-project-path"), @@ -29,6 +30,7 @@ function extractProxyHeaders(req: Request): Record { "x-release-id": req.headers.get("x-release-id"), "x-branch-id": req.headers.get("x-branch-id"), "x-branch-name": req.headers.get("x-branch-name"), + "x-default-branch-name": req.headers.get("x-default-branch-name"), }; } @@ -206,6 +208,7 @@ describe("Proxy-Renderer Mode Parity", () => { "x-project-path": "/tmp/attacker", "x-token": "attacker-token", "x-environment": "production", + "x-default-branch-name": "attacker-default", }, }); @@ -214,6 +217,7 @@ describe("Proxy-Renderer Mode Parity", () => { assertEquals(injected.headers.get("x-project-path"), null); assertEquals(injected.headers.get("x-token"), null); assertEquals(injected.headers.get("x-environment"), "preview"); + assertEquals(injected.headers.get("x-default-branch-name"), null); }); it("replaces every internal proxy header with proxy-derived values", () => { @@ -225,6 +229,7 @@ describe("Proxy-Renderer Mode Parity", () => { branchId: "branch-id", branchName: "feature-branch", environmentId: "env-id", + environmentName: "production", environment: "production", contentSourceId: "release-rel-id", host: "proj.production.veryfront.com", @@ -257,6 +262,7 @@ describe("Proxy-Renderer Mode Parity", () => { assertEquals(headers["x-project-slug"], "proj"); assertEquals(headers["x-environment"], "production"); assertEquals(headers["x-environment-id"], "env-id"); + assertEquals(headers["x-environment-name"], "production"); assertEquals(headers["x-content-source-id"], "release-rel-id"); assertEquals(headers["x-forwarded-host"], "proj.production.veryfront.com"); assertEquals(headers["x-project-path"], null); @@ -264,9 +270,38 @@ describe("Proxy-Renderer Mode Parity", () => { assertEquals(headers["x-release-id"], "rel-id"); assertEquals(headers["x-branch-id"], "branch-id"); assertEquals(headers["x-branch-name"], "feature-branch"); + assertEquals(headers["x-default-branch-name"], null); assertEquals(injected.headers.get("accept"), "text/html"); }); + it("injects a trusted non-main default branch without preview identity", () => { + const ctx: ProxyContext = { + projectSlug: "proj", + projectId: "proj-id", + defaultBranchName: "trunk", + environment: "preview", + contentSourceId: "preview-trunk", + host: "proj.preview.veryfront.com", + parsedDomain: { + slug: "proj", + isVeryfrontDomain: true, + environment: "preview", + branch: null, + isDraft: true, + allowIframeEmbed: true, + }, + isLocalProject: false, + }; + const injected = injectContextHeaders( + new Request("http://proj.preview.veryfront.com/page"), + ctx, + ); + + assertEquals(injected.headers.get("x-default-branch-name"), "trunk"); + assertEquals(injected.headers.get("x-branch-id"), null); + assertEquals(injected.headers.get("x-branch-name"), null); + }); + it("preserves request cancellation in the injected request", () => { const controller = new AbortController(); const original = new Request("http://proj.preview.veryfront.com/page", { diff --git a/src/proxy/split-forward-request.test.ts b/src/proxy/split-forward-request.test.ts index a4f9b5a54b..c8a81e18e3 100644 --- a/src/proxy/split-forward-request.test.ts +++ b/src/proxy/split-forward-request.test.ts @@ -29,6 +29,7 @@ Deno.test("split proxy forwarding uses the shared end-to-end header policy", () projectSlug: "project", projectId: "project-id", environmentId: "environment-id", + environmentName: "preview", environment: "preview", contentSourceId: "preview-main", host: "project.preview.veryfront.test", @@ -63,4 +64,5 @@ Deno.test("split proxy forwarding uses the shared end-to-end header policy", () assertEquals(headers.get("x-content-source-id"), "preview-main"); assertEquals(headers.get("x-project-id"), "project-id"); assertEquals(headers.get("x-environment-id"), "environment-id"); + assertEquals(headers.get("x-environment-name"), "preview"); }); diff --git a/src/release-assets/manifest-cache.ts b/src/release-assets/manifest-cache.ts index bf5d45e85f..0490cbc12b 100644 --- a/src/release-assets/manifest-cache.ts +++ b/src/release-assets/manifest-cache.ts @@ -307,7 +307,7 @@ export function getReadyManifestForRender( * falls back to null when the flag is off, no fetcher is registered, the * manifest is unavailable, or the fetch fails. */ -export async function getReadyManifestForRenderAsync( +async function getReadyManifestAsync( releaseId: string | null | undefined, options: ReadyManifestReadOptions = {}, ): Promise { @@ -319,10 +319,6 @@ export async function getReadyManifestForRenderAsync( markManifestDecision("invalid_release_id"); return null; } - if (!isReleaseAssetManifestEnabled()) { - markManifestDecision("disabled"); - return null; - } const activeFetcher = fetcherRegistry.get(releaseId); if (!activeFetcher) { markManifestDecision("no_fetcher"); @@ -355,6 +351,29 @@ export async function getReadyManifestForRenderAsync( return await fetchManifest(releaseId); } +export async function getReadyManifestForRenderAsync( + releaseId: string | null | undefined, + options: ReadyManifestReadOptions = {}, +): Promise { + if (!isReleaseAssetManifestEnabled()) { + markManifestDecision("disabled"); + return null; + } + return await getReadyManifestAsync(releaseId, options); +} + +/** + * Resolve the release manifest used as the production browser-module admission + * boundary. Unlike rendering optimizations, this security decision is never + * controlled by the release-manifest rollout flag. + */ +export async function getReadyManifestForBrowserModuleAdmission( + releaseId: string | null | undefined, + options: ReadyManifestReadOptions = {}, +): Promise { + return await getReadyManifestAsync(releaseId, options); +} + function scheduleFetch(releaseId: string): void { void fetchManifest(releaseId); } diff --git a/src/security/README.md b/src/security/README.md index af450fcea2..a9c1e7450c 100644 --- a/src/security/README.md +++ b/src/security/README.md @@ -69,6 +69,34 @@ Ambiguous environment configuration fails closed. Unauthorized responses are non-cacheable and receive the resolved CORS and security policy. Credential verification uses constant-time comparison. +### Shared proxy identity and project environments + +Production shared-proxy mode requires an operator-owned, private edge and +`VERYFRONT_TRUST_FORWARDED_HEADERS=1`. That edge must remove client-supplied +forwarding, project, environment, release, branch, path, and token headers +before setting the canonical values. The runtime origin must not be reachable +directly. Every tenant-bearing route, including module and WebSocket routes, +requires the edge-supplied project slug and request credential; a process-level +API token is never substituted for a missing shared-request credential. + +Project and environment IDs become cache or secret-fetch authority only after +that same proxy trust check succeeds. The proxy forwards an environment ID only +with the canonical environment name selected from project metadata; the runtime +rejects partial pairs and ignores both headers outside the trusted topology. +Environment cache identity includes the canonical project slug, project ID, +environment ID, and a digest of the request credential. Fetch failure, timeout, +credential rejection, or explicit invalidation never returns stale or empty +secret data. + +If both `VERYFRONT_API_INTERNAL_USER` and `VERYFRONT_API_INTERNAL_PASS` are +configured, `VERYFRONT_API_BASE_URL` must provide the canonical +`/internal/project-environment-variables` endpoint. Before using those host +credentials, the runtime verifies the request bearer token against the +project-scoped management endpoint. A missing, redirected, or failed internal +endpoint is an error; there is no compatibility fallback to masked management +values. Leave both internal credential variables unset when that endpoint is +not deployed. + ### Input validation Each standalone body, form, and query parser applies the same snapshotted diff --git a/src/security/http/base-handler.test.ts b/src/security/http/base-handler.test.ts index 5b553407da..c1d0fe6e1f 100644 --- a/src/security/http/base-handler.test.ts +++ b/src/security/http/base-handler.test.ts @@ -217,7 +217,7 @@ describe("BaseHandler.withProxyContext", () => { } }); - it("runs fn() when requireToken is true and token is present", async () => { + it("does not treat the host token as a request credential", async () => { setEnv("VERYFRONT_API_TOKEN", "vf_test_token"); const handler = new TestHandler(); let called = false; @@ -231,7 +231,9 @@ describe("BaseHandler.withProxyContext", () => { { requireToken: true }, ); - assertEquals(called, true, "fn should run with valid token"); + // This standalone test adapter is not credentialed, so direct execution is + // still valid. Contextual adapters below must reject without ctx.proxyToken. + assertEquals(called, true); }); it("runs fn() when requireToken is false even without token", async () => { @@ -286,6 +288,7 @@ describe("BaseHandler.withProxyContext", () => { for (const mode of ["multi-project", "contextual"] as const) { it(`rejects missing required credentials before ${mode} filesystem work`, async () => { + setEnv("VERYFRONT_API_TOKEN", "must-not-be-used-for-request-identity"); const handler = new TestHandler(); let callbackCalled = false; const fs = mode === "multi-project" diff --git a/src/security/http/base-handler.ts b/src/security/http/base-handler.ts index 7c1801ecef..7bbbf58556 100644 --- a/src/security/http/base-handler.ts +++ b/src/security/http/base-handler.ts @@ -140,9 +140,11 @@ export abstract class BaseHandler implements Handler { ); } - // Framework-owned token: bypass project env overlay so proxy mode works - // when a remote project overlay is active. - const effectiveToken = ctx.proxyToken || getHostEnv("VERYFRONT_API_TOKEN") || ""; + // Credential selection happens once at request admission. Falling back to + // the process token here would combine attacker-selected tenant identity + // with a host credential on routes that reached this helper without a + // trusted proxy context. + const effectiveToken = ctx.proxyToken || ""; const fsWrapper = ctx.adapter.fs as { setRequestToken?: (t: string) => void; setRequestBranch?: (b: string | null) => void; diff --git a/src/security/sandbox/worker-pool.test.ts b/src/security/sandbox/worker-pool.test.ts index 6fa311932e..eea1a4a7cf 100644 --- a/src/security/sandbox/worker-pool.test.ts +++ b/src/security/sandbox/worker-pool.test.ts @@ -438,27 +438,36 @@ testSuite("WorkerPool", () => { const controlled = createControlledPool(); await pool.shutdown(); pool = controlled.pool; + const projectRoot = Deno.makeTempDirSync({ + prefix: "vf-worker-permissions-project-", + }); - const stream = pool.executeStream( - "ssr-permissions", - ["/tmp"], - makeSSRRequest("ssr-permissions-request"), - ); - const worker = latestWorker(controlled.workers, "ssr-permissions"); - const readPermissions = worker.permissions.read; - assert(Array.isArray(readPermissions)); - assert( - TEST_ISOLATED_SSR_RENDERER_PROVIDER.readRootUrls.every((rootUrl) => - readPermissions.includes(Deno.realPathSync(fromFileUrl(rootUrl))) - ), - ); - assertEquals( - worker.isolatedSsrRendererModuleUrl, - TEST_ISOLATED_SSR_RENDERER_PROVIDER.moduleUrl, - ); + try { + const stream = pool.executeStream( + "ssr-permissions", + [projectRoot], + makeSSRRequest("ssr-permissions-request", { + pageModulePath: `${projectRoot}/page.tsx`, + }), + ); + const worker = latestWorker(controlled.workers, "ssr-permissions"); + const readPermissions = worker.permissions.read; + assert(Array.isArray(readPermissions)); + assert( + TEST_ISOLATED_SSR_RENDERER_PROVIDER.readRootUrls.every((rootUrl) => + readPermissions.includes(Deno.realPathSync(fromFileUrl(rootUrl))) + ), + ); + assertEquals( + worker.isolatedSsrRendererModuleUrl, + TEST_ISOLATED_SSR_RENDERER_PROVIDER.moduleUrl, + ); - worker.completeStream("ssr-permissions-request"); - await new Response(stream).arrayBuffer(); + worker.completeStream("ssr-permissions-request"); + await new Response(stream).arrayBuffer(); + } finally { + Deno.removeSync(projectRoot, { recursive: true }); + } }); it("returns the same worker for the same project", () => { diff --git a/src/server/bootstrap.test.ts b/src/server/bootstrap.test.ts index 975cca4471..b8887674a5 100644 --- a/src/server/bootstrap.test.ts +++ b/src/server/bootstrap.test.ts @@ -43,6 +43,7 @@ const validationEnvKeys = [ "PROXY_MODE", "VERYFRONT_CLI_LOCAL_PROXY_MODE", "CHANNEL_DISPATCH_SIGNING_PUBLIC_KEY", + "VERYFRONT_TRUST_FORWARDED_HEADERS", ] as const; const originalValidationEnv = new Map( validationEnvKeys.map((key) => [key, Deno.env.get(key)]), @@ -299,6 +300,7 @@ describe("validateProductionEnvironmentForTests()", () => { Deno.env.set("NODE_ENV", "staging"); Deno.env.delete("DENO_ENV"); Deno.env.set("CHANNEL_DISPATCH_SIGNING_PUBLIC_KEY", "test-public-key"); + Deno.env.set("VERYFRONT_TRUST_FORWARDED_HEADERS", "1"); const warnings = captureWarns(() => validateProductionEnvironmentForTests()); @@ -308,6 +310,21 @@ describe("validateProductionEnvironmentForTests()", () => { ); assertEquals(warnings.some((message) => message.includes("%s")), false); }); + + it("rejects hosted proxy mode without an explicit trusted topology", () => { + Deno.env.set("PROXY_MODE", "1"); + Deno.env.delete("VERYFRONT_CLI_LOCAL_PROXY_MODE"); + Deno.env.set("NODE_ENV", "production"); + Deno.env.delete("DENO_ENV"); + Deno.env.set("CHANNEL_DISPATCH_SIGNING_PUBLIC_KEY", "test-public-key"); + Deno.env.delete("VERYFRONT_TRUST_FORWARDED_HEADERS"); + + assertThrows( + () => validateProductionEnvironmentForTests(), + Error, + "VERYFRONT_TRUST_FORWARDED_HEADERS must be exactly '1'", + ); + }); }); describe("teardownFileLog()", () => { diff --git a/src/server/bootstrap.ts b/src/server/bootstrap.ts index 0c753fb85f..17f55427d2 100644 --- a/src/server/bootstrap.ts +++ b/src/server/bootstrap.ts @@ -40,6 +40,7 @@ import { getErrorMessage, INVALID_ARGUMENT } from "#veryfront/errors"; import { enhanceAdapterWithFS } from "#veryfront/platform/adapters/fs/integration.ts"; import { isExtendedFSAdapter } from "#veryfront/platform/adapters/fs/wrapper.ts"; import { getEnv, getHostEnv } from "#veryfront/platform/compat/process.ts"; +import { isProxyTopologyTrusted } from "#veryfront/platform/compat/proxy-topology.ts"; import { initializeEsbuild } from "#veryfront/platform/compat/esbuild.ts"; import { __registerLogRecordEmitter, logger } from "#veryfront/utils"; import { isDebugEnabled } from "#veryfront/utils/constants/env.ts"; @@ -610,6 +611,17 @@ function validateProductionEnvironment(): void { "CHANNEL_DISPATCH_SIGNING_PUBLIC_KEY must be set when running in proxy mode (PROXY_MODE=1)", }); } + + if (!isProxyTopologyTrusted()) { + logger.error( + "[Bootstrap:Prod] CRITICAL: proxy mode does not trust its upstream topology. " + + "Set VERYFRONT_TRUST_FORWARDED_HEADERS=1 only when this process is private behind a sanitising edge.", + ); + throw INVALID_ARGUMENT.create({ + detail: + "VERYFRONT_TRUST_FORWARDED_HEADERS must be exactly '1' for hosted proxy mode behind a sanitising edge", + }); + } } // Log effective configuration for debugging diff --git a/src/server/context/request-context.test.ts b/src/server/context/request-context.test.ts index 6692e70402..a0c8959a81 100644 --- a/src/server/context/request-context.test.ts +++ b/src/server/context/request-context.test.ts @@ -284,6 +284,20 @@ describe("createRequestContext", () => { assertEquals(typeof ctx.token, "string"); }); + it("disables host-token fallback for shared proxy admission", () => { + Deno.env.set("VERYFRONT_API_TOKEN", "host-only-secret"); + try { + const req = makeRequest("https://example.com/page", { + host: "example.com", + "x-project-slug": "attacker-selected-project", + }); + const ctx = createRequestContext(req, { allowHostTokenFallback: false }); + assertEquals(ctx.token, ""); + } finally { + Deno.env.delete("VERYFRONT_API_TOKEN"); + } + }); + it("defaults slug to empty string when no header and no domain slug", () => { const req = makeRequest("https://example.com/page", { host: "example.com", diff --git a/src/server/context/request-context.ts b/src/server/context/request-context.ts index 09e1c39790..08db6a7c54 100644 --- a/src/server/context/request-context.ts +++ b/src/server/context/request-context.ts @@ -12,6 +12,12 @@ export interface RequestContext { export interface CreateRequestContextOptions { /** Whether the request has already passed the proxy trust check. */ proxyTrusted?: boolean; + /** + * Whether a missing request credential may use the standalone host API + * token. Shared proxy requests must set this to false so attacker-selected + * project identity is never combined with a host credential. + */ + allowHostTokenFallback?: boolean; } export function createRequestContext( @@ -45,7 +51,8 @@ export function createRequestContext( return { // Framework-owned token: bypass project env overlay so proxy mode works // when a remote project overlay is active. - token: req.headers.get("x-token") ?? getHostEnv("VERYFRONT_API_TOKEN") ?? "", + token: req.headers.get("x-token") ?? + (options.allowHostTokenFallback === false ? "" : getHostEnv("VERYFRONT_API_TOKEN") ?? ""), slug: headerProjectSlug ?? parsed.slug ?? "", branch: parsed.branch, mode, diff --git a/src/server/handlers/dev/files/esbuild-plugins.ts b/src/server/handlers/dev/files/esbuild-plugins.ts index f1cb801f1c..384feb3424 100644 --- a/src/server/handlers/dev/files/esbuild-plugins.ts +++ b/src/server/handlers/dev/files/esbuild-plugins.ts @@ -1,5 +1,6 @@ import type { OnLoadArgs, OnResolveArgs, Plugin, PluginBuild } from "veryfront/extensions/bundler"; import { NETWORK_ERROR } from "#veryfront/errors"; +import { isCanonicalNotFoundError } from "#veryfront/platform/compat/not-found-error.ts"; // Direct import from base.ts to avoid circular dependency through barrel import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; import { wrapWithCurrentContext } from "#veryfront/platform/adapters/fs/veryfront/multi-project-adapter.ts"; @@ -55,6 +56,11 @@ interface ProjectFsPluginData { export interface RelativeFsPluginOptions { enforceBrowserBoundaries?: boolean; + /** + * Root-bound, symlink-safe, bounded read authority for browser builds. When + * supplied, the read itself replaces mutable directory-walk admission. + */ + readBrowserModule?: (path: string) => Promise; } function getLoaderForPath(path: string): EsbuildLoader { @@ -184,11 +190,13 @@ export function createRelativeFsPlugin( const st = await adapter.fs.stat(f); if (st.isFile) { if (options.enforceBrowserBoundaries) { - const pathStatus = await inspectBrowserModulePath(projectDir, f, adapter); - if (pathStatus !== "trusted") { - return { - errors: [{ text: dependencyPathError(pathStatus), location: null }], - }; + if (!options.readBrowserModule) { + const pathStatus = await inspectBrowserModulePath(projectDir, f, adapter); + if (pathStatus !== "trusted") { + return { + errors: [{ text: dependencyPathError(pathStatus), location: null }], + }; + } } return { path: getProjectModuleIdentity(projectDir, f), @@ -198,8 +206,9 @@ export function createRelativeFsPlugin( } return { path: f }; } - } catch (_) { - // expected: candidate path doesn't exist, try next + } catch (error) { + // Only a genuine missing candidate is a resolution miss. + if (!isCanonicalNotFoundError(error)) throw error; } } @@ -235,7 +244,7 @@ export function createRelativeFsPlugin( }], }; } - if (enforceBrowserBoundaries) { + if (enforceBrowserBoundaries && !options.readBrowserModule) { const pathStatus = await inspectBrowserModulePath(projectDir, filePath, adapter); if (pathStatus !== "trusted") { return { @@ -244,7 +253,9 @@ export function createRelativeFsPlugin( } } try { - const contents = await adapter.fs.readFile(filePath); + const contents = enforceBrowserBoundaries && options.readBrowserModule + ? await options.readBrowserModule(filePath) + : await adapter.fs.readFile(filePath); if (enforceBrowserBoundaries) { const violation = await inspectBrowserModuleBoundary(contents, filePath); if (violation) { diff --git a/src/server/handlers/preview/hmr.handler.test.ts b/src/server/handlers/preview/hmr.handler.test.ts index f3b29df702..0718ba8bb9 100644 --- a/src/server/handlers/preview/hmr.handler.test.ts +++ b/src/server/handlers/preview/hmr.handler.test.ts @@ -238,7 +238,7 @@ describe("server/handlers/preview/hmr.handler", () => { assertEquals(result.continue, false); }); - it("proceeds when x-environment=preview query param is set", async () => { + it("does not let x-environment=preview query param unlock HMR", async () => { const handler = new HMRHandler(); const req = new Request("http://example.com/_ws?x-environment=preview"); const ctx = makeCtx({ @@ -246,10 +246,10 @@ describe("server/handlers/preview/hmr.handler", () => { adapter: createMockAdapter({ upgradeWebSocket: undefined }), }); const result = await handler.handle(req, ctx); - assertEquals(result.continue, false); + assertEquals(result.continue, true); }); - it("proceeds when host header is localhost", async () => { + it("does not treat a client-controlled localhost Host as local-project proof", async () => { const handler = new HMRHandler(); const req = new Request("http://example.com/_ws", { headers: { host: "localhost:3000" }, @@ -259,16 +259,15 @@ describe("server/handlers/preview/hmr.handler", () => { adapter: createMockAdapter({ upgradeWebSocket: undefined }), }); const result = await handler.handle(req, ctx); - assertEquals(result.continue, false); + assertEquals(result.continue, true); }); - it("proceeds when x-forwarded-host is a local preview host AND request is proxy-trusted", async () => { + it("does not infer preview authorization from forwarded host inside the handler", async () => { const handler = new HMRHandler(); const req = new Request("http://example.com/_ws", { headers: { host: "internal.proxy:3000", "x-forwarded-host": "preview.veryfront.me:3000", - "x-veryfront-dispatch-jws": await mintTrustedDispatchJws(), }, }); const ctx = makeCtx({ @@ -276,7 +275,7 @@ describe("server/handlers/preview/hmr.handler", () => { adapter: createMockAdapter({ upgradeWebSocket: undefined }), }); const result = await handler.handle(req, ctx); - assertEquals(result.continue, false); + assertEquals(result.continue, true); }); it("continues when x-forwarded-host is external even if host header is localhost", async () => { @@ -285,7 +284,6 @@ describe("server/handlers/preview/hmr.handler", () => { headers: { host: "localhost:3000", "x-forwarded-host": "evil.example.com", - "x-veryfront-dispatch-jws": await mintTrustedDispatchJws(), }, }); const ctx = makeCtx({ @@ -331,7 +329,7 @@ describe("server/handlers/preview/hmr.handler", () => { assertEquals(result.continue, true); }); - it("HONOURS x-forwarded-host: localhost when request IS proxy-trusted", async () => { + it("does not let a valid dispatch JWS promote forwarded-host trust", async () => { const handler = new HMRHandler(); const req = new Request("http://internal.proxy/_ws", { headers: { @@ -342,11 +340,10 @@ describe("server/handlers/preview/hmr.handler", () => { }); const ctx = makeCtx({ isLocalProject: false, - adapter: createMockAdapter({ upgradeWebSocket: undefined }), + requestContext: { mode: "production" } as any, }); const result = await handler.handle(req, ctx); - // Handler path entered — not short-circuited. - assertEquals(result.continue, false); + assertEquals(result.continue, true); }); it( @@ -375,7 +372,7 @@ describe("server/handlers/preview/hmr.handler", () => { }, ); - it("HONOURS raw Host: localhost even without proxy trust (bare-metal local dev)", async () => { + it("does not let raw Host localhost replace local-project resolution", async () => { const handler = new HMRHandler(); const req = new Request("http://localhost:3000/_ws", { headers: { host: "localhost:3000" }, @@ -385,7 +382,7 @@ describe("server/handlers/preview/hmr.handler", () => { adapter: createMockAdapter({ upgradeWebSocket: undefined }), }); const result = await handler.handle(req, ctx); - assertEquals(result.continue, false); + assertEquals(result.continue, true); }); it('treats "localhost.evil.com" as non-local (must not match by prefix)', async () => { diff --git a/src/server/handlers/preview/hmr.handler.ts b/src/server/handlers/preview/hmr.handler.ts index e1a053227d..6aa325ebd3 100644 --- a/src/server/handlers/preview/hmr.handler.ts +++ b/src/server/handlers/preview/hmr.handler.ts @@ -11,7 +11,6 @@ import { import { ReloadNotifier } from "../../reload-notifier.ts"; import { invalidateProjectCaches } from "../../context/cache-invalidation.ts"; import { isExtendedFSAdapter } from "#veryfront/platform/adapters/fs/wrapper.ts"; -import { isLocalDevHost } from "../../utils/domain-parser.ts"; import { addClient, clearAll, @@ -23,9 +22,6 @@ import { import { handleHmrClientMessage } from "./hmr-client-message.ts"; import { getPingIntervalMs, startPingInterval, stopPingInterval } from "./hmr-ping-keepalive.ts"; import { broadcastUpdate, getMetrics } from "./hmr-message-router.ts"; -import { getEffectiveRequestHost } from "../../utils/request-host.ts"; -import { isProxyTrusted } from "../../utils/proxy-trust.ts"; -import { getHostEnv } from "#veryfront/platform/compat/process.ts"; const logger = serverLogger.component("hmr-handler"); const HMR_WEBSOCKET_UPGRADE_OPTIONS = { idleTimeout: 0 } as const; @@ -94,32 +90,15 @@ export class HMRHandler extends BaseHandler { async handle(req: Request, ctx: HandlerContext): Promise { if (!this.shouldHandle(req, ctx)) return this.continue(); - const url = new URL(req.url); - const queryEnv = url.searchParams.get("x-environment"); - const isPreviewMode = ctx.requestContext?.mode === "preview" || queryEnv === "preview"; + const isPreviewMode = ctx.requestContext?.mode === "preview"; const isLocal = !!ctx.isLocalProject; - // SECURITY: x-forwarded-host is client-controlled unless we trust the upstream proxy. - // Honouring it unconditionally lets any remote client present `x-forwarded-host: localhost` - // and unlock the localhost short-circuit that opens HMR (VULN-SRV-4). Only consult - // forwarded headers when the request is proxy-trusted; otherwise use Host / url.host. - // Proxy trust requires a verifiable dispatch JWS (or operator opt-in). Mere header - // presence is not enough, since `x-veryfront-dispatch-jws` is not stripped on ingress. - const publicKeyPem = ctx.adapter?.env?.get("CHANNEL_DISPATCH_SIGNING_PUBLIC_KEY") ?? - getHostEnv("CHANNEL_DISPATCH_SIGNING_PUBLIC_KEY"); - const host = (await isProxyTrusted(req, { publicKeyPem })) - ? getEffectiveRequestHost(req, url, true) - : (req.headers.get("host") ?? url.host); - const isLocalhost = isLocalDevHost(host); - - if (!isPreviewMode && !isLocal && !isLocalhost) { - logger.warn("Skipping /_ws - not preview, local dev, or localhost", { + + if (!isPreviewMode && !isLocal) { + logger.warn("Skipping /_ws - not a resolved preview or local project", { mode: ctx.requestContext?.mode, - queryEnv, isLocalProject: ctx.isLocalProject, - host, isPreviewMode, isLocal, - isLocalhost, }); return this.continue(); } diff --git a/src/server/handlers/request/agent-stream.handler.test-helpers.ts b/src/server/handlers/request/agent-stream.handler.test-helpers.ts index 3ff56bcd4c..9573243fca 100644 --- a/src/server/handlers/request/agent-stream.handler.test-helpers.ts +++ b/src/server/handlers/request/agent-stream.handler.test-helpers.ts @@ -13,6 +13,7 @@ export function createAgentStreamRequestBody(overrides: Record agentId = "assistant-1", threadId = "10000000-1000-4000-8000-100000000001", runId = "run_1", + project: projectOverrides = {}, ...invocationOverrides } = overrides; @@ -29,6 +30,7 @@ export function createAgentStreamRequestBody(overrides: Record projectId: "10000000-1000-4000-8000-100000000005", projectSlug: "test-project", runtimeTargetKind: "main_branch", + ...(projectOverrides as Record), }, }, agentSource: { type: "branch", branch: "main" }, @@ -158,6 +160,9 @@ export function createSourceCapableAgentStreamContext( const context = createBaseInternalAgentRunContext(publicKeyPem); return { ...context, + branchId: "10000000-1000-4000-8000-100000000006", + branchName: "main", + defaultBranchName: "main", adapter: { ...context.adapter, fs: createNoopFsAdapter(runWithContextCalls), diff --git a/src/server/handlers/request/agent-stream.handler.test.ts b/src/server/handlers/request/agent-stream.handler.test.ts index 1d00351779..3ce583f509 100644 --- a/src/server/handlers/request/agent-stream.handler.test.ts +++ b/src/server/handlers/request/agent-stream.handler.test.ts @@ -10,7 +10,7 @@ import { DEFAULT_MAX_BODY_SIZE_BYTES } from "#veryfront/utils/constants/index.ts import { getEnv } from "#veryfront/platform/compat/process.ts"; import { getVerifiedCacheApiCredential } from "#veryfront/cache/verified-api-credential-context.ts"; import { AgentRunResumeHandler } from "./agent-run-resume.handler.ts"; -import { AgentStreamHandler } from "./agent-stream.handler.ts"; +import { AgentStreamHandler, type AgentStreamHandlerDeps } from "./agent-stream.handler.ts"; import type { HandlerContext } from "../types.ts"; import { createAgent, @@ -40,6 +40,13 @@ import { const TEST_PUBLIC_API_ORIGIN = "https://93.184.216.34"; const TEST_PUBLIC_STUDIO_MCP_URL = "https://93.184.216.35/studio-mcp"; +function createTestAgentStreamHandler(deps: AgentStreamHandlerDeps): AgentStreamHandler { + return new AgentStreamHandler({ + loadAgentSourceEnvironment: () => Promise.resolve({}), + ...deps, + }); +} + function createRuntimeAgentRunInvocationBody() { return JSON.stringify({ run: { @@ -83,10 +90,108 @@ function createRuntimeAgentRunInvocationBody() { } describe("server/handlers/request/agent-stream.handler", () => { + it("rejects a preview source whose signed branch ID differs from the trusted target", async () => { + let discoveryCalls = 0; + const handler = createTestAgentStreamHandler({ + ensureProjectDiscovery: async () => { + discoveryCalls += 1; + }, + getAgent: () => undefined, + getAllAgentIds: () => [], + sessionManager: new AgentRunSessionManager(), + }); + const body = createAgentStreamRequestBody({ + project: { + runtimeTargetKind: "preview_branch", + runtimeTargetBranchId: "10000000-1000-4000-8000-100000000006", + }, + agentSource: { type: "branch", branch: "main" }, + }); + const { jws, publicKeyPem } = await createControlPlaneSignature(body, { + requestId: "run_1", + }); + + const result = await handler.handle( + new Request("https://example.com/api/control-plane/runs/run_1/stream", { + method: "POST", + headers: { + "content-type": "application/json", + "x-veryfront-control-plane-jws": jws, + }, + body, + }), + { + ...createCtx(publicKeyPem), + branchId: "20000000-2000-4000-8000-200000000006", + }, + ); + + assertExists(result.response); + assertEquals(result.response.status, 403); + assertEquals(result.response.headers.get("content-type"), "application/problem+json"); + assertEquals(discoveryCalls, 0); + }); + + it("accepts a non-main default branch only when it matches trusted proxy metadata", async () => { + let discoveryCalls = 0; + const handler = createTestAgentStreamHandler({ + ensureProjectDiscovery: async () => { + discoveryCalls += 1; + }, + getAgent: () => undefined, + getAllAgentIds: () => [], + sessionManager: new AgentRunSessionManager(), + }); + const body = createAgentStreamRequestBody({ + project: { + runtimeTargetKind: "main_branch", + runtimeTargetBranchId: null, + }, + agentSource: { type: "branch", branch: "trunk" }, + }); + const { jws, publicKeyPem } = await createControlPlaneSignature(body, { + requestId: "run_1", + }); + const ctx = createCtx(publicKeyPem); + ctx.defaultBranchName = "trunk"; + + const accepted = await handler.handle( + new Request("https://example.com/api/control-plane/runs/run_1/stream", { + method: "POST", + headers: { + "content-type": "application/json", + "x-veryfront-control-plane-jws": jws, + }, + body, + }), + ctx, + ); + assertExists(accepted.response); + assertEquals(accepted.response.status, 404); + assertEquals(discoveryCalls, 1); + + discoveryCalls = 0; + ctx.defaultBranchName = "main"; + const rejected = await handler.handle( + new Request("https://example.com/api/control-plane/runs/run_1/stream", { + method: "POST", + headers: { + "content-type": "application/json", + "x-veryfront-control-plane-jws": jws, + }, + body, + }), + ctx, + ); + assertExists(rejected.response); + assertEquals(rejected.response.status, 403); + assertEquals(discoveryCalls, 0); + }); + it("streams AG-UI events for a valid signed request", async () => { let discoveryCalls = 0; let streamContext: Record | undefined; - const handler = new AgentStreamHandler({ + const handler = createTestAgentStreamHandler({ ensureProjectDiscovery: async () => { discoveryCalls += 1; }, @@ -202,7 +307,7 @@ describe("server/handlers/request/agent-stream.handler", () => { let streamContext: Record | undefined; let runtimeSystem: unknown; let runtimeMessages: AgentMessage[] | undefined; - const handler = new AgentStreamHandler({ + const handler = createTestAgentStreamHandler({ ensureProjectDiscovery: async () => { discoveryCalls += 1; }, @@ -322,7 +427,7 @@ describe("server/handlers/request/agent-stream.handler", () => { }); it("accepts the public control-plane stream route", async () => { - const handler = new AgentStreamHandler({ + const handler = createTestAgentStreamHandler({ ensureProjectDiscovery: async () => {}, getAgent: (id) => id === "assistant-1" ? createAgent("assistant-1") : undefined, getAllAgentIds: () => ["assistant-1"], @@ -380,7 +485,7 @@ describe("server/handlers/request/agent-stream.handler", () => { let runtimeAgentId: string | undefined; let runtimeToolNames: string[] | undefined; - const handler = new AgentStreamHandler({ + const handler = createTestAgentStreamHandler({ ensureProjectDiscovery: async () => {}, getAgent: (id) => agents.get(id), getAllAgentIds: () => [...agents.keys()], @@ -455,7 +560,7 @@ describe("server/handlers/request/agent-stream.handler", () => { it("rejects the removed internal AG-UI request shape on the public stream route", async () => { let discoveryCalls = 0; - const handler = new AgentStreamHandler({ + const handler = createTestAgentStreamHandler({ ensureProjectDiscovery: async () => { discoveryCalls++; }, @@ -520,7 +625,7 @@ describe("server/handlers/request/agent-stream.handler", () => { required: ["query"], }; - const handler = new AgentStreamHandler({ + const handler = createTestAgentStreamHandler({ ensureProjectDiscovery: async () => {}, getAgent: (id) => id === "incident-responder" ? createAgent("incident-responder") : undefined, getAllAgentIds: () => ["incident-responder"], @@ -609,6 +714,8 @@ describe("server/handlers/request/agent-stream.handler", () => { }, }); const { jws, publicKeyPem } = await createControlPlaneSignature(body, { requestId: "run_1" }); + const ctx = createCtx(publicKeyPem); + ctx.branchId = "50000000-5000-4000-8000-500000000001"; const result = await handler.handle( new Request("https://example.com/api/control-plane/runs/run_1/stream", { @@ -619,7 +726,7 @@ describe("server/handlers/request/agent-stream.handler", () => { }, body, }), - createCtx(publicKeyPem), + ctx, ); assertExists(result.response); @@ -694,7 +801,7 @@ describe("server/handlers/request/agent-stream.handler", () => { }) as typeof fetch; try { - const handler = new AgentStreamHandler({ + const handler = createTestAgentStreamHandler({ ensureProjectDiscovery: async () => {}, getAgent: (id) => id === "assistant-1" ? createAgent("assistant-1") : undefined, getAllAgentIds: () => ["assistant-1"], @@ -782,7 +889,7 @@ describe("server/handlers/request/agent-stream.handler", () => { it("does not pass undeclared forwarded remote tool allowlists into the runtime agent config", async () => { let capturedAllowedTools: string[] | undefined; - const handler = new AgentStreamHandler({ + const handler = createTestAgentStreamHandler({ ensureProjectDiscovery: async () => {}, getAgent: (id) => id === "assistant-1" ? createAgent("assistant-1") : undefined, getAllAgentIds: () => ["assistant-1"], @@ -845,7 +952,7 @@ describe("server/handlers/request/agent-stream.handler", () => { let capturedSourcePolicy: ReturnType; let discoveryConfig: HandlerContext["config"]; - const handler = new AgentStreamHandler({ + const handler = createTestAgentStreamHandler({ ensureProjectDiscovery: async (ctx) => { discoveryConfig = ctx.config; }, @@ -917,11 +1024,17 @@ describe("server/handlers/request/agent-stream.handler", () => { }); const body = createAgentStreamRequestBody({ + project: { + runtimeTargetKind: "preview_branch", + runtimeTargetBranchId: "50000000-5000-4000-8000-500000000002", + }, agentSource: { type: "branch", branch: "restrict-gmail" }, credentials: { authToken: "request-scoped-user-token" }, }); const { jws, publicKeyPem } = await createControlPlaneSignature(body, { requestId: "run_1" }); const ctx = createCtx(publicKeyPem); + ctx.branchId = "50000000-5000-4000-8000-500000000002"; + ctx.branchName = "restrict-gmail"; ctx.adapter = { ...ctx.adapter, env: createNoopEnvAdapter(publicKeyPem), @@ -957,7 +1070,7 @@ describe("server/handlers/request/agent-stream.handler", () => { it("fails closed before discovery when the runtime cannot select the signed source", async () => { let discoveryCalls = 0; - const handler = new AgentStreamHandler({ + const handler = createTestAgentStreamHandler({ ensureProjectDiscovery: async () => { discoveryCalls += 1; }, @@ -980,13 +1093,13 @@ describe("server/handlers/request/agent-stream.handler", () => { createSingleProjectCtx(publicKeyPem), ); - assertEquals(result.response?.status, 500); + assertEquals(result.response?.status, 400); assertEquals(discoveryCalls, 0); }); it("rejects a missing source before discovery", async () => { let discoveryCalls = 0; - const handler = new AgentStreamHandler({ + const handler = createTestAgentStreamHandler({ ensureProjectDiscovery: async () => { discoveryCalls += 1; }, @@ -1015,7 +1128,7 @@ describe("server/handlers/request/agent-stream.handler", () => { it("does not use an outer config when the exact source config cannot load", async () => { let discoveryCalls = 0; - const handler = new AgentStreamHandler({ + const handler = createTestAgentStreamHandler({ ensureProjectDiscovery: async () => { discoveryCalls += 1; }, @@ -1046,14 +1159,14 @@ describe("server/handlers/request/agent-stream.handler", () => { ctx, ); - assertEquals(result.response?.status, 500); + assertEquals(result.response?.status, 400); assertEquals(discoveryCalls, 0); }); it("drops undeclared Studio runtime tool allowlists for untrusted clients", async () => { let capturedAllowedTools: string[] | undefined; - const handler = new AgentStreamHandler({ + const handler = createTestAgentStreamHandler({ ensureProjectDiscovery: async () => {}, getAgent: (id) => id === "assistant-1" ? createAgent("assistant-1") : undefined, getAllAgentIds: () => ["assistant-1"], @@ -1153,7 +1266,7 @@ describe("server/handlers/request/agent-stream.handler", () => { }) as typeof fetch; try { - const handler = new AgentStreamHandler({ + const handler = createTestAgentStreamHandler({ ensureProjectDiscovery: async () => {}, getAgent: (id) => id === "assistant-1" ? createAgent("assistant-1") : undefined, getAllAgentIds: () => ["assistant-1"], @@ -1245,7 +1358,7 @@ describe("server/handlers/request/agent-stream.handler", () => { }) as typeof fetch; try { - const handler = new AgentStreamHandler({ + const handler = createTestAgentStreamHandler({ ensureProjectDiscovery: async () => {}, getAgent: (id) => id === "assistant-1" @@ -1333,7 +1446,7 @@ describe("server/handlers/request/agent-stream.handler", () => { it("fails closed for malformed runtime integration tool allowlists from forwarded props", async () => { let capturedAllowedTools: string[] | undefined; - const handler = new AgentStreamHandler({ + const handler = createTestAgentStreamHandler({ ensureProjectDiscovery: async () => {}, getAgent: (id) => id === "assistant-1" ? createAgent("assistant-1") : undefined, getAllAgentIds: () => ["assistant-1"], @@ -1404,7 +1517,7 @@ describe("server/handlers/request/agent-stream.handler", () => { }; try { - const handler = new AgentStreamHandler({ + const handler = createTestAgentStreamHandler({ ensureProjectDiscovery: async () => {}, getAgent: (id) => id === "assistant-1" @@ -1493,7 +1606,7 @@ describe("server/handlers/request/agent-stream.handler", () => { return Promise.resolve(new Response(null, { status: 503 })); }) as typeof fetch; - const handler = new AgentStreamHandler({ + const handler = createTestAgentStreamHandler({ ensureProjectDiscovery: async () => {}, getAgent: (id) => id === "assistant-1" @@ -1632,7 +1745,7 @@ describe("server/handlers/request/agent-stream.handler", () => { }) as typeof fetch; try { - const handler = new AgentStreamHandler({ + const handler = createTestAgentStreamHandler({ ensureProjectDiscovery: async () => {}, getAgent: (id) => id === "assistant-1" @@ -1784,6 +1897,15 @@ describe("server/handlers/request/agent-stream.handler", () => { }); const body = createAgentStreamRequestBody({ + project: { + runtimeTargetKind: "environment", + runtimeTargetEnvironmentId: "10000000-1000-4000-8000-100000000097", + }, + agentSource: { + type: "environment", + environmentName: "production", + releaseId: "release-production", + }, credentials: { authToken: "request-scoped-user-token" }, }); const { jws, publicKeyPem } = await createControlPlaneSignature(body, { @@ -1814,7 +1936,12 @@ describe("server/handlers/request/agent-stream.handler", () => { JSON.stringify({ data: [ { id: "env-staging", name: "staging", protected: true }, - { id: "env-production", name: "production", protected: false }, + { + id: "10000000-1000-4000-8000-100000000097", + name: "production", + protected: false, + active_release_id: "release-production", + }, ], }), { headers: { "content-type": "application/json" } }, @@ -1823,7 +1950,12 @@ describe("server/handlers/request/agent-stream.handler", () => { } if (String(url).includes("/projects/support-agent-fork/environment-variables?")) { - assertEquals(String(url).includes("environment_id=env-production"), true); + assertEquals( + String(url).includes( + "environment_id=10000000-1000-4000-8000-100000000097", + ), + true, + ); return Promise.resolve( new Response( JSON.stringify({ @@ -1920,7 +2052,7 @@ describe("server/handlers/request/agent-stream.handler", () => { // both the config and the MCP tool headers see the same variables. assertEquals(fetchUrls, [ `${TEST_PUBLIC_API_ORIGIN}/projects/support-agent-fork/environments`, - `${TEST_PUBLIC_API_ORIGIN}/projects/support-agent-fork/environment-variables?environment_id=env-production&limit=100`, + `${TEST_PUBLIC_API_ORIGIN}/projects/support-agent-fork/environment-variables?environment_id=10000000-1000-4000-8000-100000000097&limit=100`, `${TEST_PUBLIC_API_ORIGIN}/mcp`, ]); }); @@ -1936,13 +2068,23 @@ describe("server/handlers/request/agent-stream.handler", () => { Deno.env.set("VERYFRONT_API_URL", "https://api.veryfront.org"); Deno.env.delete("VERYFRONT_API_BASE_URL"); globalThis.fetch = ((url, init) => { - fetchUrls.push(String(url)); + const urlString = String(url); + fetchUrls.push(urlString); assertEquals( new Headers(init?.headers).get("authorization"), "Bearer request-scoped-user-token", ); + if (urlString === "https://api.veryfront.org/projects/demo-project/environments") { + return Promise.resolve(Response.json({ + data: [{ + id: targetEnvironmentId, + name: "staging", + active_release_id: "release-staging", + }], + })); + } assertEquals( - String(url), + urlString, `https://api.veryfront.org/projects/demo-project/environment-variables?environment_id=${targetEnvironmentId}&limit=100`, ); return Promise.resolve( @@ -1982,14 +2124,18 @@ describe("server/handlers/request/agent-stream.handler", () => { }), }); - const invocation = JSON.parse( - createAgentStreamRequestBody({ - credentials: { authToken: "request-scoped-user-token" }, - }), - ); - invocation.run.project.runtimeTargetKind = "environment"; - invocation.run.project.runtimeTargetEnvironmentId = targetEnvironmentId; - const body = JSON.stringify(invocation); + const body = createAgentStreamRequestBody({ + project: { + runtimeTargetKind: "environment", + runtimeTargetEnvironmentId: targetEnvironmentId, + }, + agentSource: { + type: "environment", + environmentName: "staging", + releaseId: "release-staging", + }, + credentials: { authToken: "request-scoped-user-token" }, + }); const { jws, publicKeyPem } = await createControlPlaneSignature(body, { requestId: "run_1", }); @@ -2022,12 +2168,14 @@ describe("server/handlers/request/agent-stream.handler", () => { assertEquals(result.response.status, 200); assertStringIncludes(capturedProjectEnv ?? "", "target=staging-value"); assertEquals(fetchUrls, [ + "https://api.veryfront.org/projects/demo-project/environments", `https://api.veryfront.org/projects/demo-project/environment-variables?environment_id=${targetEnvironmentId}&limit=100`, ]); }); it("prefers VERYFRONT_API_BASE_URL over VERYFRONT_API_URL", async () => { const apiBaseUrl = "http://93.184.216.34:8080"; + const canonicalApiOrigin = new URL(apiBaseUrl).origin; let capturedEnv: Record | null = null; let capturedSystem: string | null = null; @@ -2074,6 +2222,15 @@ describe("server/handlers/request/agent-stream.handler", () => { }); const body = createAgentStreamRequestBody({ + project: { + runtimeTargetKind: "environment", + runtimeTargetEnvironmentId: "10000000-1000-4000-8000-100000000096", + }, + agentSource: { + type: "environment", + environmentName: "production", + releaseId: "release-production", + }, credentials: { authToken: "request-scoped-user-token" }, }); const { jws, publicKeyPem } = await createControlPlaneSignature(body, { @@ -2098,7 +2255,7 @@ describe("server/handlers/request/agent-stream.handler", () => { "Bearer request-scoped-user-token", ); - if (String(url) === `${apiBaseUrl}/mcp`) { + if (String(url) === `${canonicalApiOrigin}/mcp`) { return Promise.resolve( new Response( JSON.stringify({ @@ -2119,12 +2276,17 @@ describe("server/handlers/request/agent-stream.handler", () => { ); } - if (String(url) === `${apiBaseUrl}/projects/base-url-agent-fork/environments`) { + if (String(url) === `${canonicalApiOrigin}/projects/base-url-agent-fork/environments`) { return Promise.resolve( new Response( JSON.stringify({ data: [ - { id: "env-production-base-url", name: "production", protected: false }, + { + id: "10000000-1000-4000-8000-100000000096", + name: "production", + protected: false, + active_release_id: "release-production", + }, ], }), { headers: { "content-type": "application/json" } }, @@ -2135,7 +2297,12 @@ describe("server/handlers/request/agent-stream.handler", () => { if ( String(url).includes(`${apiBaseUrl}/projects/base-url-agent-fork/environment-variables?`) ) { - assertEquals(String(url).includes("environment_id=env-production-base-url"), true); + assertEquals( + String(url).includes( + "environment_id=10000000-1000-4000-8000-100000000096", + ), + true, + ); return Promise.resolve( new Response( JSON.stringify({ @@ -2180,14 +2347,14 @@ describe("server/handlers/request/agent-stream.handler", () => { }); assertStringIncludes(capturedSystem ?? "", `api=${apiBaseUrl}`); assertEquals(fetchUrls, [ - `${apiBaseUrl}/projects/base-url-agent-fork/environments`, - `${apiBaseUrl}/projects/base-url-agent-fork/environment-variables?environment_id=env-production-base-url&limit=100`, - `${new URL(apiBaseUrl).origin}/mcp`, + `${canonicalApiOrigin}/projects/base-url-agent-fork/environments`, + `${apiBaseUrl}/projects/base-url-agent-fork/environment-variables?environment_id=10000000-1000-4000-8000-100000000096&limit=100`, + `${canonicalApiOrigin}/mcp`, ]); }); it("rejects oversized internal agent stream payloads before parsing", async () => { - const handler = new AgentStreamHandler({ + const handler = createTestAgentStreamHandler({ ensureProjectDiscovery: async () => {}, getAgent: () => createAgent("assistant-1"), getAllAgentIds: () => ["assistant-1"], @@ -2217,7 +2384,7 @@ describe("server/handlers/request/agent-stream.handler", () => { }); it("returns 404 when the requested agent is not available", async () => { - const handler = new AgentStreamHandler({ + const handler = createTestAgentStreamHandler({ ensureProjectDiscovery: async () => {}, getAgent: () => undefined, getAllAgentIds: () => [], @@ -2245,7 +2412,7 @@ describe("server/handlers/request/agent-stream.handler", () => { }); it("returns 400 for malformed internal agent stream payloads", async () => { - const handler = new AgentStreamHandler({ + const handler = createTestAgentStreamHandler({ ensureProjectDiscovery: async () => {}, getAgent: () => createAgent("assistant-1"), getAllAgentIds: () => ["assistant-1"], @@ -2273,7 +2440,7 @@ describe("server/handlers/request/agent-stream.handler", () => { }); it("returns 400 when the runtime input exceeds the message limit", async () => { - const handler = new AgentStreamHandler({ + const handler = createTestAgentStreamHandler({ ensureProjectDiscovery: async () => {}, getAgent: () => createAgent("assistant-1"), getAllAgentIds: () => ["assistant-1"], @@ -2307,7 +2474,7 @@ describe("server/handlers/request/agent-stream.handler", () => { }); it("accepts generic control-plane tool names like invoke_agent", async () => { - const handler = new AgentStreamHandler({ + const handler = createTestAgentStreamHandler({ ensureProjectDiscovery: async () => {}, getAgent: (id) => id === "assistant-1" ? createAgent("assistant-1") : undefined, getAllAgentIds: () => ["assistant-1"], @@ -2387,8 +2554,23 @@ describe("server/handlers/request/agent-stream.handler", () => { branch?: string | null; environmentName?: string | null; }> = []; + let observedEnvironmentTarget: + | { + environmentName: string; + environmentId: string | null; + token: string; + } + | undefined; - const handler = new AgentStreamHandler({ + const handler = createTestAgentStreamHandler({ + loadAgentSourceEnvironment: (_ctx, source, target, token) => { + observedEnvironmentTarget = { + environmentName: source.type === "environment" ? source.environmentName : source.type, + environmentId: target.runtimeTargetEnvironmentId ?? null, + token, + }; + return Promise.resolve({}); + }, ensureProjectDiscovery: async () => { observedCacheCredential = getVerifiedCacheApiCredential(); }, @@ -2434,6 +2616,10 @@ describe("server/handlers/request/agent-stream.handler", () => { }); const body = createAgentStreamRequestBody({ + project: { + runtimeTargetKind: "environment", + runtimeTargetEnvironmentId: "10000000-1000-4000-8000-100000000098", + }, agentSource: { type: "environment", environmentName: "staging", @@ -2481,6 +2667,11 @@ describe("server/handlers/request/agent-stream.handler", () => { assertEquals(runWithContextCalls[0]?.environmentName, "staging"); assertEquals(runWithContextCalls[0]?.releaseId, "10000000-1000-4000-8000-100000000099"); assertEquals(runWithContextCalls[0]?.productionMode, true); + assertEquals(observedEnvironmentTarget, { + environmentName: "staging", + environmentId: "10000000-1000-4000-8000-100000000098", + token: "request-scoped-user-token", + }); assertEquals(observedCacheCredential, { token: "request-scoped-user-token", projectId: "proj-1", @@ -2493,7 +2684,7 @@ describe("server/handlers/request/agent-stream.handler", () => { let observedCacheCredential: | ReturnType | undefined; - const handler = new AgentStreamHandler({ + const handler = createTestAgentStreamHandler({ ensureProjectDiscovery: async () => { observedCacheCredential = getVerifiedCacheApiCredential(); }, @@ -2538,9 +2729,84 @@ describe("server/handlers/request/agent-stream.handler", () => { assertEquals(getVerifiedCacheApiCredential(), undefined); }); + it("does not fall back to the host token for a signed exact environment source", async () => { + let environmentLoadCalls = 0; + const runWithContextCalls: Array<{ + token?: string; + productionMode?: boolean; + releaseId?: string | null; + branch?: string | null; + environmentName?: string | null; + }> = []; + + const handler = createTestAgentStreamHandler({ + loadAgentSourceEnvironment: () => { + environmentLoadCalls += 1; + return Promise.resolve({}); + }, + ensureProjectDiscovery: async () => {}, + getAgent: (id) => id === "assistant-1" ? createAgent("assistant-1") : undefined, + getAllAgentIds: () => ["assistant-1"], + sessionManager: new AgentRunSessionManager(), + createRuntime: () => { + throw new Error("runtime should not be created before env lookup"); + }, + }); + + const body = createAgentStreamRequestBody({ + project: { + runtimeTargetKind: "environment", + runtimeTargetEnvironmentId: "10000000-1000-4000-8000-100000000098", + }, + agentSource: { + type: "environment", + environmentName: "staging", + releaseId: "10000000-1000-4000-8000-100000000099", + }, + }); + const { jws, publicKeyPem } = await createControlPlaneSignature(body, { requestId: "run_1" }); + const ctx = createCtx(publicKeyPem); + ctx.proxyToken = undefined; + ctx.adapter = { + ...ctx.adapter, + env: createNoopEnvAdapter(publicKeyPem), + fs: createNoopFsAdapter(runWithContextCalls), + }; + + const originalHostToken = Deno.env.get("VERYFRONT_API_TOKEN"); + const signingKeyEnv = "CHANNEL_DISPATCH_SIGNING_PUBLIC_KEY"; + const originalSigningKey = Deno.env.get(signingKeyEnv); + Deno.env.set("VERYFRONT_API_TOKEN", "host-only-token"); + Deno.env.set(signingKeyEnv, publicKeyPem); + let result; + try { + result = await handler.handle( + new Request("https://example.com/api/control-plane/runs/run_1/stream", { + method: "POST", + headers: { + "content-type": "application/json", + "x-veryfront-control-plane-jws": jws, + }, + body, + }), + ctx, + ); + } finally { + if (originalHostToken === undefined) Deno.env.delete("VERYFRONT_API_TOKEN"); + else Deno.env.set("VERYFRONT_API_TOKEN", originalHostToken); + if (originalSigningKey === undefined) Deno.env.delete(signingKeyEnv); + else Deno.env.set(signingKeyEnv, originalSigningKey); + } + + assertExists(result.response); + assertEquals(result.response.status, 401); + assertEquals(runWithContextCalls.length, 0); + assertEquals(environmentLoadCalls, 0); + }); + it("returns 409 when the same run is started twice", async () => { const sessionManager = new AgentRunSessionManager(); - const handler = new AgentStreamHandler({ + const handler = createTestAgentStreamHandler({ ensureProjectDiscovery: async () => {}, getAgent: (id) => id === "assistant-1" ? createAgent("assistant-1") : undefined, getAllAgentIds: () => ["assistant-1"], @@ -2584,7 +2850,7 @@ describe("server/handlers/request/agent-stream.handler", () => { it("returns 500 when runtime execution setup fails unexpectedly", async () => { const sessionManager = new AgentRunSessionManager(); - const handler = new AgentStreamHandler({ + const handler = createTestAgentStreamHandler({ ensureProjectDiscovery: async () => {}, getAgent: (id) => id === "assistant-1" ? createAgent("assistant-1") : undefined, getAllAgentIds: () => ["assistant-1"], @@ -2615,9 +2881,177 @@ describe("server/handlers/request/agent-stream.handler", () => { assertEquals(sessionManager.getRunStatus("run_1"), null); }); + it("fails closed with typed authorization semantics when named env lookup is denied", async () => { + const originalFetch = globalThis.fetch; + let discoveryCalls = 0; + let redirect: RequestRedirect | undefined; + globalThis.fetch = ((_input, init) => { + redirect = init?.redirect; + return Promise.resolve(new Response(null, { status: 403 })); + }) as typeof fetch; + const handler = new AgentStreamHandler({ + ensureProjectDiscovery: async () => { + discoveryCalls += 1; + }, + getAgent: () => undefined, + getAllAgentIds: () => [], + sessionManager: new AgentRunSessionManager(), + }); + const body = createAgentStreamRequestBody({ + project: { + runtimeTargetKind: "environment", + runtimeTargetEnvironmentId: "10000000-1000-4000-8000-100000000098", + }, + agentSource: { + type: "environment", + environmentName: "staging", + releaseId: "10000000-1000-4000-8000-100000000099", + }, + credentials: { authToken: "denied-project-token" }, + }); + const { jws, publicKeyPem } = await createControlPlaneSignature(body, { + requestId: "run_1", + }); + + try { + const result = await handler.handle( + new Request("https://example.com/api/control-plane/runs/run_1/stream", { + method: "POST", + headers: { + "content-type": "application/json", + "x-veryfront-control-plane-jws": jws, + }, + body, + }), + createCtx(publicKeyPem), + ); + + assertExists(result.response); + assertEquals(result.response.status, 403); + assertEquals(result.response.headers.get("content-type"), "application/problem+json"); + assertEquals( + (await result.response.json()).type, + "https://veryfront.com/docs/errors/permission-denied", + ); + assertEquals(discoveryCalls, 0); + assertEquals(redirect, "error"); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("rejects a stale named environment source before loading project secrets", async () => { + const originalFetch = globalThis.fetch; + let discoveryCalls = 0; + let environmentVariableCalls = 0; + globalThis.fetch = ((input) => { + const url = String(input); + if (url.endsWith("/projects/support-agent-fork/environments")) { + return Promise.resolve(Response.json({ + data: [{ + id: "10000000-1000-4000-8000-100000000098", + name: "staging", + active_release_id: "release-new", + }], + })); + } + if (url.includes("/environment-variables?")) { + environmentVariableCalls += 1; + } + return Promise.reject(new Error(`unexpected fetch: ${url}`)); + }) as typeof fetch; + const handler = new AgentStreamHandler({ + ensureProjectDiscovery: async () => { + discoveryCalls += 1; + }, + getAgent: () => undefined, + getAllAgentIds: () => [], + sessionManager: new AgentRunSessionManager(), + }); + const body = createAgentStreamRequestBody({ + project: { + runtimeTargetKind: "environment", + runtimeTargetEnvironmentId: "10000000-1000-4000-8000-100000000098", + }, + agentSource: { + type: "environment", + environmentName: "staging", + releaseId: "release-old", + }, + credentials: { authToken: "project-token" }, + }); + const { jws, publicKeyPem } = await createControlPlaneSignature(body, { + audience: "support-agent-fork", + requestId: "run_1", + }); + + try { + const result = await handler.handle( + new Request("https://example.com/api/control-plane/runs/run_1/stream", { + method: "POST", + headers: { + "content-type": "application/json", + "x-veryfront-control-plane-jws": jws, + }, + body, + }), + { ...createCtx(publicKeyPem), projectSlug: "support-agent-fork" }, + ); + + assertExists(result.response); + assertEquals(result.response.status, 403); + assertEquals(result.response.headers.get("content-type"), "application/problem+json"); + assertEquals(environmentVariableCalls, 0); + assertEquals(discoveryCalls, 0); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("does not discover or inject production secrets for a branch source", async () => { + const originalFetch = globalThis.fetch; + let fetchCalls = 0; + globalThis.fetch = (() => { + fetchCalls += 1; + return Promise.reject(new Error("branch source must not fetch an environment")); + }) as typeof fetch; + const handler = new AgentStreamHandler({ + ensureProjectDiscovery: async () => {}, + getAgent: () => undefined, + getAllAgentIds: () => [], + sessionManager: new AgentRunSessionManager(), + }); + const body = createAgentStreamRequestBody({ + credentials: { authToken: "branch-project-token" }, + }); + const { jws, publicKeyPem } = await createControlPlaneSignature(body, { + requestId: "run_1", + }); + + try { + const result = await handler.handle( + new Request("https://example.com/api/control-plane/runs/run_1/stream", { + method: "POST", + headers: { + "content-type": "application/json", + "x-veryfront-control-plane-jws": jws, + }, + body, + }), + createCtx(publicKeyPem), + ); + + assertExists(result.response); + assertEquals(result.response.status, 404); + assertEquals(fetchCalls, 0); + } finally { + globalThis.fetch = originalFetch; + } + }); + it("emits a cancellation error instead of finishing after an abort during a pending read", async () => { const sessionManager = new TrackingSessionManager(); - const handler = new AgentStreamHandler({ + const handler = createTestAgentStreamHandler({ ensureProjectDiscovery: async () => {}, getAgent: (id) => id === "assistant-1" ? createAgent("assistant-1") : undefined, getAllAgentIds: () => ["assistant-1"], @@ -2666,7 +3100,7 @@ describe("server/handlers/request/agent-stream.handler", () => { it("keeps a waiting run resumable after the client disconnects", async () => { const sessionManager = new TrackingSessionManager(); - const handler = new AgentStreamHandler({ + const handler = createTestAgentStreamHandler({ ensureProjectDiscovery: async () => {}, getAgent: (id) => id === "assistant-1" ? createAgent("assistant-1") : undefined, getAllAgentIds: () => ["assistant-1"], @@ -2755,7 +3189,7 @@ describe("server/handlers/request/agent-stream.handler", () => { it("accepts an early resume before the runtime registers the tool wait", async () => { const sessionManager = new TrackingSessionManager(); const resumeHandler = new AgentRunResumeHandler(sessionManager); - const handler = new AgentStreamHandler({ + const handler = createTestAgentStreamHandler({ ensureProjectDiscovery: async () => {}, getAgent: (id) => id === "assistant-1" ? createAgent("assistant-1") : undefined, getAllAgentIds: () => ["assistant-1"], @@ -2891,7 +3325,7 @@ describe("server/handlers/request/agent-stream.handler", () => { it("rejects new agent stream requests with 503 while the runtime is shutting down", async () => { let discoveryCalls = 0; let resolveOwnerCalls = 0; - const handler = new AgentStreamHandler({ + const handler = createTestAgentStreamHandler({ ensureProjectDiscovery: async () => { discoveryCalls += 1; }, diff --git a/src/server/handlers/request/agent-stream.handler.ts b/src/server/handlers/request/agent-stream.handler.ts index 6067b14d26..f1c21b5076 100644 --- a/src/server/handlers/request/agent-stream.handler.ts +++ b/src/server/handlers/request/agent-stream.handler.ts @@ -53,7 +53,13 @@ import { type RuntimeRunAgentInput, toRuntimeRunAgentInput, } from "#veryfront/internal-agents/schema.ts"; -import { INVALID_ARGUMENT } from "#veryfront/errors"; +import { + AUTHENTICATION_REQUIRED, + errorToResponse, + INVALID_ARGUMENT, + isVeryfrontError, + PERMISSION_DENIED, +} from "#veryfront/errors"; import { BaseHandler } from "../response/base.ts"; import type { HandlerContext, HandlerMetadata, HandlerPriority, HandlerResult } from "../types.ts"; import { PRIORITY_MEDIUM_API } from "#veryfront/utils/constants/index.ts"; @@ -62,11 +68,11 @@ import { isServerShuttingDown } from "../../shutdown-state.ts"; import { getHostEnv } from "#veryfront/platform/compat/process.ts"; import { resolveVeryfrontApiBaseUrlFromHostEnv } from "#veryfront/platform/cloud/resolver.ts"; import { serverLogger } from "#veryfront/utils"; -import { LRUCacheAdapter } from "#veryfront/utils/cache/stores/memory/lru-cache-adapter.ts"; import { EnvironmentVariableCache, fetchProjectEnvVars, filterRuntimeProjectEnv, + ProjectEnvironmentIdentityResolver, runWithProjectEnv, } from "../../project-env/index.ts"; import { getHostedConfig, type VeryfrontConfig } from "#veryfront/config/loader.ts"; @@ -78,12 +84,27 @@ export interface AgentStreamHandlerDeps extends RuntimeAgentDiscoveryDeps, RuntimeAgentStreamExecutionDeps { resolveRuntimeOwnerInvokeUrl?: typeof resolveRuntimeOwnerInvokeUrl; getLocalTools?: (agentId: string) => RuntimeAgentStreamExecutionDeps["localTools"]; + loadAgentSourceEnvironment?: AgentSourceEnvironmentLoader; } +type AgentSourceTargetIdentity = Pick< + InternalAgentStreamRequest, + "runtimeTargetKind" | "runtimeTargetEnvironmentId" | "runtimeTargetBranchId" +>; + +export type AgentSourceEnvironmentLoader = ( + ctx: HandlerContext, + sourceContext: RuntimeAgentSourceContext, + targetIdentity: AgentSourceTargetIdentity, + apiAuthToken: string, + signal?: AbortSignal, +) => Promise>; + const defaultDeps: AgentStreamHandlerDeps = { ...defaultChannelInvokeDeps, sessionManager: agentRunSessionManager, resolveRuntimeOwnerInvokeUrl, + loadAgentSourceEnvironment: resolveAgentSourceEnvironment, getLocalTools: (agentId) => getDiscoveredHostTools({ agentId }) as RuntimeAgentStreamExecutionDeps["localTools"], }; @@ -102,60 +123,18 @@ const STUDIO_RUNTIME_REMOTE_TOOL_NAMES = new Set( // Per-environment env var cache shared across all agent stream requests (60s TTL) const _agentEnvVarCache = new EnvironmentVariableCache( - (environmentId, token, projectSlug) => { + ({ environmentId, token, projectSlug }, signal) => { return fetchProjectEnvVars( resolveVeryfrontApiBaseUrlFromHostEnv(), projectSlug, environmentId, token, + signal, ); }, ); -// Cache: projectSlug → production environmentId (stable across restarts) -const _productionEnvIdCache = new LRUCacheAdapter({ maxEntries: 1000 }); - -async function _resolveProductionEnvironmentId( - projectSlug: string, - token: string, -): Promise { - const cached = _productionEnvIdCache.get(projectSlug); - if (cached) return cached; - const apiBaseUrl = resolveVeryfrontApiBaseUrlFromHostEnv(); - try { - const res = await fetch( - `${apiBaseUrl}/projects/${encodeURIComponent(projectSlug)}/environments`, - { headers: { Authorization: `Bearer ${token}`, Accept: "application/json" } }, - ); - if (!res.ok) { - await res.body?.cancel(); - logger.warn("Unable to resolve production environment for agent stream", { - projectSlug, - apiBaseUrl, - status: res.status, - }); - return null; - } - const body = await res.json() as { data?: Array<{ id: string; name?: string }> }; - const env = body.data?.find((e) => e.name === "production") ?? body.data?.[0]; - if (!env?.id) { - logger.warn("Production environment missing for agent stream", { - projectSlug, - apiBaseUrl, - }); - return null; - } - _productionEnvIdCache.set(projectSlug, env.id); - return env.id; - } catch (error) { - logger.warn("Unable to resolve production environment for agent stream", { - projectSlug, - apiBaseUrl, - error: error instanceof Error ? error.message : String(error), - }); - return null; - } -} +const _environmentIdentityResolver = new ProjectEnvironmentIdentityResolver(); function mergeAllowedRemoteTools( current: RuntimeRemoteToolConfig["__vfAllowedRemoteTools"], @@ -374,23 +353,55 @@ function buildAgentSourceEnvironmentName(sourceContext: RuntimeAgentSourceContex /** * Load the project environment this agent source may read. * - * Control-plane requests bind a validated runtime target environment to the - * request context. Runs without one discover the production environment from - * the API (one fetch per project per server lifetime, then cached). + * Branch and bare-release sources do not carry an authoritative environment + * identity, so they receive no project environment variables. Named sources + * must carry an exact signed environment target, which is revalidated against + * project metadata before any secrets are fetched. + * Main-branch runs may omit a target environment pin and use the request-scoped + * production fallback path. */ async function resolveAgentSourceEnvironment( ctx: HandlerContext, sourceContext: RuntimeAgentSourceContext, + targetIdentity: AgentSourceTargetIdentity, apiAuthToken: string, + signal?: AbortSignal, ): Promise> { - if (sourceContext.type === "release") return {}; - if (!ctx.projectSlug || !apiAuthToken) return {}; + if (sourceContext.type !== "environment") return {}; + if (!ctx.projectSlug) { + throw INVALID_ARGUMENT.create({ + detail: "Agent source environment requires a canonical project identity", + }); + } + if ( + targetIdentity.runtimeTargetKind !== "environment" || + !targetIdentity.runtimeTargetEnvironmentId || + targetIdentity.runtimeTargetBranchId + ) { + throw INVALID_ARGUMENT.create({ + detail: "Named agent source requires an exact signed environment target", + }); + } - const environmentId = ctx.environmentId ?? - await _resolveProductionEnvironmentId(ctx.projectSlug, apiAuthToken); - if (!environmentId) return {}; + const environmentId = await _environmentIdentityResolver.resolveNamedForActiveRelease( + { + apiBaseUrl: resolveVeryfrontApiBaseUrlFromHostEnv(), + projectSlug: ctx.projectSlug, + projectId: ctx.projectId, + token: apiAuthToken, + environmentName: sourceContext.environmentName, + expectedEnvironmentId: targetIdentity.runtimeTargetEnvironmentId, + expectedReleaseId: sourceContext.releaseId, + }, + signal, + ); - return await _agentEnvVarCache.get(environmentId, apiAuthToken, ctx.projectSlug); + return await _agentEnvVarCache.get({ + environmentId, + token: apiAuthToken, + projectSlug: ctx.projectSlug, + projectId: ctx.projectId, + }); } /** @@ -587,6 +598,38 @@ type SourceContextFsWrapper = { ) => Promise; }; +function assertAgentSourceMatchesHostedTarget( + ctx: HandlerContext, + payload: InternalAgentStreamRequest, +): void { + const fsWrapper = ctx.adapter.fs as SourceContextFsWrapper; + if (!fsWrapper.isMultiProjectMode?.()) return; + if (payload.runtimeTargetKind === "preview_branch") { + if ( + payload.agentSource.type !== "branch" || + !ctx.branchId || + !ctx.branchName || + payload.runtimeTargetBranchId !== ctx.branchId || + payload.agentSource.branch !== ctx.branchName + ) { + throw PERMISSION_DENIED.create({ + detail: "Signed agent source does not match the trusted preview branch target", + }); + } + return; + } + + if ( + payload.runtimeTargetKind === "main_branch" && + payload.agentSource.type === "branch" && + (!ctx.defaultBranchName || payload.agentSource.branch !== ctx.defaultBranchName) + ) { + throw PERMISSION_DENIED.create({ + detail: "Signed agent source does not match the trusted default branch target", + }); + } +} + function buildAgentSourceRunOptions(sourceContext: RuntimeAgentSourceContext): { productionMode: boolean; releaseId?: string | null; @@ -676,7 +719,7 @@ export class AgentStreamHandler extends BaseHandler { }); } - const token = ctx.proxyToken || getHostEnv("VERYFRONT_API_TOKEN") || ""; + const token = ctx.proxyToken || ""; return fsWrapper.runWithContext( ctx.projectSlug, token, @@ -718,8 +761,13 @@ export class AgentStreamHandler extends BaseHandler { expectedSubject: payload.runId, expectedSurface: "studio", }); - const apiAuthToken = payload.credentials?.authToken || ctx.proxyToken || - getHostEnv("VERYFRONT_API_TOKEN") || ""; + assertAgentSourceMatchesHostedTarget(ctx, payload); + const apiAuthToken = payload.credentials?.authToken || ctx.proxyToken || ""; + if (payload.agentSource.type === "environment" && !apiAuthToken) { + throw AUTHENTICATION_REQUIRED.create({ + detail: "Named agent source environment requires a request-scoped API token", + }); + } const requestScopedContext: HandlerContext = { ...ctx, proxyToken: apiAuthToken || undefined, @@ -749,10 +797,14 @@ export class AgentStreamHandler extends BaseHandler { async () => { // Resolved before the config load because hosted evaluation binds // config to the same environment the run will execute with. - const envVarsForAgent = await resolveAgentSourceEnvironment( + const envVarsForAgent = await ( + this.deps.loadAgentSourceEnvironment ?? resolveAgentSourceEnvironment + )( requestScopedContext, payload.agentSource, + payload, apiAuthToken, + req.signal, ); const sourceConfig = await resolveAgentSourceConfig( requestScopedContext, @@ -888,6 +940,11 @@ export class AgentStreamHandler extends BaseHandler { ); } + if (isVeryfrontError(error)) { + const response = errorToResponse(error, new URL(req.url).pathname); + return this.respond(applyBuilderHeaders(response, builder.headers)); + } + this.logWarn("Internal agent stream request failed", { error: error instanceof Error ? error.message : String(error), projectId: ctx.projectId, diff --git a/src/server/handlers/request/internal-agents-list.handler.test.ts b/src/server/handlers/request/internal-agents-list.handler.test.ts index e06f7a180f..0c80d48a3c 100644 --- a/src/server/handlers/request/internal-agents-list.handler.test.ts +++ b/src/server/handlers/request/internal-agents-list.handler.test.ts @@ -301,7 +301,7 @@ describe("server/handlers/request/internal-agents-list.handler", () => { assertEquals(await result.response.json(), { error: "Invalid internal agents request" }); }); - it("uses VERYFRONT_API_TOKEN for multi-project proxy context when request token is absent", async () => { + it("does not forward VERYFRONT_API_TOKEN when the request token is absent", async () => { let discoveryCalls = 0; let receivedToken: string | undefined; @@ -325,8 +325,8 @@ describe("server/handlers/request/internal-agents-list.handler", () => { requestId: "agents-1", }); - // withProxyContext now reads VERYFRONT_API_TOKEN via getHostEnv() (bypassing - // project env overlays), so we set it on the real host environment. + // A host token must not be combined with request-selected project context. + // Set it on the real process environment to prove it is not forwarded. const tokenKey = "VERYFRONT_API_TOKEN"; const originalToken = Deno.env.get(tokenKey); Deno.env.set(tokenKey, "server-api-token"); @@ -374,7 +374,7 @@ describe("server/handlers/request/internal-agents-list.handler", () => { assertExists(result.response); assertEquals(result.response.status, 200); - assertEquals(receivedToken, "server-api-token"); + assertEquals(receivedToken, ""); assertEquals(discoveryCalls, 1); assertEquals(await result.response.json(), { agents: [ diff --git a/src/server/handlers/request/module/module-server-handler.ts b/src/server/handlers/request/module/module-server-handler.ts index 43d46e12ab..43774a20c3 100644 --- a/src/server/handlers/request/module/module-server-handler.ts +++ b/src/server/handlers/request/module/module-server-handler.ts @@ -37,6 +37,8 @@ export function handleModuleServer( contentSourceId: dependencyIdentity.contentSourceId, dependencyPinningSource, isLocalProject: ctx.isLocalProject, + isProxyMode: ctx.isProxyMode, + allowSSRModuleMode: ctx.isLocalProject === true, allowedImportDirs: ctx.config?.security?.allowedImportDirs, config: ctx.config, mode: ctx.requestContext?.mode, diff --git a/src/server/handlers/request/rsc/index.test.ts b/src/server/handlers/request/rsc/index.test.ts index 4e1318494a..42ba652e02 100644 --- a/src/server/handlers/request/rsc/index.test.ts +++ b/src/server/handlers/request/rsc/index.test.ts @@ -94,17 +94,17 @@ describe("server/handlers/request/rsc", () => { const handler = new RSCHandler(); let contextActive = false; let runWithContextArgs: unknown[] | undefined; - const adapter = createMockAdapter({ - exists: () => { + const adapter = createMockAdapter(); + + adapter.fs = { + ...adapter.fs, + symlinkSemantics: "none", + readFileBytesWithinLimit: (path: string, _byteLimit: number) => { if (!contextActive) { throw new Error("missing multi-project context"); } - return Promise.resolve(false); + return Promise.reject(new Deno.errors.NotFound(path)); }, - }); - - adapter.fs = { - ...adapter.fs, isVeryfrontAdapter: () => true, getUnderlyingAdapter: () => ({}), isMultiProjectMode: () => true, diff --git a/src/server/project-env/cache.test.ts b/src/server/project-env/cache.test.ts index 324dcd95f4..976611e1e0 100644 --- a/src/server/project-env/cache.test.ts +++ b/src/server/project-env/cache.test.ts @@ -1,196 +1,412 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals } from "#veryfront/testing/assert"; +import { assertEquals, assertRejects } from "#veryfront/testing/assert"; import { describe, it } from "#veryfront/testing/bdd"; -import { EnvironmentVariableCache } from "./cache.ts"; +import { EnvironmentVariableCache, type ProjectEnvironmentScope } from "./cache.ts"; function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} + +function scope(overrides: Partial = {}): ProjectEnvironmentScope { + return { + projectSlug: "project-a", + projectId: "project-id-a", + environmentId: "env-shared", + token: "token-a", + ...overrides, + }; +} + describe("project-env/cache", () => { - it("returns fresh data on first call", async () => { - let fetchCount = 0; - const cache = new EnvironmentVariableCache(async () => { - fetchCount++; + it("fetches cold data for the complete canonical scope", async () => { + let received: ProjectEnvironmentScope | undefined; + const cache = new EnvironmentVariableCache(async (input) => { + received = input; return { API_KEY: "secret" }; }); - const result = await cache.get("env-1", "token", "my-project"); - assertEquals(result, { API_KEY: "secret" }); - assertEquals(fetchCount, 1); + const input = scope(); + assertEquals(await cache.get(input), { API_KEY: "secret" }); + assertEquals(received, input); + }); + + it("snapshots scope and fetched values before retaining them", async () => { + let received: ProjectEnvironmentScope | undefined; + const cache = new EnvironmentVariableCache(async (input) => { + received = input; + return { API_KEY: "secret" }; + }); + const input = scope(); + + const pending = cache.get(input); + input.projectSlug = "mutated-project"; + input.token = "mutated-token"; + const result = await pending; + + assertEquals(received?.projectSlug, "project-a"); + assertEquals(received?.token, "token-a"); + assertEquals(Object.isFrozen(received), true); + assertEquals(Object.isFrozen(result), true); + assertEquals(Object.getPrototypeOf(result), null); }); - it("returns cached data within TTL", async () => { + it("reuses warm data only for the identical scope and credential", async () => { let fetchCount = 0; const cache = new EnvironmentVariableCache(async () => { fetchCount++; - return { API_KEY: "secret" }; - }, 10_000); + return { API_KEY: `v${fetchCount}` }; + }); - await cache.get("env-1", "token", "my-project"); - await cache.get("env-1", "token", "my-project"); + assertEquals(await cache.get(scope()), { API_KEY: "v1" }); + assertEquals(await cache.get(scope()), { API_KEY: "v1" }); assertEquals(fetchCount, 1); }); - it("fetches again after TTL expires", async () => { + it("does not share a warm environment ID across projects", async () => { let fetchCount = 0; - const cache = new EnvironmentVariableCache(async () => { + const cache = new EnvironmentVariableCache(async (input) => { fetchCount++; - return { API_KEY: `v${fetchCount}` }; - }, 50); // 50ms TTL + return { OWNER: input.projectSlug }; + }); - const first = await cache.get("env-1", "token", "my-project"); - assertEquals(first, { API_KEY: "v1" }); + const projectA = await cache.get(scope()); + const projectB = await cache.get(scope({ + projectSlug: "project-b", + projectId: "project-id-b", + })); - await delay(60); + assertEquals(projectA, { OWNER: "project-a" }); + assertEquals(projectB, { OWNER: "project-b" }); + assertEquals(fetchCount, 2); + }); - const second = await cache.get("env-1", "token", "my-project"); - assertEquals(second, { API_KEY: "v2" }); + it("does not share a warm project environment across credential principals", async () => { + let fetchCount = 0; + const cache = new EnvironmentVariableCache(async (input) => { + fetchCount++; + return { PRINCIPAL: input.token }; + }); + + assertEquals(await cache.get(scope()), { PRINCIPAL: "token-a" }); + assertEquals(await cache.get(scope({ token: "token-b" })), { + PRINCIPAL: "token-b", + }); assertEquals(fetchCount, 2); }); - it("deduplicates concurrent fetches", async () => { + it("deduplicates identical in-flight work without coalescing other tenants", async () => { let fetchCount = 0; - const cache = new EnvironmentVariableCache(async () => { + const cache = new EnvironmentVariableCache(async (input) => { fetchCount++; await delay(20); - return { API_KEY: "secret" }; + return { OWNER: `${input.projectSlug}:${input.token}` }; }); - const [r1, r2, r3] = await Promise.all([ - cache.get("env-1", "token", "my-project"), - cache.get("env-1", "token", "my-project"), - cache.get("env-1", "token", "my-project"), + const [a1, a2, b] = await Promise.all([ + cache.get(scope()), + cache.get(scope()), + cache.get(scope({ + projectSlug: "project-b", + projectId: "project-id-b", + token: "token-b", + })), ]); - assertEquals(r1, { API_KEY: "secret" }); - assertEquals(r2, { API_KEY: "secret" }); - assertEquals(r3, { API_KEY: "secret" }); - assertEquals(fetchCount, 1); + assertEquals(a1, { OWNER: "project-a:token-a" }); + assertEquals(a2, a1); + assertEquals(b, { OWNER: "project-b:token-b" }); + assertEquals(fetchCount, 2); }); - it("returns stale data on fetch error", async () => { + it("fails closed after TTL instead of serving stale secrets", async () => { let fetchCount = 0; const cache = new EnvironmentVariableCache(async () => { fetchCount++; - if (fetchCount === 1) return { API_KEY: "stale" }; - throw new Error("Network error"); - }, 50); // 50ms TTL + if (fetchCount === 1) return { API_KEY: "now-stale" }; + throw new Error("credential revoked"); + }, 20); - // First call succeeds - const first = await cache.get("env-1", "token", "my-project"); - assertEquals(first, { API_KEY: "stale" }); + assertEquals(await cache.get(scope()), { API_KEY: "now-stale" }); + await delay(30); + await assertRejects(() => cache.get(scope()), Error, "credential revoked"); + }); - // Wait for TTL to expire - await delay(60); + it("fails closed on a cold fetch error", async () => { + const cache = new EnvironmentVariableCache(() => Promise.reject(new Error("network error"))); + await assertRejects(() => cache.get(scope()), Error, "network error"); + }); - // Second call fails but returns stale - const second = await cache.get("env-1", "token", "my-project"); - assertEquals(second, { API_KEY: "stale" }); + it("does not leak another scope's stale value after a failed fetch", async () => { + const cache = new EnvironmentVariableCache(async (input) => { + if (input.projectSlug === "project-a") return { OWNER: "project-a" }; + throw new Error("project-b denied"); + }, 1); + + assertEquals(await cache.get(scope()), { OWNER: "project-a" }); + await delay(5); + await assertRejects( + () => + cache.get(scope({ + projectSlug: "project-b", + projectId: "project-id-b", + token: "token-b", + })), + Error, + "project-b denied", + ); }); - it("returns empty object on fetch error with no stale data", async () => { - const cache = new EnvironmentVariableCache(async () => { - throw new Error("Network error"); - }); + it("invalidates every credential-scoped entry for an environment", async () => { + let fetchCount = 0; + const cache = new EnvironmentVariableCache(async () => ({ VALUE: `${++fetchCount}` })); - const result = await cache.get("env-1", "token", "my-project"); - assertEquals(result, {}); + await cache.get(scope()); + await cache.get(scope({ token: "token-b" })); + cache.invalidate("env-shared"); + await cache.get(scope()); + await cache.get(scope({ token: "token-b" })); + + assertEquals(fetchCount, 4); }); - it("invalidate clears specific entry", async () => { + it("rejects invalidated in-flight work and never commits its old result", async () => { + const oldFetch = deferred>(); + const started = deferred(); let fetchCount = 0; const cache = new EnvironmentVariableCache(async () => { fetchCount++; - return { API_KEY: `v${fetchCount}` }; + if (fetchCount === 1) { + started.resolve(); + return await oldFetch.promise; + } + return { VALUE: "fresh" }; }); - await cache.get("env-1", "token", "my-project"); - cache.invalidate("env-1"); - const result = await cache.get("env-1", "token", "my-project"); - assertEquals(result, { API_KEY: "v2" }); + const oldWaiter = cache.get(scope()); + await started.promise; + cache.invalidate("env-shared"); + + await assertRejects( + () => oldWaiter, + Error, + "Project environment fetch was invalidated", + ); + assertEquals(await cache.get(scope()), { VALUE: "fresh" }); + + oldFetch.resolve({ VALUE: "stale" }); + await delay(0); + assertEquals(await cache.get(scope()), { VALUE: "fresh" }); assertEquals(fetchCount, 2); }); - it("invalidate with no arg clears all entries", async () => { + it("rejects all in-flight work after global invalidation", async () => { + const oldFetch = deferred>(); + const started = deferred(); let fetchCount = 0; const cache = new EnvironmentVariableCache(async () => { fetchCount++; - return { API_KEY: `v${fetchCount}` }; + if (fetchCount === 1) { + started.resolve(); + return await oldFetch.promise; + } + return { VALUE: "fresh" }; }); - await cache.get("env-1", "token", "my-project"); - await cache.get("env-2", "token", "my-project"); + const oldWaiter = cache.get(scope()); + await started.promise; + cache.invalidate(); + + await assertRejects( + () => oldWaiter, + Error, + "Project environment fetch was invalidated", + ); + assertEquals(await cache.get(scope()), { VALUE: "fresh" }); + + oldFetch.resolve({ VALUE: "stale" }); + await delay(0); + assertEquals(await cache.get(scope()), { VALUE: "fresh" }); assertEquals(fetchCount, 2); + }); - cache.invalidate(); + it("invalidating one environment leaves other in-flight work intact", async () => { + const envAFetch = deferred>(); + const envBFetch = deferred>(); + let started = 0; + const bothStarted = deferred(); + const cache = new EnvironmentVariableCache(async (input) => { + started++; + if (started === 2) bothStarted.resolve(); + return await (input.environmentId === "env-a" ? envAFetch.promise : envBFetch.promise); + }); - await cache.get("env-1", "token", "my-project"); - await cache.get("env-2", "token", "my-project"); - assertEquals(fetchCount, 4); + const envAWaiter = cache.get(scope({ environmentId: "env-a" })); + const envBWaiter = cache.get(scope({ environmentId: "env-b" })); + await bothStarted.promise; + cache.invalidate("env-a"); + + await assertRejects( + () => envAWaiter, + Error, + "Project environment fetch was invalidated", + ); + envBFetch.resolve({ VALUE: "env-b" }); + assertEquals(await envBWaiter, { VALUE: "env-b" }); + + envAFetch.resolve({ VALUE: "old-env-a" }); + await delay(0); + assertEquals(await cache.get(scope({ environmentId: "env-b" })), { + VALUE: "env-b", + }); }); - it("evicts oldest entries when maxEntries exceeded", async () => { + it("clears timed-out in-flight capacity so a retry can succeed", async () => { let fetchCount = 0; const cache = new EnvironmentVariableCache( async () => { fetchCount++; - return { KEY: `v${fetchCount}` }; + if (fetchCount === 1) return await new Promise>(() => {}); + return { VALUE: "recovered" }; }, 60_000, - 3, // maxEntries = 3 + 100, + { fetchTimeoutMs: 10, maxInflight: 1 }, ); - await cache.get("env-1", "token", "p"); - await cache.get("env-2", "token", "p"); - await cache.get("env-3", "token", "p"); - assertEquals(fetchCount, 3); + await assertRejects( + () => cache.get(scope()), + Error, + "Project environment fetch timed out", + ); + assertEquals(await cache.get(scope()), { VALUE: "recovered" }); + assertEquals(fetchCount, 2); + }); - // Adding a 4th should evict env-1 - await cache.get("env-4", "token", "p"); - assertEquals(fetchCount, 4); + it("clears rejected in-flight work so a retry can succeed", async () => { + let fetchCount = 0; + const cache = new EnvironmentVariableCache(async () => { + fetchCount++; + if (fetchCount === 1) throw new Error("temporary failure"); + return { VALUE: "recovered" }; + }); - // env-1 was evicted, so it should re-fetch - await cache.get("env-1", "token", "p"); - assertEquals(fetchCount, 5); + await assertRejects(() => cache.get(scope()), Error, "temporary failure"); + assertEquals(await cache.get(scope()), { VALUE: "recovered" }); + assertEquals(fetchCount, 2); + }); - // env-3 should still be cached - await cache.get("env-3", "token", "p"); - assertEquals(fetchCount, 5); + it("rejects excess global work without invoking the fetcher and recovers capacity", async () => { + const firstFetch = deferred>(); + const firstFetchStarted = deferred(); + let fetchCount = 0; + const cache = new EnvironmentVariableCache( + async (input) => { + fetchCount++; + if (input.projectSlug === "project-a") { + firstFetchStarted.resolve(); + return await firstFetch.promise; + } + return { OWNER: input.projectSlug }; + }, + 60_000, + 100, + { maxInflight: 1, maxInflightPerProject: 1 }, + ); + + const first = cache.get(scope()); + await firstFetchStarted.promise; + const overload = await assertRejects(() => + cache.get(scope({ + projectSlug: "project-b", + projectId: "project-id-b", + token: "token-b", + })) + ); + assertEquals((overload as { slug?: string }).slug, "service-overloaded"); + assertEquals(fetchCount, 1); + + firstFetch.resolve({ OWNER: "project-a" }); + assertEquals(await first, { OWNER: "project-a" }); + assertEquals( + await cache.get(scope({ + projectSlug: "project-b", + projectId: "project-id-b", + token: "token-b", + })), + { OWNER: "project-b" }, + ); + assertEquals(fetchCount, 2); }); - it("refreshed entries move to end of eviction order (LRU)", async () => { + it("bounds distinct in-flight work per project while preserving exact deduplication", async () => { + const firstFetch = deferred>(); + const firstFetchStarted = deferred(); let fetchCount = 0; const cache = new EnvironmentVariableCache( async () => { fetchCount++; - return { KEY: `v${fetchCount}` }; + firstFetchStarted.resolve(); + return await firstFetch.promise; }, - 50, // 50ms TTL - 3, + 60_000, + 100, + { maxInflight: 10, maxInflightPerProject: 1 }, ); - // Fill cache: env-1, env-2, env-3 - await cache.get("env-1", "token", "p"); - await cache.get("env-2", "token", "p"); - await cache.get("env-3", "token", "p"); - assertEquals(fetchCount, 3); + const first = cache.get(scope()); + const deduplicated = cache.get(scope()); + await firstFetchStarted.promise; + const overload = await assertRejects(() => cache.get(scope({ environmentId: "env-other" }))); + assertEquals((overload as { slug?: string }).slug, "service-overloaded"); + assertEquals(fetchCount, 1); - // Wait for TTL to expire, then refresh env-1 (moves it to end) - await delay(60); - await cache.get("env-1", "token", "p"); - assertEquals(fetchCount, 4); + firstFetch.resolve({ VALUE: "same" }); + assertEquals(await Promise.all([first, deduplicated]), [ + { VALUE: "same" }, + { VALUE: "same" }, + ]); + }); - // Add env-4 — should evict env-2 (oldest), NOT env-1 (just refreshed) - await cache.get("env-4", "token", "p"); - assertEquals(fetchCount, 5); + it("cleans the cache-owned deadline after a successful fetch", async () => { + let fetchSignal: AbortSignal | undefined; + const cache = new EnvironmentVariableCache( + async (_input, signal) => { + fetchSignal = signal; + return { VALUE: "done" }; + }, + 60_000, + 100, + { fetchTimeoutMs: 10 }, + ); - // env-1 should still be cached (was refreshed, moved to end) - await cache.get("env-1", "token", "p"); - assertEquals(fetchCount, 5); + assertEquals(await cache.get(scope()), { VALUE: "done" }); + await delay(20); + assertEquals(fetchSignal?.aborted, false); + }); - // env-2 should have been evicted - await cache.get("env-2", "token", "p"); - assertEquals(fetchCount, 6); + it("keeps the scoped cache bounded", async () => { + let fetchCount = 0; + const cache = new EnvironmentVariableCache( + async () => ({ VALUE: `${++fetchCount}` }), + 60_000, + 2, + ); + + await cache.get(scope({ environmentId: "env-1" })); + await cache.get(scope({ environmentId: "env-2" })); + await cache.get(scope({ environmentId: "env-3" })); + await cache.get(scope({ environmentId: "env-1" })); + + assertEquals(fetchCount, 4); }); }); diff --git a/src/server/project-env/cache.ts b/src/server/project-env/cache.ts index fe824d99ff..ebd0a892d4 100644 --- a/src/server/project-env/cache.ts +++ b/src/server/project-env/cache.ts @@ -1,107 +1,355 @@ /** * In-memory cache for project environment variables with TTL and request deduplication. * + * Cache authority is the complete project/environment/credential scope. Neither a + * tenant-supplied environment ID nor a reusable platform credential may alias a + * different project's cached secrets. + * * @module server/project-env/cache */ -import { serverLogger } from "#veryfront/utils"; +import { createProjectEnvSnapshot } from "./snapshot.ts"; +import { + CACHE_ERROR, + SERVICE_OVERLOADED, + TIMEOUT_ERROR, + type VeryfrontError, +} from "#veryfront/errors"; -const logger = serverLogger.component("project-env-cache"); +export interface ProjectEnvironmentScope { + /** Canonical project slug authorized by the credential. */ + projectSlug: string; + /** Canonical project ID when the control plane supplied one. */ + projectId?: string; + environmentId: string; + token: string; +} interface CacheEntry { vars: Record; fetchedAt: number; + environmentId: string; } type Fetcher = ( - environmentId: string, - token: string, - projectSlug: string, + scope: ProjectEnvironmentScope, + signal: AbortSignal, ) => Promise>; -/** Max number of environments to cache. Evicts oldest entry when exceeded. */ +export interface EnvironmentVariableCacheOptions { + /** Maximum time allowed for one cache-owned upstream fetch. */ + fetchTimeoutMs?: number; + /** Maximum number of distinct upstream fetches across all projects. */ + maxInflight?: number; + /** Maximum number of distinct upstream fetches for one canonical project. */ + maxInflightPerProject?: number; +} + +/** Max number of scoped environments to cache. Evicts oldest entry when exceeded. */ const DEFAULT_MAX_ENTRIES = 100; +const DEFAULT_FETCH_TIMEOUT_MS = 10_000; +const DEFAULT_MAX_INFLIGHT = 100; +const DEFAULT_MAX_INFLIGHT_PER_PROJECT = 10; +const encodeText = TextEncoder.prototype.encode; +const subtleDigest = crypto.subtle.digest.bind(crypto.subtle); +const textEncoder = new TextEncoder(); + +function frame(value: string): string { + return `${value.length}:${value}`; +} + +async function digestCredential(token: string): Promise { + const bytes = encodeText.call(textEncoder, token); + const digest = await subtleDigest("SHA-256", bytes); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +async function buildCacheKey(scope: ProjectEnvironmentScope): Promise { + const credentialPrincipal = await digestCredential(scope.token); + return [ + "project-env-v2", + frame(scope.projectSlug), + frame(scope.projectId ?? ""), + frame(scope.environmentId), + credentialPrincipal, + ].join("|"); +} + +function normalizeScope(scope: ProjectEnvironmentScope): ProjectEnvironmentScope { + const projectSlug = scope?.projectSlug; + const projectId = scope?.projectId; + const environmentId = scope?.environmentId; + const token = scope?.token; + + if (typeof projectSlug !== "string" || !projectSlug.trim()) { + throw new TypeError("Project environment scope requires a slug"); + } + if (projectId !== undefined && (typeof projectId !== "string" || !projectId.trim())) { + throw new TypeError("Project environment scope project ID must be a non-empty string"); + } + if (typeof environmentId !== "string" || !environmentId.trim()) { + throw new TypeError("Project environment scope requires an environment ID"); + } + if (typeof token !== "string" || !token) { + throw new TypeError("Project environment scope requires a credential"); + } + + return Object.freeze({ projectSlug, projectId, environmentId, token }); +} + +interface Epoch { + global: number; + environment: number; +} + +interface InflightEntry { + controller: AbortController; + environmentId: string; + epoch: Epoch; + key: string; + projectSlug: string; + promise: Promise>; + removed: boolean; + scope: ProjectEnvironmentScope; +} + +function positiveInteger(value: number, name: string): number { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new TypeError(`${name} must be a positive safe integer`); + } + return value; +} + +function nonNegativeInteger(value: number, name: string): number { + if (!Number.isSafeInteger(value) || value < 0) { + throw new TypeError(`${name} must be a non-negative safe integer`); + } + return value; +} + +function abortReason(signal: AbortSignal): unknown { + return signal.reason ?? CACHE_ERROR.create({ + detail: "Project environment fetch was cancelled", + }); +} + +function invalidatedError(): VeryfrontError { + return CACHE_ERROR.create({ + detail: "Project environment fetch was invalidated", + }); +} export class EnvironmentVariableCache { private cache = new Map(); - private inflight = new Map>>(); + private inflight = new Map(); + private inflightByProject = new Map(); private fetcher: Fetcher; private ttlMs: number; private maxEntries: number; + private fetchTimeoutMs: number; + private maxInflight: number; + private maxInflightPerProject: number; + private globalEpoch = 0; + private environmentEpochs = new Map(); - constructor(fetcher: Fetcher, ttlMs = 60_000, maxEntries = DEFAULT_MAX_ENTRIES) { + constructor( + fetcher: Fetcher, + ttlMs = 60_000, + maxEntries = DEFAULT_MAX_ENTRIES, + options: EnvironmentVariableCacheOptions = {}, + ) { this.fetcher = fetcher; - this.ttlMs = ttlMs; - this.maxEntries = maxEntries; + this.ttlMs = nonNegativeInteger(ttlMs, "ttlMs"); + this.maxEntries = nonNegativeInteger(maxEntries, "maxEntries"); + this.fetchTimeoutMs = positiveInteger( + options.fetchTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS, + "fetchTimeoutMs", + ); + this.maxInflight = positiveInteger( + options.maxInflight ?? DEFAULT_MAX_INFLIGHT, + "maxInflight", + ); + this.maxInflightPerProject = positiveInteger( + options.maxInflightPerProject ?? DEFAULT_MAX_INFLIGHT_PER_PROJECT, + "maxInflightPerProject", + ); } - async get( - environmentId: string, - token: string, - projectSlug: string, - ): Promise> { - const cached = this.cache.get(environmentId); + async get(scope: ProjectEnvironmentScope): Promise> { + const normalizedScope = normalizeScope(scope); + const epoch = this.captureEpoch(normalizedScope.environmentId); + const key = await buildCacheKey(normalizedScope); + + // Invalidations that occur while the credential digest is being computed + // also invalidate this request. It must not join work from the newer epoch. + if (!this.isCurrentEpoch(normalizedScope.environmentId, epoch)) { + throw invalidatedError(); + } + + const cached = this.cache.get(key); const now = Date.now(); if (cached && now - cached.fetchedAt < this.ttlMs) { return cached.vars; } - // Deduplicate concurrent fetches for the same environment - const existing = this.inflight.get(environmentId); - if (existing) return existing; + // Deduplicate only requests with exactly the same canonical identity and + // credential principal. A shared environment ID is never sufficient. + const existing = this.inflight.get(key); + if (existing) return existing.promise; - const promise = this.fetch(environmentId, token, projectSlug, cached); - this.inflight.set(environmentId, promise); + this.assertAdmission(normalizedScope.projectSlug); - try { - return await promise; - } finally { - this.inflight.delete(environmentId); - } + const controller = new AbortController(); + const start = Promise.withResolvers(); + const promise = start.promise.then((entry) => this.fetch(entry)); + const entry: InflightEntry = { + controller, + environmentId: normalizedScope.environmentId, + epoch, + key, + projectSlug: normalizedScope.projectSlug, + promise, + removed: false, + scope: normalizedScope, + }; + + this.addInflight(entry); + start.resolve(entry); + return promise; } invalidate(environmentId?: string): void { - if (environmentId) { - this.cache.delete(environmentId); - } else { + if (!environmentId) { + this.globalEpoch++; + this.environmentEpochs.clear(); this.cache.clear(); + for (const entry of [...this.inflight.values()]) { + this.invalidateInflight(entry); + } + return; + } + + this.environmentEpochs.set( + environmentId, + (this.environmentEpochs.get(environmentId) ?? 0) + 1, + ); + + for (const [key, entry] of this.cache) { + if (entry.environmentId === environmentId) this.cache.delete(key); + } + + for (const entry of [...this.inflight.values()]) { + if (entry.environmentId === environmentId) this.invalidateInflight(entry); } } - private async fetch( - environmentId: string, - token: string, - projectSlug: string, - stale: CacheEntry | undefined, - ): Promise> { + private async fetch(entry: InflightEntry): Promise> { + const { controller, scope } = entry; + const timeoutError = TIMEOUT_ERROR.create({ + detail: "Project environment fetch timed out", + }); + const timeoutId = setTimeout(() => controller.abort(timeoutError), this.fetchTimeoutMs); + + let removeAbortListener = () => {}; + const aborted = new Promise((_resolve, reject) => { + const onAbort = () => reject(abortReason(controller.signal)); + removeAbortListener = () => controller.signal.removeEventListener("abort", onAbort); + if (controller.signal.aborted) { + onAbort(); + return; + } + controller.signal.addEventListener("abort", onAbort, { once: true }); + }); + try { - const vars = await this.fetcher(environmentId, token, projectSlug); - // Delete before set to move refreshed entries to the end of Map iteration order, - // ensuring eviction targets the least-recently-fetched entry (LRU behavior). - this.cache.delete(environmentId); - this.cache.set(environmentId, { vars, fetchedAt: Date.now() }); + // Fetch failures are deliberately not replaced by stale or empty data. + // Environment variables are an authorization-sensitive input; continuing + // after credential revocation or an identity mismatch would fail open. + const fetched = await Promise.race([ + this.fetcher(scope, controller.signal), + aborted, + ]); + + if (controller.signal.aborted) throw abortReason(controller.signal); + if (!this.isCurrentEpoch(scope.environmentId, entry.epoch)) { + throw invalidatedError(); + } + + const vars = createProjectEnvSnapshot(fetched) as Record; + + // Recheck immediately before commit. Invalidation aborts registered work, + // while the epoch prevents an older result from racing a replacement. + if (controller.signal.aborted) throw abortReason(controller.signal); + if (!this.isCurrentEpoch(scope.environmentId, entry.epoch)) { + throw invalidatedError(); + } + + this.cache.delete(entry.key); + this.cache.set(entry.key, { + vars, + fetchedAt: Date.now(), + environmentId: scope.environmentId, + }); this.evictIfNeeded(); return vars; - } catch (error) { - /* expected: stale-on-error fallback when fetch fails */ - if (stale) return stale.vars; - // No stale entry to fall back on. Returning {} here is indistinguishable - // from "project has no env vars", so surface the failure loudly before - // failing open. Callers currently rely on {} (see runtime-handler and - // agent-stream handler), so we log rather than throw to avoid turning a - // transient fetch error into a hard request failure. - logger.error("Failed to fetch project env vars and no stale entry exists", { - errorName: error instanceof Error ? error.name : typeof error, + } finally { + clearTimeout(timeoutId); + removeAbortListener(); + this.removeInflight(entry); + } + } + + private captureEpoch(environmentId: string): Epoch { + return { + global: this.globalEpoch, + environment: this.environmentEpochs.get(environmentId) ?? 0, + }; + } + + private isCurrentEpoch(environmentId: string, epoch: Epoch): boolean { + return epoch.global === this.globalEpoch && + epoch.environment === (this.environmentEpochs.get(environmentId) ?? 0); + } + + private assertAdmission(projectSlug: string): void { + const projectInflight = this.inflightByProject.get(projectSlug) ?? 0; + if ( + this.inflight.size >= this.maxInflight || + projectInflight >= this.maxInflightPerProject + ) { + throw SERVICE_OVERLOADED.create({ + detail: "Project environment fetch concurrency limit reached", }); - return {}; } } + private addInflight(entry: InflightEntry): void { + this.inflight.set(entry.key, entry); + this.inflightByProject.set( + entry.projectSlug, + (this.inflightByProject.get(entry.projectSlug) ?? 0) + 1, + ); + } + + private removeInflight(entry: InflightEntry): void { + if (entry.removed) return; + entry.removed = true; + if (this.inflight.get(entry.key) === entry) this.inflight.delete(entry.key); + + const count = this.inflightByProject.get(entry.projectSlug) ?? 0; + if (count <= 1) this.inflightByProject.delete(entry.projectSlug); + else this.inflightByProject.set(entry.projectSlug, count - 1); + } + + private invalidateInflight(entry: InflightEntry): void { + this.removeInflight(entry); + entry.controller.abort(invalidatedError()); + } + /** Evict oldest entries when cache exceeds maxEntries. */ private evictIfNeeded(): void { if (this.cache.size <= this.maxEntries) return; - // Map iterates in insertion order; delete the first (oldest) entries const excess = this.cache.size - this.maxEntries; let removed = 0; for (const key of this.cache.keys()) { diff --git a/src/server/project-env/fetcher.test.ts b/src/server/project-env/fetcher.test.ts index 0e4db31280..c9bff038dd 100644 --- a/src/server/project-env/fetcher.test.ts +++ b/src/server/project-env/fetcher.test.ts @@ -1,8 +1,8 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals, assertRejects } from "#veryfront/testing/assert"; +import { assertEquals, assertInstanceOf, assertRejects } from "#veryfront/testing/assert"; import { describe, it } from "#veryfront/testing/bdd"; import { createMockServer } from "../../../tests/_helpers/utils.ts"; -import { fetchProjectEnvVars } from "./fetcher.ts"; +import { fetchProjectEnvVars, PROJECT_ENV_RESPONSE_MAX_BYTES } from "./fetcher.ts"; const INTERNAL_USER_ENV = "VERYFRONT_API_INTERNAL_USER"; const INTERNAL_PASS_ENV = "VERYFRONT_API_INTERNAL_PASS"; @@ -32,6 +32,7 @@ async function withInternalCredentials( function fetchFromMockApi( port: number, credentials?: { username: string; password: string }, + signal?: AbortSignal, ): Promise> { return withInternalCredentials( credentials?.username, @@ -42,11 +43,45 @@ function fetchFromMockApi( "my-project", "env-123", "test-token", + signal, ), ); } +function responseWithBodyCleanup( + status: number, + cancel: () => void | Promise, +): Response { + const response = new Response("discard me", { status }); + if (!response.body) throw new Error("Expected response body"); + Object.defineProperty(response.body, "cancel", { + configurable: true, + value: cancel, + }); + return response; +} + describe("project-env/fetcher", () => { + it("maps unknown transport failures to typed 502 semantics", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (() => Promise.reject(new TypeError("connection refused"))) as typeof fetch; + + try { + const error = await assertRejects(() => + fetchProjectEnvVars( + "https://api.veryfront.test", + "my-project", + "env-123", + "test-token", + ) + ); + assertEquals((error as { slug?: string }).slug, "network-error"); + assertEquals((error as { status?: number }).status, 502); + } finally { + globalThis.fetch = originalFetch; + } + }); + it("fetches and transforms env vars from API", async () => { const { server, port } = createMockServer((req: Request) => { const url = new URL(req.url); @@ -90,15 +125,14 @@ describe("project-env/fetcher", () => { } }); - it("handles missing data field in response", async () => { + it("rejects a missing data field instead of substituting an empty environment", async () => { const { server, port } = createMockServer(() => { return Response.json({}); }); try { - const result = await fetchFromMockApi(port); - - assertEquals(result, {}); + const error = await assertRejects(() => fetchFromMockApi(port)); + assertEquals((error as { slug?: string }).slug, "network-error"); } finally { await server.shutdown(); } @@ -116,12 +150,152 @@ describe("project-env/fetcher", () => { } }); - it("uses the internal endpoint when Basic auth credentials are configured", async () => { + it("normalizes project authorization failures without exposing upstream status", async () => { + const { server, port } = createMockServer(() => { + return new Response("tenant-specific upstream detail", { status: 403 }); + }); + + try { + const error = await assertRejects(() => fetchFromMockApi(port)); + assertEquals((error as { slug?: string }).slug, "permission-denied"); + assertEquals( + (error as Error).message, + "Project credential is not authorized for the requested environment", + ); + } finally { + await server.shutdown(); + } + }); + + it("preserves the authorization error when response cleanup throws synchronously", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (() => + Promise.resolve( + responseWithBodyCleanup(403, () => { + throw new Error("cleanup failed synchronously"); + }), + )) as typeof fetch; + + try { + const error = await assertRejects(() => + fetchProjectEnvVars( + "https://api.veryfront.test", + "my-project", + "env-123", + "test-token", + ) + ); + assertEquals((error as { slug?: string }).slug, "permission-denied"); + assertEquals((error as { status?: number }).status, 403); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("preserves the internal request error when response cleanup rejects", async () => { + const originalFetch = globalThis.fetch; + let fetchCount = 0; + globalThis.fetch = (() => { + fetchCount++; + return Promise.resolve( + fetchCount === 1 + ? responseWithBodyCleanup(200, () => Promise.resolve()) + : responseWithBodyCleanup(500, () => Promise.reject(new Error("cleanup rejected"))), + ); + }) as typeof fetch; + + try { + const error = await assertRejects(() => + withInternalCredentials("runtime-user", "runtime-pass", () => + fetchProjectEnvVars( + "https://api.veryfront.test", + "my-project", + "env-123", + "test-token", + )) + ); + assertEquals((error as { slug?: string }).slug, "network-error"); + assertEquals((error as { status?: number }).status, 502); + assertEquals(fetchCount, 2); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("does not wait for response cleanup before the privileged fetch", async () => { + const originalFetch = globalThis.fetch; + let fetchCount = 0; + let timeoutId: number | undefined; + globalThis.fetch = (() => { + fetchCount++; + return Promise.resolve( + fetchCount === 1 + ? responseWithBodyCleanup(200, () => new Promise(() => {})) + : Response.json({ data: [{ key: "API_KEY", value: "plaintext-value" }] }), + ); + }) as typeof fetch; + + try { + const timeout = Symbol("timeout"); + const deadline = new Promise((resolve) => { + timeoutId = setTimeout(() => resolve(timeout), 100); + }); + const result = await Promise.race([ + withInternalCredentials("runtime-user", "runtime-pass", () => + fetchProjectEnvVars( + "https://api.veryfront.test", + "my-project", + "env-123", + "test-token", + )), + deadline, + ]); + assertEquals(result, { API_KEY: "plaintext-value" }); + assertEquals(fetchCount, 2); + } finally { + clearTimeout(timeoutId); + globalThis.fetch = originalFetch; + } + }); + + it("rejects management redirects without following them", async () => { + const paths: string[] = []; + const { server, port } = createMockServer((req: Request) => { + const url = new URL(req.url); + paths.push(url.pathname); + if (url.pathname === "/redirect-target") { + return Response.json({ data: [{ key: "LEAK", value: "followed" }] }); + } + return new Response(null, { + status: 302, + headers: { location: `http://127.0.0.1:${port}/redirect-target` }, + }); + }); + + try { + await assertRejects(() => fetchFromMockApi(port)); + assertEquals(paths, ["/projects/my-project/environment-variables"]); + } finally { + await server.shutdown(); + } + }); + + it("authorizes the canonical project before using host-level internal credentials", async () => { + const paths: string[] = []; const { server, port } = createMockServer((req: Request) => { const url = new URL(req.url); + paths.push(url.pathname); + + if (url.pathname === "/projects/my-project/environment-variables") { + assertEquals(url.searchParams.get("environment_id"), "env-123"); + assertEquals(req.headers.get("authorization"), "Bearer test-token"); + return Response.json({ data: [{ key: "API_KEY", value: "********" }] }); + } assertEquals(url.pathname, "/internal/project-environment-variables"); assertEquals(url.searchParams.get("environment_id"), "env-123"); + assertEquals(url.searchParams.get("project_slug"), "my-project"); + assertEquals(req.headers.get("x-project-slug"), "my-project"); assertEquals(req.headers.get("authorization"), `Basic ${btoa("runtime-user:runtime-pass")}`); return Response.json({ data: [{ key: "API_KEY", value: "plaintext-value" }] }); @@ -134,17 +308,26 @@ describe("project-env/fetcher", () => { }); assertEquals(result, { API_KEY: "plaintext-value" }); + assertEquals(paths, [ + "/projects/my-project/environment-variables", + "/internal/project-environment-variables", + ]); } finally { await server.shutdown(); } }); - it("falls back to the management endpoint when the internal endpoint is absent", async () => { + it("fails closed when the configured internal endpoint is absent", async () => { const paths: string[] = []; const { server, port } = createMockServer((req: Request) => { const url = new URL(req.url); paths.push(url.pathname); + if (url.pathname === "/projects/my-project/environment-variables") { + assertEquals(req.headers.get("authorization"), "Bearer test-token"); + return Response.json({ data: [] }); + } + if (url.pathname === "/internal/project-environment-variables") { assertEquals( req.headers.get("authorization"), @@ -153,36 +336,66 @@ describe("project-env/fetcher", () => { return new Response(null, { status: 404 }); } - assertEquals(req.headers.get("authorization"), "Bearer test-token"); - return Response.json({ data: [{ key: "API_KEY", value: "legacy-plaintext" }] }); + throw new Error(`Unexpected path: ${url.pathname}`); }); try { - const result = await fetchFromMockApi(port, { - username: "runtime-user", - password: "runtime-pass", - }); + await assertRejects(() => + fetchFromMockApi(port, { + username: "runtime-user", + password: "runtime-pass", + }) + ); assertEquals(paths, [ + "/projects/my-project/environment-variables", "/internal/project-environment-variables", + ]); + } finally { + await server.shutdown(); + } + }); + + it("rejects internal redirects without following them or falling back", async () => { + const paths: string[] = []; + const { server, port } = createMockServer((req: Request) => { + const url = new URL(req.url); + paths.push(url.pathname); + if (url.pathname === "/projects/my-project/environment-variables") { + return Response.json({ data: [] }); + } + if (url.pathname === "/internal/project-environment-variables") { + return new Response(null, { + status: 302, + headers: { location: `http://127.0.0.1:${port}/redirect-target` }, + }); + } + return Response.json({ data: [{ key: "LEAK", value: "followed" }] }); + }); + + try { + await assertRejects(() => + fetchFromMockApi(port, { + username: "runtime-user", + password: "runtime-pass", + }) + ); + assertEquals(paths, [ "/projects/my-project/environment-variables", + "/internal/project-environment-variables", ]); - assertEquals(result, { API_KEY: "legacy-plaintext" }); } finally { await server.shutdown(); } }); - it("does not fall back when the internal endpoint rejects the request", async () => { + it("does not use internal credentials when project authorization is denied", async () => { let requestCount = 0; const { server, port } = createMockServer((req: Request) => { requestCount++; - assertEquals(new URL(req.url).pathname, "/internal/project-environment-variables"); - assertEquals( - req.headers.get("authorization"), - `Basic ${btoa("runtime-user:runtime-pass")}`, - ); - return new Response(null, { status: 401 }); + assertEquals(new URL(req.url).pathname, "/projects/my-project/environment-variables"); + assertEquals(req.headers.get("authorization"), "Bearer test-token"); + return new Response(null, { status: 403 }); }); try { @@ -198,8 +411,70 @@ describe("project-env/fetcher", () => { } }); + it("does not call the internal endpoint after management authorization times out", async () => { + const paths: string[] = []; + const { server, port } = createMockServer(async (req: Request) => { + paths.push(new URL(req.url).pathname); + await new Promise((resolve) => setTimeout(resolve, 40)); + return Response.json({ data: [] }); + }); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(new Error("management timeout")), 10); + + try { + const error = await assertRejects(() => + fetchFromMockApi( + port, + { username: "runtime-user", password: "runtime-pass" }, + controller.signal, + ) + ); + assertInstanceOf(error, Error); + assertEquals(error.message, "management timeout"); + assertEquals(paths, ["/projects/my-project/environment-variables"]); + } finally { + clearTimeout(timeoutId); + await server.shutdown(); + } + }); + + it("does not fall back after the internal request times out", async () => { + const paths: string[] = []; + const { server, port } = createMockServer(async (req: Request) => { + const path = new URL(req.url).pathname; + paths.push(path); + if (path === "/projects/my-project/environment-variables") { + return Response.json({ data: [] }); + } + await new Promise((resolve) => setTimeout(resolve, 40)); + return Response.json({ data: [{ key: "API_KEY", value: "late" }] }); + }); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(new Error("internal timeout")), 10); + + try { + await assertRejects(() => + fetchFromMockApi( + port, + { username: "runtime-user", password: "runtime-pass" }, + controller.signal, + ) + ); + assertEquals(paths, [ + "/projects/my-project/environment-variables", + "/internal/project-environment-variables", + ]); + } finally { + clearTimeout(timeoutId); + await server.shutdown(); + } + }); + it("rejects masked values returned by the internal endpoint", async () => { - const { server, port } = createMockServer(() => { + const { server, port } = createMockServer((req: Request) => { + if (new URL(req.url).pathname === "/projects/my-project/environment-variables") { + return Response.json({ data: [] }); + } return Response.json({ data: [{ key: "API_KEY", value: "********" }] }); }); @@ -226,4 +501,54 @@ describe("project-env/fetcher", () => { await server.shutdown(); } }); + + it("rejects malformed and duplicate environment entries", async () => { + const responses: unknown[] = [ + { data: "not-an-array" }, + { data: [null] }, + { data: [{ key: "VALID", value: 123 }] }, + { data: [{ key: "DUP", value: "one" }, { key: "DUP", value: "two" }] }, + { data: Array.from({ length: 101 }, (_, index) => ({ key: `KEY_${index}`, value: "x" })) }, + ]; + const { server, port } = createMockServer(() => Response.json(responses.shift())); + + try { + for (let index = 0; index < 5; index += 1) { + const error = await assertRejects(() => fetchFromMockApi(port)); + assertEquals((error as { slug?: string }).slug, "network-error"); + } + } finally { + await server.shutdown(); + } + }); + + it("bounds streamed environment responses before JSON parsing", async () => { + const chunk = new Uint8Array(64 * 1024).fill(0x20); + let emittedBytes = 0; + const { server, port } = createMockServer(() => { + return new Response( + new ReadableStream({ + pull(controller) { + if (emittedBytes > PROJECT_ENV_RESPONSE_MAX_BYTES) { + controller.close(); + return; + } + emittedBytes += chunk.byteLength; + controller.enqueue(chunk); + }, + }), + { + headers: { "content-type": "application/json" }, + }, + ); + }); + + try { + const error = await assertRejects(() => fetchFromMockApi(port)); + assertEquals((error as { slug?: string }).slug, "network-error"); + assertEquals(emittedBytes <= PROJECT_ENV_RESPONSE_MAX_BYTES + chunk.byteLength * 2, true); + } finally { + await server.shutdown(); + } + }); }); diff --git a/src/server/project-env/fetcher.ts b/src/server/project-env/fetcher.ts index 82ed1090ab..4deb2cb357 100644 --- a/src/server/project-env/fetcher.ts +++ b/src/server/project-env/fetcher.ts @@ -5,8 +5,15 @@ */ import { encodeBase64, getBaseLogger } from "#veryfront/utils"; -import { NETWORK_ERROR } from "#veryfront/errors"; +import { readResponseTextPrefix } from "#veryfront/utils/response-body.ts"; +import { + AUTHENTICATION_REQUIRED, + isVeryfrontError, + NETWORK_ERROR, + PERMISSION_DENIED, +} from "#veryfront/errors"; import { getHostEnv } from "#veryfront/platform/compat/process.ts"; +import { createProjectEnvSnapshot } from "./snapshot.ts"; const baseLogger = getBaseLogger("PROJECT-ENV"); @@ -15,10 +22,101 @@ const logger = baseLogger.component("project-env"); /** Max env vars per request. API enforces a hard cap of 100. */ const ENV_VARS_FETCH_LIMIT = 100; const MASKED_ENV_VALUE = "********"; +/** Hard ceiling for the complete JSON envelope returned by the env API. */ +export const PROJECT_ENV_RESPONSE_MAX_BYTES = 8 * 1024 * 1024; +const UTF8_ENCODER = new TextEncoder(); -type EnvironmentVariableResponse = { - data?: Array<{ key: string; value: string }>; -}; +function discardResponseBody(response: Response): void { + try { + void response.body?.cancel().catch(() => {}); + } catch { + // Best-effort cleanup; admission has already failed closed. + } +} + +function parseDeclaredContentLength(response: Response): number | undefined { + const raw = response.headers.get("content-length"); + if (raw === null || !/^(0|[1-9]\d*)$/.test(raw)) return undefined; + const value = Number(raw); + return Number.isSafeInteger(value) ? value : undefined; +} + +function invalidEnvironmentResponse(detail: string, cause?: unknown): Error { + return NETWORK_ERROR.create({ detail, cause }); +} + +async function readBoundedEnvironmentResponse( + response: Response, + signal?: AbortSignal, +): Promise { + const declaredLength = parseDeclaredContentLength(response); + if (declaredLength !== undefined && declaredLength > PROJECT_ENV_RESPONSE_MAX_BYTES) { + discardResponseBody(response); + throw invalidEnvironmentResponse("Project environment response exceeded its size limit"); + } + + const { text, truncated } = await readResponseTextPrefix( + response, + PROJECT_ENV_RESPONSE_MAX_BYTES + 1, + signal, + { fatalUtf8: true }, + ); + if ( + truncated || UTF8_ENCODER.encode(text).byteLength > PROJECT_ENV_RESPONSE_MAX_BYTES + ) { + throw invalidEnvironmentResponse("Project environment response exceeded its size limit"); + } + return text; +} + +function parseEnvironmentResponse(text: string): Readonly> { + let body: unknown; + try { + body = JSON.parse(text); + } catch (cause) { + throw invalidEnvironmentResponse("Project environment response was not valid JSON", cause); + } + + if (typeof body !== "object" || body === null || Array.isArray(body)) { + throw invalidEnvironmentResponse("Project environment response must be an object"); + } + const data = (body as { data?: unknown }).data; + if (!Array.isArray(data)) { + throw invalidEnvironmentResponse("Project environment response must contain a data array"); + } + if (data.length > ENV_VARS_FETCH_LIMIT) { + throw invalidEnvironmentResponse("Project environment response contained too many entries"); + } + + const result = Object.create(null) as Record; + const keys = new Set(); + for (const entry of data) { + if (typeof entry !== "object" || entry === null || Array.isArray(entry)) { + throw invalidEnvironmentResponse("Project environment response contained an invalid entry"); + } + const key = (entry as { key?: unknown }).key; + const value = (entry as { value?: unknown }).value; + if (typeof key !== "string" || typeof value !== "string") { + throw invalidEnvironmentResponse( + "Project environment response entries must contain string keys and values", + ); + } + if (keys.has(key)) { + throw invalidEnvironmentResponse("Project environment response contained a duplicate key"); + } + keys.add(key); + if (value === MASKED_ENV_VALUE) { + throw invalidEnvironmentResponse("Refusing masked environment variable response"); + } + result[key] = value; + } + + try { + return createProjectEnvSnapshot(result); + } catch (cause) { + throw invalidEnvironmentResponse("Project environment response violated runtime limits", cause); + } +} function getInternalAuthorization(): string | undefined { const username = getHostEnv("VERYFRONT_API_INTERNAL_USER"); @@ -32,13 +130,18 @@ async function fetchEnvironmentVariables( authorization: string, projectSlug: string, environmentId: string, + signal?: AbortSignal, + headers: HeadersInit = {}, ): Promise { try { return await fetch(url, { headers: { Authorization: authorization, Accept: "application/json", + ...headers, }, + redirect: "error", + signal, }); } catch (error) { logger.error("Env var fetch network error", { @@ -46,15 +149,45 @@ async function fetchEnvironmentVariables( environmentId, error: error instanceof Error ? error.message : String(error), }); - throw error; + if (signal?.aborted) { + throw signal.reason instanceof Error + ? signal.reason + : new DOMException("Project environment request was cancelled", "AbortError"); + } + if (isVeryfrontError(error)) throw error; + throw NETWORK_ERROR.create({ + detail: "Project environment request failed", + cause: error, + }); + } +} + +function projectAuthorizationError(status: number): Error { + if (status === 401) { + return AUTHENTICATION_REQUIRED.create({ + detail: "Project credential was rejected", + }); + } + if (status === 403 || status === 404) { + return PERMISSION_DENIED.create({ + detail: "Project credential is not authorized for the requested environment", + }); } + return NETWORK_ERROR.create({ + detail: "Project environment authorization request failed", + }); } /** * Fetch environment variables for a project from the Veryfront API. * - * Hosted runtimes call the internal Basic-auth endpoint first. Older API deployments - * without that endpoint fall back to the bearer-auth management endpoint. + * The caller's project credential is always checked against the project-scoped + * management endpoint before host-level internal credentials may retrieve secret + * values. This prevents a tenant-controlled environment ID from turning the + * runtime's internal credentials into a cross-project confused deputy. + * + * Deployments that configure internal credentials must expose the internal + * endpoint. There is intentionally no fallback after that privileged path fails. * Response: { data: [{ key: string, value: string }] } */ export async function fetchProjectEnvVars( @@ -62,6 +195,7 @@ export async function fetchProjectEnvVars( projectSlug: string, environmentId: string, token: string, + signal?: AbortSignal, ): Promise> { const managementUrl = `${apiBaseUrl}/projects/${ encodeURIComponent(projectSlug) @@ -70,57 +204,55 @@ export async function fetchProjectEnvVars( }&limit=${ENV_VARS_FETCH_LIMIT}`; const internalUrl = `${apiBaseUrl}/internal/project-environment-variables?environment_id=${ encodeURIComponent(environmentId) - }`; + }&project_slug=${encodeURIComponent(projectSlug)}`; - const internalAuthorization = getInternalAuthorization(); - let response = internalAuthorization - ? await fetchEnvironmentVariables( - internalUrl, - internalAuthorization, - projectSlug, - environmentId, - ) - : await fetchEnvironmentVariables( - managementUrl, - `Bearer ${token}`, + let response = await fetchEnvironmentVariables( + managementUrl, + `Bearer ${token}`, + projectSlug, + environmentId, + signal, + ); + + if (!response.ok) { + discardResponseBody(response); + logger.warn("Project credential cannot access requested environment", { projectSlug, environmentId, - ); + status: response.status, + }); + throw projectAuthorizationError(response.status); + } - if (internalAuthorization && response.status === 404) { - await response.body?.cancel(); + // Do not even materialize the host credential until the tenant credential + // has proved access to this canonical project/environment pair. + const internalAuthorization = getInternalAuthorization(); + if (internalAuthorization) { + discardResponseBody(response); response = await fetchEnvironmentVariables( - managementUrl, - `Bearer ${token}`, + internalUrl, + internalAuthorization, projectSlug, environmentId, + signal, + { "x-project-slug": projectSlug }, ); } if (!response.ok) { - await response.body?.cancel(); + discardResponseBody(response); logger.warn("Failed to fetch env vars", { projectSlug, environmentId, status: response.status, }); - throw NETWORK_ERROR.create({ detail: `Failed to fetch env vars: ${response.status}` }); + throw NETWORK_ERROR.create({ detail: "Internal project environment request failed" }); } try { - const body = await response.json() as EnvironmentVariableResponse; - - const result: Record = {}; - if (body.data) { - for (const entry of body.data) { - if (entry.value === MASKED_ENV_VALUE) { - throw NETWORK_ERROR.create({ - detail: "Refusing masked environment variable response", - }); - } - result[entry.key] = entry.value; - } - } + const result = parseEnvironmentResponse( + await readBoundedEnvironmentResponse(response, signal), + ); logger.debug("Fetched env vars", { projectSlug, diff --git a/src/server/project-env/index.ts b/src/server/project-env/index.ts index 4dc19ddd67..3c884a1c83 100644 --- a/src/server/project-env/index.ts +++ b/src/server/project-env/index.ts @@ -10,6 +10,16 @@ export { isProjectEnvActive, runWithProjectEnv, } from "./storage.ts"; -export { EnvironmentVariableCache } from "./cache.ts"; +export { + EnvironmentVariableCache, + type EnvironmentVariableCacheOptions, + type ProjectEnvironmentScope, +} from "./cache.ts"; export { filterRuntimeProjectEnv, filterSharedRuntimeProjectEnv } from "./reserved-env.ts"; export { fetchProjectEnvVars } from "./fetcher.ts"; +export { + type NamedProjectEnvironmentScope, + ProductionEnvironmentResolver, + type ProductionEnvironmentScope, + ProjectEnvironmentIdentityResolver, +} from "./production-environment-resolver.ts"; diff --git a/src/server/project-env/production-environment-resolver.test.ts b/src/server/project-env/production-environment-resolver.test.ts new file mode 100644 index 0000000000..a7502cb500 --- /dev/null +++ b/src/server/project-env/production-environment-resolver.test.ts @@ -0,0 +1,229 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; +import { afterEach, describe, it } from "#veryfront/testing/bdd.ts"; +import { + MAX_ENVIRONMENT_LIST_RESPONSE_BYTES, + ProductionEnvironmentResolver, + ProjectEnvironmentIdentityResolver, +} from "./production-environment-resolver.ts"; + +const originalFetch = globalThis.fetch; + +function scope() { + return { + apiBaseUrl: "https://api.veryfront.test", + projectSlug: "project-one", + projectId: "project-id-one", + token: "project-token", + }; +} + +describe("ProductionEnvironmentResolver", () => { + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("uses bounded, redirect-safe authenticated transport and selects title-case production", async () => { + let requestInit: RequestInit | undefined; + globalThis.fetch = ((_input, init) => { + requestInit = init; + return Promise.resolve(Response.json({ + data: [ + { id: "env-preview", name: "Preview" }, + { id: "env-production", name: "Production" }, + ], + })); + }) as typeof fetch; + + const resolver = new ProductionEnvironmentResolver(); + assertEquals(await resolver.resolve(scope()), "env-production"); + assertEquals(requestInit?.redirect, "error"); + assertEquals(new Headers(requestInit?.headers).get("authorization"), "Bearer project-token"); + }); + + it("preserves authorization semantics for failed lookups", async () => { + globalThis.fetch = (() => Promise.resolve(new Response(null, { status: 403 }))) as typeof fetch; + + const error = await assertRejects(() => new ProductionEnvironmentResolver().resolve(scope())); + assertEquals((error as { slug?: string }).slug, "permission-denied"); + assertEquals((error as { status?: number }).status, 403); + }); + + it("resolves an exact named environment and verifies its signed ID", async () => { + globalThis.fetch = (() => + Promise.resolve(Response.json({ + data: [ + { id: "env-production", name: "production" }, + { id: "env-staging", name: "staging" }, + ], + }))) as typeof fetch; + + const resolver = new ProjectEnvironmentIdentityResolver(); + assertEquals( + await resolver.resolveNamed({ + ...scope(), + environmentName: "staging", + expectedEnvironmentId: "env-staging", + }), + "env-staging", + ); + }); + + it("matches canonical named environments without depending on API letter case", async () => { + globalThis.fetch = (() => + Promise.resolve(Response.json({ + data: [{ id: "env-staging", name: "Staging" }], + }))) as typeof fetch; + + const resolver = new ProjectEnvironmentIdentityResolver(); + assertEquals( + await resolver.resolveNamed({ + ...scope(), + environmentName: "STAGING", + expectedEnvironmentId: "env-staging", + }), + "env-staging", + ); + }); + + it("fails closed when a signed environment ID does not match project metadata", async () => { + globalThis.fetch = (() => + Promise.resolve(Response.json({ + data: [{ id: "env-staging", name: "staging" }], + }))) as typeof fetch; + + const error = await assertRejects(() => + new ProjectEnvironmentIdentityResolver().resolveNamed({ + ...scope(), + environmentName: "staging", + expectedEnvironmentId: "env-production", + }) + ); + assertEquals((error as { slug?: string }).slug, "permission-denied"); + assertEquals((error as { status?: number }).status, 403); + }); + + it("binds a named environment to its current active release", async () => { + globalThis.fetch = (() => + Promise.resolve(Response.json({ + data: [{ + id: "env-staging", + name: "staging", + active_release_id: "release-staging-42", + }], + }))) as typeof fetch; + + const resolver = new ProjectEnvironmentIdentityResolver(); + assertEquals( + await resolver.resolveNamedForActiveRelease({ + ...scope(), + environmentName: "staging", + expectedEnvironmentId: "env-staging", + expectedReleaseId: "release-staging-42", + }), + "env-staging", + ); + }); + + it("fails closed when active release metadata is missing or does not match", async () => { + const activeReleaseIds: unknown[] = [undefined, null, "release-other"]; + globalThis.fetch = (() => + Promise.resolve(Response.json({ + data: [{ + id: "env-staging", + name: "staging", + active_release_id: activeReleaseIds.shift(), + }], + }))) as typeof fetch; + + const resolver = new ProjectEnvironmentIdentityResolver(); + for (let index = 0; index < 3; index += 1) { + const error = await assertRejects(() => + resolver.resolveNamedForActiveRelease({ + ...scope(), + environmentName: "staging", + expectedEnvironmentId: "env-staging", + expectedReleaseId: "release-staging-42", + }) + ); + assertEquals((error as { slug?: string }).slug, "permission-denied"); + assertEquals((error as { status?: number }).status, 403); + } + }); + + it("does not cache mutable active-release metadata", async () => { + let fetchCalls = 0; + globalThis.fetch = (() => { + fetchCalls += 1; + return Promise.resolve(Response.json({ + data: [{ + id: "env-staging", + name: "staging", + active_release_id: fetchCalls === 1 ? "release-one" : "release-two", + }], + })); + }) as typeof fetch; + + const resolver = new ProjectEnvironmentIdentityResolver(); + await resolver.resolveNamedForActiveRelease({ + ...scope(), + environmentName: "staging", + expectedEnvironmentId: "env-staging", + expectedReleaseId: "release-one", + }); + await resolver.resolveNamedForActiveRelease({ + ...scope(), + environmentName: "staging", + expectedEnvironmentId: "env-staging", + expectedReleaseId: "release-two", + }); + + assertEquals(fetchCalls, 2); + }); + + it("rejects oversized lookup responses before parsing", async () => { + globalThis.fetch = (() => + Promise.resolve( + new Response(" ".repeat(MAX_ENVIRONMENT_LIST_RESPONSE_BYTES + 1)), + )) as typeof fetch; + + const error = await assertRejects(() => new ProductionEnvironmentResolver().resolve(scope())); + assertEquals((error as { slug?: string }).slug, "network-error"); + assertEquals((error as { status?: number }).status, 502); + }); + + it("aborts stalled lookup work at the transport deadline", async () => { + let observedAbort = false; + globalThis.fetch = ((_input, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + observedAbort = true; + reject(init.signal?.reason); + }, { once: true }); + })) as typeof fetch; + + const error = await assertRejects(() => + new ProductionEnvironmentResolver({ timeoutMs: 5 }).resolve(scope()) + ); + assertEquals(observedAbort, true); + assertEquals((error as { slug?: string }).slug, "network-error"); + }); + + it("fails closed when production identity is absent or ambiguous", async () => { + const bodies = [ + { data: [{ id: "env-preview", name: "preview" }] }, + { + data: [ + { id: "env-prod-one", name: "production" }, + { id: "env-prod-two", name: "Production" }, + ], + }, + ]; + globalThis.fetch = (() => Promise.resolve(Response.json(bodies.shift()))) as typeof fetch; + + for (let index = 0; index < 2; index += 1) { + const error = await assertRejects(() => new ProductionEnvironmentResolver().resolve(scope())); + assertEquals((error as { slug?: string }).slug, "network-error"); + } + }); +}); diff --git a/src/server/project-env/production-environment-resolver.ts b/src/server/project-env/production-environment-resolver.ts new file mode 100644 index 0000000000..7169e7787d --- /dev/null +++ b/src/server/project-env/production-environment-resolver.ts @@ -0,0 +1,327 @@ +/** + * Bounded project-environment identity discovery for hosted runtime requests. + * + * @module server/project-env/production-environment-resolver + */ + +import { + createVeryfrontApiTransport, + type VeryfrontApiTransport, +} from "#veryfront/platform/adapters/veryfront-api-transport.ts"; +import { + AUTHENTICATION_REQUIRED, + isVeryfrontError, + NETWORK_ERROR, + PERMISSION_DENIED, +} from "#veryfront/errors"; +import { LRUCacheAdapter } from "#veryfront/utils/cache/stores/memory/lru-cache-adapter.ts"; + +export const MAX_ENVIRONMENT_LIST_RESPONSE_BYTES = 256 * 1024; +const MAX_ENVIRONMENT_COUNT = 100; +const ENVIRONMENT_LOOKUP_TIMEOUT_MS = 5_000; +const ENVIRONMENT_ID_CACHE_TTL_MS = 5 * 60_000; + +export interface ProductionEnvironmentScope { + apiBaseUrl: string; + projectSlug: string; + projectId?: string; + token: string; +} + +export interface NamedProjectEnvironmentScope extends ProductionEnvironmentScope { + environmentName: string; + expectedEnvironmentId?: string; +} + +export interface ReleaseBoundNamedProjectEnvironmentScope extends NamedProjectEnvironmentScope { + expectedReleaseId: string; +} + +interface NamedProjectEnvironmentIdentity { + environmentId: string; + activeReleaseId: string | null; +} + +function frame(value: string): string { + return `${value.length}:${value}`; +} + +function normalizeScope(scope: ProductionEnvironmentScope): ProductionEnvironmentScope { + if (typeof scope?.apiBaseUrl !== "string" || !scope.apiBaseUrl) { + throw new TypeError("Production environment lookup requires an API base URL"); + } + if (typeof scope.projectSlug !== "string" || !scope.projectSlug.trim()) { + throw new TypeError("Production environment lookup requires a project slug"); + } + if ( + scope.projectId !== undefined && + (typeof scope.projectId !== "string" || !scope.projectId.trim()) + ) { + throw new TypeError("Production environment lookup project ID must be non-empty"); + } + if (typeof scope.token !== "string" || !scope.token) { + throw AUTHENTICATION_REQUIRED.create({ + detail: "Production environment lookup requires a project credential", + }); + } + return Object.freeze({ ...scope }); +} + +const encodeText = TextEncoder.prototype.encode; +const subtleDigest = crypto.subtle.digest.bind(crypto.subtle); +const textEncoder = new TextEncoder(); + +async function cacheKey(scope: NamedProjectEnvironmentScope): Promise { + const tokenBytes = encodeText.call(textEncoder, scope.token); + const tokenDigest = new Uint8Array(await subtleDigest("SHA-256", tokenBytes)); + const credentialPrincipal = Array.from( + tokenDigest, + (byte) => byte.toString(16).padStart(2, "0"), + ).join(""); + return [ + "named-environment-v1", + frame(scope.apiBaseUrl), + frame(scope.projectSlug), + frame(scope.projectId ?? ""), + frame(normalizeEnvironmentName(scope.environmentName)), + credentialPrincipal, + ].join("|"); +} + +function normalizeEnvironmentName(value: string): string { + return value.toLowerCase(); +} + +function mapLookupError(error: unknown, signal?: AbortSignal): Error { + if (signal?.aborted) { + return signal.reason instanceof Error + ? signal.reason + : new DOMException("Project environment lookup was cancelled", "AbortError"); + } + if (isVeryfrontError(error)) { + if (error.status === 401) { + return AUTHENTICATION_REQUIRED.create({ + detail: "Project credential was rejected during environment lookup", + }); + } + if (error.status === 403 || error.status === 404) { + return PERMISSION_DENIED.create({ + detail: "Project credential cannot access environment metadata", + }); + } + } + return NETWORK_ERROR.create({ + detail: "Project environment lookup failed", + cause: error, + }); +} + +function normalizeNamedScope(input: NamedProjectEnvironmentScope): NamedProjectEnvironmentScope { + const scope = normalizeScope(input); + if ( + typeof input.environmentName !== "string" || + !input.environmentName.trim() || + input.environmentName !== input.environmentName.trim() || + input.environmentName.length > 255 + ) { + throw new TypeError("Environment lookup requires a canonical environment name"); + } + if ( + input.expectedEnvironmentId !== undefined && + (typeof input.expectedEnvironmentId !== "string" || !input.expectedEnvironmentId.trim()) + ) { + throw new TypeError("Expected environment ID must be non-empty"); + } + return Object.freeze({ + ...scope, + environmentName: input.environmentName, + expectedEnvironmentId: input.expectedEnvironmentId, + }); +} + +function parseNamedEnvironmentIdentity( + body: unknown, + environmentName: string, +): NamedProjectEnvironmentIdentity { + if (typeof body !== "object" || body === null || Array.isArray(body)) { + throw NETWORK_ERROR.create({ + detail: "Project environment lookup returned an invalid response", + }); + } + const data = (body as { data?: unknown }).data; + if (!Array.isArray(data) || data.length > MAX_ENVIRONMENT_COUNT) { + throw NETWORK_ERROR.create({ + detail: "Project environment lookup returned an invalid environment list", + }); + } + + const normalizedEnvironmentName = normalizeEnvironmentName(environmentName); + const matching: NamedProjectEnvironmentIdentity[] = []; + for (const entry of data) { + if (typeof entry !== "object" || entry === null || Array.isArray(entry)) { + throw NETWORK_ERROR.create({ + detail: "Project environment lookup returned an invalid environment entry", + }); + } + const id = (entry as { id?: unknown }).id; + const name = (entry as { name?: unknown }).name; + if (typeof id !== "string" || !id || typeof name !== "string" || !name) { + throw NETWORK_ERROR.create({ + detail: "Project environment lookup returned an invalid environment entry", + }); + } + const rawActiveReleaseId = (entry as { active_release_id?: unknown }).active_release_id; + if ( + rawActiveReleaseId !== undefined && + rawActiveReleaseId !== null && + ( + typeof rawActiveReleaseId !== "string" || + !rawActiveReleaseId.trim() || + rawActiveReleaseId !== rawActiveReleaseId.trim() + ) + ) { + throw NETWORK_ERROR.create({ + detail: "Project environment lookup returned an invalid active release identity", + }); + } + if (normalizeEnvironmentName(name) === normalizedEnvironmentName) { + matching.push({ + environmentId: id, + activeReleaseId: typeof rawActiveReleaseId === "string" ? rawActiveReleaseId : null, + }); + } + } + + if (matching.length !== 1) { + throw NETWORK_ERROR.create({ + detail: matching.length === 0 + ? "Requested environment is not configured" + : "Requested environment identity is ambiguous", + }); + } + return matching[0]!; +} + +/** Resolve and briefly cache canonical named project-environment identities. */ +export class ProjectEnvironmentIdentityResolver { + private readonly cache = new LRUCacheAdapter({ + maxEntries: 1_000, + ttlMs: ENVIRONMENT_ID_CACHE_TTL_MS, + }); + + constructor( + private readonly options: { + timeoutMs?: number; + maxResponseBytes?: number; + } = {}, + ) {} + + async resolve( + input: ProductionEnvironmentScope, + signal?: AbortSignal, + ): Promise { + return await this.resolveNamed( + { ...input, environmentName: "production" }, + signal, + ); + } + + /** Resolve one exact named environment and optionally bind its expected ID. */ + async resolveNamed( + input: NamedProjectEnvironmentScope, + signal?: AbortSignal, + ): Promise { + signal?.throwIfAborted(); + const scope = normalizeNamedScope(input); + const key = await cacheKey(scope); + signal?.throwIfAborted(); + const cached = this.cache.get(key); + if (cached) { + this.assertExpectedEnvironmentId(cached, scope.expectedEnvironmentId); + return cached; + } + + const identity = await this.fetchNamedEnvironmentIdentity(scope, signal); + this.assertExpectedEnvironmentId(identity.environmentId, scope.expectedEnvironmentId); + this.cache.set(key, identity.environmentId); + return identity.environmentId; + } + + /** + * Resolve a named environment and bind it to its current immutable release. + * + * Active-release metadata is deliberately fetched on every call: unlike an + * environment ID, it is mutable and must not inherit the identity cache TTL. + */ + async resolveNamedForActiveRelease( + input: ReleaseBoundNamedProjectEnvironmentScope, + signal?: AbortSignal, + ): Promise { + signal?.throwIfAborted(); + const scope = normalizeNamedScope(input); + if ( + typeof input.expectedReleaseId !== "string" || + !input.expectedReleaseId.trim() || + input.expectedReleaseId !== input.expectedReleaseId.trim() + ) { + throw new TypeError("Expected release ID must be canonical and non-empty"); + } + + const identity = await this.fetchNamedEnvironmentIdentity(scope, signal); + this.assertExpectedEnvironmentId(identity.environmentId, scope.expectedEnvironmentId); + if (identity.activeReleaseId !== input.expectedReleaseId) { + throw PERMISSION_DENIED.create({ + detail: "Signed release identity does not match the environment active release", + }); + } + return identity.environmentId; + } + + private async fetchNamedEnvironmentIdentity( + scope: NamedProjectEnvironmentScope, + signal?: AbortSignal, + ): Promise { + let transport: VeryfrontApiTransport; + try { + transport = createVeryfrontApiTransport({ + baseUrl: scope.apiBaseUrl, + getToken: () => scope.token, + retry: { maxRetries: 0, initialDelay: 0, maxDelay: 0 }, + timeoutMs: this.options.timeoutMs ?? ENVIRONMENT_LOOKUP_TIMEOUT_MS, + wrapFinalError: (error) => error, + }); + const body = await transport.request( + `/projects/${encodeURIComponent(scope.projectSlug)}/environments`, + { + headers: { Accept: "application/json" }, + maxResponseBytes: this.options.maxResponseBytes ?? + MAX_ENVIRONMENT_LIST_RESPONSE_BYTES, + redirect: "error", + includeErrorBodyInDiagnostics: false, + signal, + }, + ); + return parseNamedEnvironmentIdentity(body, scope.environmentName); + } catch (error) { + throw mapLookupError(error, signal); + } + } + + private assertExpectedEnvironmentId( + actualEnvironmentId: string, + expectedEnvironmentId: string | undefined, + ): void { + if (expectedEnvironmentId !== undefined && actualEnvironmentId !== expectedEnvironmentId) { + throw PERMISSION_DENIED.create({ + detail: "Signed environment identity does not match project metadata", + }); + } + } + + clear(): void { + this.cache.clear(); + } +} + +/** Backwards-compatible production-only resolver name. */ +export class ProductionEnvironmentResolver extends ProjectEnvironmentIdentityResolver {} diff --git a/src/server/runtime-handler/adapter-factory.test.ts b/src/server/runtime-handler/adapter-factory.test.ts index 48856e2a69..db3eb44a1e 100644 --- a/src/server/runtime-handler/adapter-factory.test.ts +++ b/src/server/runtime-handler/adapter-factory.test.ts @@ -169,6 +169,7 @@ describe("adapter-factory", () => { afterEach(() => { localProjectCache.clear(); localAdapterCache.clear(); + Deno.env.delete("VERYFRONT_TRUST_FORWARDED_HEADERS"); }); it("ignores x-project-path override outside proxy mode", async () => { @@ -206,6 +207,7 @@ describe("adapter-factory", () => { }); it("accepts validated x-project-path override in proxy mode when proxy trusted", async () => { + Deno.env.set("VERYFRONT_TRUST_FORWARDED_HEADERS", "1"); const adapter = createMockAdapter({ "/trusted/project": { isDirectory: true }, "/trusted/project/app": { isDirectory: true }, @@ -325,7 +327,7 @@ describe("adapter-factory", () => { }, ); - it("honours x-project-path in proxy mode when dispatch-JWS header is present", async () => { + it("does not let a valid dispatch JWS authorize x-project-path", async () => { const adapter = createMockAdapter({ "/trusted/project": { isDirectory: true }, "/trusted/project/app": { isDirectory: true }, @@ -356,8 +358,8 @@ describe("adapter-factory", () => { prepareHostedConfigContext: preparePreviewHostedConfigContext, }); - assertEquals(result.isLocalProject, true); - assertEquals(result.projectDir, "/trusted/project"); + assertEquals(result.isLocalProject, false); + assertEquals(result.projectDir, "/base/project"); }); it("returns original adapter when no local project found and not proxy mode", async () => { @@ -520,6 +522,7 @@ describe("adapter-factory", () => { }); it("uses injected cache instead of default singleton", async () => { + Deno.env.set("VERYFRONT_TRUST_FORWARDED_HEADERS", "1"); const cache = new ProjectDiscoveryCache(); const adapter = createMockAdapter({ "/trusted/project": { isDirectory: true }, diff --git a/src/server/runtime-handler/adapter-factory.ts b/src/server/runtime-handler/adapter-factory.ts index b09b87f5a1..55244f05e5 100644 --- a/src/server/runtime-handler/adapter-factory.ts +++ b/src/server/runtime-handler/adapter-factory.ts @@ -27,7 +27,6 @@ import { } from "./local-project-discovery.ts"; import type { ParsedDomain } from "../utils/domain-parser.ts"; import { isProxyTrusted } from "../utils/proxy-trust.ts"; -import { getHostEnv } from "#veryfront/platform/compat/process.ts"; const baseLogger = getBaseLogger("SERVER"); @@ -141,15 +140,11 @@ export async function resolveAdapter( // SECURITY: `x-project-path` is a client-controlled header. Honouring it from any // request would let an attacker reaching the runtime directly aim project discovery // (and therefore `/_veryfront/fs/...`) at arbitrary filesystem paths (VULN-SRV-3). - // Only read it when the request is proxy-trusted: either the operator opted in via - // VERYFRONT_TRUST_FORWARDED_HEADERS=1, or the request carries a dispatch JWS that - // verifies against CHANNEL_DISPATCH_SIGNING_PUBLIC_KEY. Mere header presence is - // NOT sufficient — a direct-access attacker could otherwise spoof `x-project-path` - // by attaching any value in `x-veryfront-dispatch-jws`. - const publicKeyPem = opts.adapter.env.get("CHANNEL_DISPATCH_SIGNING_PUBLIC_KEY") ?? - getHostEnv("CHANNEL_DISPATCH_SIGNING_PUBLIC_KEY"); + // Only read it when the operator explicitly declares a private, sanitising + // upstream topology. An operation-scoped dispatch JWS does not bind this path + // override and therefore cannot authorize generic proxy headers. const proxyTrusted = opts.isProxyMode && - (opts.proxyTrusted ?? await isProxyTrusted(opts.req, { publicKeyPem })); + (opts.proxyTrusted ?? await isProxyTrusted(opts.req)); const trustedHeaderProjectPath = proxyTrusted ? opts.req.headers.get("x-project-path")?.trim() || undefined : undefined; diff --git a/src/server/runtime-handler/environment-resolution.test.ts b/src/server/runtime-handler/environment-resolution.test.ts index 6ab87a0d34..24579b5aae 100644 --- a/src/server/runtime-handler/environment-resolution.test.ts +++ b/src/server/runtime-handler/environment-resolution.test.ts @@ -107,6 +107,33 @@ describe("environment-resolution", () => { assertEquals(result.releaseId, undefined); }); + it("requires a release for both hosted browser module path variants", () => { + for ( + const pathname of [ + "/_vf_modules/components/Secret.js", + "/_veryfront/modules/components/Secret.js", + ] + ) { + const result = resolveEnvironment({ + proxyEnv: "production", + reqCtxMode: "production", + releaseId: undefined, + projectSlug: "my-project", + projectId: "proj_123", + environmentName: "Production", + host: "my-project.production.veryfront.com", + isLocalProject: false, + isProxyMode: true, + pathname, + defaultEnvironment: undefined, + }); + + assertEquals(result.errorResponse?.status, 404, pathname); + assertEquals(result.resolvedEnvironment, "production", pathname); + assertEquals(result.releaseId, undefined, pathname); + } + }); + it("falls back to preview in standalone production without releaseId", () => { const result = resolveEnvironment({ proxyEnv: undefined, diff --git a/src/server/runtime-handler/environment-resolution.ts b/src/server/runtime-handler/environment-resolution.ts index 8e20810611..3ad3f33795 100644 --- a/src/server/runtime-handler/environment-resolution.ts +++ b/src/server/runtime-handler/environment-resolution.ts @@ -67,9 +67,14 @@ export function resolveEnvironment( const isControlPlanePath = opts.pathname.startsWith("/api/control-plane/"); // Skip releaseId validation for development assets and signed control-plane - // requests because they do not require a user-facing release context. + // requests because they do not require a user-facing release context. Module + // transports are deliberately excluded: both the canonical and legacy paths + // can resolve tenant source, so hosted production requests must carry an + // immutable release identity before they reach the module server. + const isBrowserModulePath = opts.pathname.startsWith("/_vf_modules/") || + opts.pathname.startsWith("/_veryfront/modules/"); const canSkipReleaseIdValidation = opts.pathname === "/_ws" || - opts.pathname.startsWith("/_veryfront/") || + (opts.pathname.startsWith("/_veryfront/") && !isBrowserModulePath) || isControlPlanePath; // Validate releaseId in proxy mode production diff --git a/src/server/runtime-handler/handler-context-builder.test.ts b/src/server/runtime-handler/handler-context-builder.test.ts index b83a2b2a82..6373441749 100644 --- a/src/server/runtime-handler/handler-context-builder.test.ts +++ b/src/server/runtime-handler/handler-context-builder.test.ts @@ -30,6 +30,7 @@ function makeOpts(overrides: Partial = {}): HandlerContex }, routeRegistry: {} as any, isLocalProject: false, + isProxyMode: true, moduleServerUrl: "https://modules.example.com", environmentId: "env-789", ...overrides, @@ -57,6 +58,7 @@ describe("buildHandlerContext", () => { assertEquals(ctx.resolvedEnvironment, "production"); assertEquals(ctx.routeRegistry, opts.routeRegistry); assertEquals(ctx.isLocalProject, false); + assertEquals(ctx.isProxyMode, true); assertEquals(ctx.environmentId, "env-789"); assertEquals(ctx.enriched !== undefined, true); }); diff --git a/src/server/runtime-handler/handler-context-builder.ts b/src/server/runtime-handler/handler-context-builder.ts index 9d5b50096b..4deedea712 100644 --- a/src/server/runtime-handler/handler-context-builder.ts +++ b/src/server/runtime-handler/handler-context-builder.ts @@ -40,6 +40,12 @@ export interface HandlerContextOptions { projectId: string | undefined; /** Release ID */ releaseId: string | undefined; + /** Canonical branch ID from the trusted proxy boundary. */ + branchId?: string; + /** Canonical branch name from the trusted proxy boundary. */ + branchName?: string; + /** Canonical project default branch name from the trusted proxy boundary. */ + defaultBranchName?: string; /** Proxy token (undefined for local projects) */ proxyToken: string | undefined; /** Environment name */ @@ -54,9 +60,11 @@ export interface HandlerContextOptions { isLocalProject: boolean; /** Narrow host-owned capability for project-code execution. */ allowHostProjectCodeExecution?: boolean; + /** Whether this request is executing in the shared multi-project proxy runtime. */ + isProxyMode: boolean; /** Module server URL */ moduleServerUrl: string | undefined; - /** Environment ID for env var resolution (from proxy x-environment-id header) */ + /** Canonical environment ID resolved at the operator-authenticated proxy boundary. */ environmentId: string | undefined; /** Skip render-specific enriched context requirements for non-render control-plane routes */ skipEnrichedContext?: boolean; @@ -113,6 +121,9 @@ export function buildHandlerContext(opts: HandlerContextOptions): HandlerContext projectSlug: opts.projectSlug, projectId: opts.projectId, releaseId: opts.releaseId, + branchId: opts.branchId, + branchName: opts.branchName, + defaultBranchName: opts.defaultBranchName, proxyToken: opts.isLocalProject ? undefined : opts.proxyToken, environmentName: opts.environmentName, resolvedEnvironment: opts.resolvedEnvironment, @@ -120,6 +131,7 @@ export function buildHandlerContext(opts: HandlerContextOptions): HandlerContext routeRegistry: opts.routeRegistry, isLocalProject: opts.isLocalProject, allowHostProjectCodeExecution: opts.allowHostProjectCodeExecution, + isProxyMode: opts.isProxyMode, environmentId: opts.environmentId, prepareHostedConfigContext: opts.prepareHostedConfigContext, enriched: enrichedContext, diff --git a/src/server/runtime-handler/index.test.ts b/src/server/runtime-handler/index.test.ts index dc39873db4..8e51aeb8e8 100644 --- a/src/server/runtime-handler/index.test.ts +++ b/src/server/runtime-handler/index.test.ts @@ -1,5 +1,5 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals, assertExists } from "#veryfront/testing/assert.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; import { afterEach, describe, it } from "#veryfront/testing/bdd.ts"; import type { RuntimeAdapter, RuntimeId } from "#veryfront/platform/adapters/base.ts"; import { DenoAdapter } from "#veryfront/platform/adapters/runtime/deno/index.ts"; @@ -108,6 +108,7 @@ describe("server/runtime-handler/index", () => { injectIsolationDepsForTests(null); HMRHandler.shutdown(); requestTracker.shutdown(); + Deno.env.delete("VERYFRONT_TRUST_FORWARDED_HEADERS"); }); it("preserves debug flags supplied by binding-backed runtime adapters", () => { @@ -165,6 +166,20 @@ describe("server/runtime-handler/index", () => { it("returns 502 when x-project-slug is missing in proxy mode", async () => { const handler = createProxyModeHandler(); + const isolationCalls = { check: 0, start: 0, complete: 0 }; + injectIsolationDepsForTests({ + checkRequest: () => { + isolationCalls.check += 1; + return { allowed: true }; + }, + startRequest: () => { + isolationCalls.start += 1; + }, + completeRequest: () => { + isolationCalls.complete += 1; + }, + }); + const trackerBefore = requestTracker.getStats(); const response = await handler( new Request("http://localhost/page", { @@ -178,6 +193,8 @@ describe("server/runtime-handler/index", () => { error: "Missing project context", detail: "x-project-slug header is required in proxy mode", }); + assertEquals(isolationCalls, { check: 0, start: 0, complete: 0 }); + assertEquals(requestTracker.getStats(), trackerBefore); }); it("does not emit security guidance for the safe development defaults", async () => { @@ -257,8 +274,9 @@ describe("server/runtime-handler/index", () => { }); }); - it("allows standard first-party proxy context headers without an extra trust proof", async () => { + it("allows proxy context only behind the operator-trusted topology", async () => { const handler = createProxyModeHandler(); + Deno.env.set("VERYFRONT_TRUST_FORWARDED_HEADERS", "1"); const response = await handler( new Request("http://localhost/page", { @@ -293,11 +311,11 @@ describe("server/runtime-handler/index", () => { assertEquals(response.headers.get("Content-Type"), "application/json"); assertEquals(await response.json(), { error: "Untrusted proxy context", - detail: "proxy context headers require a trusted upstream proxy", + detail: "proxy mode requires an operator-trusted upstream proxy", }); }); - it("skips the proxy header guard for websocket requests", async () => { + it("rejects websocket query identity before HMR", async () => { const handler = createProxyModeHandler(); const response = await handler( @@ -306,10 +324,11 @@ describe("server/runtime-handler/index", () => { ), ); - assertEquals(response.status, 200); - const body = await response.json(); - assertEquals(body.status, "ok"); - assertExists(body.metrics); + assertEquals(response.status, 502); + assertEquals(await response.json(), { + error: "Missing project context", + detail: "x-project-slug header is required in proxy mode", + }); }); it("keeps the native HMR upgrade request connected", async () => { @@ -370,7 +389,7 @@ describe("server/runtime-handler/index", () => { } }); - it("skips the proxy header guard for lightweight module requests", async () => { + it("applies the proxy header guard to lightweight module requests", async () => { const handler = createProxyModeHandler(); const response = await handler( @@ -379,9 +398,47 @@ describe("server/runtime-handler/index", () => { }), ); - assertEquals(response.status === 502, false); - const body = await response.text(); - assertEquals(body.includes("x-project-slug header is required in proxy mode"), false); - assertEquals(body.includes("x-token header is required in proxy mode"), false); + assertEquals(response.status, 502); + assertEquals(await response.json(), { + error: "Missing project context", + detail: "x-project-slug header is required in proxy mode", + }); + }); + + it("rejects tokenless module identity before env loading even behind a trusted edge", async () => { + Deno.env.set("VERYFRONT_TRUST_FORWARDED_HEADERS", "1"); + const originalHostToken = Deno.env.get("VERYFRONT_API_TOKEN"); + Deno.env.set("VERYFRONT_API_TOKEN", "host-token-must-not-authorize-request"); + const originalFetch = globalThis.fetch; + let apiCalls = 0; + globalThis.fetch = ((..._args: Parameters) => { + apiCalls += 1; + return Promise.reject(new Error("project environment fetch must not run")); + }) as typeof fetch; + + try { + const handler = createProxyModeHandler(); + const response = await handler( + new Request("http://internal.proxy/_vf_modules/components/App.js", { + headers: { + "x-project-slug": "attacker-project", + "x-project-id": "attacker-project-id", + "x-environment-id": "attacker-environment-id", + "x-environment": "preview", + }, + }), + ); + + assertEquals(response.status, 502); + assertEquals(await response.json(), { + error: "Missing authentication context", + detail: "x-token header is required in proxy mode", + }); + assertEquals(apiCalls, 0); + } finally { + globalThis.fetch = originalFetch; + if (originalHostToken === undefined) Deno.env.delete("VERYFRONT_API_TOKEN"); + else Deno.env.set("VERYFRONT_API_TOKEN", originalHostToken); + } }); }); diff --git a/src/server/runtime-handler/index.ts b/src/server/runtime-handler/index.ts index b1f1cf95bc..f039505cce 100644 --- a/src/server/runtime-handler/index.ts +++ b/src/server/runtime-handler/index.ts @@ -92,7 +92,6 @@ import { } from "./request-lifecycle.ts"; import { checkRequestIsolation, - completeIsolatedRequest, completeIsolatedRequestOnSettlement, createIsolationErrorResponse, startIsolatedRequest, @@ -334,8 +333,8 @@ export function createVeryfrontHandler( // Per-project environment variable cache (fetches from API, caches with 60s TTL) const apiBaseUrl = adapter.env.get("VERYFRONT_API_BASE_URL") ?? "https://api.veryfront.com/api"; const envVarCache = new EnvironmentVariableCache( - (environmentId, token, projectSlug) => - fetchProjectEnvVars(apiBaseUrl, projectSlug, environmentId, token), + ({ environmentId, token, projectSlug }, signal) => + fetchProjectEnvVars(apiBaseUrl, projectSlug, environmentId, token, signal), ); let config: VeryfrontConfig | undefined = opts.config; @@ -393,7 +392,6 @@ export function createVeryfrontHandler( req, url, isProxyMode, - adapterEnv: adapter.env, }); const { headers, requestContext: reqCtx } = preparedRequest; const { proxyTrusted } = preparedRequest.proxyTrust; @@ -420,6 +418,26 @@ export function createVeryfrontHandler( const spanInfo = startRequestTracing(req, url.pathname); setRequestAttributes(spanInfo.span, req, url); + // Reject untrusted/malformed proxy identity before any project-keyed + // accounting is touched. In particular, isolation creates per-slug + // state on first access; admitting attacker-controlled slugs there would + // let rejected requests grow shared-process state indefinitely. + if (preparedRequest.proxyGuard) { + try { + logger.warn(preparedRequest.proxyGuard.detail, { + pathname: url.pathname, + domain: preparedRequest.loggerFacts.domain, + projectSlug: headers.projectSlug, + host: req.headers.get("host"), + forwardedHost: req.headers.get("x-forwarded-host"), + }); + endRequestTracing(spanInfo.span, preparedRequest.proxyGuard.response.status); + return preparedRequest.proxyGuard.response; + } finally { + endRequestLifecycle(lifecycle); + } + } + startRequestTracking( lifecycle.requestId, preparedRequest.trackingFacts.projectSlug, @@ -453,25 +471,6 @@ export function createVeryfrontHandler( startIsolatedRequest(headers.projectSlug, lifecycle.shouldCheckIsolation); try { - if (preparedRequest.proxyGuard) { - logger.warn(preparedRequest.proxyGuard.detail, { - pathname: url.pathname, - domain: preparedRequest.loggerFacts.domain, - projectSlug: headers.projectSlug, - host: req.headers.get("host"), - forwardedHost: req.headers.get("x-forwarded-host"), - }); - endContentMetrics({ - requestId: lifecycle.requestId, - pathname: url.pathname, - mode: "proxy", - }); - completeRequestTracking(lifecycle.requestId, 502, false); - completeIsolatedRequest(headers.projectSlug, lifecycle.shouldCheckIsolation, false); - endRequestTracing(spanInfo.span, 502); - return preparedRequest.proxyGuard.response; - } - const profileCategory = url.pathname.startsWith("/_vf_styles/") ? "css" : url.pathname.startsWith("/_vf_modules/") @@ -500,7 +499,10 @@ export function createVeryfrontHandler( await configPromise; })); - const wsSlugOverride = url.searchParams.get("x-project-slug") || undefined; + // Browser-controlled WebSocket query parameters cannot select tenant + // identity. Local development uses the configured default project; + // hosted requests use the edge-derived header or routed host. + const wsSlugOverride = undefined; // Resolve project from various sources const projectRes = await profilePhase( diff --git a/src/server/runtime-handler/project-middleware.test.ts b/src/server/runtime-handler/project-middleware.test.ts index c169306349..e60717ec89 100644 --- a/src/server/runtime-handler/project-middleware.test.ts +++ b/src/server/runtime-handler/project-middleware.test.ts @@ -195,7 +195,7 @@ describe("ProjectMiddlewareRuntime", () => { assertEquals(loadCount, 2); }); - it("scopes preview middleware by branch and supports explicit project invalidation", async () => { + it("does not cache mutable preview middleware by branch name", async () => { const adapter = createAdapter(); let loadCount = 0; const runtime = new ProjectMiddlewareRuntime({ @@ -219,11 +219,55 @@ describe("ProjectMiddlewareRuntime", () => { await execute(runtime, previewContext("feature-a")); await execute(runtime, previewContext("feature-a")); await execute(runtime, previewContext("feature-b")); - assertEquals(loadCount, 2); + assertEquals(loadCount, 3); + assertEquals(runtime.size, 0); - assertEquals(runtime.invalidateProject("project-a"), 2); + assertEquals(runtime.invalidateProject("project-a"), 0); await execute(runtime, previewContext("feature-b")); - assertEquals(loadCount, 3); + assertEquals(loadCount, 4); + }); + + it("does not cache shared middleware without a canonical project ID", async () => { + const adapter = createAdapter(); + let loadCount = 0; + const runtime = new ProjectMiddlewareRuntime({ + loadMiddleware: () => { + loadCount++; + return Promise.resolve([]); + }, + }); + const context = createContext(adapter, { projectId: undefined }); + + await execute(runtime, context); + await execute(runtime, context); + + assertEquals(loadCount, 2); + assertEquals(runtime.size, 0); + }); + + it("separates identical releases for distinct canonical projects", async () => { + const adapter = createAdapter(); + let loadCount = 0; + const runtime = new ProjectMiddlewareRuntime({ + loadMiddleware: () => { + const version = ++loadCount; + return Promise.resolve([() => new Response(`middleware-${version}`)]); + }, + }); + + const projectA = await execute(runtime, createContext(adapter)); + const projectB = await execute( + runtime, + createContext(adapter, { + projectSlug: "trusted-project-b", + projectId: "project-b", + releaseId: "release-a", + }), + ); + + assertEquals(await projectA?.text(), "middleware-1"); + assertEquals(await projectB?.text(), "middleware-2"); + assertEquals(loadCount, 2); }); it("passes through when the project has no root middleware", async () => { diff --git a/src/server/runtime-handler/project-middleware.ts b/src/server/runtime-handler/project-middleware.ts index 13fcb922b9..7b709deff7 100644 --- a/src/server/runtime-handler/project-middleware.ts +++ b/src/server/runtime-handler/project-middleware.ts @@ -123,6 +123,7 @@ export class ProjectMiddlewareRuntime { ctx, environment, branch, + isSharedProxy, allowHostProjectCodeExecution, ); } catch (error) { @@ -184,12 +185,14 @@ export class ProjectMiddlewareRuntime { ctx: HandlerContext, environment: "production" | "preview", branch: string | null, + isSharedProxy: boolean, allowHostProjectCodeExecution: boolean, ): Promise { const key = this.#buildCacheKey( ctx, environment, branch, + isSharedProxy, allowHostProjectCodeExecution, ); if (!key) return this.#load(ctx, allowHostProjectCodeExecution); @@ -211,21 +214,29 @@ export class ProjectMiddlewareRuntime { #buildCacheKey( ctx: HandlerContext, environment: "production" | "preview", - branch: string | null, + _branch: string | null, + isSharedProxy: boolean, allowHostProjectCodeExecution: boolean, ): string | null { + // A branch name identifies a mutable pointer, not a source generation. Do + // not retain preview middleware across requests until the adapter exposes a + // verified content digest. Production release IDs are immutable snapshots. + if (environment !== "production" || !ctx.releaseId) return null; + + // Shared caches require the canonical ID resolved at an authenticated + // boundary. A tenant-selected slug alone must never be cache authority. + if (isSharedProxy && (!ctx.projectId || !ctx.projectSlug)) return null; + const projectIdentity = ctx.projectId ?? ctx.projectSlug; if (!projectIdentity) return null; - const sourceIdentity = environment === "production" ? ctx.releaseId : branch ?? "default"; - if (!sourceIdentity) return null; - const environmentIdentity = ctx.environmentId ?? ctx.environmentName ?? "default"; return [ cacheSegment(projectIdentity), + cacheSegment(ctx.projectSlug ?? ""), allowHostProjectCodeExecution ? "host" : "isolated", environment, - cacheSegment(sourceIdentity), + cacheSegment(ctx.releaseId), cacheSegment(environmentIdentity), ].join(":"); } diff --git a/src/server/runtime-handler/project-resolution.test.ts b/src/server/runtime-handler/project-resolution.test.ts index 2f955c3714..20595e5959 100644 --- a/src/server/runtime-handler/project-resolution.test.ts +++ b/src/server/runtime-handler/project-resolution.test.ts @@ -29,11 +29,19 @@ describe("server/runtime-handler/project-resolution", () => { assertEquals(headers.projectSlug, "my-project"); }); - it("extracts project id from header", () => { + it("ignores project id from an untrusted request", () => { const req = new Request("http://localhost/", { headers: { "x-project-id": "proj-123" }, }); const headers = extractRequestHeaders(req, new URL(req.url)); + assertEquals(headers.projectId, undefined); + }); + + it("extracts project id only at an operator-authenticated proxy boundary", () => { + const req = new Request("http://localhost/", { + headers: { "x-project-id": "proj-123" }, + }); + const headers = extractRequestHeaders(req, new URL(req.url), false, true); assertEquals(headers.projectId, "proj-123"); }); @@ -45,22 +53,62 @@ describe("server/runtime-handler/project-resolution", () => { assertEquals(headers.releaseId, "rel-456"); }); - it("extracts branch id from header", () => { + it("extracts branch id only at an operator-authenticated proxy boundary", () => { const req = new Request("http://localhost/", { headers: { "x-branch-id": "branch-1" }, }); - const headers = extractRequestHeaders(req, new URL(req.url)); + const headers = extractRequestHeaders(req, new URL(req.url), false, true); assertEquals(headers.branchId, "branch-1"); }); - it("extracts branch name from header", () => { + it("extracts branch name only at an operator-authenticated proxy boundary", () => { const req = new Request("http://localhost/", { headers: { "x-branch-name": "feature-x" }, }); - const headers = extractRequestHeaders(req, new URL(req.url)); + const headers = extractRequestHeaders(req, new URL(req.url), false, true); assertEquals(headers.branchName, "feature-x"); }); + it("normalizes the trusted branch name like the default branch name", () => { + const req = new Request("http://localhost/", { + headers: { + "x-branch-name": "vf-utf8:%20%20feature-x%20%20", + "x-default-branch-name": "vf-utf8:%20%20trunk%20%20", + }, + }); + const headers = extractRequestHeaders(req, new URL(req.url), false, true); + assertEquals(headers.branchName, "feature-x"); + assertEquals(headers.defaultBranchName, "trunk"); + }); + + it("extracts the default branch name only at an operator-authenticated proxy boundary", () => { + const req = new Request("http://localhost/", { + headers: { "x-default-branch-name": "trunk" }, + }); + assertEquals( + extractRequestHeaders(req, new URL(req.url), false, true).defaultBranchName, + "trunk", + ); + assertEquals( + extractRequestHeaders(req, new URL(req.url), false, false).defaultBranchName, + undefined, + ); + }); + + it("ignores branch identity from an untrusted request", () => { + const req = new Request("http://localhost/", { + headers: { + "x-branch-id": "branch-1", + "x-branch-name": "feature-x", + "x-default-branch-name": "trunk", + }, + }); + const headers = extractRequestHeaders(req, new URL(req.url)); + assertEquals(headers.branchId, undefined); + assertEquals(headers.branchName, undefined); + assertEquals(headers.defaultBranchName, undefined); + }); + it("extracts environment from header when proxy headers are trusted", () => { Deno.env.set("VERYFRONT_TRUST_FORWARDED_HEADERS", "1"); try { @@ -116,12 +164,28 @@ describe("server/runtime-handler/project-resolution", () => { assertEquals(result.proxyEnv, undefined); }); - it("extracts environment-id from header", () => { + it("ignores environment-id from an untrusted request", () => { const req = new Request("http://localhost/", { - headers: { "x-environment-id": "env-1" }, + headers: { + "x-environment-id": "env-1", + "x-environment-name": "production", + }, }); const headers = extractRequestHeaders(req, new URL(req.url)); + assertEquals(headers.environmentId, undefined); + assertEquals(headers.environmentName, undefined); + }); + + it("extracts environment identity only at an operator-authenticated proxy boundary", () => { + const req = new Request("http://localhost/", { + headers: { + "x-environment-id": "env-1", + "x-environment-name": "staging", + }, + }); + const headers = extractRequestHeaders(req, new URL(req.url), false, true); assertEquals(headers.environmentId, "env-1"); + assertEquals(headers.environmentName, "staging"); }); // x-forwarded-host is client-controlled and only honoured behind a trusted diff --git a/src/server/runtime-handler/project-resolution.ts b/src/server/runtime-handler/project-resolution.ts index ef09713b33..ced4a029ef 100644 --- a/src/server/runtime-handler/project-resolution.ts +++ b/src/server/runtime-handler/project-resolution.ts @@ -19,6 +19,7 @@ import { SpanNames, withSpan } from "./tracing.ts"; import { isInternalHost } from "./request-utils.ts"; import { getEffectiveRequestHost } from "../utils/request-host.ts"; import { getHostEnv } from "#veryfront/platform/compat/process.ts"; +import { decodeIdentityHeaderValue } from "#veryfront/utils/header-identity.ts"; const baseLogger = getBaseLogger("SERVER"); @@ -61,10 +62,14 @@ interface RequestHeaders { branchId: string | undefined; /** Branch name from x-branch-name header */ branchName: string | undefined; + /** Project default branch name from x-default-branch-name header */ + defaultBranchName: string | undefined; /** Environment from x-environment header */ environment: string | undefined; /** Environment ID from x-environment-id header (for env var resolution) */ environmentId: string | undefined; + /** Canonical environment name paired with x-environment-id by the trusted proxy */ + environmentName: string | undefined; /** Token from authorization header */ token: string | undefined; /** Content source ID from x-content-source-id header */ @@ -79,9 +84,8 @@ function trustForwardedHeaders(): boolean { function getEffectiveHost(req: Request, url: URL, proxyTrusted?: boolean): string { // x-forwarded-host is client-controlled and only trustworthy behind a trusted - // upstream proxy. Honour it only after the operator opt-in or a verified - // dispatch JWS, matching createRequestContext; otherwise fall back to Host. - // The runtime handler performs async verification and passes the result here. + // upstream proxy. Honour it only after the operator opt-in; otherwise fall + // back to Host. Signed application requests are not general proxy authority. return getEffectiveRequestHost(req, url, proxyTrusted ?? trustForwardedHeaders()); } @@ -97,28 +101,36 @@ export function extractRequestHeaders( req: Request, url: URL, proxyTrusted?: boolean, + identityHeadersTrusted = trustForwardedHeaders(), ): RequestHeaders { const host = getEffectiveHost(req, url, proxyTrusted); const parsedDomain = parseProjectDomain(host); const projectSlugHeader = req.headers.get("x-project-slug")?.trim() || undefined; - // The WebSocket endpoint uses this query parameter for its existing HMR - // handshake. Other routes must not let client-controlled query/header values - // override the host-derived environment unless a trusted proxy supplied them. - const websocketEnvironment = url.pathname === "/_ws" - ? url.searchParams.get("x-environment") ?? undefined - : undefined; + // Routing identity supplied in a header or query is meaningful only behind + // the operator-declared sanitising edge. WebSocket query parameters are + // browser-controlled and must not independently unlock preview behavior. const environment = (proxyTrusted ?? trustForwardedHeaders()) ? req.headers.get("x-environment") ?? url.searchParams.get("x-environment") ?? undefined - : websocketEnvironment; + : undefined; return { projectSlug: projectSlugHeader ?? parsedDomain.slug ?? undefined, - projectId: req.headers.get("x-project-id") ?? undefined, + projectId: identityHeadersTrusted ? req.headers.get("x-project-id") ?? undefined : undefined, releaseId: req.headers.get("x-release-id") ?? undefined, - branchId: req.headers.get("x-branch-id") ?? undefined, - branchName: req.headers.get("x-branch-name") ?? undefined, + branchId: identityHeadersTrusted ? req.headers.get("x-branch-id") ?? undefined : undefined, + branchName: identityHeadersTrusted + ? decodeIdentityHeaderValue(req.headers.get("x-branch-name"))?.trim() || undefined + : undefined, + defaultBranchName: identityHeadersTrusted + ? decodeIdentityHeaderValue(req.headers.get("x-default-branch-name"))?.trim() || undefined + : undefined, environment, - environmentId: req.headers.get("x-environment-id") ?? undefined, + environmentId: identityHeadersTrusted + ? req.headers.get("x-environment-id") ?? undefined + : undefined, + environmentName: identityHeadersTrusted + ? req.headers.get("x-environment-name")?.trim() || undefined + : undefined, token: undefined, // Extracted separately from request context contentSourceId: req.headers.get("x-content-source-id") ?? undefined, projectPath: req.headers.get("x-project-path") ?? undefined, @@ -196,7 +208,7 @@ export async function resolveProject( let projectId: string | undefined = headers.projectId ?? (slugMatchesDefault ? opts.defaultProjectId : undefined); let releaseId: string | undefined = headers.releaseId ?? opts.defaultReleaseId; - let environmentName: string | undefined; + let environmentName: string | undefined = headers.environmentName; let proxyEnv = parseProxyEnvironment(headers.environment ?? null); const shouldSkipDomainLookup = isInternalHost(host); diff --git a/src/server/runtime-handler/project-runtime-context.test.ts b/src/server/runtime-handler/project-runtime-context.test.ts index 4bdf288cf2..0074b536fe 100644 --- a/src/server/runtime-handler/project-runtime-context.test.ts +++ b/src/server/runtime-handler/project-runtime-context.test.ts @@ -7,6 +7,7 @@ import { } from "#veryfront/testing/assert.ts"; import { afterEach, describe, it } from "#veryfront/testing/bdd.ts"; import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; +import type { ProjectEnvironmentScope } from "#veryfront/server/project-env/cache.ts"; import type { VeryfrontConfig } from "#veryfront/config"; import { __registerLogRecordEmitter, @@ -15,6 +16,8 @@ import { type LogEntry, } from "#veryfront/utils/logger/logger.ts"; import { createRequestContext } from "../context/request-context.ts"; +import { AuthHandler } from "#veryfront/security/http/auth.ts"; +import { CsrfHandler } from "#veryfront/security/http/csrf/csrf-handler.ts"; import type { DomainLookupResult } from "../utils/domain-lookup.ts"; import type { ParsedDomain } from "#veryfront/types"; import { defaultDiscoveryCache } from "./local-project-discovery.ts"; @@ -116,6 +119,18 @@ function createExtendedMockAdapter( return { ...base, fs: extendedFs } as unknown as RuntimeAdapter; } +function createHostedConfigAdapter(source: string): RuntimeAdapter { + const configPath = "/base/project/veryfront.config.ts"; + const adapter = createMockAdapter({ + [configPath]: { isDirectory: false, isFile: true }, + }); + adapter.fs.readFile = (path: string) => + path === configPath + ? Promise.resolve(source) + : Promise.reject(new Deno.errors.NotFound(`Not found: ${path}`)); + return adapter; +} + function makeRuntimeContextInput( overrides: Record = {}, ): Parameters[0] { @@ -125,10 +140,12 @@ function makeRuntimeContextInput( "x-project-id": "proj-remote", "x-token": "proxy-token", "x-environment-id": "env-remote", + "x-environment-name": "preview", + "x-default-branch-name": "trunk", }, }); const url = new URL(req.url); - const headers = extractRequestHeaders(req, url); + const headers = extractRequestHeaders(req, url, false, true); const requestContext = createRequestContext(req); const adapter = createMockAdapter(); const config = { @@ -215,12 +232,22 @@ describe("prepareProjectRequest", () => { assertEquals(trustChecks, 1); assertStrictEquals(prepared.proxyTrust.proxyTrusted, false); + assertStrictEquals(prepared.proxyTrust.identityHeadersTrusted, false); assertEquals(prepared.headers, extractRequestHeaders(req, url, false)); - assertEquals(prepared.requestContext, createRequestContext(req, { proxyTrusted: false })); + assertEquals( + prepared.requestContext, + createRequestContext(req, { + proxyTrusted: false, + allowHostTokenFallback: false, + }), + ); assertEquals(prepared.headers.environment, undefined); assertEquals(prepared.loggerFacts.projectSlug, "header-project"); assertEquals(prepared.trackingFacts.releaseId, "rel_123"); - assertEquals(prepared.proxyGuard, undefined); + assertEquals( + prepared.proxyGuard?.detail, + "proxy mode requires an operator-trusted upstream proxy", + ); }); it("returns the existing missing slug proxy guard response", async () => { @@ -261,7 +288,7 @@ describe("prepareProjectRequest", () => { }); }); - it("guards only trust-sensitive x-project-path in untrusted proxy requests", async () => { + it("rejects every untrusted proxy identity, not only x-project-path", async () => { const forwardedOnly = new Request("http://localhost/page", { headers: { "x-project-slug": "my-project", @@ -270,14 +297,17 @@ describe("prepareProjectRequest", () => { }, }); - const allowed = await prepareProjectRequest({ + const rejectedForwarded = await prepareProjectRequest({ req: forwardedOnly, url: new URL(forwardedOnly.url), isProxyMode: true, trustProxy: () => Promise.resolve(false), }); - assertEquals(allowed.proxyGuard, undefined); + assertEquals( + rejectedForwarded.proxyGuard?.detail, + "proxy mode requires an operator-trusted upstream proxy", + ); const projectPath = new Request("http://localhost/page", { headers: { @@ -296,15 +326,197 @@ describe("prepareProjectRequest", () => { assertEquals( rejected.proxyGuard?.detail, - "proxy context headers require a trusted upstream proxy", + "proxy mode requires an operator-trusted upstream proxy", ); await assertJsonResponse(rejected.proxyGuard!.response, 502, { error: "Untrusted proxy context", - detail: "proxy context headers require a trusted upstream proxy", + detail: "proxy mode requires an operator-trusted upstream proxy", + }); + }); + + it("rejects unbound identity IDs even when a dispatch signature is trusted", async () => { + const req = new Request("http://localhost/page", { + headers: { + "x-project-slug": "project-a", + "x-project-id": "victim-project-id", + "x-environment-id": "victim-environment-id", + "x-environment-name": "victim-environment-name", + "x-token": "project-token", + }, + }); + + const prepared = await prepareProjectRequest({ + req, + url: new URL(req.url), + isProxyMode: true, + trustProxy: () => Promise.resolve(false), + }); + + assertEquals(prepared.headers.projectId, undefined); + assertEquals(prepared.headers.environmentId, undefined); + await assertJsonResponse(prepared.proxyGuard!.response, 502, { + error: "Untrusted identity context", + detail: + "project, environment, and branch identity headers require an operator-authenticated proxy boundary", }); }); - it("preserves websocket environment query and skips the proxy guard", async () => { + it("rejects an untrusted default branch identity on its own", async () => { + const req = new Request("http://localhost/page", { + headers: { + "x-project-slug": "project-a", + "x-default-branch-name": "attacker-branch", + "x-token": "project-token", + }, + }); + + const prepared = await prepareProjectRequest({ + req, + url: new URL(req.url), + isProxyMode: true, + trustProxy: () => Promise.resolve(false), + }); + + assertEquals(prepared.headers.defaultBranchName, undefined); + await assertJsonResponse(prepared.proxyGuard!.response, 502, { + error: "Untrusted identity context", + detail: + "project, environment, and branch identity headers require an operator-authenticated proxy boundary", + }); + }); + + it("accepts canonical identity IDs from an operator-authenticated proxy", async () => { + const req = new Request("http://localhost/page", { + headers: { + "x-project-slug": "project-a", + "x-project-id": "project-id-a", + "x-environment-id": "environment-id-a", + "x-environment-name": "staging", + "x-token": "project-token", + }, + }); + + const prepared = await prepareProjectRequest({ + req, + url: new URL(req.url), + isProxyMode: true, + trustProxy: () => Promise.resolve(true), + }); + + assertEquals(prepared.proxyTrust.identityHeadersTrusted, true); + assertEquals(prepared.headers.projectId, "project-id-a"); + assertEquals(prepared.headers.environmentId, "environment-id-a"); + assertEquals(prepared.headers.environmentName, "staging"); + assertEquals(prepared.proxyGuard, undefined); + }); + + it("rejects an incomplete environment identity from a trusted proxy", async () => { + const req = new Request("http://localhost/page", { + headers: { + "x-project-slug": "project-a", + "x-project-id": "project-id-a", + "x-environment-id": "environment-id-a", + "x-token": "project-token", + }, + }); + + const prepared = await prepareProjectRequest({ + req, + url: new URL(req.url), + isProxyMode: true, + trustProxy: () => Promise.resolve(true), + }); + + await assertJsonResponse(prepared.proxyGuard!.response, 502, { + error: "Incomplete environment identity", + detail: "x-environment-id and x-environment-name must be supplied together", + }); + }); + + it("rejects incomplete and conflicting branch identity from a trusted proxy", async () => { + const invalidBranchIdentities: Array> = [ + { "x-branch-id": "branch-id-a" }, + { "x-branch-name": "feature-a" }, + { + "x-branch-id": "branch-id-a", + "x-branch-name": "feature-a", + "x-default-branch-name": "main", + }, + ]; + for (const identityHeaders of invalidBranchIdentities) { + const req = new Request("http://localhost/page", { + headers: { + "x-project-slug": "project-a", + "x-project-id": "project-id-a", + "x-token": "project-token", + ...identityHeaders, + }, + }); + + const prepared = await prepareProjectRequest({ + req, + url: new URL(req.url), + isProxyMode: true, + trustProxy: () => Promise.resolve(true), + }); + + await assertJsonResponse(prepared.proxyGuard!.response, 502, { + error: "Invalid branch identity", + detail: + "x-branch-id and x-branch-name must be supplied together and cannot be combined with x-default-branch-name", + }); + } + }); + + it("accepts complete preview or default branch identity from a trusted proxy", async () => { + const validBranchIdentities: Array> = [ + { "x-branch-id": "branch-id-a", "x-branch-name": "feature-a" }, + { "x-default-branch-name": "main" }, + ]; + for (const identityHeaders of validBranchIdentities) { + const req = new Request("http://localhost/page", { + headers: { + "x-project-slug": "project-a", + "x-project-id": "project-id-a", + "x-token": "project-token", + ...identityHeaders, + }, + }); + + const prepared = await prepareProjectRequest({ + req, + url: new URL(req.url), + isProxyMode: true, + trustProxy: () => Promise.resolve(true), + }); + + assertEquals(prepared.proxyGuard, undefined); + } + }); + + it("reports missing authentication before validating a trusted environment pair", async () => { + const req = new Request("http://localhost/page", { + headers: { + "x-project-slug": "project-a", + "x-project-id": "project-id-a", + "x-environment-id": "environment-id-a", + }, + }); + + const prepared = await prepareProjectRequest({ + req, + url: new URL(req.url), + isProxyMode: true, + trustProxy: () => Promise.resolve(true), + }); + + await assertJsonResponse(prepared.proxyGuard!.response, 502, { + error: "Missing authentication context", + detail: "x-token header is required in proxy mode", + }); + }); + + it("rejects untrusted websocket query identity", async () => { const req = new Request( "http://localhost/_ws?x-environment=preview&x-project-slug=test-project", ); @@ -316,11 +528,11 @@ describe("prepareProjectRequest", () => { trustProxy: () => Promise.resolve(false), }); - assertEquals(prepared.headers.environment, "preview"); - assertEquals(prepared.proxyGuard, undefined); + assertEquals(prepared.headers.environment, undefined); + assertEquals(prepared.proxyGuard?.detail, "x-project-slug header is required in proxy mode"); }); - it("skips the proxy guard for lightweight requests", async () => { + it("applies the proxy guard to lightweight requests", async () => { const req = new Request("http://localhost/_veryfront/hydration-runtime.js", { headers: { "x-release-id": "rel_123" }, }); @@ -332,7 +544,7 @@ describe("prepareProjectRequest", () => { trustProxy: () => Promise.resolve(false), }); - assertEquals(prepared.proxyGuard, undefined); + assertEquals(prepared.proxyGuard?.detail, "x-project-slug header is required in proxy mode"); }); }); @@ -402,6 +614,40 @@ describe("resolveProjectIdentity", () => { assertEquals(trusted.parsedDomain.environment, "preview"); }); + it("preserves the trusted canonical environment name when release identity is complete", async () => { + for (const environmentName of ["staging", "production"]) { + const req = new Request(`http://project.${environmentName}.veryfront.com/`, { + headers: { + "x-project-slug": "project", + "x-project-id": "project-id", + "x-release-id": `release-${environmentName}`, + "x-environment": "production", + "x-environment-id": `environment-${environmentName}`, + "x-environment-name": environmentName, + "x-token": "project-token", + }, + }); + const url = new URL(req.url); + const headers = extractRequestHeaders(req, url, true, true); + const result = await resolveProjectIdentity({ + req, + url, + headers, + requestContext: createRequestContext(req, { proxyTrusted: true }), + config: undefined, + defaultProjectSlug: undefined, + defaultProjectId: undefined, + defaultReleaseId: undefined, + wsSlugOverride: undefined, + proxyTrust: { proxyTrusted: true }, + }); + + assertEquals(result.environmentName, environmentName); + assertEquals(result.releaseId, `release-${environmentName}`); + assertEquals(result.proxyEnv, "production"); + } + }); + it("preserves explicit slug and suppresses unrelated default project id", async () => { __injectDepsForTests({ parseProjectDomain: () => defaultParsedDomain, @@ -536,6 +782,77 @@ describe("resolveProjectIdentity", () => { }); describe("resolveProjectRuntimeContext", () => { + it("evaluates staging config and security with the matching trusted environment secrets", async () => { + const adapter = createHostedConfigAdapter(` + import { defineConfigWithEnv, getEnv } from "veryfront"; + export default defineConfigWithEnv((environmentName) => ({ + title: environmentName + ":" + getEnv("TENANT_MARKER"), + security: { + auth: { bearer: { token: getEnv("AUTH_TOKEN") } }, + csrf: true, + }, + })); + `); + const req = new Request("http://project.staging.veryfront.com/page", { + headers: { + "x-project-slug": "project", + "x-project-id": "project-id", + "x-release-id": "release-staging", + "x-environment": "production", + "x-environment-id": "environment-staging", + "x-environment-name": "staging", + "x-token": "project-token", + }, + }); + const url = new URL(req.url); + const headers = extractRequestHeaders(req, url, true, true); + const requestContext = createRequestContext(req, { proxyTrusted: true }); + const projectIdentity = await resolveProjectIdentity({ + req, + url, + headers, + requestContext, + config: undefined, + defaultProjectSlug: undefined, + defaultProjectId: undefined, + defaultReleaseId: undefined, + wsSlugOverride: undefined, + proxyTrust: { proxyTrusted: true }, + }); + let observedEnvironmentId: string | undefined; + + const result = await resolveProjectRuntimeContext(makeRuntimeContextInput({ + req, + url, + adapter, + headers, + requestContext, + projectIdentity, + isProxyMode: true, + proxyTrust: { proxyTrusted: true }, + envVarCache: { + get: (scope: ProjectEnvironmentScope) => { + observedEnvironmentId = scope.environmentId; + return Promise.resolve({ + TENANT_MARKER: "staging-environment", + AUTH_TOKEN: "staging-secret", + }); + }, + }, + })); + + assertEquals(observedEnvironmentId, "environment-staging"); + assertEquals(result.handlerContext?.config?.title, "staging:staging-environment"); + assertEquals(result.handlerContext?.isProxyMode, true); + assertEquals(result.handlerContext?.securityConfig?.auth, { + bearer: { token: "staging-secret" }, + }); + assertEquals(result.rawEnvVars, { + TENANT_MARKER: "staging-environment", + AUTH_TOKEN: "staging-secret", + }); + }); + it("returns handler context, raw env vars, and normalized source policy for remote requests", async () => { let envLoadCount = 0; const adapter = createMockAdapter(); @@ -548,15 +865,12 @@ describe("resolveProjectRuntimeContext", () => { securityConfig, cspUserHeader, envVarCache: { - get: ( - environmentId: string, - token: string, - projectSlug: string, - ) => { + get: ({ environmentId, token, projectSlug, projectId }: ProjectEnvironmentScope) => { envLoadCount += 1; assertEquals(environmentId, "env-remote"); assertEquals(token, "proxy-token"); assertEquals(projectSlug, "remote-project"); + assertEquals(projectId, "proj-remote"); return Promise.resolve({ REMOTE_ONLY: "1", SECRET_VALUE: "present" }); }, }, @@ -585,7 +899,9 @@ describe("resolveProjectRuntimeContext", () => { assertEquals(ctx.releaseId, "rel-remote"); assertEquals(ctx.proxyToken, "proxy-token"); assertEquals(ctx.environmentId, "env-remote"); + assertEquals(ctx.defaultBranchName, "trunk"); assertEquals(ctx.moduleServerUrl, "https://modules.example.test"); + assertEquals(ctx.isProxyMode, false); assertEquals(ctx.requestContext?.mode, "preview"); assertEquals(result.environment.resolvedEnvironment, "preview"); }); @@ -661,6 +977,12 @@ describe("resolveProjectRuntimeContext", () => { import { defineConfigWithEnv, getEnv } from "veryfront"; export default defineConfigWithEnv((environmentName) => ({ title: environmentName + ":" + getEnv("TENANT"), + security: { + auth: { bearer: { token: getEnv("AUTH_TOKEN") } }, + cors: { origin: ["https://client.example"] }, + csrf: true, + csp: { defaultSrc: ["'none'"] }, + }, })); `) : Promise.reject(new Deno.errors.NotFound(`Not found: ${path}`)); @@ -686,6 +1008,7 @@ describe("resolveProjectRuntimeContext", () => { requestContext: createRequestContext(req, { proxyTrusted: false }), isProxyMode: true, proxyTrust: { proxyTrusted: false }, + environmentId: "env-remote", projectIdentity: { projectSlug: "remote-project", projectId: "proj-remote", @@ -697,7 +1020,7 @@ describe("resolveProjectRuntimeContext", () => { envVarCache: { get: () => { envLoadCount += 1; - return Promise.resolve({ TENANT: "tenant-value" }); + return Promise.resolve({ TENANT: "tenant-value", AUTH_TOKEN: "tenant-secret" }); }, }, })); @@ -707,7 +1030,146 @@ describe("resolveProjectRuntimeContext", () => { assertEquals(result.adapter.projectDir, "/base/project"); assertEquals(defaultDiscoveryCache.projects.has("remote-project"), false); assertEquals(result.handlerContext?.config?.title, "preview:tenant-value"); - assertEquals(result.rawEnvVars, { TENANT: "tenant-value" }); + assertEquals(result.rawEnvVars, { + TENANT: "tenant-value", + AUTH_TOKEN: "tenant-secret", + }); + const ctx = result.handlerContext!; + assertEquals(ctx.securityConfig?.auth, { + bearer: { token: "tenant-secret" }, + }); + assertEquals(ctx.securityConfig?.csrf, true); + assertEquals(ctx.securityConfig?.cors, { + origin: ["https://client.example"], + }); + assertEquals(ctx.cspUserHeader, "default-src 'none'"); + + const authResult = await new AuthHandler().handle( + new Request("http://localhost/page"), + ctx, + ); + assertEquals(authResult.response?.status, 401); + const csrfResult = await new CsrfHandler().handle( + new Request("http://localhost/action", { method: "POST" }), + ctx, + ); + assertEquals(csrfResult.response?.status, 403); + }); + + it("derives isolated hosted security snapshots for concurrent tenants", async () => { + const makeTenantResolution = ( + projectSlug: string, + projectId: string, + token: string, + ) => { + const adapter = createHostedConfigAdapter(` + import { defineConfigWithEnv, getEnv } from "veryfront"; + export default defineConfigWithEnv((environmentName) => ({ + title: environmentName, + security: { + auth: { bearer: { token: getEnv("AUTH_TOKEN") } }, + cors: { origin: [getEnv("CLIENT_ORIGIN")] }, + csrf: true, + csp: { defaultSrc: [getEnv("CSP_SOURCE")] }, + }, + })); + `); + const req = new Request(`http://${projectSlug}.preview.lvh.me/page`, { + headers: { + "x-project-slug": projectSlug, + "x-project-id": projectId, + "x-token": "proxy-token", + "x-environment-id": `env-${projectId}`, + }, + }); + const url = new URL(req.url); + return resolveProjectRuntimeContext(makeRuntimeContextInput({ + req, + url, + adapter, + headers: extractRequestHeaders(req, url, true, true), + requestContext: createRequestContext(req, { proxyTrusted: true }), + isProxyMode: true, + proxyTrust: { proxyTrusted: true }, + projectIdentity: { + projectSlug, + projectId, + releaseId: undefined, + environmentName: undefined, + proxyEnv: "preview", + parsedDomain: defaultParsedDomain, + }, + envVarCache: { + get: () => + Promise.resolve({ + AUTH_TOKEN: token, + CLIENT_ORIGIN: `https://${projectSlug}.client.example`, + CSP_SOURCE: `https://${projectSlug}.assets.example`, + }), + }, + })); + }; + + const [alpha, beta] = await Promise.all([ + makeTenantResolution("alpha-project", "proj-alpha-security", "alpha-secret"), + makeTenantResolution("beta-project", "proj-beta-security", "beta-secret"), + ]); + + const alphaSecurity = alpha.handlerContext?.securityConfig; + const betaSecurity = beta.handlerContext?.securityConfig; + assertEquals(alphaSecurity?.auth, { bearer: { token: "alpha-secret" } }); + assertEquals(betaSecurity?.auth, { bearer: { token: "beta-secret" } }); + assertEquals(alphaSecurity?.cors, { + origin: ["https://alpha-project.client.example"], + }); + assertEquals(betaSecurity?.cors, { + origin: ["https://beta-project.client.example"], + }); + assertEquals( + alpha.handlerContext?.cspUserHeader, + "default-src https://alpha-project.assets.example", + ); + assertEquals( + beta.handlerContext?.cspUserHeader, + "default-src https://beta-project.assets.example", + ); + assertEquals(Object.isFrozen(alphaSecurity), true); + assertEquals(Object.isFrozen(betaSecurity), true); + assertEquals(alphaSecurity === betaSecurity, false); + }); + + it("enables the production CSRF default for hosted project config", async () => { + const adapter = createHostedConfigAdapter("export default {};"); + const req = new Request("http://production-project.production.veryfront.com/page", { + headers: { + "x-project-slug": "production-project", + "x-project-id": "proj-production-security", + "x-token": "proxy-token", + "x-release-id": "rel-production-security", + }, + }); + const url = new URL(req.url); + const result = await resolveProjectRuntimeContext(makeRuntimeContextInput({ + req, + url, + adapter, + headers: extractRequestHeaders(req, url, true, true), + requestContext: createRequestContext(req, { proxyTrusted: true }), + isProxyMode: true, + proxyTrust: { proxyTrusted: true }, + projectIdentity: { + projectSlug: "production-project", + projectId: "proj-production-security", + releaseId: "rel-production-security", + environmentName: "Production", + proxyEnv: "production", + parsedDomain: defaultParsedDomain, + }, + })); + + assertEquals(result.environment.resolvedEnvironment, "production"); + assertEquals(result.handlerContext?.securityConfig?.csrf, true); + assertEquals(result.handlerContext?.securityConfig?.cors, false); }); it("returns production 404 responses and standalone synthetic fallback from environment resolution", async () => { @@ -752,6 +1214,7 @@ describe("resolveProjectRuntimeContext", () => { standaloneProduction.handlerContext.allowHostProjectCodeExecution, true, ); + assertEquals(standaloneProduction.handlerContext.isProxyMode, false); }); it("returns production environment errors before reading source policy config", async () => { diff --git a/src/server/runtime-handler/project-runtime-context.ts b/src/server/runtime-handler/project-runtime-context.ts index 78a887f2dd..947f86c22d 100644 --- a/src/server/runtime-handler/project-runtime-context.ts +++ b/src/server/runtime-handler/project-runtime-context.ts @@ -5,6 +5,7 @@ import type { VirtualConfigSourceContext } from "#veryfront/cache/keys.ts"; import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; import type { RouteRegistry } from "#veryfront/routing/registry/index.ts"; import type { SecurityConfig } from "#veryfront/types"; +import { deriveSecurityContext } from "#veryfront/security/http/config.ts"; import { normalizeSourceIntegrationPolicy } from "#veryfront/integrations/source-policy.ts"; import { createRequestContext } from "../context/request-context.ts"; import type { HandlerContext } from "../handlers/types.ts"; @@ -14,22 +15,14 @@ import { resolveAdapter } from "./adapter-factory.ts"; import { resolveEnvironment } from "./environment-resolution.ts"; import { buildHandlerContext } from "./handler-context-builder.ts"; import { extractRequestHeaders, resolveProject } from "./project-resolution.ts"; -import { isLightweightPath, isWebSocketPath, shouldSkipEnrichedContext } from "./request-utils.ts"; +import { shouldSkipEnrichedContext } from "./request-utils.ts"; -type AdapterEnvLike = { - get(key: string): string | undefined; -}; - -type ProxyTrustVerifier = ( - req: Request, - options: { publicKeyPem?: string }, -) => Promise; +type ProxyTrustVerifier = (req: Request) => Promise; export interface PrepareProjectRequestInput { req: Request; url: URL; isProxyMode: boolean; - adapterEnv?: AdapterEnvLike; trustProxy?: ProxyTrustVerifier; } @@ -41,7 +34,12 @@ type ProjectEnvironmentResolution = ReturnType; type SourceIntegrationPolicy = ReturnType; type ProjectEnvVarCacheLike = { - get(environmentId: string, token: string, projectSlug: string): Promise>; + get(scope: { + environmentId: string; + token: string; + projectSlug: string; + projectId?: string; + }): Promise>; }; type RuntimeContextProfiler = (operation: () => Promise) => Promise; @@ -52,6 +50,7 @@ export interface PreparedProjectRequest { requestContext: ProjectRequestContext; proxyTrust: { proxyTrusted: boolean | undefined; + identityHeadersTrusted: boolean; }; loggerFacts: RequestContextFacts; trackingFacts: RequestTrackingFacts; @@ -119,6 +118,7 @@ interface RequestContextFacts { releaseId: string | undefined; branchId: string | undefined; branchName: string | undefined; + defaultBranchName: string | undefined; pathname: string; } @@ -139,14 +139,16 @@ export async function prepareProjectRequest( input: PrepareProjectRequestInput, ): Promise { const { req, url, isProxyMode } = input; - const proxyTrusted = isProxyMode - ? await (input.trustProxy ?? isProxyTrusted)(req, { - publicKeyPem: input.adapterEnv?.get("CHANNEL_DISPATCH_SIGNING_PUBLIC_KEY") ?? - getHostEnv("CHANNEL_DISPATCH_SIGNING_PUBLIC_KEY"), - }) - : undefined; - const headers = extractRequestHeaders(req, url, proxyTrusted); - const requestContext = createRequestContext(req, { proxyTrusted }); + const proxyTrusted = isProxyMode ? await (input.trustProxy ?? isProxyTrusted)(req) : undefined; + // In shared mode, only the same operator-owned proxy decision that admits + // the request may authorize canonical cache and secret-fetch identity. + // Standalone runtimes retain their existing direct-header contract. + const identityHeadersTrusted = !isProxyMode || proxyTrusted === true; + const headers = extractRequestHeaders(req, url, proxyTrusted, identityHeadersTrusted); + const requestContext = createRequestContext(req, { + proxyTrusted, + allowHostTokenFallback: !isProxyMode, + }); const hostHeader = req.headers.get("host") ?? url.host; const domain = hostHeader.replace(/:\d+$/, ""); @@ -155,7 +157,7 @@ export async function prepareProjectRequest( url, headers, requestContext, - proxyTrust: { proxyTrusted }, + proxyTrust: { proxyTrusted, identityHeadersTrusted }, loggerFacts: { domain, projectSlug: headers.projectSlug, @@ -163,6 +165,7 @@ export async function prepareProjectRequest( releaseId: headers.releaseId, branchId: headers.branchId, branchName: headers.branchName, + defaultBranchName: headers.defaultBranchName, pathname: url.pathname, }, trackingFacts: { @@ -172,7 +175,13 @@ export async function prepareProjectRequest( environment: headers.environment, releaseId: headers.releaseId, }, - proxyGuard: createProxyGuard(req, url, isProxyMode, headers, proxyTrusted), + proxyGuard: createProxyGuard( + req, + isProxyMode, + headers, + proxyTrusted, + identityHeadersTrusted, + ), }; } @@ -229,7 +238,12 @@ export async function resolveProjectRuntimeContext( const environment = mayLoadEnvironment && environmentId && reqCtx.token && projectRes.projectSlug ? await profileEnvVars(() => - input.envVarCache.get(environmentId, reqCtx.token!, projectRes.projectSlug!) + input.envVarCache.get({ + environmentId, + token: reqCtx.token!, + projectSlug: projectRes.projectSlug!, + projectId: projectRes.projectId, + }) ) : {}; @@ -296,17 +310,33 @@ export async function resolveProjectRuntimeContext( }; } + // The process-wide SecurityConfigLoader is valid only for a standalone + // project. A shared proxy loads one authenticated, source-qualified config + // snapshot per request above; derive security from that exact snapshot so a + // tenant cannot inherit another tenant's auth/CORS/CSRF/CSP state. Keep the + // deliberately config-less control-plane path config-less: those endpoints + // authenticate their signed operation envelope and do not expose an + // application/browser surface. + const requestSecurity = input.isProxyMode && adapterRes.config !== undefined + ? deriveSecurityContext(adapterRes.config, { + productionDefaults: envRes.resolvedEnvironment === "production", + }) + : undefined; + const handlerContext = buildHandlerContext({ projectDir: adapterRes.projectDir, adapter: adapterRes.adapter, - securityConfig: input.securityConfig, - cspUserHeader: input.cspUserHeader, + securityConfig: requestSecurity?.securityConfig ?? input.securityConfig, + cspUserHeader: requestSecurity?.cspUserHeader ?? input.cspUserHeader, debug: input.debug, config: adapterRes.config, parsedDomain: projectRes.parsedDomain, projectSlug: projectRes.projectSlug, projectId: projectRes.projectId, releaseId: envRes.releaseId, + branchId: input.headers.branchId, + branchName: input.headers.branchName, + defaultBranchName: input.headers.defaultBranchName, proxyToken: reqCtx.token, environmentName: projectRes.environmentName, resolvedEnvironment: envRes.resolvedEnvironment ?? "preview", @@ -314,6 +344,7 @@ export async function resolveProjectRuntimeContext( routeRegistry: input.routeRegistry, isLocalProject: adapterRes.isLocalProject, allowHostProjectCodeExecution: input.allowHostProjectCodeExecution, + isProxyMode: input.isProxyMode, moduleServerUrl: input.moduleServerUrl, environmentId: input.environmentId ?? input.headers.environmentId, skipEnrichedContext: input.skipEnrichedContext ?? shouldSkipEnrichedContext(input.url.pathname), @@ -339,11 +370,12 @@ export async function resolveProjectRuntimeContext( ) { const projectSlug = projectRes.projectSlug; rawEnvVars = await profileEnvVars(() => - input.envVarCache.get( + input.envVarCache.get({ environmentId, - reqCtx.token, + token: reqCtx.token, projectSlug, - ) + projectId: projectRes.projectId, + }) ); input.logDebug?.("[runtime-handler] Project env vars fetched", { @@ -368,17 +400,37 @@ export async function resolveProjectRuntimeContext( function createProxyGuard( req: Request, - url: URL, isProxyMode: boolean, headers: ProjectRequestHeaders, proxyTrusted: boolean | undefined, + identityHeadersTrusted: boolean, ): ProxyGuardResult | undefined { - if (!isProxyMode || isLightweightPath(url.pathname) || isWebSocketPath(url.pathname)) { - return undefined; - } + if (!isProxyMode) return undefined; const token = req.headers.get("x-token"); - const body = !headers.projectSlug + const hasUntrustedIdentityHeaders = !identityHeadersTrusted && + ( + req.headers.has("x-project-id") || + req.headers.has("x-environment-id") || + req.headers.has("x-environment-name") || + req.headers.has("x-branch-id") || + req.headers.has("x-branch-name") || + req.headers.has("x-default-branch-name") + ); + const hasIncompleteEnvironmentIdentity = identityHeadersTrusted && + Boolean(headers.environmentId) !== Boolean(headers.environmentName); + const hasIncompleteBranchIdentity = identityHeadersTrusted && + Boolean(headers.branchId) !== Boolean(headers.branchName); + const hasConflictingBranchIdentity = identityHeadersTrusted && + Boolean(headers.defaultBranchName) && + (Boolean(headers.branchId) || Boolean(headers.branchName)); + const body = hasUntrustedIdentityHeaders + ? { + error: "Untrusted identity context", + detail: + "project, environment, and branch identity headers require an operator-authenticated proxy boundary", + } + : !headers.projectSlug ? { error: "Missing project context", detail: "x-project-slug header is required in proxy mode", @@ -388,10 +440,21 @@ function createProxyGuard( error: "Missing authentication context", detail: "x-token header is required in proxy mode", } - : req.headers.get("x-project-path") && !proxyTrusted + : hasIncompleteEnvironmentIdentity + ? { + error: "Incomplete environment identity", + detail: "x-environment-id and x-environment-name must be supplied together", + } + : hasIncompleteBranchIdentity || hasConflictingBranchIdentity + ? { + error: "Invalid branch identity", + detail: + "x-branch-id and x-branch-name must be supplied together and cannot be combined with x-default-branch-name", + } + : !proxyTrusted ? { error: "Untrusted proxy context", - detail: "proxy context headers require a trusted upstream proxy", + detail: "proxy mode requires an operator-trusted upstream proxy", } : undefined; diff --git a/src/server/runtime-handler/timeout-manager.test.ts b/src/server/runtime-handler/timeout-manager.test.ts index b0fd2a09f5..86c8fd6f9e 100644 --- a/src/server/runtime-handler/timeout-manager.test.ts +++ b/src/server/runtime-handler/timeout-manager.test.ts @@ -1,6 +1,7 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals, assertExists } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; +import { PERMISSION_DENIED } from "#veryfront/errors"; import { HTTP_GATEWAY_TIMEOUT } from "./request-utils.ts"; import { withRequestTimeout } from "./timeout-manager.ts"; @@ -49,6 +50,34 @@ describe("timeout-manager", () => { assertEquals(error.message, "Synchronous handler failure"); }); + it("preserves registered env authorization errors at the outer request boundary", async () => { + const registeredError = PERMISSION_DENIED.create({ + detail: "Project credential is not authorized for the requested environment", + }); + const handler = async (): Promise => { + throw registeredError; + }; + + const { response, error } = await withRequestTimeout( + handler, + "/hosted-project", + "GET", + ); + + assertEquals(error, registeredError); + assertEquals(response.status, 403); + assertEquals(response.headers.get("content-type"), "application/problem+json"); + assertEquals(await response.json(), { + type: "https://veryfront.com/docs/errors/permission-denied", + title: "File/resource permission denied", + status: 403, + detail: "Project credential is not authorized for the requested environment", + instance: "/hosted-project", + category: "GENERAL", + suggestion: "Check file permissions and access rights", + }); + }); + it("aborts the handler signal on timeout and reports settlement separately", async () => { let releaseHandler!: () => void; let handlerSignal: AbortSignal | undefined; diff --git a/src/server/runtime-handler/timeout-manager.ts b/src/server/runtime-handler/timeout-manager.ts index dd7065f6a2..beeb2b70c5 100644 --- a/src/server/runtime-handler/timeout-manager.ts +++ b/src/server/runtime-handler/timeout-manager.ts @@ -7,6 +7,7 @@ */ import { getBaseLogger } from "#veryfront/utils"; +import { errorToResponse, isVeryfrontError } from "#veryfront/errors"; import { getRequestTimeout, HTTP_GATEWAY_TIMEOUT, TIMEOUT_SENTINEL } from "./request-utils.ts"; import { ErrorPages } from "../utils/error-html.ts"; @@ -107,10 +108,16 @@ export async function withRequestTimeout( stack: error.stack, }); return { - response: new Response(ErrorPages.serverError(), { - status: 500, - headers: { "Content-Type": "text/html; charset=utf-8" }, - }), + // Errors raised while resolving the hosted project context happen + // outside the route registry's error boundary. Preserve registered HTTP + // semantics here (for example env authorization 403 and upstream 502) + // while retaining the legacy opaque 500 page for unknown exceptions. + response: isVeryfrontError(e) + ? errorToResponse(e, pathname) + : new Response(ErrorPages.serverError(), { + status: 500, + headers: { "Content-Type": "text/html; charset=utf-8" }, + }), error, settled, }; diff --git a/src/server/services/rsc/endpoints/endpoint-router.test-helpers.ts b/src/server/services/rsc/endpoints/endpoint-router.test-helpers.ts index e8abbc0b65..5933118746 100644 --- a/src/server/services/rsc/endpoints/endpoint-router.test-helpers.ts +++ b/src/server/services/rsc/endpoints/endpoint-router.test-helpers.ts @@ -22,6 +22,12 @@ export function createMockAdapter( }>; } = {}, ): RuntimeAdapter { + const exists = fsOverrides.exists ?? + ((path: string) => Promise.resolve(fsOverrides.knownFiles?.includes(path) === true)); + const readFile = fsOverrides.readFile ?? (async (path: string) => { + if (await exists(path)) return ""; + throw new Deno.errors.NotFound("not found"); + }); return { id: "memory", name: "mock", @@ -34,14 +40,20 @@ export function createMockAdapter( workers: false, }, fs: { - exists: fsOverrides.exists ?? (() => Promise.resolve(false)), - readFile: fsOverrides.readFile ?? (() => Promise.resolve("")), + symlinkSemantics: "none" as const, + exists, + readFile, + readFileBytesWithinLimit: async (path: string, byteLimit: number) => { + const bytes = new TextEncoder().encode(await readFile(path)); + if (bytes.byteLength > byteLimit) throw new RangeError("mock file exceeds byte limit"); + return bytes; + }, writeFile: () => Promise.resolve(), readDir: fsOverrides.readDir ?? createKnownFilesReader(fsOverrides.knownFiles ?? []), mkdir: () => Promise.resolve(), remove: () => Promise.resolve(), stat: fsOverrides.stat ?? (async (path: string) => { - if (await (fsOverrides.exists?.(path) ?? Promise.resolve(false))) { + if (await exists(path)) { return { isFile: true, isDirectory: false, size: 0, mtime: null }; } throw new Deno.errors.NotFound("not found"); diff --git a/src/server/services/rsc/endpoints/endpoint-router.test.ts b/src/server/services/rsc/endpoints/endpoint-router.test.ts index 575c314e6d..b418933757 100644 --- a/src/server/services/rsc/endpoints/endpoint-router.test.ts +++ b/src/server/services/rsc/endpoints/endpoint-router.test.ts @@ -449,6 +449,107 @@ describe("server/services/rsc/endpoints/endpoint-router", () => { } }); + it("rejects missing and malformed pins before dependency metadata I/O", async () => { + const originalFlag = getHostEnv(DEPENDENCY_PINNING_ENV_FLAG); + let metadataOperations = 0; + const dependencyPinningSource: DependencyPinningSource = { + projectDir: "/tmp/test-project", + cacheNamespace: "rsc-pre-admission-pin-rejection", + fs: { + readFile: () => { + metadataOperations += 1; + return Promise.resolve("{}"); + }, + stat: () => { + metadataOperations += 1; + return Promise.resolve({ + size: 2, + isFile: true, + isDirectory: false, + isSymlink: false, + mtime: new Date(1), + }); + }, + }, + }; + setEnv(DEPENDENCY_PINNING_ENV_FLAG, "1"); + clearReactVersionCache(); + + try { + for ( + const query of [ + "rel=app%2FCounter.client.ts", + "rel=app%2FCounter.client.ts&pins=on%3A", + "rel=app%2FCounter.client.ts&pins=on%3A1&pins=on%3A1", + ] + ) { + const response = await handleRSCEndpoint( + makeParams({ + pathname: "/_veryfront/rsc/module", + dependencyPinningSource, + req: new Request(`http://localhost/_veryfront/rsc/module?${query}`), + }), + ); + assertEquals(response?.status, 409); + } + assertEquals(metadataOperations, 0); + } finally { + setEnv(DEPENDENCY_PINNING_ENV_FLAG, originalFlag ?? ""); + clearReactVersionCache(); + } + }); + + it("hands a valid requested snapshot to the admitted browser builder without pre-reading it", async () => { + const originalFlag = getHostEnv(DEPENDENCY_PINNING_ENV_FLAG); + let metadataOperations = 0; + const dependencyPinningSource: DependencyPinningSource = { + projectDir: "/tmp/test-project", + cacheNamespace: "rsc-admitted-pin-resolution", + fs: { + readFile: () => { + metadataOperations += 1; + return Promise.reject(new Error("snapshot resolved before admission")); + }, + stat: () => { + metadataOperations += 1; + return Promise.reject(new Error("snapshot resolved before admission")); + }, + }, + }; + setEnv(DEPENDENCY_PINNING_ENV_FLAG, "1"); + clearReactVersionCache(); + setBrowserModuleBuilderForTesting((_path, options) => { + assertEquals(options.requestedDependencyPinningCacheKey, "on:1"); + assertEquals(metadataOperations, 0); + return Promise.resolve({ + source: "export default 1;", + contentHash: "content", + importMapHash: "import-map", + dependencyPinningCacheKey: "on:1", + dependencyPinningDependencies: Object.freeze({}), + dependencies: Object.freeze([]), + resolutionProbes: Object.freeze([]), + }); + }); + + try { + const response = await handleRSCEndpoint( + makeParams({ + pathname: "/_veryfront/rsc/module", + dependencyPinningSource, + req: new Request( + "http://localhost/_veryfront/rsc/module?rel=app%2FCounter.client.ts&pins=on%3A1", + ), + }), + ); + assertEquals(response?.status, 200); + assertEquals(metadataOperations, 0); + } finally { + setEnv(DEPENDENCY_PINNING_ENV_FLAG, originalFlag ?? ""); + clearReactVersionCache(); + } + }); + it("rejects unsupported hybrid CommonJS and ESM JSX suffixes", async () => { for (const extension of [".mtsx", ".ctsx", ".mjsx", ".cjsx"] as const) { const filePath = `/tmp/test-project/app/Unsupported${extension}`; @@ -513,7 +614,6 @@ describe("server/services/rsc/endpoints/endpoint-router", () => { } }, }); - const result = await handleRSCEndpoint( makeParams({ pathname: "/_veryfront/rsc/module", @@ -560,6 +660,13 @@ describe("server/services/rsc/endpoints/endpoint-router", () => { } }, }); + Object.defineProperty(adapter.fs, "symlinkSemantics", { + configurable: true, + enumerable: true, + value: undefined, + }); + adapter.fs.readFileSnapshotWithinLimit = () => + Promise.reject(new TypeError("snapshot rejected symbolic link")); const result = await handleRSCEndpoint( makeParams({ @@ -578,7 +685,7 @@ describe("server/services/rsc/endpoints/endpoint-router", () => { assertEquals((await result!.text()).includes("OUTSIDE_PROJECT_MARKER"), false); }); - it("fails closed when app path metadata cannot be inspected", async () => { + it("uses exact no-link reads instead of mutable directory metadata", async () => { const modulePath = "/tmp/test-project/app/Counter.ts"; const reads: string[] = []; const adapter = createMockAdapter({ @@ -606,8 +713,8 @@ describe("server/services/rsc/endpoints/endpoint-router", () => { }), ); - assertEquals(result?.status, 404); - assertEquals(reads, []); + assertEquals(result?.status, 200); + assertEquals(reads, [modulePath]); }); it("serves declared client modules from the app root", async () => { @@ -933,6 +1040,72 @@ describe("server/services/rsc/endpoints/endpoint-router", () => { } }); + it("rejects protected client entry paths before browser compilation", async () => { + const projectDir = "/tmp/rsc-protected-entry"; + const entryPath = `${projectDir}/app/actions/private.client.ts`; + const marker = "PROTECTED_ACTION_ENTRY_MARKER"; + const adapter = createMockAdapter({ + knownFiles: [entryPath], + readFile: () => Promise.resolve(`"use client"; export const secret = "${marker}";`), + }); + let buildCalls = 0; + setBrowserModuleBuilderForTesting(async () => { + buildCalls++; + throw new Error("protected entry must not reach the browser compiler"); + }); + + const result = await handleRSCEndpoint( + makeParams({ + pathname: "/_veryfront/rsc/module", + projectDir, + config: rscDisabledConfig, + adapter, + req: new Request( + "http://localhost/_veryfront/rsc/module?rel=app%2Factions%2Fprivate.client.ts", + ), + }), + ); + + assertEquals(result?.status, 404); + assertEquals((await result!.text()).includes(marker), false); + assertEquals(buildCalls, 0); + }); + + it("rejects bundles containing protected transitive project paths", async () => { + const projectDir = "/tmp/rsc-protected-dependency"; + const entryPath = `${projectDir}/app/Counter.client.ts`; + const protectedPath = `${projectDir}/app/actions/private.ts`; + const marker = "PROTECTED_TRANSITIVE_ACTION_MARKER"; + const adapter = createMockAdapter({ knownFiles: [entryPath, protectedPath] }); + setBrowserModuleBuilderForTesting(() => + Promise.resolve({ + source: `export default "${marker}";`, + contentHash: "protected-transitive", + importMapHash: "unused", + dependencies: [ + { path: entryPath, contentHash: "entry", byteLength: 1 }, + { path: protectedPath, contentHash: "protected", byteLength: 1 }, + ], + resolutionProbes: [], + }) + ); + + const result = await handleRSCEndpoint( + makeParams({ + pathname: "/_veryfront/rsc/module", + projectDir, + config: rscDisabledConfig, + adapter, + req: new Request( + "http://localhost/_veryfront/rsc/module?rel=app%2FCounter.client.ts", + ), + }), + ); + + assertEquals(result?.status, 404); + assertEquals((await result!.text()).includes(marker), false); + }); + it("serves client modules from the configured app directory", async () => { const filePath = "/tmp/test-project/frontend/Counter.tsx"; const adapter = createMockAdapter({ @@ -1047,10 +1220,23 @@ describe("server/services/rsc/endpoints/endpoint-router", () => { 'import React from "react";', "export default function Counter() { return React.createElement('div'); }", ].join("\n"); + const packagePath = `${projectDir}/package.json`; + let packageSource = JSON.stringify({ dependencies: { react: "19.1.0" } }); + let packageGeneration = 1; const adapter = createMockAdapter({ - knownFiles: [entryPath], - exists: (path) => Promise.resolve(path === entryPath), - readFile: () => Promise.resolve(source), + knownFiles: [entryPath, packagePath], + readFile: (path) => Promise.resolve(path === packagePath ? packageSource : source), + stat: (path) => { + if (path === entryPath || path === packagePath) { + return Promise.resolve({ + isFile: true, + isDirectory: false, + size: path === packagePath ? packageSource.length : source.length, + mtime: path === packagePath ? new Date(packageGeneration) : null, + }); + } + return Promise.reject(new Deno.errors.NotFound("not found")); + }, }); const bundler = tryResolve("Bundler"); if (!bundler) throw new Error("Bundler test contract is not registered"); @@ -1076,18 +1262,12 @@ describe("server/services/rsc/endpoints/endpoint-router", () => { ); try { - await Deno.writeTextFile( - `${projectDir}/package.json`, - JSON.stringify({ dependencies: { react: "19.1.0" } }), - ); clearReactVersionCache(); assertEquals((await request())?.status, 200); assertEquals(getBrowserModuleEndpointStatsForTesting().cacheEntries, 1); - await Deno.writeTextFile( - `${projectDir}/package.json`, - JSON.stringify({ dependencies: { react: "19.2.0" } }), - ); + packageSource = JSON.stringify({ dependencies: { react: "19.2.0" } }); + packageGeneration++; clearReactVersionCache(); assertEquals((await request())?.status, 200); assertEquals(getBrowserModuleEndpointStatsForTesting().cacheEntries, 1); @@ -1160,7 +1340,7 @@ describe("server/services/rsc/endpoints/endpoint-router", () => { knownFiles: Object.keys(files), exists: (path) => Promise.resolve(path in files), readFile: async (path) => { - if (path === firstPath && ++firstReads === 2) { + if (path === firstPath && ++firstReads === 1) { signalStarted(); await release; } @@ -1235,7 +1415,7 @@ describe("server/services/rsc/endpoints/endpoint-router", () => { } const output = logs.join("\n"); - assertEquals(result?.status, 500); + assertEquals(result?.status, 404); assertEquals(output.includes("ATTACKER_LOG_PREFIX"), false); assertEquals(output.includes("ATTACKER_LOG_SUFFIX"), false); assertEquals(output.length < 1000, true); @@ -2086,6 +2266,8 @@ Deno.test("RSC module endpoint preserves the exact proxy dependency source", asy source: "export default null;", contentHash: "proxy-source-test", importMapHash: await computeHash(options.importMapJson ?? ""), + dependencyPinningCacheKey: snapshot.cacheKey, + dependencyPinningDependencies: snapshot.dependencies, dependencies: [], resolutionProbes: [], }; diff --git a/src/server/services/rsc/endpoints/endpoint-router.ts b/src/server/services/rsc/endpoints/endpoint-router.ts index a355fcef27..c0afc1116f 100644 --- a/src/server/services/rsc/endpoints/endpoint-router.ts +++ b/src/server/services/rsc/endpoints/endpoint-router.ts @@ -7,12 +7,13 @@ import { HTTP_SERVER_ERROR, isRSCEnabled, serverLogger } from "#veryfront/utils" import { metrics } from "#veryfront/observability"; import { HttpStatus, jsonErrorResponse } from "#veryfront/http/responses"; import { isWithinDirectory, joinPath, normalizePath } from "#veryfront/utils/path-utils.ts"; -import { buildImportMapJson } from "#veryfront/html"; import { escapeHtml } from "#veryfront/html/html-escape.ts"; import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; import type { VeryfrontConfig } from "#veryfront/config"; import { type BrowserModuleBundle, + BrowserModuleDependencySnapshotError, + BrowserModuleEntryRejectedError, bundleBrowserModuleWithMetadata, validateBrowserModuleBundle, } from "#veryfront/server/shared/browser-module-bundler.ts"; @@ -33,12 +34,13 @@ import { handleActionRequest } from "./action-handler.ts"; import { getRSCHandler } from "./handler-registry.ts"; import { handleClientScript, handleDomScript } from "./script-handlers.ts"; import type { RSCEndpointParams } from "./types.ts"; -import { analyzeComponent } from "#veryfront/rendering/rsc/component-analyzer.ts"; import { computeHash } from "#veryfront/utils/hash-utils.ts"; import { createErrorResponseFromDefinition, PROJECT_EXECUTION_UNAVAILABLE, } from "#veryfront/errors"; +import { classifyBrowserModuleAbsoluteSourcePath } from "#veryfront/modules/server/browser-module-admission.ts"; +import { isCanonicalDependencyPinningCacheKey } from "#veryfront/cache/keys/dependency-pinning.ts"; const rscEndpointRouterLog = serverLogger.component("rsc-endpoint-router"); const rscLog = serverLogger.component("rsc"); @@ -48,6 +50,20 @@ let browserModuleBuilder = bundleBrowserModuleWithMetadata; let browserModuleAdapterIds = new WeakMap(); let nextBrowserModuleAdapterId = 1; +function hasProtectedBrowserModuleDependency( + bundle: BrowserModuleBundle, + projectDir: string, + config?: VeryfrontConfig, +): boolean { + return bundle.dependencies.some((dependency) => + classifyBrowserModuleAbsoluteSourcePath( + dependency.path, + projectDir, + { config, rscEnabled: true }, + ).protectionReason !== null + ); +} + export function resetBrowserModuleEndpointStateForTesting( options: BrowserModuleBuildCoordinatorOptions = {}, ): void { @@ -412,18 +428,10 @@ async function handleModuleEndpoint({ const rel = normalizedRel.startsWith("/") ? normalizedRel : `/${normalizedRel}`; const requestedPinKeys = searchParams.getAll("pins"); const hasMalformedPinKey = requestedPinKeys.length > 1 || - (requestedPinKeys.length === 1 && !requestedPinKeys[0]?.startsWith("on:")); + (requestedPinKeys.length === 1 && + !isCanonicalDependencyPinningCacheKey(requestedPinKeys[0] ?? "")); const requestedPinKey = requestedPinKeys[0]; - const dependencyPinningSnapshot = hasMalformedPinKey - ? undefined - : await resolveRequestedDependencyPinningSnapshot( - dependencyPinningSource, - requestedPinKey, - ); - if ( - !dependencyPinningSnapshot || - (requestedPinKeys.length === 0 && dependencyPinningSnapshot.cacheKey !== "off") - ) { + if (hasMalformedPinKey || (requestedPinKeys.length === 0 && isDependencyPinningEnabled())) { return new Response("Unknown dependency snapshot", { status: HttpStatus.CONFLICT, headers: { "cache-control": "no-store" }, @@ -432,17 +440,28 @@ async function handleModuleEndpoint({ try { const moduleServerOrigin = new URL(req.url).origin; - const modulePath = await resolveModuleEndpointPath(rel, projectDir, adapter, config); + const modulePath = resolveModuleEndpointPath(rel, projectDir, config); if (!modulePath) { return new Response("Not Found", { status: 404, headers: { "cache-control": "no-store" }, }); } + const entryPolicy = classifyBrowserModuleAbsoluteSourcePath( + modulePath, + projectDir, + { config, rscEnabled: true }, + ); + if (entryPolicy.protectionReason) { + return new Response("Not Found", { + status: HttpStatus.NOT_FOUND, + headers: { "cache-control": "no-store" }, + }); + } const adapterId = getBrowserModuleAdapterId(adapter); const configHash = await computeHash(stableSerialize(config ?? null)); - const dependencyPinningCacheKey = dependencyPinningSnapshot.cacheKey; + const dependencyPinningCacheKey = requestedPinKey ?? "off"; const projectKey = projectId ?? projectSlug ?? projectDir; const cacheKey = buildBrowserModuleCacheKey({ adapterId, @@ -459,38 +478,49 @@ async function handleModuleEndpoint({ cacheKey, projectKey, build: async () => { - const importMapJson = await buildImportMapJson({ - projectDir, - config, - moduleServerOrigin, - dependencyPinningCacheKey, - dependencyPinningDependencies: dependencyPinningSnapshot.dependencies, - dependencyPinningSource, - }); - return browserModuleBuilder(modulePath, { + const bundle = await browserModuleBuilder(modulePath, { adapter, projectDir, projectId: projectId ?? projectSlug, config, projectSlug, - importMapJson, moduleServerOrigin, - dependencyPinningCacheKey, - dependencyPinningDependencies: dependencyPinningSnapshot.dependencies, + ...(requestedPinKey + ? { requestedDependencyPinningCacheKey: requestedPinKey } + : { dependencyPinningCacheKey }), dependencyPinningSource, + signal: req.signal, + requireClientBoundary: true, }); + if (hasProtectedBrowserModuleDependency(bundle, projectDir, config)) { + throw new BrowserModuleEntryRejectedError(); + } + if (bundle.dependencyPinningCacheKey !== dependencyPinningCacheKey) { + throw new BrowserModuleDependencySnapshotError(); + } + return bundle; }, validate: async (bundle) => { - const importMapJson = await buildImportMapJson({ + if (hasProtectedBrowserModuleDependency(bundle, projectDir, config)) return false; + if ( + bundle.dependencyPinningCacheKey !== dependencyPinningCacheKey || + (dependencyPinningCacheKey.startsWith("on:") && + bundle.dependencyPinningDependencies === undefined) + ) { + return false; + } + return validateBrowserModuleBundle(bundle, { + adapter, projectDir, - config, - moduleServerOrigin, - dependencyPinningCacheKey, - dependencyPinningDependencies: dependencyPinningSnapshot.dependencies, - dependencyPinningSource, + signal: req.signal, + importMap: { + config, + moduleServerOrigin, + dependencyPinningCacheKey: bundle.dependencyPinningCacheKey, + dependencyPinningDependencies: bundle.dependencyPinningDependencies, + dependencyPinningSource, + }, }); - if (await computeHash(importMapJson) !== bundle.importMapHash) return false; - return validateBrowserModuleBundle(bundle, { adapter, projectDir }); }, sizeOf: estimateBrowserModuleBundleSize, }); @@ -512,6 +542,18 @@ async function handleModuleEndpoint({ }, }); } catch (error) { + if (error instanceof BrowserModuleDependencySnapshotError) { + return new Response("Unknown dependency snapshot", { + status: HttpStatus.CONFLICT, + headers: { "cache-control": "no-store" }, + }); + } + if (error instanceof BrowserModuleEntryRejectedError) { + return new Response("Not Found", { + status: HttpStatus.NOT_FOUND, + headers: { "cache-control": "no-store" }, + }); + } if (error instanceof BrowserModuleCapacityError) { rscEndpointRouterLog.debug("module build capacity exhausted", { errorName: error.name, @@ -600,9 +642,13 @@ function stableSerialize(value: unknown, seen = new WeakSet()): string { function estimateBrowserModuleBundleSize(bundle: BrowserModuleBundle): number { let size = new TextEncoder().encode(bundle.source).byteLength + - bundle.contentHash.length + bundle.importMapHash.length; + bundle.contentHash.length + bundle.importMapHash.length + + (bundle.dependencyPinningCacheKey?.length ?? 0); + for (const [name, declaration] of Object.entries(bundle.dependencyPinningDependencies ?? {})) { + size += name.length + declaration.length; + } for (const dependency of bundle.dependencies) { - size += dependency.path.length + dependency.contentHash.length; + size += dependency.path.length + dependency.contentHash.length + 8; } for (const probe of bundle.resolutionProbes) { size += probe.path.length + probe.state.length; @@ -618,12 +664,11 @@ function ifNoneMatch(header: string | null, etag: string): boolean { }); } -async function resolveModuleEndpointPath( +function resolveModuleEndpointPath( rel: string, projectDir: string, - adapter: RuntimeAdapter, config?: VeryfrontConfig, -): Promise { +): string | null { const normalizedRel = rel.replace(/^\/+/, ""); if (!/\.(?:[jt]sx?|[cm][jt]s)$/i.test(normalizedRel)) return null; @@ -637,87 +682,9 @@ async function resolveModuleEndpointPath( const modulePath = normalizePath(joinPath(root, pathRelativeToRoot)); if (!isWithinDirectory(root, modulePath)) return null; - try { - if (!(await adapter.fs.exists(modulePath))) return null; - if ( - !(await hasTrustedPathMetadata({ - adapter, - projectDir, - rootRelative, - pathRelativeToRoot, - })) - ) return null; - if (!(await isTrustedBrowserModuleEntry(modulePath, adapter))) return null; - } catch (error) { - rscEndpointRouterLog.debug("module lookup failed", { - errorName: error instanceof Error ? error.name : "UnknownError", - }); - throw error; - } - return modulePath; } -async function hasTrustedPathMetadata(options: { - projectDir: string; - rootRelative: string; - pathRelativeToRoot: string; - adapter: RuntimeAdapter; -}): Promise { - const segments = [options.rootRelative, options.pathRelativeToRoot] - .flatMap((path) => path.split("/")) - .filter(Boolean); - if (segments.length < 2 || segments.some((segment) => segment === "." || segment === "..")) { - return false; - } - - let parent = normalizePath(options.projectDir); - try { - for (const [index, segment] of segments.entries()) { - let matchingEntry: - | { isFile: boolean; isDirectory: boolean; isSymlink: boolean } - | undefined; - for await (const entry of options.adapter.fs.readDir(parent)) { - if (entry.name === segment) { - matchingEntry = entry; - break; - } - } - - const isLast = index === segments.length - 1; - if ( - !matchingEntry || matchingEntry.isSymlink || - (isLast ? !matchingEntry.isFile : !matchingEntry.isDirectory) - ) { - return false; - } - parent = normalizePath(joinPath(parent, segment)); - } - } catch (error) { - rscEndpointRouterLog.debug("module path metadata inspection failed", { - errorName: error instanceof Error ? error.name : "UnknownError", - }); - return false; - } - - return true; -} - -async function isTrustedBrowserModuleEntry( - modulePath: string, - adapter: RuntimeAdapter, -): Promise { - try { - const analysis = await analyzeComponent(modulePath, adapter.fs); - return analysis.type === "client" && !analysis.hasUseServer; - } catch (error) { - rscEndpointRouterLog.debug("client module analysis failed", { - errorName: error instanceof Error ? error.name : "UnknownError", - }); - return false; - } -} - /** Extract name parameter with fallback to "World" */ function getNameParam(searchParams: URLSearchParams): string { return searchParams.get("name")?.trim() || "World"; diff --git a/src/server/shared/browser-module-bundler.test.ts b/src/server/shared/browser-module-bundler.test.ts index 01268ed884..3b24699cae 100644 --- a/src/server/shared/browser-module-bundler.test.ts +++ b/src/server/shared/browser-module-bundler.test.ts @@ -6,7 +6,12 @@ import { afterEach, describe, it } from "#veryfront/testing/bdd.ts"; import { register, tryResolve, unregister } from "#veryfront/extensions/contracts.ts"; import type { Bundler } from "#veryfront/extensions/bundler/bundler.ts"; import { computeHash } from "#veryfront/utils/hash-utils.ts"; +import { hashString } from "#veryfront/cache/hash.ts"; +import { DEPENDENCY_PINNING_ENV_FLAG } from "#veryfront/release-assets/constants.ts"; +import { getHostEnv, setEnv } from "#veryfront/platform/compat/process.ts"; +import { clearReactVersionCache } from "#veryfront/transforms/esm/package-registry.ts"; import { + BrowserModuleBundleError, bundleBrowserModule, bundleBrowserModuleWithMetadata, getSafeBrowserModuleIdentity, @@ -55,18 +60,11 @@ describe( entryPath, 'export const marker = "SYMLINKED_ENTRY_MARKER";', ); - const readDir = adapter.fs.readDir; - adapter.fs.readDir = (path: string) => - path === `${projectDir}/app` - ? (async function* () { - yield { - name: "Leak.ts", - isFile: false, - isDirectory: false, - isSymlink: true, - }; - })() - : readDir(path); + const readSnapshot = adapter.fs.readFileSnapshotWithinLimit!; + adapter.fs.readFileSnapshotWithinLimit = (path, root, limit) => + path === entryPath + ? Promise.reject(new Error("snapshot rejected symbolic link")) + : readSnapshot(path, root, limit); await assertRejects( () => bundleBrowserModule(entryPath, { adapter, projectDir }), @@ -300,6 +298,761 @@ describe( assertStringIncludes(output, 'await Promise.resolve("BROWSER_TLA_MARKER")'); }); + it("enforces dependency, aggregate input, and aggregate output limits", async () => { + const projectDir = "/bounded-project"; + const entryPath = `${projectDir}/app/Counter.ts`; + const adapter = createMockAdapter(); + adapter.fs.files.set(entryPath, 'import "./a.ts"; import "./b.ts"; export default 1;'); + adapter.fs.files.set(`${projectDir}/app/a.ts`, "export const a = 1;"); + adapter.fs.files.set(`${projectDir}/app/b.ts`, "export const b = 1;"); + + const dependencyError = await assertRejects( + () => + bundleBrowserModuleWithMetadata(entryPath, { + adapter, + projectDir, + importMapJson: "{}", + limits: { maxDependencies: 2 }, + }), + BrowserModuleBundleError, + ); + assertEquals((dependencyError as BrowserModuleBundleError).kind, "limit"); + + const encoder = new TextEncoder(); + const aggregateInputLimit = encoder.encode(adapter.fs.files.get(entryPath)!).byteLength + + encoder.encode(adapter.fs.files.get(`${projectDir}/app/a.ts`)!).byteLength; + const inputError = await assertRejects( + () => + bundleBrowserModuleWithMetadata(entryPath, { + adapter, + projectDir, + importMapJson: "{}", + limits: { maxAggregateInputBytes: aggregateInputLimit }, + }), + BrowserModuleBundleError, + ); + assertEquals((inputError as BrowserModuleBundleError).kind, "limit"); + + const previous = tryResolve("Bundler"); + register("Bundler", { + bundle: () => + Promise.resolve({ + outputFiles: [ + { + path: "out-1.js", + contents: new Uint8Array(5), + text: "12345", + }, + { + path: "out-2.js", + contents: new Uint8Array(5), + text: "67890", + }, + ], + warnings: [], + errors: [], + }), + transform: () => Promise.resolve({ code: "", warnings: [] }), + }); + try { + const outputError = await assertRejects( + () => + bundleBrowserModuleWithMetadata(entryPath, { + adapter, + projectDir, + importMapJson: "{}", + limits: { maxOutputBytes: 8 }, + }), + BrowserModuleBundleError, + ); + assertEquals((outputError as BrowserModuleBundleError).kind, "limit"); + } finally { + if (previous) register("Bundler", previous); + else unregister("Bundler"); + } + }); + + it("uses stable bounded snapshots without raw reads or directory walks", async () => { + const projectDir = "/snapshot-project"; + const entryPath = `${projectDir}/app/Counter.ts`; + const dependencyPath = `${projectDir}/app/shared.ts`; + const adapter = createMockAdapter(); + adapter.fs.files.set( + entryPath, + 'import "./shared.ts"; import "./shared.ts"; export default 1;', + ); + adapter.fs.files.set(dependencyPath, "export const shared = true;"); + const snapshotRead = adapter.fs.readFileSnapshotWithinLimit!; + const stat = adapter.fs.stat; + let snapshotReads = 0; + let dependencyStats = 0; + adapter.fs.readFile = () => Promise.reject(new Error("raw read must not be used")); + adapter.fs.readDir = () => { + throw new Error("directory walk must not be used"); + }; + adapter.fs.readFileSnapshotWithinLimit = (path, root, limit) => { + snapshotReads += 1; + return snapshotRead(path, root, limit); + }; + adapter.fs.stat = (path) => { + if (path === dependencyPath) dependencyStats += 1; + return stat(path); + }; + + const bundle = await bundleBrowserModuleWithMetadata(entryPath, { + adapter, + projectDir, + importMapJson: "{}", + }); + + assertEquals(bundle.dependencies.length, 2); + assertEquals(snapshotReads, 2); + assertEquals(dependencyStats, 1); + }); + + it("charges package metadata to the exact aggregate input budget", async () => { + const projectDir = "/bounded-package-metadata"; + const entryPath = `${projectDir}/app/Counter.ts`; + const packagePath = `${projectDir}/package.json`; + const adapter = createMockAdapter(); + adapter.fs.files.set(entryPath, "export default 1;"); + adapter.fs.files.set(packagePath, "{}" + " ".repeat(2 * 1024 * 1024)); + let rawReads = 0; + adapter.fs.readFile = () => { + rawReads++; + return Promise.reject(new Error("raw package metadata read is forbidden")); + }; + const originalFlag = getHostEnv(DEPENDENCY_PINNING_ENV_FLAG); + setEnv(DEPENDENCY_PINNING_ENV_FLAG, "1"); + clearReactVersionCache(); + try { + const error = await assertRejects( + () => + bundleBrowserModuleWithMetadata(entryPath, { + adapter, + projectDir, + projectId: "bounded-package-metadata", + requestedDependencyPinningCacheKey: `on:${hashString("[]")}`, + dependencyPinningSource: { + projectDir, + fs: adapter.fs, + cacheNamespace: "bounded-package-metadata", + }, + limits: { maxAggregateInputBytes: 1024 * 1024 }, + }), + BrowserModuleBundleError, + ); + + assertEquals((error as BrowserModuleBundleError).kind, "limit"); + assertEquals(rawReads, 0); + } finally { + setEnv(DEPENDENCY_PINNING_ENV_FLAG, originalFlag ?? ""); + clearReactVersionCache(); + } + }); + + it("rejects invalid UTF-8 through the stable bounded reader", async () => { + const projectDir = "/invalid-utf8-project"; + const entryPath = `${projectDir}/app/Counter.ts`; + const adapter = createMockAdapter(); + adapter.fs.byteFiles.set(entryPath, new Uint8Array([0xff])); + + await assertRejects( + async () => + await bundleBrowserModuleWithMetadata(entryPath, { + adapter, + projectDir, + importMapJson: "{}", + }), + TypeError, + "valid UTF-8", + ); + }); + + it("does not accept inherited no-symlink authority", async () => { + const projectDir = "/inherited-capability-project"; + const entryPath = `${projectDir}/app/Counter.ts`; + const adapter = createMockAdapter(); + adapter.fs.files.set(entryPath, "export default 1;"); + const inherited = Object.create({ symlinkSemantics: "none" }) as typeof adapter.fs; + for (const key of Reflect.ownKeys(adapter.fs)) { + if (key === "symlinkSemantics" || key === "readFileSnapshotWithinLimit") continue; + const descriptor = Object.getOwnPropertyDescriptor(adapter.fs, key); + if (descriptor) Object.defineProperty(inherited, key, descriptor); + } + adapter.fs = inherited; + + await assertRejects( + () => bundleBrowserModule(entryPath, { adapter, projectDir }), + TypeError, + "stable bounded snapshot reader", + ); + }); + + it("rejects attempts to raise production graph ceilings", async () => { + const projectDir = "/raised-limit-project"; + const entryPath = `${projectDir}/app/Counter.ts`; + const adapter = createMockAdapter(); + adapter.fs.files.set(entryPath, "export default 1;"); + + await assertRejects( + async () => + await bundleBrowserModuleWithMetadata(entryPath, { + adapter, + projectDir, + limits: { maxDependencies: 1_001 }, + }), + RangeError, + "cannot exceed", + ); + }); + + it("keeps distinct entries separate when a caller reuses a singleflight key", async () => { + const projectDir = "/distinct-entry-project"; + const firstPath = `${projectDir}/app/first.ts`; + const secondPath = `${projectDir}/app/second.ts`; + const adapter = createMockAdapter(); + adapter.fs.files.set(firstPath, "export default 1;"); + adapter.fs.files.set(secondPath, "export default 2;"); + let calls = 0; + const previous = tryResolve("Bundler"); + register("Bundler", { + bundle: (options) => { + calls += 1; + const source = options.stdin?.contents ?? ""; + return Promise.resolve({ + outputFiles: [{ + path: "out.js", + contents: new TextEncoder().encode(source), + text: source, + }], + warnings: [], + errors: [], + }); + }, + transform: () => Promise.resolve({ code: "", warnings: [] }), + }); + + try { + const common = { + adapter, + projectDir, + importMapJson: "{}", + singleflightKey: "accidentally-reused", + }; + const [first, second] = await Promise.all([ + bundleBrowserModuleWithMetadata(firstPath, common), + bundleBrowserModuleWithMetadata(secondPath, common), + ]); + assertEquals(calls, 2); + assertEquals(first.source, "export default 1;"); + assertEquals(second.source, "export default 2;"); + } finally { + if (previous) register("Bundler", previous); + else unregister("Bundler"); + } + }); + + it("coalesces equivalent work and bounds distinct bundles per project", async () => { + const projectDir = "/coalesced-project"; + const entryPath = `${projectDir}/app/Counter.ts`; + const adapter = createMockAdapter(); + adapter.fs.files.set(entryPath, "export default 1;"); + const release = Promise.withResolvers(); + const twoActive = Promise.withResolvers(); + let calls = 0; + let active = 0; + let maximumActive = 0; + const previous = tryResolve("Bundler"); + register("Bundler", { + bundle: async () => { + calls += 1; + active += 1; + maximumActive = Math.max(maximumActive, active); + if (active === 2) twoActive.resolve(); + await release.promise; + active -= 1; + return { + outputFiles: [{ + path: "out.js", + contents: new TextEncoder().encode("export default 1;"), + text: "export default 1;", + }], + warnings: [], + errors: [], + }; + }, + transform: () => Promise.resolve({ code: "", warnings: [] }), + }); + + try { + const common = { + adapter, + projectDir, + importMapJson: "{}", + }; + const first = bundleBrowserModuleWithMetadata(entryPath, { + ...common, + singleflightKey: "same", + }); + const joined = bundleBrowserModuleWithMetadata(entryPath, { + ...common, + singleflightKey: "same", + }); + const second = bundleBrowserModuleWithMetadata(entryPath, { + ...common, + singleflightKey: "different-1", + }); + const queued = bundleBrowserModuleWithMetadata(entryPath, { + ...common, + singleflightKey: "different-2", + }); + + await twoActive.promise; + assertEquals(calls, 2); + assertEquals(maximumActive, 2); + release.resolve(); + const [firstResult, joinedResult] = await Promise.all([first, joined, second, queued]); + assertEquals(firstResult === joinedResult, true); + assertEquals(calls, 3); + assertEquals(maximumActive, 2); + } finally { + release.resolve(); + if (previous) register("Bundler", previous); + else unregister("Bundler"); + } + }); + + it("rejects excess per-project bundle queues without starting more work", async () => { + const projectDir = "/capacity-project"; + const entryPath = `${projectDir}/app/Counter.ts`; + const createAdapter = () => { + const adapter = createMockAdapter(); + adapter.fs.files.set(entryPath, "export default 1;"); + return adapter; + }; + const adapters = Array.from({ length: 11 }, createAdapter); + const twoStarted = Promise.withResolvers(); + const release = Promise.withResolvers(); + let calls = 0; + const previous = tryResolve("Bundler"); + register("Bundler", { + bundle: async () => { + calls += 1; + if (calls === 2) twoStarted.resolve(); + await release.promise; + return { + outputFiles: [{ + path: "out.js", + contents: new TextEncoder().encode("export default 1;"), + text: "export default 1;", + }], + warnings: [], + errors: [], + }; + }, + transform: () => Promise.resolve({ code: "", warnings: [] }), + }); + + try { + const common = { + projectDir, + projectId: "capacity-project", + importMapJson: "{}", + }; + const admitted = adapters.slice(0, 10).map((adapter, index) => + bundleBrowserModuleWithMetadata(entryPath, { + ...common, + adapter, + singleflightKey: `admitted-${index}`, + }) + ); + admitted.forEach((promise) => void promise.catch(() => undefined)); + await twoStarted.promise; + const rejected = await assertRejects( + () => + bundleBrowserModuleWithMetadata(entryPath, { + ...common, + adapter: adapters[10]!, + singleflightKey: "rejected", + }), + BrowserModuleBundleError, + ); + assertEquals((rejected as BrowserModuleBundleError).kind, "capacity"); + assertEquals(calls, 2); + + release.resolve(); + await Promise.all(admitted); + assertEquals(calls, 10); + } finally { + release.resolve(); + if (previous) register("Bundler", previous); + else unregister("Bundler"); + } + }); + + it("bounds aggregate bundle work across project identities", async () => { + const release = Promise.withResolvers(); + const eightStarted = Promise.withResolvers(); + let active = 0; + let maximumActive = 0; + let calls = 0; + const previous = tryResolve("Bundler"); + register("Bundler", { + bundle: async () => { + calls += 1; + active += 1; + maximumActive = Math.max(maximumActive, active); + if (active === 8) eightStarted.resolve(); + await release.promise; + active -= 1; + return { + outputFiles: [{ + path: "out.js", + contents: new TextEncoder().encode("export default 1;"), + text: "export default 1;", + }], + warnings: [], + errors: [], + }; + }, + transform: () => Promise.resolve({ code: "", warnings: [] }), + }); + + try { + const admitted = Array.from({ length: 40 }, (_, index) => { + const projectDir = `/global-capacity-${index}`; + const entryPath = `${projectDir}/app/Counter.ts`; + const adapter = createMockAdapter(); + adapter.fs.files.set(entryPath, "export default 1;"); + return bundleBrowserModuleWithMetadata(entryPath, { + adapter, + projectDir, + projectId: `global-capacity-${index}`, + importMapJson: "{}", + singleflightKey: `global-capacity-${index}`, + }); + }); + admitted.forEach((promise) => void promise.catch(() => undefined)); + await eightStarted.promise; + + const overflowProjectDir = "/global-capacity-overflow"; + const overflowEntryPath = `${overflowProjectDir}/app/Counter.ts`; + const overflowAdapter = createMockAdapter(); + overflowAdapter.fs.files.set(overflowEntryPath, "export default 1;"); + const rejected = await assertRejects( + () => + bundleBrowserModuleWithMetadata(overflowEntryPath, { + adapter: overflowAdapter, + projectDir: overflowProjectDir, + projectId: "global-capacity-overflow", + importMapJson: "{}", + singleflightKey: "global-capacity-overflow", + }), + BrowserModuleBundleError, + ); + assertEquals((rejected as BrowserModuleBundleError).kind, "capacity"); + assertEquals(maximumActive, 8); + + release.resolve(); + await Promise.all(admitted); + assertEquals(calls, 40); + assertEquals(maximumActive, 8); + } finally { + release.resolve(); + if (previous) register("Bundler", previous); + else unregister("Bundler"); + } + }); + + it("defers requested snapshot metadata I/O until project admission is held", async () => { + const projectDir = "/snapshot-admission-project"; + const packagePath = `${projectDir}/package.json`; + const dependencies = { react: "19.2.4" }; + const requestedCacheKey = `on:${hashString(JSON.stringify(Object.entries(dependencies)))}`; + const adapter = createMockAdapter(); + const entryPaths = ["One", "Two", "Three"].map( + (name) => `${projectDir}/app/${name}.ts`, + ); + for (const entryPath of entryPaths) { + adapter.fs.files.set(entryPath, "export default 1;"); + } + adapter.fs.files.set(packagePath, JSON.stringify({ dependencies })); + const snapshotRead = adapter.fs.readFileSnapshotWithinLimit!; + const stat = adapter.fs.stat; + let packageReads = 0; + let packageStats = 0; + adapter.fs.readFileSnapshotWithinLimit = (path, root, limit) => { + if (path === packagePath) packageReads += 1; + return snapshotRead(path, root, limit); + }; + adapter.fs.stat = (path) => { + if (path === packagePath) packageStats += 1; + return stat(path); + }; + + const release = Promise.withResolvers(); + const twoStarted = Promise.withResolvers(); + let buildCalls = 0; + const previous = tryResolve("Bundler"); + const originalFlag = getHostEnv(DEPENDENCY_PINNING_ENV_FLAG); + register("Bundler", { + bundle: async () => { + buildCalls += 1; + if (buildCalls === 2) twoStarted.resolve(); + await release.promise; + return { + outputFiles: [{ + path: "out.js", + contents: new TextEncoder().encode("export default 1;"), + text: "export default 1;", + }], + warnings: [], + errors: [], + }; + }, + transform: () => Promise.resolve({ code: "", warnings: [] }), + }); + setEnv(DEPENDENCY_PINNING_ENV_FLAG, "1"); + clearReactVersionCache(); + + try { + const occupying = entryPaths.slice(0, 2).map((entryPath, index) => + bundleBrowserModuleWithMetadata(entryPath, { + adapter, + projectDir, + projectId: "snapshot-admission-project", + dependencyPinningCacheKey: "off", + importMapJson: "{}", + singleflightKey: `occupying-${index}`, + }) + ); + occupying.forEach((promise) => void promise.catch(() => undefined)); + await twoStarted.promise; + + const queued = bundleBrowserModuleWithMetadata(entryPaths[2]!, { + adapter, + projectDir, + projectId: "snapshot-admission-project", + requestedDependencyPinningCacheKey: requestedCacheKey, + dependencyPinningSource: { + projectDir, + fs: adapter.fs, + cacheNamespace: "snapshot-admission-project", + }, + singleflightKey: "queued-snapshot", + }); + void queued.catch(() => undefined); + await Promise.resolve(); + await Promise.resolve(); + + assertEquals(packageStats, 0); + assertEquals(packageReads, 0); + + release.resolve(); + await Promise.all([...occupying, queued]); + assertEquals(packageStats, 1); + assertEquals(packageReads, 1); + } finally { + release.resolve(); + setEnv(DEPENDENCY_PINNING_ENV_FLAG, originalFlag ?? ""); + clearReactVersionCache(); + if (previous) register("Bundler", previous); + else unregister("Bundler"); + } + }); + + it("counts project-lane waiters against the isolate-wide queue ceiling", async () => { + const release = Promise.withResolvers(); + const eightStarted = Promise.withResolvers(); + let calls = 0; + const previous = tryResolve("Bundler"); + register("Bundler", { + bundle: async () => { + calls += 1; + if (calls === 8) eightStarted.resolve(); + await release.promise; + return { + outputFiles: [{ + path: "out.js", + contents: new TextEncoder().encode("export default 1;"), + text: "export default 1;", + }], + warnings: [], + errors: [], + }; + }, + transform: () => Promise.resolve({ code: "", warnings: [] }), + }); + + try { + const admitted = Array.from( + { length: 4 }, + (_, projectIndex) => + Array.from({ length: 10 }, (_, operationIndex) => { + const projectDir = `/nested-global-capacity-${projectIndex}`; + const entryPath = `${projectDir}/app/Counter.ts`; + const adapter = createMockAdapter(); + adapter.fs.files.set(entryPath, "export default 1;"); + return bundleBrowserModuleWithMetadata(entryPath, { + adapter, + projectDir, + projectId: `nested-global-capacity-${projectIndex}`, + importMapJson: "{}", + singleflightKey: `operation-${operationIndex}`, + }); + }), + ).flat(); + admitted.forEach((promise) => void promise.catch(() => undefined)); + await eightStarted.promise; + + const overflowProjectDir = "/nested-global-capacity-overflow"; + const overflowEntryPath = `${overflowProjectDir}/app/Counter.ts`; + const overflowAdapter = createMockAdapter(); + overflowAdapter.fs.files.set(overflowEntryPath, "export default 1;"); + const rejected = await assertRejects( + () => + bundleBrowserModuleWithMetadata(overflowEntryPath, { + adapter: overflowAdapter, + projectDir: overflowProjectDir, + projectId: "nested-global-capacity-overflow", + importMapJson: "{}", + singleflightKey: "overflow", + }), + BrowserModuleBundleError, + ); + assertEquals((rejected as BrowserModuleBundleError).kind, "capacity"); + assertEquals(calls, 8); + + release.resolve(); + await Promise.all(admitted); + assertEquals(calls, 40); + } finally { + release.resolve(); + if (previous) register("Bundler", previous); + else unregister("Bundler"); + } + }); + + it("propagates request cancellation and a hard deadline into the bundler", async () => { + const projectDir = "/cancelled-project"; + const entryPath = `${projectDir}/app/Counter.ts`; + const adapter = createMockAdapter(); + adapter.fs.files.set(entryPath, "export default 1;"); + const started = Promise.withResolvers(); + const cancelled = Promise.withResolvers(); + const previous = tryResolve("Bundler"); + register("Bundler", { + bundle: (options) => + new Promise((_resolve, reject) => { + started.resolve(); + const onAbort = () => { + cancelled.resolve(options.signal?.reason); + reject(options.signal?.reason); + }; + options.signal?.addEventListener("abort", onAbort, { once: true }); + if (options.signal?.aborted) onAbort(); + }), + transform: () => Promise.resolve({ code: "", warnings: [] }), + }); + + try { + const controller = new AbortController(); + const bundling = bundleBrowserModuleWithMetadata(entryPath, { + adapter, + projectDir, + importMapJson: "{}", + signal: controller.signal, + singleflightKey: "cancel-me", + }); + await started.promise; + controller.abort(new DOMException("request cancelled", "AbortError")); + await assertRejects(() => bundling, DOMException); + const reason = await cancelled.promise; + assertEquals(reason instanceof DOMException, true); + + const deadlineError = await assertRejects( + () => + bundleBrowserModuleWithMetadata(entryPath, { + adapter, + projectDir, + importMapJson: "{}", + singleflightKey: "deadline", + limits: { maxDurationMs: 10 }, + }), + BrowserModuleBundleError, + ); + assertEquals((deadlineError as BrowserModuleBundleError).kind, "deadline"); + } finally { + if (previous) register("Bundler", previous); + else unregister("Bundler"); + } + }); + + it("cancels a bounded requested-snapshot metadata read without releasing its permit early", async () => { + const projectDir = "/cancelled-snapshot-project"; + const entryPath = `${projectDir}/app/Counter.ts`; + const packagePath = `${projectDir}/package.json`; + const dependencies = { react: "19.2.4" }; + const adapter = createMockAdapter(); + adapter.fs.files.set(entryPath, "export default 1;"); + adapter.fs.files.set(packagePath, JSON.stringify({ dependencies })); + const snapshotRead = adapter.fs.readFileSnapshotWithinLimit!; + const metadataStarted = Promise.withResolvers(); + const releaseMetadata = Promise.withResolvers(); + adapter.fs.readFileSnapshotWithinLimit = async (path, root, limit) => { + if (path === packagePath) { + metadataStarted.resolve(); + await releaseMetadata.promise; + } + return await snapshotRead(path, root, limit); + }; + const originalFlag = getHostEnv(DEPENDENCY_PINNING_ENV_FLAG); + setEnv(DEPENDENCY_PINNING_ENV_FLAG, "1"); + clearReactVersionCache(); + + try { + const controller = new AbortController(); + const bundling = bundleBrowserModuleWithMetadata(entryPath, { + adapter, + projectDir, + projectId: "cancelled-snapshot-project", + requestedDependencyPinningCacheKey: `on:${ + hashString(JSON.stringify(Object.entries(dependencies))) + }`, + dependencyPinningSource: { + projectDir, + fs: adapter.fs, + cacheNamespace: "cancelled-snapshot-project", + }, + signal: controller.signal, + }); + await metadataStarted.promise; + controller.abort(new DOMException("metadata cancelled", "AbortError")); + await assertRejects(() => bundling, DOMException, "metadata cancelled"); + + releaseMetadata.resolve(); + const next = await bundleBrowserModuleWithMetadata(entryPath, { + adapter, + projectDir, + projectId: "cancelled-snapshot-project", + requestedDependencyPinningCacheKey: `on:${ + hashString(JSON.stringify(Object.entries(dependencies))) + }`, + dependencyPinningSource: { + projectDir, + fs: adapter.fs, + cacheNamespace: "cancelled-snapshot-project-next", + }, + }); + assertEquals(next.dependencyPinningCacheKey?.startsWith("on:"), true); + } finally { + releaseMetadata.resolve(); + setEnv(DEPENDENCY_PINNING_ENV_FLAG, originalFlag ?? ""); + clearReactVersionCache(); + } + }); + it("uses only project-relative identities for source files and spans", () => { assertEquals( getSafeBrowserModuleIdentity( diff --git a/src/server/shared/browser-module-bundler.ts b/src/server/shared/browser-module-bundler.ts index 24f88ffa24..8bc057d2b2 100644 --- a/src/server/shared/browser-module-bundler.ts +++ b/src/server/shared/browser-module-bundler.ts @@ -9,14 +9,111 @@ import { createBareExternalPlugin, createHttpExternalPlugin, createRelativeFsPlugin, - inspectBrowserModulePath, } from "#veryfront/server/handlers/dev/files/esbuild-plugins.ts"; import { describeBrowserModuleBoundaryViolation, inspectBrowserModuleBoundary, } from "./browser-module-boundary.ts"; import { computeHash } from "#veryfront/utils/hash-utils.ts"; -import type { DependencyPinningSourceInput } from "#veryfront/transforms/esm/package-registry.ts"; +import type { + DependencyPinningSource, + DependencyPinningSourceInput, +} from "#veryfront/transforms/esm/package-registry.ts"; +import { resolveRequestedDependencyPinningSnapshot } from "#veryfront/transforms/esm/package-registry.ts"; +import { PermitSemaphore } from "#veryfront/utils/permit-semaphore.ts"; +import { waitForSharedPromise } from "#veryfront/utils/singleflight.ts"; +import { createAbortError, throwIfAborted } from "#veryfront/utils/abort.ts"; +import { utf8ByteLength } from "#veryfront/utils/utf8-byte-length.ts"; +import { + captureFileSystemCapabilities, + captureSnapshotReadCapability, + copyFixedUint8ArrayWithinLimit, + getFixedUint8ArrayByteLength, +} from "#veryfront/platform/adapters/file-system-capabilities.ts"; +import { + isNativeErrorWithoutHooks, + readNativeErrorNameWithoutHooks, +} from "#veryfront/platform/compat/error-introspection.ts"; +import { isCanonicalNotFoundError } from "#veryfront/platform/compat/not-found-error.ts"; +import { + hasClientFileName, + hasUseClientDirective, + hasUseServerDirective, +} from "#veryfront/rendering/rsc/page-island.ts"; + +export interface BrowserModuleBundleLimits { + maxDependencies: number; + maxAggregateInputBytes: number; + maxOutputBytes: number; + maxResolutionProbes: number; + maxDurationMs: number; + maxConcurrentPerIdentity: number; + maxQueuedPerIdentity: number; +} + +export type BrowserModuleBundleLimitOverrides = Partial< + Pick< + BrowserModuleBundleLimits, + | "maxDependencies" + | "maxAggregateInputBytes" + | "maxOutputBytes" + | "maxResolutionProbes" + | "maxDurationMs" + > +>; + +/** Hard production ceilings for request-triggered browser compilation. */ +export const DEFAULT_BROWSER_MODULE_BUNDLE_LIMITS: Readonly = Object + .freeze({ + maxDependencies: 1_000, + maxAggregateInputBytes: 16 * 1024 * 1024, + maxOutputBytes: 16 * 1024 * 1024, + maxResolutionProbes: 10_000, + maxDurationMs: 10_000, + maxConcurrentPerIdentity: 2, + maxQueuedPerIdentity: 8, + }); + +/** Isolate-wide ceiling that cannot be raised by a project or caller. */ +export const MAX_CONCURRENT_BROWSER_MODULE_BUNDLES = 8; +/** Isolate-wide queue ceiling that cannot be raised by a project or caller. */ +export const MAX_QUEUED_BROWSER_MODULE_BUNDLES = 32; + +export type BrowserModuleBundleFailureKind = "capacity" | "deadline" | "limit"; + +export class BrowserModuleBundleError extends Error { + constructor( + readonly kind: BrowserModuleBundleFailureKind, + message: string, + ) { + super(message); + this.name = "BrowserModuleBundleError"; + } +} + +/** Server-only syntax found while validating an otherwise admitted browser entry. */ +export class BrowserModuleBoundaryError extends Error { + constructor(message: string) { + super(message); + this.name = "BrowserModuleBoundaryError"; + } +} + +/** Browser endpoint entry rejection that is safe to surface as not-found. */ +export class BrowserModuleEntryRejectedError extends Error { + constructor(cause?: unknown) { + super("Browser module entry is not an admitted client boundary", { cause }); + this.name = "BrowserModuleEntryRejectedError"; + } +} + +/** Requested dependency snapshot rejection that callers surface as a conflict. */ +export class BrowserModuleDependencySnapshotError extends Error { + constructor() { + super("Unknown dependency snapshot"); + this.name = "BrowserModuleDependencySnapshotError"; + } +} function createIgnoreCSSImportsPlugin(): Plugin { return { @@ -46,6 +143,20 @@ export interface BrowserModuleBundlerOptions { dependencyPinningCacheKey?: string; dependencyPinningDependencies?: Readonly>; dependencyPinningSource?: DependencyPinningSourceInput; + /** Resolve this request token only after browser-bundle admission is held. */ + requestedDependencyPinningCacheKey?: string; + /** Caller cancellation is propagated into the bundler implementation. */ + signal?: AbortSignal; + /** Stable request identity used to coalesce equivalent concurrent bundles. */ + singleflightKey?: string; + /** Already-admitted entry snapshot. Avoids a second mutable filesystem read. */ + entrySource?: string; + /** Content-derived identity required whenever entrySource is supplied. */ + entrySourceKey?: string; + /** Require an explicit directive or `.client` filename on the entry source. */ + requireClientBoundary?: boolean; + /** Optional tightening of the hard limits. Values above the defaults are rejected. */ + limits?: BrowserModuleBundleLimitOverrides; } export function getSafeBrowserModuleIdentity(absPath: string, projectDir: string): string { @@ -61,41 +172,277 @@ export interface BrowserModuleBundle { source: string; contentHash: string; importMapHash: string; - dependencies: ReadonlyArray<{ path: string; contentHash: string }>; + dependencyPinningCacheKey?: string; + dependencyPinningDependencies?: Readonly>; + dependencies: ReadonlyArray<{ path: string; contentHash: string; byteLength: number }>; resolutionProbes: ReadonlyArray<{ path: string; state: ResolutionProbeState }>; } +export type BrowserModuleImportMapValidationOptions = Pick< + BrowserModuleBundlerOptions, + | "config" + | "moduleServerOrigin" + | "dependencyPinningCacheKey" + | "dependencyPinningDependencies" + | "dependencyPinningSource" +>; + +export interface BrowserModuleBundleValidationOptions extends + Pick< + BrowserModuleBundlerOptions, + "adapter" | "projectDir" | "signal" | "limits" + > { + /** Rebuild and compare the effective import map through the same bounded reader. */ + importMap?: BrowserModuleImportMapValidationOptions; +} + interface TrackingAdapterResult { adapter: RuntimeAdapter; contents: Map; probes: Map; + readSource(path: string): Promise; + readMetadataSource(path: string): Promise; + admitSource(path: string, content: string): void; + chargeText(content: string): number; + getFailure(): BrowserModuleBundleError | undefined; +} + +interface AdmittedText { + content: string; + byteLength: number; +} + +const apply = Reflect.apply; +const strictUtf8Decoder = new TextDecoder("utf-8", { fatal: true }); +const decodeUtf8 = TextDecoder.prototype.decode; + +function isNativeRangeError(value: unknown): boolean { + return isNativeErrorWithoutHooks(value) && + readNativeErrorNameWithoutHooks(value) === "RangeError"; +} + +function isNativeTypeError(value: unknown): boolean { + return isNativeErrorWithoutHooks(value) && + readNativeErrorNameWithoutHooks(value) === "TypeError"; +} + +function decodeBrowserModuleSource(bytes: Uint8Array): string { + try { + return apply(decodeUtf8, strictUtf8Decoder, [bytes]) as string; + } catch (cause) { + throw new TypeError("Browser module source must contain valid UTF-8", { cause }); + } } -function createTrackingAdapter(adapter: RuntimeAdapter): TrackingAdapterResult { +/** + * Capture stable filesystem authority once. Native filesystems must provide a + * root-bound no-follow snapshot read. A virtual filesystem may instead make + * an own, immutable-in-contract declaration that it cannot traverse links and + * provide a genuine exact bounded reader. + */ +function createBrowserModuleSourceReader( + adapter: RuntimeAdapter, + projectDir: string, +): (path: string, maximumBytes: number) => Promise { + const snapshot = captureSnapshotReadCapability( + adapter.fs, + "Browser module filesystem", + ); + if (snapshot) { + return async (path: string, maximumBytes: number): Promise => { + const bytes = await snapshot.read(path, projectDir, maximumBytes); + return { + content: decodeBrowserModuleSource(bytes), + byteLength: getFixedUint8ArrayByteLength(bytes, "Browser module source"), + }; + }; + } + + const semantics = Object.getOwnPropertyDescriptor(adapter.fs, "symlinkSemantics"); + if (semantics && "value" in semantics && semantics.value === "none") { + const bounded = captureFileSystemCapabilities( + adapter.fs, + "Browser module filesystem", + "bounded-text", + ); + if (!bounded.readFileBytesWithinLimit && !bounded.wholeFileReader) { + throw new TypeError( + "Link-free browser module filesystem requires an exact bounded reader", + ); + } + return async (path: string, maximumBytes: number): Promise => { + const bytes = bounded.readFileBytesWithinLimit + ? await bounded.readFileBytesWithinLimit(path, maximumBytes) + : bounded.wholeFileReader && bounded.wholeFileReader.maximumBytes <= maximumBytes + ? copyFixedUint8ArrayWithinLimit( + await bounded.wholeFileReader.read(path), + maximumBytes, + "Browser module source", + ) + : (() => { + throw new TypeError("Browser module source requires an exact bounded reader"); + })(); + return { + content: decodeBrowserModuleSource(bytes), + byteLength: getFixedUint8ArrayByteLength(bytes, "Browser module source"), + }; + }; + } + + throw new TypeError( + "Browser module filesystem requires a stable bounded snapshot reader", + ); +} + +function requirePositiveSafeInteger(value: number, name: string): number { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new RangeError(`${name} must be a positive safe integer`); + } + return value; +} + +function resolveLimits( + overrides: BrowserModuleBundleLimitOverrides | undefined, +): BrowserModuleBundleLimits { + const resolved = { ...DEFAULT_BROWSER_MODULE_BUNDLE_LIMITS, ...overrides }; + for (const [name, value] of Object.entries(resolved)) { + requirePositiveSafeInteger(value, `Browser module bundle limit ${name}`); + const hardMaximum = DEFAULT_BROWSER_MODULE_BUNDLE_LIMITS[ + name as keyof BrowserModuleBundleLimits + ]; + if (value > hardMaximum) { + throw new RangeError( + `Browser module bundle limit ${name} cannot exceed the production ceiling`, + ); + } + if ( + (name === "maxConcurrentPerIdentity" || name === "maxQueuedPerIdentity") && + value !== hardMaximum + ) { + throw new RangeError( + `Browser module bundle admission limit ${name} is process-owned and cannot be overridden`, + ); + } + } + return resolved; +} + +function createTrackingAdapter( + adapter: RuntimeAdapter, + projectDir: string, + limits: BrowserModuleBundleLimits, + signal: AbortSignal, +): TrackingAdapterResult { const contents = new Map(); const probes = new Map(); + const dependencyPaths = new Set(); + const probePaths = new Set(); + const sourceReads = new Map>(); + const metadataReads = new Map>(); + const statReads = new Map>>>(); + const readBoundedSource = createBrowserModuleSourceReader(adapter, projectDir); + let sourceReadTail = Promise.resolve(); + let failure: BrowserModuleBundleError | undefined; + const fail = (message: string): never => { + failure ??= new BrowserModuleBundleError("limit", message); + throw failure; + }; + let aggregateInputBytes = 0; + const chargeText = (content: string): number => { + const remaining = limits.maxAggregateInputBytes - aggregateInputBytes; + const contentBytes = utf8ByteLength(content, remaining); + if (contentBytes > remaining) { + fail("Browser module bundle input exceeds the aggregate byte limit"); + } + aggregateInputBytes += contentBytes; + return contentBytes; + }; + const reserveDependency = (path: string): void => { + if (dependencyPaths.has(path)) return; + if (dependencyPaths.size >= limits.maxDependencies) { + fail("Browser module bundle exceeds the dependency limit"); + } + dependencyPaths.add(path); + }; + const admitSource = (path: string, content: string): void => { + reserveDependency(path); + if (contents.has(path)) return; + chargeText(content); + contents.set(path, content); + sourceReads.set(path, Promise.resolve(content)); + }; + const readAccountedSource = ( + path: string, + kind: "module" | "metadata", + ): Promise => { + const reads = kind === "module" ? sourceReads : metadataReads; + const existing = reads.get(path); + if (existing) return existing; + if (kind === "module") reserveDependency(path); + const reading = sourceReadTail.then(async () => { + throwIfAborted(signal); + const remaining = limits.maxAggregateInputBytes - aggregateInputBytes; + if (remaining <= 0) { + fail("Browser module bundle input exceeds the aggregate byte limit"); + } + let admitted: AdmittedText; + try { + admitted = await readBoundedSource(path, remaining); + } catch (error) { + if (isNativeRangeError(error)) { + fail("Browser module bundle input exceeds the aggregate byte limit"); + } + throw error; + } + throwIfAborted(signal); + aggregateInputBytes += admitted.byteLength; + if (kind === "module") contents.set(path, admitted.content); + return admitted.content; + }); + sourceReadTail = reading.then( + () => undefined, + () => undefined, + ); + reads.set(path, reading); + return reading; + }; + const readSource = (path: string): Promise => readAccountedSource(path, "module"); + const readMetadataSource = (path: string): Promise => + readAccountedSource(path, "metadata"); const trackedFs = new Proxy(adapter.fs, { get(target, property, receiver) { if (property === "readFile") { - return async (path: string) => { - const content = await target.readFile(path); - contents.set(path, content); - return content; - }; + return readSource; } if (property === "stat") { - return async (path: string) => { - try { - const info = await target.stat(path); - probes.set( - path, - info.isFile ? "file" : info.isDirectory ? "directory" : "other", - ); - return info; - } catch (error) { - probes.set(path, "missing"); - throw error; + return (path: string) => { + const existing = statReads.get(path); + if (existing) return existing; + throwIfAborted(signal); + if (!probePaths.has(path)) { + if (probePaths.size >= limits.maxResolutionProbes) { + fail("Browser module bundle exceeds the resolution probe limit"); + } + probePaths.add(path); } + const reading = (async () => { + try { + const info = await target.stat(path); + throwIfAborted(signal); + probes.set( + path, + info.isFile ? "file" : info.isDirectory ? "directory" : "other", + ); + return info; + } catch (error) { + if (isCanonicalNotFoundError(error)) { + probes.set(path, "missing"); + } + throw error; + } + })(); + statReads.set(path, reading); + return reading; }; } @@ -111,7 +458,298 @@ function createTrackingAdapter(adapter: RuntimeAdapter): TrackingAdapterResult { }, }); - return { adapter: trackedAdapter, contents, probes }; + return { + adapter: trackedAdapter, + contents, + probes, + readSource, + readMetadataSource, + admitSource, + chargeText, + getFailure: () => failure, + }; +} + +function createTrackedDependencyPinningSource( + source: DependencyPinningSourceInput, + projectDir: string, + tracked: TrackingAdapterResult, +): DependencyPinningSource { + const objectSource = typeof source === "object" && source !== null ? source : undefined; + const sourceProjectDir = objectSource + ? objectSource.projectDir + : typeof source === "string" + ? source + : projectDir; + const assertMetadataPath = (path: string): void => { + if (!isWithinDirectory(projectDir, path)) { + throw new TypeError("Browser module metadata path is not trusted"); + } + }; + + return Object.freeze({ + ...(objectSource ?? {}), + projectDir: sourceProjectDir, + fs: Object.freeze({ + stat: (path: string) => { + assertMetadataPath(path); + return tracked.adapter.fs.stat(path); + }, + readFile: (path: string) => { + assertMetadataPath(path); + return tracked.readMetadataSource(path); + }, + }), + }); +} + +async function buildTrackedImportMapJson( + options: Pick< + BrowserModuleBundlerOptions, + | "projectDir" + | "config" + | "moduleServerOrigin" + | "dependencyPinningCacheKey" + | "dependencyPinningDependencies" + | "dependencyPinningSource" + >, + tracked: TrackingAdapterResult, + signal: AbortSignal, + trackedDependencyPinningSource?: DependencyPinningSource, +): Promise { + const dependencyPinningSource = trackedDependencyPinningSource ?? + createTrackedDependencyPinningSource( + options.dependencyPinningSource, + options.projectDir, + tracked, + ); + const importMapJson = await buildImportMapJson({ + projectDir: options.projectDir, + config: options.config, + moduleServerOrigin: options.moduleServerOrigin, + dependencyPinningCacheKey: options.dependencyPinningCacheKey, + dependencyPinningDependencies: options.dependencyPinningDependencies, + dependencyPinningSource, + }); + const trackedFailure = tracked.getFailure(); + if (trackedFailure) throw trackedFailure; + throwIfAborted(signal); + tracked.chargeText(importMapJson); + return importMapJson; +} + +interface BundleFlight { + controller: AbortController; + promise: Promise; + waiters: number; + settled: boolean; +} + +interface BundleLane { + semaphore: PermitSemaphore; + flights: Map; + participants: number; +} + +const bundleLanes = new Map(); +// Reserve one host-owned participant slot before a request may wait in any +// project lane. This makes the advertised 8 active + 32 queued ceiling true +// across the whole isolate instead of allowing every project to accumulate a +// private queue outside the host bound. +const globalBundleParticipants = new PermitSemaphore( + MAX_CONCURRENT_BROWSER_MODULE_BUNDLES + MAX_QUEUED_BROWSER_MODULE_BUNDLES, + { maxQueueSize: 0 }, +); +const globalBundleAdmission = new PermitSemaphore(MAX_CONCURRENT_BROWSER_MODULE_BUNDLES, { + maxQueueSize: MAX_QUEUED_BROWSER_MODULE_BUNDLES, +}); +const objectIdentities = new WeakMap(); +let nextObjectIdentity = 1; + +function getObjectIdentity(value: object): number { + let identity = objectIdentities.get(value); + if (identity === undefined) { + identity = nextObjectIdentity++; + objectIdentities.set(value, identity); + } + return identity; +} + +function getBundleLane( + identity: string, + limits: BrowserModuleBundleLimits, +): { key: string; lane: BundleLane } { + const key = identity; + let lane = bundleLanes.get(key); + if (!lane) { + lane = { + semaphore: new PermitSemaphore(limits.maxConcurrentPerIdentity, { + maxQueueSize: limits.maxQueuedPerIdentity, + }), + flights: new Map(), + participants: 0, + }; + bundleLanes.set(key, lane); + } + return { key, lane }; +} + +function releaseBundleLane(key: string, lane: BundleLane): void { + if ( + lane.participants === 0 && + lane.flights.size === 0 && + lane.semaphore.available === lane.semaphore.capacity && + lane.semaphore.waiting === 0 && + bundleLanes.get(key) === lane + ) { + bundleLanes.delete(key); + } +} + +function createBundleDeadline( + parentSignal: AbortSignal | undefined, + timeoutMs: number, +): { signal: AbortSignal; dispose(): void } { + const controller = new AbortController(); + const onParentAbort = (): void => controller.abort(createAbortError(parentSignal?.reason)); + if (parentSignal?.aborted) onParentAbort(); + else parentSignal?.addEventListener("abort", onParentAbort, { once: true }); + + const timeoutId = setTimeout(() => { + controller.abort( + new BrowserModuleBundleError("deadline", "Browser module bundle deadline exceeded"), + ); + }, timeoutMs); + + return { + signal: controller.signal, + dispose() { + clearTimeout(timeoutId); + parentSignal?.removeEventListener("abort", onParentAbort); + }, + }; +} + +async function runWithBundleAdmission( + options: BrowserModuleBundlerOptions, + limits: BrowserModuleBundleLimits, + operationIdentity: string, + operation: (signal: AbortSignal) => Promise, +): Promise { + throwIfAborted(options.signal); + const identity = options.projectId ?? options.projectSlug ?? options.projectDir; + const { key: laneKey, lane } = getBundleLane(identity, limits); + lane.participants += 1; + + const execute = async (parentSignal: AbortSignal | undefined): Promise => { + const deadline = createBundleDeadline(parentSignal, limits.maxDurationMs); + let participantAcquired = false; + let projectAcquired = false; + let globalAcquired = false; + let releaseWhenSettled = false; + const releaseAdmission = (): void => { + if (globalAcquired) { + globalAcquired = false; + globalBundleAdmission.release(); + } + if (projectAcquired) { + projectAcquired = false; + lane.semaphore.release(); + } + if (participantAcquired) { + participantAcquired = false; + globalBundleParticipants.release(); + } + releaseBundleLane(laneKey, lane); + }; + try { + participantAcquired = await globalBundleParticipants.tryAcquire(0, { + signal: deadline.signal, + }); + if (!participantAcquired) { + throw new BrowserModuleBundleError( + "capacity", + "Browser module bundle host capacity is exhausted", + ); + } + projectAcquired = await lane.semaphore.tryAcquire(Number.POSITIVE_INFINITY, { + signal: deadline.signal, + }); + if (!projectAcquired) { + throw new BrowserModuleBundleError( + "capacity", + "Browser module bundle project capacity is exhausted", + ); + } + globalAcquired = await globalBundleAdmission.tryAcquire(Number.POSITIVE_INFINITY, { + signal: deadline.signal, + }); + if (!globalAcquired) { + throw new BrowserModuleBundleError( + "capacity", + "Browser module bundle host capacity is exhausted", + ); + } + + const running = operation(deadline.signal); + // A client receives its deadline promptly, while the underlying permit is + // retained until non-abortable adapter work actually settles. This keeps + // legacy transports bounded instead of detaching unlimited background I/O. + void running.then(releaseAdmission, releaseAdmission); + releaseWhenSettled = true; + return await waitForSharedPromise(running, deadline.signal); + } finally { + deadline.dispose(); + if (!releaseWhenSettled) releaseAdmission(); + } + }; + + try { + if (!options.singleflightKey) return await execute(options.signal); + + const flightKey = [ + options.singleflightKey, + operationIdentity, + getObjectIdentity(options.adapter), + options.projectDir, + options.config ? getObjectIdentity(options.config) : "no-config", + ...Object.values(limits), + ].join("\0"); + let flight = lane.flights.get(flightKey); + if (!flight) { + const controller = new AbortController(); + const promise = execute(controller.signal); + flight = { + controller, + promise, + waiters: 0, + settled: false, + }; + lane.flights.set(flightKey, flight); + const settleFlight = (): void => { + flight!.settled = true; + if (lane.flights.get(flightKey) === flight) lane.flights.delete(flightKey); + releaseBundleLane(laneKey, lane); + }; + void flight.promise.then( + settleFlight, + settleFlight, + ); + } + + flight.waiters += 1; + try { + return await waitForSharedPromise(flight.promise, options.signal); + } finally { + flight.waiters -= 1; + if (flight.waiters === 0 && !flight.settled) { + flight.controller.abort(createAbortError(options.signal?.reason)); + } + } + } finally { + lane.participants -= 1; + releaseBundleLane(laneKey, lane); + } } export function bundleBrowserModule( @@ -125,121 +763,260 @@ export function bundleBrowserModuleWithMetadata( absPath: string, options: BrowserModuleBundlerOptions, ): Promise { - return withSpan( - "server.browser-module.bundle", - async () => { - const tracked = createTrackingAdapter(options.adapter); - const entryPathStatus = await inspectBrowserModulePath( - options.projectDir, - absPath, - tracked.adapter, - ); - if (entryPathStatus !== "trusted") { - throw new Error("Browser module entry path is not trusted"); - } + const limits = resolveLimits(options.limits); + if ( + options.entrySource !== undefined && + (typeof options.entrySourceKey !== "string" || options.entrySourceKey.length === 0) + ) { + return Promise.reject( + new TypeError("Browser module entrySource requires a content-derived entrySourceKey"), + ); + } + if ( + options.requestedDependencyPinningCacheKey !== undefined && + (options.dependencyPinningCacheKey !== undefined || + options.dependencyPinningDependencies !== undefined) + ) { + return Promise.reject( + new TypeError( + "Browser module requested dependency snapshot cannot be combined with resolved pins", + ), + ); + } + const operationIdentity = options.entrySourceKey === undefined + ? absPath + : `${absPath}\0${options.entrySourceKey}`; + return runWithBundleAdmission(options, limits, operationIdentity, (signal) => + withSpan( + "server.browser-module.bundle", + async () => { + throwIfAborted(signal); + if (!isWithinDirectory(options.projectDir, absPath)) { + throw new Error("Browser module entry path is not trusted"); + } + const tracked = createTrackingAdapter( + options.adapter, + options.projectDir, + limits, + signal, + ); + const dependencyPinningSource = createTrackedDependencyPinningSource( + options.dependencyPinningSource, + options.projectDir, + tracked, + ); + const dependencySnapshot = options.requestedDependencyPinningCacheKey === undefined + ? undefined + : await resolveRequestedDependencyPinningSnapshot( + dependencyPinningSource, + options.requestedDependencyPinningCacheKey, + ); + const dependencySnapshotReadFailure = tracked.getFailure(); + if (dependencySnapshotReadFailure) throw dependencySnapshotReadFailure; + if ( + options.requestedDependencyPinningCacheKey !== undefined && + (!dependencySnapshot || + dependencySnapshot.cacheKey !== options.requestedDependencyPinningCacheKey) + ) { + throw new BrowserModuleDependencySnapshotError(); + } + const dependencyPinningCacheKey = dependencySnapshot?.cacheKey ?? + options.dependencyPinningCacheKey; + const dependencyPinningDependencies = dependencySnapshot?.dependencies ?? + options.dependencyPinningDependencies; + const effectiveOptions: BrowserModuleBundlerOptions = { + ...options, + dependencyPinningCacheKey, + dependencyPinningDependencies, + dependencyPinningSource, + }; - const { build } = await import("veryfront/extensions/bundler"); - const src = await tracked.adapter.fs.readFile(absPath); - const boundaryViolation = await inspectBrowserModuleBoundary(src, absPath); - if (boundaryViolation) { - throw new Error(describeBrowserModuleBoundaryViolation(boundaryViolation)); - } - const importMapJson = options.importMapJson ?? await buildImportMapJson({ - projectDir: options.projectDir, - config: options.config, - moduleServerOrigin: options.moduleServerOrigin, - dependencyPinningCacheKey: options.dependencyPinningCacheKey, - dependencyPinningDependencies: options.dependencyPinningDependencies, - dependencyPinningSource: options.dependencyPinningSource, - }); - const importMap = JSON.parse(importMapJson) as { imports?: Record }; - - const { outputFiles } = await build({ - bundle: true, - write: false, - format: "esm", - platform: "browser", - target: "es2022", - jsx: "automatic", - jsxImportSource: "react", - external: ["react", "react-dom", "react/jsx-runtime", "react/jsx-dev-runtime"], - stdin: { - contents: src, - loader: getEsbuildLoader(absPath), - resolveDir: getDirectory(absPath), - sourcefile: getSafeBrowserModuleIdentity(absPath, options.projectDir), - }, - plugins: [ - createIgnoreCSSImportsPlugin(), - createRelativeFsPlugin(options.projectDir, tracked.adapter, { - enforceBrowserBoundaries: true, - }), - createBareExternalPlugin({ - importMapImports: importMap.imports, - projectDir: options.projectDir, - projectId: options.projectId ?? options.projectSlug, - dependencyPinningCacheKey: options.dependencyPinningCacheKey, - dependencyPinningDependencies: options.dependencyPinningDependencies, - dependencyPinningSource: options.dependencyPinningSource, - }), - createHttpExternalPlugin({ - moduleServerOrigin: options.moduleServerOrigin, - dependencyPinningCacheKey: options.dependencyPinningCacheKey, - }), - ], - }); + const { build } = await import("veryfront/extensions/bundler"); + if (options.entrySource !== undefined) { + tracked.admitSource(absPath, options.entrySource); + } + let src: string; + try { + src = options.entrySource ?? await tracked.readSource(absPath); + } catch (error) { + if ( + options.requireClientBoundary && + (isCanonicalNotFoundError(error) || isNativeTypeError(error)) + ) { + throw new BrowserModuleEntryRejectedError(error); + } + throw error; + } + if ( + options.requireClientBoundary && + ( + (!hasUseClientDirective(src, absPath) && !hasClientFileName(absPath)) || + hasUseServerDirective(src) + ) + ) { + throw new BrowserModuleEntryRejectedError(); + } + const boundaryViolation = await inspectBrowserModuleBoundary(src, absPath); + if (boundaryViolation) { + throw new BrowserModuleBoundaryError( + describeBrowserModuleBoundaryViolation(boundaryViolation), + ); + } + const importMapJson = options.importMapJson === undefined + ? await buildTrackedImportMapJson( + effectiveOptions, + tracked, + signal, + dependencyPinningSource, + ) + : options.importMapJson; + if (options.importMapJson !== undefined) tracked.chargeText(importMapJson); + const importMap = JSON.parse(importMapJson) as { imports?: Record }; - const output = outputFiles?.[0]; - if (!output) { - throw new Error("Browser module bundler produced no output"); - } - const source = output.text; - const dependencies = await Promise.all( - [...tracked.contents.entries()] - .sort(([left], [right]) => left.localeCompare(right)) - .map(async ([path, content]) => ({ - path, - contentHash: await computeHash(content), - })), - ); - const resolutionProbes = [...tracked.probes.entries()] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([path, state]) => ({ path, state })); + let outputFiles; + try { + ({ outputFiles } = await build({ + bundle: true, + write: false, + format: "esm", + platform: "browser", + target: "es2022", + jsx: "automatic", + jsxImportSource: "react", + external: ["react", "react-dom", "react/jsx-runtime", "react/jsx-dev-runtime"], + stdin: { + contents: src, + loader: getEsbuildLoader(absPath), + resolveDir: getDirectory(absPath), + sourcefile: getSafeBrowserModuleIdentity(absPath, options.projectDir), + }, + plugins: [ + createIgnoreCSSImportsPlugin(), + createRelativeFsPlugin(options.projectDir, tracked.adapter, { + enforceBrowserBoundaries: true, + readBrowserModule: tracked.readSource, + }), + createBareExternalPlugin({ + importMapImports: importMap.imports, + projectDir: options.projectDir, + projectId: options.projectId ?? options.projectSlug, + dependencyPinningCacheKey, + dependencyPinningDependencies, + dependencyPinningSource, + }), + createHttpExternalPlugin({ + moduleServerOrigin: options.moduleServerOrigin, + dependencyPinningCacheKey, + }), + ], + signal, + })); + } catch (error) { + const trackedFailure = tracked.getFailure(); + if (trackedFailure) throw trackedFailure; + throw error; + } + throwIfAborted(signal); - return { - source, - contentHash: await computeHash(source), - importMapHash: await computeHash(importMapJson), - dependencies, - resolutionProbes, - }; - }, - { - "bundle.filePath": getSafeBrowserModuleIdentity(absPath, options.projectDir), - "bundle.projectSlug": options.projectSlug ?? "unknown", - }, - ); + const output = outputFiles?.[0]; + if (!output) { + throw new Error("Browser module bundler produced no output"); + } + let remainingOutputBytes = limits.maxOutputBytes; + for (const file of outputFiles) { + if (file.contents.byteLength > remainingOutputBytes) { + throw new BrowserModuleBundleError( + "limit", + "Browser module bundle output exceeds the byte limit", + ); + } + remainingOutputBytes -= file.contents.byteLength; + } + const source = output.text; + const dependencies = Object.freeze( + await Promise.all( + [...tracked.contents.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(async ([path, content]) => + Object.freeze({ + path, + contentHash: await computeHash(content), + byteLength: utf8ByteLength(content), + }) + ), + ), + ); + const resolutionProbes = Object.freeze( + [...tracked.probes.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([path, state]) => Object.freeze({ path, state })), + ); + throwIfAborted(signal); + + const contentHash = await computeHash(source); + throwIfAborted(signal); + const importMapHash = await computeHash(importMapJson); + throwIfAborted(signal); + return Object.freeze({ + source, + contentHash, + importMapHash, + dependencyPinningCacheKey, + dependencyPinningDependencies, + dependencies, + resolutionProbes, + }); + }, + { + "bundle.filePath": getSafeBrowserModuleIdentity(absPath, options.projectDir), + "bundle.projectSlug": options.projectSlug ?? "unknown", + }, + )); } export async function validateBrowserModuleBundle( bundle: BrowserModuleBundle, - options: Pick, + options: BrowserModuleBundleValidationOptions, ): Promise { + const limits = resolveLimits(options.limits); + if ( + bundle.dependencies.length > limits.maxDependencies || + bundle.resolutionProbes.length > limits.maxResolutionProbes + ) { + return false; + } + let admittedBytes = 0; + for (const dependency of bundle.dependencies) { + if (!Number.isSafeInteger(dependency.byteLength) || dependency.byteLength < 0) return false; + admittedBytes += dependency.byteLength; + if (admittedBytes > limits.maxAggregateInputBytes) return false; + } + const signal = options.signal ?? new AbortController().signal; + let tracked: TrackingAdapterResult; + try { + throwIfAborted(signal); + tracked = createTrackingAdapter(options.adapter, options.projectDir, limits, signal); + if (options.importMap) { + const importMapJson = await buildTrackedImportMapJson( + { projectDir: options.projectDir, ...options.importMap }, + tracked, + signal, + ); + if (await computeHash(importMapJson) !== bundle.importMapHash) return false; + } + } catch { + throwIfAborted(signal); + return false; + } for (const dependency of bundle.dependencies) { if (!isWithinDirectory(options.projectDir, dependency.path)) return false; - if ( - await inspectBrowserModulePath(options.projectDir, dependency.path, options.adapter) !== - "trusted" - ) return false; try { - if ( - await computeHash(await options.adapter.fs.readFile(dependency.path)) !== - dependency.contentHash - ) { + if (await computeHash(await tracked.readSource(dependency.path)) !== dependency.contentHash) { return false; } } catch { + throwIfAborted(signal); return false; } } @@ -248,9 +1025,11 @@ export async function validateBrowserModuleBundle( if (!isWithinDirectory(options.projectDir, probe.path)) return false; let currentState: ResolutionProbeState; try { - const info = await options.adapter.fs.stat(probe.path); + const info = await tracked.adapter.fs.stat(probe.path); currentState = info.isFile ? "file" : info.isDirectory ? "directory" : "other"; - } catch { + } catch (error) { + throwIfAborted(signal); + if (!isCanonicalNotFoundError(error)) return false; currentState = "missing"; } if (currentState !== probe.state) return false; diff --git a/src/server/utils/proxy-trust.test.ts b/src/server/utils/proxy-trust.test.ts index 7673b5792b..7ae4919f39 100644 --- a/src/server/utils/proxy-trust.test.ts +++ b/src/server/utils/proxy-trust.test.ts @@ -80,12 +80,12 @@ describe("server/utils/proxy-trust", () => { assertEquals(await isProxyTrusted(req), false); }); - it("returns true for a validly signed, fresh dispatch JWS", async () => { + it("does not promote a valid dispatch JWS to generic proxy trust", async () => { const { jws, publicKeyPem } = await mintDispatchJws(); const req = new Request("http://example.com/", { headers: { "x-veryfront-dispatch-jws": jws }, }); - assertEquals(await isProxyTrusted(req, { publicKeyPem }), true); + assertEquals(await isProxyTrusted(req, { publicKeyPem }), false); }); it("returns false when a dispatch JWS is present but no public key is configured", async () => { @@ -176,12 +176,12 @@ describe("server/utils/proxy-trust", () => { assertEquals(await isProxyTrusted(req, { publicKeyPem }), false); }); - it("is case-insensitive on the dispatch JWS header name", async () => { + it("does not trust differently-cased dispatch JWS headers", async () => { const { jws, publicKeyPem } = await mintDispatchJws(); const req = new Request("http://example.com/", { headers: { "X-Veryfront-Dispatch-JWS": jws }, }); - assertEquals(await isProxyTrusted(req, { publicKeyPem }), true); + assertEquals(await isProxyTrusted(req, { publicKeyPem }), false); }); it('returns true when VERYFRONT_TRUST_FORWARDED_HEADERS === "1"', async () => { diff --git a/src/server/utils/proxy-trust.ts b/src/server/utils/proxy-trust.ts index 4690e036a0..edc26f13b4 100644 --- a/src/server/utils/proxy-trust.ts +++ b/src/server/utils/proxy-trust.ts @@ -6,51 +6,29 @@ * Any other treatment lets an attacker reaching the runtime directly spoof the * origin host or point project discovery at arbitrary filesystem paths. * - * A request is considered proxy-trusted when either: - * 1. The operator has opted in via `VERYFRONT_TRUST_FORWARDED_HEADERS=1` - * (strict "1" match — "true", "yes", whitespace-padded values do NOT count - * so misconfiguration fails closed); or - * 2. The request carries a valid `x-veryfront-dispatch-jws` header that - * cryptographically verifies against the configured control-plane public - * key and whose `iat`/`exp` claims are within the allowed freshness - * window. Presence alone is NOT trusted because the proxy does not strip - * this header from untrusted inbound requests (it has to pass through to - * the channel-invoke handler unchanged), so a - * direct-access attacker could otherwise set any value and promote - * forwarded-header spoofing. + * Trust is an operator-owned deployment property, not a property of an + * arbitrary application request. In particular, a channel-dispatch JWS is + * intentionally not accepted here: that token is not bound to this request's + * method, path, body, or routing identity and can therefore be replayed as an + * unrelated proxy credential while it is fresh. * * @module server/utils/proxy-trust */ -import { verifyDispatchJwsSignature } from "#veryfront/channels/control-plane.ts"; -import { getHostEnv } from "#veryfront/platform/compat/process.ts"; - -const DISPATCH_JWS_HEADER = "x-veryfront-dispatch-jws"; -const MAX_DISPATCH_SIGNATURE_AGE_SECONDS = 60; +import { isProxyTopologyTrusted } from "#veryfront/platform/compat/proxy-topology.ts"; export interface ProxyTrustOptions { /** - * PEM-encoded Ed25519 public key used to verify `x-veryfront-dispatch-jws`. - * When absent, the dispatch-JWS trust signal is disabled (fails closed) and - * only the operator opt-in env var can unlock proxy trust. + * Retained for call-site compatibility. Control-plane signing keys authorize + * only the exact operation verified by the control-plane handler and never + * promote a general HTTP request to proxy-trusted. */ publicKeyPem?: string; } export async function isProxyTrusted( - req: Request, - options: ProxyTrustOptions = {}, + _req: Request, + _options: ProxyTrustOptions = {}, ): Promise { - if (getHostEnv("VERYFRONT_TRUST_FORWARDED_HEADERS") === "1") return true; - - const jws = req.headers.get(DISPATCH_JWS_HEADER); - if (!jws) return false; - - const { publicKeyPem } = options; - if (!publicKeyPem) return false; - - return verifyDispatchJwsSignature(jws, { - publicKeyPem, - maxAgeSeconds: MAX_DISPATCH_SIGNATURE_AGE_SECONDS, - }); + return isProxyTopologyTrusted(); } diff --git a/src/transforms/esm/http-cache-helpers.ts b/src/transforms/esm/http-cache-helpers.ts index 6d0bfa1616..a4ee43aecd 100644 --- a/src/transforms/esm/http-cache-helpers.ts +++ b/src/transforms/esm/http-cache-helpers.ts @@ -39,6 +39,10 @@ export type CacheOptions = { importMap: ImportMapConfig; /** React version to use for esm.sh URLs (defaults to DEFAULT_REACT_VERSION) */ reactVersion?: string; + /** Absolute request origin used to identify same-origin module-server URLs. */ + moduleServerOrigin?: string; + /** Request-scoped dependency-pinning state used to isolate module-server URLs. */ + dependencyPinningCacheKey?: string; }; export type HttpCacheIdentityOptions = Pick; diff --git a/src/transforms/esm/http-cache.test.ts b/src/transforms/esm/http-cache.test.ts index e215746d1a..982a896d63 100644 --- a/src/transforms/esm/http-cache.test.ts +++ b/src/transforms/esm/http-cache.test.ts @@ -139,6 +139,44 @@ describe("HTTP Bundle Cache", { sanitizeResources: false, sanitizeOps: false }, }); }); + it("pins same-origin module-server imports before fetching", async () => { + const origin = "http://93.184.216.34:3000"; + const source = `export { value } from "${origin}/_vf_modules/shared/Absolute.js";`; + const snapshotKey = "on:snapshot-a"; + const fetchedUrls: string[] = []; + + await withIsolatedHttpCache( + "vf-esm-module-origin-pins-", + ((input) => { + fetchedUrls.push(String(input)); + return Promise.resolve( + new Response(`export const value = "abs";`, { + headers: { "content-type": "application/javascript" }, + }), + ); + }) as typeof fetch, + async (tempDir) => { + const result = await cacheHttpImportsToLocal(source, { + cacheDir: tempDir, + importMap: { imports: {}, scopes: {} }, + moduleServerOrigin: origin, + dependencyPinningCacheKey: snapshotKey, + }); + + assertEquals(result.code.includes("file://"), true); + }, + ); + + assertEquals(fetchedUrls.length, 1); + const fetchedUrlString = fetchedUrls[0]; + assert(fetchedUrlString); + const fetchedUrl = new URL(fetchedUrlString); + assertEquals(fetchedUrl.origin, origin); + assertEquals(fetchedUrl.pathname, "/_vf_modules/shared/Absolute.js"); + assertEquals(fetchedUrl.searchParams.get("ssr"), "true"); + assertEquals(fetchedUrl.searchParams.get("pins"), snapshotKey); + }); + it("does not retry permanent HTTP module failures", async () => { let fetchCount = 0; let bodyCancelled = false; diff --git a/src/transforms/esm/package-registry.test.ts b/src/transforms/esm/package-registry.test.ts index e249fef9f3..340fde96df 100644 --- a/src/transforms/esm/package-registry.test.ts +++ b/src/transforms/esm/package-registry.test.ts @@ -583,6 +583,35 @@ describe("package-registry adapter-backed snapshot sources", () => { ); }); + it("never treats a missing mtime as package-version freshness authority", async () => { + setEnv(DEPENDENCY_PINNING_ENV_FLAG, ""); + let content = JSON.stringify({ dependencies: { react: "19.1.0" } }); + let reads = 0; + const source: DependencyPinningSource = { + projectDir: "/shared/proxy-project", + cacheNamespace: "missing-mtime-freshness", + fs: { + readFile: () => { + reads += 1; + return Promise.resolve(content); + }, + stat: () => + Promise.resolve({ + size: content.length, + isFile: true, + isDirectory: false, + isSymlink: false, + mtime: null, + }), + }, + }; + + assertEquals((await readProjectDependencyVersions(source)).react, "19.1.0"); + content = JSON.stringify({ dependencies: { react: "19.2.0" } }); + assertEquals((await readProjectDependencyVersions(source)).react, "19.2.0"); + assertEquals(reads, 2); + }); + it("captures config versions in the token and keeps historical config authoritative", async () => { const state = { content: JSON.stringify({ dependencies: { tenant: "1.0.0" } }), diff --git a/src/transforms/esm/package-registry.ts b/src/transforms/esm/package-registry.ts index ca28c0a042..544389f9ce 100644 --- a/src/transforms/esm/package-registry.ts +++ b/src/transforms/esm/package-registry.ts @@ -466,6 +466,18 @@ function getDependencyPinningSnapshotSync( ); } +/** + * Return an already verified snapshot without consulting project metadata. + * Browser-module callers use this as an I/O-free fast path so a remembered + * request snapshot need not re-read package metadata. + */ +export function getRememberedDependencyPinningSnapshot( + source: DependencyPinningSourceInput, + cacheKey: string, +): DependencyPinningSnapshot | undefined { + return getDependencyPinningSnapshotSync(source, cacheKey); +} + /** * Return whether a snapshot is still the authoritative current package state * for this exact source namespace. Remembered historical snapshots deliberately @@ -723,6 +735,7 @@ async function readProjectDependencyVersionsUncoalesced( if ( cached && !pinningOn && + mtimeMs !== null && cached.mtimeMs === mtimeMs ) { return { diff --git a/src/transforms/esm/specifier-resolver.ts b/src/transforms/esm/specifier-resolver.ts index 35da187c59..c69d099787 100644 --- a/src/transforms/esm/specifier-resolver.ts +++ b/src/transforms/esm/specifier-resolver.ts @@ -10,6 +10,7 @@ import { basename } from "#veryfront/compat/path/index.ts"; import { resolveImport } from "#veryfront/modules/import-map/resolver.ts"; import { OutboundRequestBlockedError } from "#veryfront/security/http/outbound-fetch.ts"; +import { appendSameOriginSSRDependencyPinningKey } from "#veryfront/transforms/import-rewriter/url-builder.ts"; import { parseBarePackageSpecifier } from "../shared/package-specifier.ts"; import { isServerOnlyPackage } from "../shared/server-only-packages.ts"; import { parseImports, replaceSpecifiers } from "./lexer.ts"; @@ -89,9 +90,14 @@ async function resolveSpecifier( return resolveSpecifier(mapped, baseUrl, options, cacheHttpModule); } - const cached = await cacheHttpModule(specifier, options); + const effectiveSpecifier = appendSameOriginSSRDependencyPinningKey( + specifier, + options.dependencyPinningCacheKey, + options.moduleServerOrigin, + ); + const cached = await cacheHttpModule(effectiveSpecifier, options); if (!cached) { - throw new Error(`Failed to cache absolute HTTP module ${specifier}`); + throw new Error(`Failed to cache absolute HTTP module ${effectiveSpecifier}`); } if (isParentHttpModule(baseUrl)) { diff --git a/src/transforms/mdx/esm-module-loader/module-fetcher/http-fallback.ts b/src/transforms/mdx/esm-module-loader/module-fetcher/http-fallback.ts index fa02b6cfcd..29eba6d4f8 100644 --- a/src/transforms/mdx/esm-module-loader/module-fetcher/http-fallback.ts +++ b/src/transforms/mdx/esm-module-loader/module-fetcher/http-fallback.ts @@ -48,6 +48,7 @@ export async function resolveUnresolvedModuleViaHttpFallback( input.projectSlug, input.isLocalProject, input.dependencyPinningCacheKey, + { moduleServerOrigin: input.moduleServerOrigin }, ); if (moduleCode) { diff --git a/src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.test.ts b/src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.test.ts index 44276407db..a79ee50f93 100644 --- a/src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.test.ts +++ b/src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.test.ts @@ -60,6 +60,122 @@ describe("module-fetcher/http-fetcher", () => { } }); + it("uses the request origin for pinned local module fetches", async () => { + const logger = { debug: () => {}, warn: () => {} } as unknown as Logger; + const adapter = { + env: { + get(key: string) { + if (key === "VERYFRONT_DEV_PORT") return "3001"; + return undefined; + }, + }, + } as RuntimeAdapter; + let requestedUrl = ""; + + const result = await fetchModuleViaHTTP( + "_vf_modules/shared/Absolute.js", + adapter, + (path) => Promise.resolve(`/cache/${path.replaceAll("/", "__")}.mjs`), + logger, + "docs", + true, + "on:pins-a", + { + moduleServerOrigin: "http://93.184.216.34:3000", + fetchFn: ((input) => { + requestedUrl = String(input); + return Promise.resolve(new Response(`export const value = "abs";`)); + }) as typeof fetch, + }, + ); + + assertEquals(result, `export const value = "abs";`); + assertEquals( + requestedUrl, + "http://93.184.216.34:3000/_vf_modules/shared/Absolute.js?ssr=true&pins=on%3Apins-a", + ); + }); + + it("uses an explicit module server origin without validating fallback host inputs", async () => { + const logger = { debug: () => {}, warn: () => {} } as unknown as Logger; + const adapter = { + env: { + get(key: string) { + return key === "PORT" ? "not-a-port" : undefined; + }, + }, + } as RuntimeAdapter; + let requestedUrl = ""; + + const result = await fetchModuleViaHTTP( + "_vf_modules/shared/Explicit.js", + adapter, + () => Promise.resolve(null), + logger, + "docs.example", + true, + undefined, + { + moduleServerOrigin: "https://preview.example.test:8443", + fetchFn: ((input) => { + requestedUrl = String(input); + return Promise.resolve(new Response(`export const value = "explicit";`)); + }) as typeof fetch, + }, + ); + + assertEquals(result, `export const value = "explicit";`); + assertEquals( + requestedUrl, + "https://preview.example.test:8443/_vf_modules/shared/Explicit.js?ssr=true", + ); + }); + + it("strips credentials, path, query, and fragment from the module server origin", async () => { + const warnings: string[] = []; + const logger = { + debug: () => {}, + warn: (message: string) => warnings.push(message), + } as unknown as Logger; + const adapter = { + env: { + get(key: string) { + if (key === "VERYFRONT_DEV_PORT") return "3001"; + return undefined; + }, + }, + } as RuntimeAdapter; + let requestedUrl = ""; + + const result = await fetchModuleViaHTTP( + "_vf_modules/shared/Secret.js", + adapter, + () => Promise.resolve(null), + logger, + "docs", + true, + "on:pins-a", + { + moduleServerOrigin: + "https://user:pass@example.test:8443/debug/source.js?token=secret#fragment", + fetchFn: ((input) => { + requestedUrl = String(input); + return Promise.resolve(new Response("missing", { status: 404 })); + }) as typeof fetch, + }, + ); + + assertEquals(result, null); + assertEquals( + requestedUrl, + "https://example.test:8443/_vf_modules/shared/Secret.js?ssr=true&pins=on%3Apins-a", + ); + assertEquals(warnings.length, 1); + assertEquals(warnings[0]?.includes("user:pass"), false); + assertEquals(warnings[0]?.includes("token=secret"), false); + assertEquals(warnings[0]?.includes("#fragment"), false); + }); + it("resolves nested HTTP imports with bounded concurrency", async () => { const importCount = MAX_MDX_MODULE_TRANSFORM_CONCURRENCY + 4; const moduleCode = Array.from( diff --git a/src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.ts b/src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.ts index ad7ff7721d..ac0cb089e3 100644 --- a/src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.ts +++ b/src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.ts @@ -25,6 +25,7 @@ import { assertMdxModuleImportCount, MAX_MDX_MODULE_TRANSFORM_CONCURRENCY } from export interface FetchModuleViaHttpOptions { fetchFn?: typeof fetch; + moduleServerOrigin?: string; timeoutMs?: number; } @@ -97,13 +98,21 @@ export async function fetchModuleViaHTTP( log.debug(`${LOG_PREFIX_MDX_LOADER} Direct read failed, falling back to HTTP: ${normalizedPath}`); - const port = requireLocalDevPort( - adapter.env.get("VERYFRONT_DEV_PORT") || adapter.env.get("PORT") || "3001", - ); - const host = requireProjectSlug(projectSlug); const timeoutMs = requireFetchTimeout(options.timeoutMs ?? HTTP_FETCH_TIMEOUT_MS); const fetchFn = options.fetchFn ?? fetch; - const moduleUrl = new URL(`http://${host}:${port}`); + const moduleServerOrigin = options.moduleServerOrigin ?? + `http://${requireProjectSlug(projectSlug)}:${ + requireLocalDevPort( + adapter.env.get("VERYFRONT_DEV_PORT") || adapter.env.get("PORT") || "3001", + ) + }`; + const moduleServerUrl = new URL( + moduleServerOrigin, + ); + if (moduleServerUrl.protocol !== "http:" && moduleServerUrl.protocol !== "https:") { + throw new TypeError("Module server origin must use http or https"); + } + const moduleUrl = new URL(moduleServerUrl.origin); moduleUrl.pathname = `/${normalizedPath}`; moduleUrl.searchParams.set("ssr", "true"); if (dependencyPinningCacheKey?.startsWith("on:")) { @@ -131,7 +140,7 @@ export async function fetchModuleViaHTTP( "http.method": "GET", "http.url": moduleUrlString, "http.target": `/${normalizedPath}`, - "http.host": host, + "http.host": moduleUrl.host, "mdx.module_path": normalizedPath, }, ); diff --git a/src/transforms/pipeline/stages/ssr-http-cache.ts b/src/transforms/pipeline/stages/ssr-http-cache.ts index 25599a40cd..3539bdb9f3 100644 --- a/src/transforms/pipeline/stages/ssr-http-cache.ts +++ b/src/transforms/pipeline/stages/ssr-http-cache.ts @@ -33,6 +33,8 @@ export const ssrHttpCachePlugin: TransformPlugin = { cacheDir: getHttpBundleCacheDir(), importMap, reactVersion: ctx.reactVersion, + moduleServerOrigin: ctx.moduleServerOrigin, + dependencyPinningCacheKey: ctx.dependencyPinningCacheKey, }); if (code !== ctx.code) { diff --git a/src/types/server.ts b/src/types/server.ts index b7e811e87c..1b2ee71e05 100644 --- a/src/types/server.ts +++ b/src/types/server.ts @@ -54,6 +54,12 @@ export interface HandlerContext { projectId?: string; /** Release ID (from domain lookup for production custom domains) */ releaseId?: string; + /** Canonical branch ID supplied by the operator-authenticated proxy. */ + branchId?: string; + /** Canonical branch name paired with branchId by the operator-authenticated proxy. */ + branchName?: string; + /** Canonical project default branch name supplied by the operator-authenticated proxy. */ + defaultBranchName?: string; /** OAuth token from proxy (via x-token header) */ proxyToken?: string; /** Actual environment name from API (e.g., "Development", "Production") */ @@ -74,6 +80,8 @@ export interface HandlerContext { * enabling development-only local-project behavior. */ allowHostProjectCodeExecution?: boolean; + /** Whether this request is executing in the shared multi-project proxy runtime. */ + isProxyMode?: boolean; /** Environment ID for per-project env var resolution (from proxy x-environment-id header) */ environmentId?: string; /** diff --git a/src/utils/header-identity.test.ts b/src/utils/header-identity.test.ts new file mode 100644 index 0000000000..32e99c3c1a --- /dev/null +++ b/src/utils/header-identity.test.ts @@ -0,0 +1,28 @@ +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { decodeIdentityHeaderValue, encodeIdentityHeaderValue } from "./header-identity.ts"; + +describe("header identity encoding", () => { + it("preserves raw ByteString branch names", () => { + assertEquals(encodeIdentityHeaderValue("feature/foo"), "feature/foo"); + assertEquals(decodeIdentityHeaderValue("feature/foo"), "feature/foo"); + }); + + it("round-trips unicode branch names through an ASCII header value", () => { + const encoded = encodeIdentityHeaderValue("功能/新"); + + assertEquals(encoded, "vf-utf8:%E5%8A%9F%E8%83%BD%2F%E6%96%B0"); + assertEquals(decodeIdentityHeaderValue(encoded), "功能/新"); + }); + + it("encodes raw values that would collide with the encoding prefix", () => { + const value = "vf-utf8:literal"; + + assertEquals(encodeIdentityHeaderValue(value), "vf-utf8:vf-utf8%3Aliteral"); + assertEquals(decodeIdentityHeaderValue(encodeIdentityHeaderValue(value)), value); + }); + + it("treats malformed encoded values as absent", () => { + assertEquals(decodeIdentityHeaderValue("vf-utf8:%"), undefined); + }); +}); diff --git a/src/utils/header-identity.ts b/src/utils/header-identity.ts new file mode 100644 index 0000000000..d4b6b437f6 --- /dev/null +++ b/src/utils/header-identity.ts @@ -0,0 +1,23 @@ +const ENCODED_HEADER_VALUE_PREFIX = "vf-utf8:"; + +function isByteString(value: string): boolean { + for (let index = 0; index < value.length; index++) { + if (value.charCodeAt(index) > 0xff) return false; + } + return true; +} + +export function encodeIdentityHeaderValue(value: string): string { + if (isByteString(value) && !value.startsWith(ENCODED_HEADER_VALUE_PREFIX)) return value; + return `${ENCODED_HEADER_VALUE_PREFIX}${encodeURIComponent(value)}`; +} + +export function decodeIdentityHeaderValue(value: string | null): string | undefined { + if (!value) return undefined; + if (!value.startsWith(ENCODED_HEADER_VALUE_PREFIX)) return value; + try { + return decodeURIComponent(value.slice(ENCODED_HEADER_VALUE_PREFIX.length)); + } catch { + return undefined; + } +} diff --git a/tests/e2e/regressions/rsc-proxy-hydration.test.ts b/tests/e2e/regressions/rsc-proxy-hydration.test.ts index 54301d67dc..a25b7aa454 100644 --- a/tests/e2e/regressions/rsc-proxy-hydration.test.ts +++ b/tests/e2e/regressions/rsc-proxy-hydration.test.ts @@ -16,7 +16,6 @@ import { startProductionServer } from "../../../src/server/production-server.ts" import { bootstrapProd } from "../../../src/server/bootstrap.ts"; import { runtime } from "#veryfront/platform/adapters/detect.ts"; import { validateVeryfrontConfig } from "#veryfront/config/schemas/index.ts"; -import { base64urlEncode, base64urlEncodeBytes } from "#veryfront/utils/base64url.ts"; const ROOT_LAYOUT_SOURCE = `export default function RootLayout({ children }: { children: React.ReactNode }) { @@ -36,55 +35,7 @@ const PROXY_MODE_CONFIG_SOURCE = `export default { } };`; -const DISPATCH_PUBLIC_KEY_ENV = "CHANNEL_DISPATCH_SIGNING_PUBLIC_KEY"; -const encoder = new TextEncoder(); - -let trustedSigningKeyPair: CryptoKeyPair | undefined; -let trustedPublicKeyPem: string | undefined; - -function encodePem(label: string, der: ArrayBuffer): string { - const base64 = btoa(String.fromCharCode(...new Uint8Array(der))); - const lines = base64.match(/.{1,64}/g) ?? [base64]; - return `-----BEGIN ${label}-----\n${lines.join("\n")}\n-----END ${label}-----`; -} - -async function ensureTrustedProxyKeyMaterial(): Promise { - if (trustedSigningKeyPair && trustedPublicKeyPem) return; - - trustedSigningKeyPair = (await crypto.subtle.generateKey( - "Ed25519", - true, - ["sign", "verify"], - )) as CryptoKeyPair; - const der = await crypto.subtle.exportKey("spki", trustedSigningKeyPair.publicKey); - trustedPublicKeyPem = encodePem("PUBLIC KEY", der); -} - -async function mintTrustedDispatchJws(projectId: string): Promise { - await ensureTrustedProxyKeyMaterial(); - - const now = Math.floor(Date.now() / 1000); - const header = { alg: "EdDSA", typ: "JWT" }; - const claims = { - iss: "veryfront-api", - aud: projectId, - sub: "rsc-proxy-hydration-test", - project_id: projectId, - platform: "browser", - body_sha256: "n/a", - iat: now, - exp: now + 60, - }; - const encodedHeader = base64urlEncode(JSON.stringify(header)); - const encodedPayload = base64urlEncode(JSON.stringify(claims)); - const signingInput = encoder.encode(`${encodedHeader}.${encodedPayload}`); - const signature = await crypto.subtle.sign( - "Ed25519", - trustedSigningKeyPair!.privateKey, - signingInput, - ); - return `${encodedHeader}.${encodedPayload}.${base64urlEncodeBytes(new Uint8Array(signature))}`; -} +const TRUST_FORWARDED_HEADERS_ENV = "VERYFRONT_TRUST_FORWARDED_HEADERS"; interface TestProjectContext { projectDir: string; @@ -233,9 +184,8 @@ async function withHostedBrowserPage( const port = await context.allocatePort(); const controller = new AbortController(); - const previousDispatchPublicKey = Deno.env.get(DISPATCH_PUBLIC_KEY_ENV); - await ensureTrustedProxyKeyMaterial(); - Deno.env.set(DISPATCH_PUBLIC_KEY_ENV, trustedPublicKeyPem!); + const previousProxyTrust = Deno.env.get(TRUST_FORWARDED_HEADERS_ENV); + Deno.env.set(TRUST_FORWARDED_HEADERS_ENV, "1"); let server: Awaited> | undefined; let disposeBootstrap: (() => void | Promise) | undefined; @@ -281,13 +231,8 @@ async function withHostedBrowserPage( // A dedicated runtime gets its environment and project identity from // host-owned startup options. Forwarded project headers belong only to the - // shared proxy topology, where the dispatch signature establishes trust. - const extraHTTPHeaders = topology === "shared" - ? { - ...headers, - "x-veryfront-dispatch-jws": await mintTrustedDispatchJws(context.projectId), - } - : undefined; + // shared topology explicitly trusted by the host-level proxy setting. + const extraHTTPHeaders = topology === "shared" ? headers : undefined; const browserContext = await browser.newContext({ extraHTTPHeaders }); await installEsmShCorsShim(browserContext); @@ -305,10 +250,10 @@ async function withHostedBrowserPage( controller.abort(); await server?.stop(); await disposeBootstrap?.(); - if (previousDispatchPublicKey === undefined) { - Deno.env.delete(DISPATCH_PUBLIC_KEY_ENV); + if (previousProxyTrust === undefined) { + Deno.env.delete(TRUST_FORWARDED_HEADERS_ENV); } else { - Deno.env.set(DISPATCH_PUBLIC_KEY_ENV, previousDispatchPublicKey); + Deno.env.set(TRUST_FORWARDED_HEADERS_ENV, previousProxyTrust); } } } diff --git a/tests/integration/adapters/proxy-fs-adapter-manager.test.ts b/tests/integration/adapters/proxy-fs-adapter-manager.test.ts index 5d2a6f2396..e08cd58ac4 100644 --- a/tests/integration/adapters/proxy-fs-adapter-manager.test.ts +++ b/tests/integration/adapters/proxy-fs-adapter-manager.test.ts @@ -8,6 +8,7 @@ import "../../_helpers/contract-init.ts"; import { assertEquals, assertThrows } from "#veryfront/testing/assert"; import { describe, it } from "#veryfront/testing/bdd"; +import { VeryfrontFSAdapter } from "#veryfront/platform/adapters/fs/veryfront/adapter.ts"; import { ProxyFSAdapterManager } from "#veryfront/platform/adapters/fs/veryfront/proxy-manager.ts"; function createLocalManager(): ProxyFSAdapterManager { @@ -99,27 +100,37 @@ describe("ProxyFSAdapterManager - Cache Isolation", () => { } }); - it("evictAdapter removes and disposes a cached preview adapter", () => { - const manager = createLocalManager(); + it("evictAdapter removes and disposes a cached preview adapter", async () => { let disposed = false; + const manager = new ProxyFSAdapterManager({ + baseConfig: { + type: "veryfront-api", + veryfront: { + apiBaseUrl: "http://localhost:4000/api", + apiToken: "test-token", + proxyMode: false, + }, + }, + adapterFactory: (config) => { + const adapter = new VeryfrontFSAdapter(config); + adapter.initialize = () => Promise.resolve(); + adapter.dispose = () => { + disposed = true; + }; + return adapter; + }, + }); try { - (manager as unknown as { - adapters: Map< - string, - { adapter: { dispose: () => void; getCacheStats: () => unknown }; lastAccessed: number } - >; - }).adapters.set("proxy:my-project:preview:main", { - adapter: { - dispose: () => { - disposed = true; - }, - getCacheStats: () => ({ - cache: { size: 0, memoryUsed: 0, hits: 0, misses: 0, hitRate: 0 }, - }), - }, - lastAccessed: Date.now(), - }); + await manager.getAdapter( + "my-project", + "test-token", + undefined, + false, + null, + null, + "main", + ); assertEquals(manager.hasAdapter("my-project", false, null, "main"), true); diff --git a/tests/integration/compiled-binary-e2e.test.ts b/tests/integration/compiled-binary-e2e.test.ts index 55535fb707..dca2735491 100644 --- a/tests/integration/compiled-binary-e2e.test.ts +++ b/tests/integration/compiled-binary-e2e.test.ts @@ -2020,32 +2020,39 @@ export default function HomePage() { `style-src should not mix unsafe-inline with a nonce, got: ${csp}`, ); - await assertCounterHydration(page, { - assertBeforeClick: async () => { - const initialBackground = await page.$eval( - "#counter", - (element) => globalThis.getComputedStyle(element).backgroundColor, - ); - const initialPadding = await page.$eval( - "#counter", - (element) => globalThis.getComputedStyle(element).paddingTop, - ); - assertEquals(initialBackground, "rgb(37, 99, 235)"); - assertEquals(initialPadding, "12px"); - }, - assertAfterClick: async () => { - const clickedBackground = await page.$eval( - "#counter", - (element) => globalThis.getComputedStyle(element).backgroundColor, - ); - const clickedPadding = await page.$eval( - "#counter", - (element) => globalThis.getComputedStyle(element).paddingTop, - ); - assertEquals(clickedBackground, "rgb(22, 101, 52)"); - assertEquals(clickedPadding, "13px"); - }, - }); + try { + await assertCounterHydration(page, { + assertBeforeClick: async () => { + const initialBackground = await page.$eval( + "#counter", + (element) => globalThis.getComputedStyle(element).backgroundColor, + ); + const initialPadding = await page.$eval( + "#counter", + (element) => globalThis.getComputedStyle(element).paddingTop, + ); + assertEquals(initialBackground, "rgb(37, 99, 235)"); + assertEquals(initialPadding, "12px"); + }, + assertAfterClick: async () => { + const clickedBackground = await page.$eval( + "#counter", + (element) => globalThis.getComputedStyle(element).backgroundColor, + ); + const clickedPadding = await page.$eval( + "#counter", + (element) => globalThis.getComputedStyle(element).paddingTop, + ); + assertEquals(clickedBackground, "rgb(22, 101, 52)"); + assertEquals(clickedPadding, "13px"); + }, + }); + } catch (error) { + throw new Error( + `${String(error)}\nBrowser diagnostics:\n${JSON.stringify(diagnostics, null, 2)}`, + { cause: error }, + ); + } assertNoBrowserHydrationErrors(diagnostics); assertNoServerLogErrors( @@ -2092,7 +2099,14 @@ export default function HomePage() { await withServer(projectDir, async (server) => { await withBrowserPageAgainstServer(server, async ({ page, response, diagnostics }) => { - await page.waitForSelector('#styled-box[data-hydrated="yes"]'); + try { + await page.waitForSelector('#styled-box[data-hydrated="yes"]'); + } catch (error) { + throw new Error( + `${String(error)}\nBrowser diagnostics:\n${JSON.stringify(diagnostics, null, 2)}`, + { cause: error }, + ); + } const csp = response?.headers()["content-security-policy"] ?? ""; const styleSources = getDirectiveSources(csp, "style-src"); @@ -3191,6 +3205,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) { PROXY_MODE: "1", PRODUCTION_MODE: "1", + VERYFRONT_TRUST_FORWARDED_HEADERS: "1", VERYFRONT_API_BASE_URL: UNREACHABLE_LOCAL_PROXY_API_BASE_URL, VERYFRONT_API_TOKEN: "", }, @@ -3261,6 +3276,7 @@ export default function Home() { { PROXY_MODE: "1", PRODUCTION_MODE: "1", + VERYFRONT_TRUST_FORWARDED_HEADERS: "1", VERYFRONT_API_BASE_URL: UNREACHABLE_LOCAL_PROXY_API_BASE_URL, VERYFRONT_API_TOKEN: "", }, diff --git a/tests/integration/server/modules/hmr-handler.test.ts b/tests/integration/server/modules/hmr-handler.test.ts index b8d285f107..772fc56f43 100644 --- a/tests/integration/server/modules/hmr-handler.test.ts +++ b/tests/integration/server/modules/hmr-handler.test.ts @@ -242,7 +242,7 @@ describe("HMR Handler Tests", { sanitizeOps: false, sanitizeResources: false }, assertEquals(result.response, undefined); }); - it("treats preview.veryfront.me as local preview host", async () => { + it("does not infer preview mode from the raw Host header", async () => { const handler = new HMRHandler(); const req = new Request("http://localhost:3000/_ws", { @@ -258,8 +258,8 @@ describe("HMR Handler Tests", { sanitizeOps: false, sanitizeResources: false }, const result = await handler.handle(req, ctx); - assertExists(result.response); - assertEquals(result.response.status, 200); + assertEquals(result.continue, true); + assertEquals(result.response, undefined); }); it("IGNORES x-forwarded-host when the request is NOT proxy-trusted (VULN-SRV-4)", async () => { @@ -289,11 +289,9 @@ describe("HMR Handler Tests", { sanitizeOps: false, sanitizeResources: false }, assertEquals(result.response, undefined); }); - it("HONOURS x-forwarded-host when the request IS proxy-trusted (valid dispatch JWS)", async () => { - // With a cryptographically-verified dispatch-JWS signal, the request - // demonstrably came through the Veryfront fronting proxy, so the forwarded - // host is safe to consult. The preview.veryfront.me host is a recognised - // local preview surface and the handler must enter the HMR path. + it("does not let a valid dispatch JWS unlock HMR through forwarded host", async () => { + // A dispatch signature authorizes one channel operation. It does not bind + // this HMR method/path or promote request headers to generic proxy trust. const handler = new HMRHandler(); const jws = await mintTrustedDispatchJws(); @@ -314,8 +312,8 @@ describe("HMR Handler Tests", { sanitizeOps: false, sanitizeResources: false }, const result = await handler.handle(req, ctx); - assertExists(result.response); - assertEquals(result.response.status, 200); + assertEquals(result.continue, true); + assertEquals(result.response, undefined); }); it("IGNORES x-forwarded-host when dispatch JWS is present but unverifiable (Codex P1 regression)", async () => { @@ -374,7 +372,7 @@ describe("HMR Handler Tests", { sanitizeOps: false, sanitizeResources: false }, assertEquals(result.response, undefined); }); - it("handle accepts preview via query param (for proxy WebSocket)", async () => { + it("does not let a query parameter unlock preview HMR", async () => { const handler = new HMRHandler(); const req = new Request("http://localhost:3000/_ws?x-environment=preview"); @@ -388,8 +386,8 @@ describe("HMR Handler Tests", { sanitizeOps: false, sanitizeResources: false }, const result = await handler.handle(req, ctx); - assertExists(result.response); - assertEquals(result.response.status, 200); + assertEquals(result.continue, true); + assertEquals(result.response, undefined); }); }); diff --git a/tests/integration/server/production-server.test.ts b/tests/integration/server/production-server.test.ts index d4c3315139..4f5da04cd7 100644 --- a/tests/integration/server/production-server.test.ts +++ b/tests/integration/server/production-server.test.ts @@ -28,6 +28,7 @@ import { withTestContext } from "../../_helpers/context.ts"; import { cleanupBundler } from "../../../src/rendering/cleanup.ts"; import { invalidateProjectMiddlewareCache } from "../../../src/server/runtime-handler/project-middleware.ts"; import { registerTailwindExtension } from "../../../src/html/styles-builder/__tests__/css-processor-setup.ts"; +import { deleteEnv, getHostEnv, setEnv } from "../../../src/platform/compat/process.ts"; function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); @@ -498,6 +499,8 @@ describe( }); it("refuses shared proxy middleware after trusted request context is resolved", async () => { + const trustEnvName = "VERYFRONT_TRUST_FORWARDED_HEADERS"; + const originalProxyTrust = getHostEnv(trustEnvName); const projectSlug = "shared-middleware-project"; const projectId = "shared-middleware-project-id"; const releaseId = "shared-middleware-release"; @@ -576,6 +579,7 @@ describe( }; let server: Awaited> | undefined; + setEnv(trustEnvName, "1"); try { server = await startProductionServer({ projectDir: "/app", @@ -633,6 +637,8 @@ describe( "Middleware loading must use production release context", ); } finally { + if (originalProxyTrust === undefined) deleteEnv(trustEnvName); + else setEnv(trustEnvName, originalProxyTrust); invalidateProjectMiddlewareCache(projectSlug, projectId); await server?.stop(); (multiProjectFs as any).manager = originalManager; diff --git a/tests/integration/vfs-proxy-mode-e2e.test.ts b/tests/integration/vfs-proxy-mode-e2e.test.ts index 076fda9c22..f74a7bd1ce 100644 --- a/tests/integration/vfs-proxy-mode-e2e.test.ts +++ b/tests/integration/vfs-proxy-mode-e2e.test.ts @@ -104,6 +104,7 @@ async function startVFSServer( ...withoutHostBinaryInfraEnv(Deno.env.toObject()), NODE_ENV: "production", PROXY_MODE: "1", + VERYFRONT_TRUST_FORWARDED_HEADERS: "1", VERYFRONT_API_BASE_URL: "https://api.veryfront.com", LOG_FORMAT: "text", VERYFRONT_CACHE_DIR: cacheDir, From 3192ceabc59356f82b6b35002aaa16abfad1181e Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 14:32:30 +0200 Subject: [PATCH 12/26] Protect proxy binary release from hidden regressions The dedicated proxy artifact now has release-path and smoke-test guardrails for the exact review concerns: the proxy matrix leg no longer cancels existing binary builds, the proxy runtime intrinsic capture evaluates before extension anchors, and the compiled smoke script bounds probes, checks ambient Redis runtime activation, and enforces a size ceiling. Constraint: PR remains draft behind the external staging rollout gate. Rejected: RSS ceiling in CI | local process RSS varies by OS, runner pressure, and optional observability startup timing, so artifact byte size is the deterministic regression guard. Confidence: high Scope-risk: narrow Directive: Keep proxy-main importing proxy-runtime before extension anchors; that order preserves CLI-owned Promise intrinsics before extension top-level code. Tested: npx --yes deno@2.7.7 test --config=scripts/test.deno.json --no-check --allow-read --allow-write --allow-run scripts/build/compile-binary.test.ts Tested: npx --yes deno@2.7.7 test --no-check --allow-all cli/commands/serve/proxy-extension-composition.test.ts cli/commands/serve/proxy-runtime.test.ts Tested: npx --yes deno@2.7.7 run -A scripts/build/compile-binary.ts --entrypoint cli/proxy-main.ts --profile proxy --target x86_64-unknown-linux-gnu --output ./veryfront-proxy-linux-x64 Tested: npx --yes deno@2.7.7 run -A scripts/build/compile-binary.ts --entrypoint cli/proxy-main.ts --profile proxy --output ./veryfront-proxy-native Tested: bash scripts/build/smoke-proxy-binary.sh ./veryfront-proxy-native 19380 Tested: bash -n scripts/build/smoke-proxy-binary.sh; git diff --check; npx --yes deno@2.7.7 fmt --check cli/proxy-main.ts scripts/build/compile-binary.test.ts; npx --yes deno@2.7.7 lint cli/proxy-main.ts scripts/build/compile-binary.test.ts; npx --yes deno@2.7.7 check --config=scripts/test.deno.json scripts/build/compile-binary.test.ts && npx --yes deno@2.7.7 check cli/proxy-main.ts Not-tested: Linux proxy binary execution on macOS host; native smoke passed locally and CI owns Linux execution. --- .github/workflows/cicd.yml | 1 + cli/proxy-main.ts | 2 ++ scripts/build/compile-binary.test.ts | 43 +++++++++++++++++++++++++++- scripts/build/smoke-proxy-binary.sh | 15 ++++++++-- 4 files changed, 57 insertions(+), 4 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index eb587fd6d6..781d1d6dce 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -433,6 +433,7 @@ jobs: runs-on: ${{ matrix.os }} continue-on-error: ${{ contains(matrix.os, 'windows') }} strategy: + fail-fast: false matrix: include: - os: macos-latest diff --git a/cli/proxy-main.ts b/cli/proxy-main.ts index ce2ae4d424..f4aedb52fc 100644 --- a/cli/proxy-main.ts +++ b/cli/proxy-main.ts @@ -1,5 +1,7 @@ /** Dedicated compiled proxy entrypoint. Optional CLI arguments are ignored. */ +import "./commands/serve/proxy-runtime.ts"; + // Keep the proxy's runtime-selected providers in the compile graph. Using // `deno compile --include` for these modules embeds the workspace file tree; // static references embed only each provider and its real dependencies. diff --git a/scripts/build/compile-binary.test.ts b/scripts/build/compile-binary.test.ts index c6d174f84a..877d46e56b 100644 --- a/scripts/build/compile-binary.test.ts +++ b/scripts/build/compile-binary.test.ts @@ -127,6 +127,14 @@ it("proxy binary embeds only the runtime-resolved proxy entrypoint", async () => assertEquals(args.at(-1), "cli/proxy-main.ts"); const entrypoint = await Deno.readTextFile("cli/proxy-main.ts"); + const runtimeImportIndex = entrypoint.indexOf( + 'import "./commands/serve/proxy-runtime.ts";', + ); + assertEquals( + runtimeImportIndex >= 0, + true, + "proxy entrypoint must evaluate proxy-runtime before extension anchors", + ); for ( const extension of [ "ext-auth-jwt", @@ -136,11 +144,19 @@ it("proxy binary embeds only the runtime-resolved proxy entrypoint", async () => "ext-observability-sentry", ] ) { + const extensionImportIndex = entrypoint.indexOf( + `../extensions/${extension}/src/index.ts`, + ); assertEquals( - entrypoint.includes(`../extensions/${extension}/src/index.ts`), + extensionImportIndex >= 0, true, `proxy entrypoint must statically embed ${extension}`, ); + assertEquals( + runtimeImportIndex < extensionImportIndex, + true, + `proxy-runtime must evaluate before ${extension} top-level code`, + ); } const lock = JSON.parse( @@ -173,6 +189,12 @@ it("proxy release verifies lock freshness and publishes an exact SBOM", async () workflow.includes("deno task sbom --lock scripts/build/proxy-deno.lock"), true, ); + assertEquals( + /build-binaries:[\s\S]*?strategy:\n\s+fail-fast: false\n\s+matrix:/ + .test(workflow), + true, + "proxy release leg must not cancel existing binary builds", + ); assertEquals( denoConfig.tasks?.["build:proxy-lock"]?.includes("--frozen=false"), false, @@ -188,6 +210,9 @@ it("compiled proxy smoke covers cache and observability providers", async () => "CACHE_TYPE=memory", "CACHE_TYPE=redis", "TokenCacheStore registered", + "ambient-redis", + "CACHE_TYPE=memory REDIS_URL=redis://127.0.0.1:1", + "[ext-redis] RedisRuntimeProvider registered", "OTEL_TRACES_EXPORTER=otlp", "[otel] Initialized", "SENTRY_DSN=https://public@example.com/1", @@ -211,6 +236,22 @@ it("compiled proxy smoke covers cache and observability providers", async () => true, "healthy proxies must retry briefly while asynchronous provider logs flush", ); + assertEquals( + smoke.includes("--connect-timeout") && smoke.includes("--max-time"), + true, + "health probes must be bounded inside the retry window", + ); + assertEquals( + smoke.includes("PROXY_BINARY_MAX_BYTES") && + smoke.includes("188743680"), + true, + "compiled proxy smoke must enforce a defensible artifact size ceiling", + ); + assertEquals( + smoke.includes('${TMPDIR:-/tmp}/veryfront-proxy-smoke.XXXXXX'), + true, + "smoke temp directory must use a portable mktemp template", + ); }); it("proxy binary smoke runs only for same-repository pull requests", async () => { diff --git a/scripts/build/smoke-proxy-binary.sh b/scripts/build/smoke-proxy-binary.sh index 01148b123b..003c7fd01d 100644 --- a/scripts/build/smoke-proxy-binary.sh +++ b/scripts/build/smoke-proxy-binary.sh @@ -3,8 +3,9 @@ set -euo pipefail binary="${1:?usage: smoke-proxy-binary.sh [port]}" base_port="${2:-18080}" -tmp_dir="$(mktemp -d)" +tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/veryfront-proxy-smoke.XXXXXX")" proxy_pid="" +max_binary_bytes="${PROXY_BINARY_MAX_BYTES:-188743680}" cleanup() { if [ -n "$proxy_pid" ]; then @@ -15,6 +16,12 @@ cleanup() { } trap cleanup EXIT +actual_binary_bytes="$(wc -c < "$binary" | tr -d '[:space:]')" +if [ "$actual_binary_bytes" -gt "$max_binary_bytes" ]; then + echo "proxy binary size ${actual_binary_bytes} exceeds ${max_binary_bytes} bytes" >&2 + exit 1 +fi + run_smoke() { local name="$1" local port="$2" @@ -27,7 +34,7 @@ run_smoke() { proxy_pid=$! for _ in {1..30}; do - if curl -fsS "http://127.0.0.1:${port}/_proxy/health" 2>/dev/null \ + if curl --connect-timeout 1 --max-time 2 -fsS "http://127.0.0.1:${port}/_proxy/health" 2>/dev/null \ | grep -Fq '"status":"ok"'; then if [ -n "$expected_log" ]; then if ! grep -Fq "$expected_log" "$log_file"; then @@ -50,7 +57,9 @@ run_smoke() { run_smoke memory "$base_port" "" CACHE_TYPE=memory run_smoke redis "$((base_port + 1))" "TokenCacheStore registered" \ CACHE_TYPE=redis REDIS_URL=redis://127.0.0.1:1 -run_smoke observability "$((base_port + 2))" "[otel] Initialized" \ +run_smoke ambient-redis "$((base_port + 2))" "[ext-redis] RedisRuntimeProvider registered" \ + CACHE_TYPE=memory REDIS_URL=redis://127.0.0.1:1 +run_smoke observability "$((base_port + 3))" "[otel] Initialized" \ CACHE_TYPE=memory \ OTEL_TRACES_ENABLED=true \ OTEL_TRACES_EXPORTER=otlp \ From f56afcd42fd607d5ef8cb939f12c62f4c51bcfe3 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 14:47:46 +0200 Subject: [PATCH 13/26] fix(security): harden rate limits with extension-owned Redis (#3304) * refactor(security): consolidate duplicated rate-limit implementations Delete the in-tree RedisRateLimitStore (src/middleware/builtin/security/ redis-rate-limit.ts) in favor of the hardened ext-redis twin, which is a strict superset in both implementation and test coverage. Take the reconcile branch's hardened MemoryRateLimitStore/rateLimit middleware (bounded capacity, fail-closed store errors, option validation) and the WebSocket RateLimiter rework (WeakMap, monotonic injectable clock). Barrels are hand-edited on top of main's versions: redis-rate-limit re-exports removed, MemoryRateLimitStore(Options) exported. Main's rate-limit-validation.ts (with control-character key rejection from createDistributedRateLimitStore is deferred to the distributed runtime-provider slice it depends on. Cherry-picked per-file from codex/module-reconcile-20260723. * fix(security): make rate-limit store guard runtime-safe * Keep rate-limit store guards CodeQL-clean The store detection already rejected null, but the combined typeof/null guard triggered CodeQL's inconvertible-type review on this PR. Widening the helper input to unknown and checking null before object property tests preserves behavior while making the narrowing explicit to static analysis. Constraint: PR #3304 has duplicate CodeQL threads for the same null comparison. Confidence: high Scope-risk: narrow Tested: npx --yes deno@2.7.7 fmt --check src/middleware/builtin/security/rate-limit.ts src/middleware/builtin/security/rate-limit.test.ts Tested: npx --yes deno@2.7.7 lint src/middleware/builtin/security/rate-limit.ts src/middleware/builtin/security/rate-limit.test.ts Tested: npx --yes deno@2.7.7 test --no-check --allow-all src/middleware/builtin/security/rate-limit.test.ts Tested: git diff --check * Keep rate-limit store guard structurally explicit * Keep rate-limit nullish guard CodeQL-clean * Preserve rate-limit compatibility while failing closed The Redis store implementation now belongs to @veryfront/ext-redis, but veryfront/middleware still needs the legacy Redis symbols so existing consumers compile. The middleware barrel exposes a lazy compatibility wrapper while the extension owns the concrete Redis behavior, and invalid generated keys are denied through the existing store-failure path instead of escaping the middleware. Constraint: PR #3304 consolidates Redis implementation ownership into @veryfront/ext-redis Rejected: Restore the deleted in-tree Redis implementation | that would undo the consolidation this PR is meant to make Confidence: high Scope-risk: moderate Directive: Keep veryfront/middleware Redis exports as a compatibility bridge unless a breaking release removes them with migration notes Tested: DENO_TESTING=1 VF_DISABLE_LRU_INTERVAL=1 npx --yes deno@2.7.7 test --no-check --allow-all src/middleware/builtin/security/rate-limit.test.ts Tested: DENO_TESTING=1 VF_DISABLE_LRU_INTERVAL=1 npx --yes deno@2.7.7 test --no-check --allow-all extensions/ext-redis/src/rate-limit-store.test.ts Tested: npx --yes deno@2.7.7 task docs:validate Tested: npx --yes deno@2.7.7 task verify:quick * test(cli): isolate skills JSON subprocess output * Keep Redis rate limit usable from root npm The middleware compatibility export must work for existing consumers that install only the root veryfront package. The Redis implementation now stays in-tree for the public middleware API, while the extension package keeps its copy for extension-owned imports. Constraint: PR #3304 keeps the public veryfront/middleware RedisRateLimitStore export compatible Constraint: dnt cannot infer a dependency hidden behind an optional dynamic @veryfront/ext-redis import Rejected: Delegate root middleware to @veryfront/ext-redis | root npm consumers can call increment without installing that extension package Confidence: high Scope-risk: moderate Directive: Do not replace the root middleware Redis store with an opaque extension import unless the root npm package also proves increment works in the install smoke test Tested: focused middleware and ext-redis rate-limit tests Tested: npm package build and npm install smoke Tested: npm package metadata suite Tested: docs:validate Tested: verify:quick * Address rate-limit review safety gaps CodeRabbit flagged six merge-readiness gaps in the rate-limit consolidation: Redis closed-client recognition, memory store capacity observability, key-resolution logging, lazy Redis loading, reset validation order, and pending connection rejection handling. This change fixes those contracts directly in the core store, middleware, and Redis extension, then locks each path with focused regressions. Constraint: Core keeps redis as a narrowly allowlisted server-only runtime dependency. Rejected: Plain error-name matching for ClientClosedError | redis 5.11.0 sets name to Error, so instanceof is required. Rejected: Logging all failures as store outages | key generation and capacity exhaustion need distinct operational signals. Confidence: high Scope-risk: moderate Directive: Keep Redis loading lazy in the core compatibility store so importing middleware does not eagerly resolve npm:redis. Tested: deno test --no-check --allow-all src/middleware/builtin/security/rate-limit.test.ts Tested: deno test --no-check --allow-all extensions/ext-redis/src/rate-limit-store.test.ts Tested: deno test --config=scripts/test.deno.json --no-check --allow-read --allow-write --allow-run scripts/lint/audit-core-deps.test.ts Tested: deno fmt --check targeted TypeScript files Tested: deno lint targeted TypeScript files Tested: deno check targeted TypeScript files Tested: deno task lint:core-deps Tested: git diff --check Not-tested: Full repository test suite * Keep packaging validation reproducible after baseline merge The current main packaging test resolves the pinned parser dependency through the scripts lock. Recording that exact resolution keeps the reconciled PR branch usable with frozen dependency checks. Constraint: Script verification must remain reproducible under Deno frozen mode. Confidence: high Scope-risk: narrow Tested: npm package metadata suite, 26 steps, with --frozen * fix(security): preserve extension-owned Redis rate limiting * Document the actual capacity log signal The API reference named a store-capacity log stage that the middleware does not emit. The implementation and tests use the store-increment stage with capacity-exhausted failure kind and a capacity field, so the public docs now match the structured signal operators will see. Constraint: Keep PR #3304 follow-up scoped to the reviewed documentation mismatch Rejected: Update implementation logging | the reviewed head already emits the intended structured fields Confidence: high Scope-risk: narrow Reversibility: clean Directive: Do not reintroduce store-capacity unless the middleware emits that exact stage Tested: deno task docs:validate Tested: deno test --no-check --allow-all src/middleware/builtin/security/rate-limit.test.ts Tested: deno task docs generation checked; broad generated-reference churn was intentionally not committed Tested: git diff --check * Keep rate-limit diagnostics stable at public boundaries Review found that malformed options leaked native property errors and operational logs erased backend error classifications. Validate both exported constructors and preserve safe Error names in throttled failure logs. Constraint: Rate-limit failures must remain fail closed and must not expose error messages. Confidence: high Scope-risk: narrow Tested: Focused middleware and WebSocket limiter tests, deno check, format check, and diff check. * Make rate-limit timeout retirement depend on registered identity Redis operation timeouts now use the shared timeout error definition, so connection retirement is driven by the Veryfront error slug instead of an arbitrary Error.name. The rate-limit store contract and maxRequests boundary are tightened at the public preset boundary, with tests pinned to the shared key length constant. Constraint: Review requested patch-only fixes for PR #3304 without widening the consolidation branch Rejected: Match TimeoutError by name | unrelated provider errors can share that name and should not retire a healthy client Confidence: high Scope-risk: narrow Tested: focused rate-limit suites; deno fmt --check touched files; deno lint touched files; deno check touched files; deno task test:unit Not-tested: integration suites * Release Redis rate-limit timeout timers from process liveness Redis operation timeouts enforce bounded backend calls, but the one-shot timeout handles should not keep Node-compatible runtimes alive after other work has drained. Core and extension Redis stores now use the shared unrefTimer path, with timeout tests proving the timer handle is unreferenced while preserving timeout rejection behavior under Deno's event-loop semantics. The memory store documentation now distinguishes direct store behavior from middleware logging so generated docs do not imply MemoryRateLimitStore itself emits rateLimit request-path logs. Constraint: PR review requested unrefTimer-compatible timeout handles in both core and extension Redis stores Constraint: Generated middleware reference must not attribute rateLimit middleware logging to direct MemoryRateLimitStore use Rejected: Direct extension import from platform/compat/process | would bypass the existing distributed rate-limit support surface Confidence: high Scope-risk: narrow Tested: npx --yes deno@2.7.7 test --no-check --allow-all src/middleware/builtin/security/rate-limit.test.ts src/middleware/builtin/security/redis-rate-limit.test.ts Tested: npx --yes deno@2.7.7 test --no-lock --allow-all extensions/ext-redis/src/rate-limit-store.test.ts Tested: npx --yes deno@2.7.7 fmt --check src/middleware/builtin/security/rate-limit.ts src/middleware/builtin/security/redis-rate-limit.ts src/middleware/builtin/security/redis-rate-limit.test.ts src/extensions/distributed/rate-limit-support.ts extensions/ext-redis/src/rate-limit-store.ts extensions/ext-redis/src/rate-limit-store.test.ts Tested: npx --yes deno@2.7.7 lint src/middleware/builtin/security/rate-limit.ts src/middleware/builtin/security/redis-rate-limit.ts src/middleware/builtin/security/redis-rate-limit.test.ts src/extensions/distributed/rate-limit-support.ts extensions/ext-redis/src/rate-limit-store.ts extensions/ext-redis/src/rate-limit-store.test.ts Tested: npx --yes deno@2.7.7 check src/middleware/builtin/security/rate-limit.ts src/middleware/builtin/security/redis-rate-limit.test.ts src/extensions/distributed/rate-limit-support.ts Tested: npx --yes deno@2.7.7 check --no-lock extensions/ext-redis/src/rate-limit-store.test.ts Tested: npx --yes deno@2.7.7 task docs:validate Tested: npx --yes deno@2.7.7 task lint:core-deps Tested: npx --yes deno@2.7.7 task lint:dependency-boundaries Tested: npx --yes deno@2.7.7 task lint:extension-contracts * test(security): pin default rate-limit parity at 100 requests per minute * Share Redis rate-limit atomic counter script The core facade and Redis extension both execute the same Lua counter semantics. Keeping the literal in two files left a small divergence risk for future rate-limit edits, so the script now lives behind the distributed rate-limit support surface used by the extension. Constraint: The Redis extension must remain the owner of the Redis package while sharing provider-neutral rate-limit semantics with core. Rejected: Add a test comparing two duplicated literals | this still leaves two update sites and preserves the drift risk. Confidence: high Scope-risk: narrow Reversibility: clean Tested: npx --yes deno@2.7.7 test --no-check --allow-all extensions/ext-redis/src/rate-limit-store.test.ts src/middleware/builtin/security/redis-rate-limit.test.ts src/middleware/builtin/security/rate-limit.test.ts src/modules/server/rate-limiter.test.ts Tested: npx --yes deno@2.7.7 fmt --check src/middleware/builtin/security/redis-rate-limit-script.ts src/extensions/distributed/rate-limit-support.ts src/middleware/builtin/security/redis-rate-limit.ts extensions/ext-redis/src/rate-limit-store.ts Tested: npx --yes deno@2.7.7 lint src/middleware/builtin/security/redis-rate-limit-script.ts src/extensions/distributed/rate-limit-support.ts src/middleware/builtin/security/redis-rate-limit.ts extensions/ext-redis/src/rate-limit-store.ts Tested: npx --yes deno@2.7.7 check src/middleware/builtin/security/redis-rate-limit-script.ts src/extensions/distributed/rate-limit-support.ts src/middleware/builtin/security/redis-rate-limit.ts extensions/ext-redis/src/rate-limit-store.ts Tested: git diff --check Not-tested: Full pre-push suite after this final extraction; the previous pre-push attempt reached 3722 passing tests before an unrelated SSR adapter dangling-timeout flake, and that test passed in isolation. --- docs/api-reference/veryfront/middleware.md | 34 +- extensions/ext-redis/src/index.ts | 2 +- .../ext-redis/src/rate-limit-store.test.ts | 209 ++++++- extensions/ext-redis/src/rate-limit-store.ts | 67 +- scripts/lint/audit-core-deps.test.ts | 40 +- .../distributed/rate-limit-support.ts | 5 + src/middleware/builtin/index.ts | 1 + src/middleware/builtin/security/index.ts | 3 + .../builtin/security/rate-limit.test.ts | 468 +++++++++++++- src/middleware/builtin/security/rate-limit.ts | 272 ++++++-- .../security/redis-rate-limit-script.ts | 9 + .../builtin/security/redis-rate-limit.test.ts | 579 ++++++++++-------- .../builtin/security/redis-rate-limit.ts | 145 ++++- src/middleware/index.ts | 1 + src/modules/server/rate-limiter.test.ts | 63 +- src/modules/server/rate-limiter.ts | 56 +- 16 files changed, 1557 insertions(+), 397 deletions(-) create mode 100644 src/middleware/builtin/security/redis-rate-limit-script.ts diff --git a/docs/api-reference/veryfront/middleware.md b/docs/api-reference/veryfront/middleware.md index 566003c095..cb85bf5a77 100644 --- a/docs/api-reference/veryfront/middleware.md +++ b/docs/api-reference/veryfront/middleware.md @@ -110,11 +110,20 @@ Options accepted by rate limit. | Property | Type | Description | Source | |----------|------|-------------|--------| -| `maxRequests?` | `number` | Max requests per window | [source](https://github.com/veryfront/veryfront-code/blob/main/src/middleware/builtin/security/rate-limit.ts#L73) | -| `windowMs?` | `number` | Time window (ms) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/middleware/builtin/security/rate-limit.ts#L74) | -| `store?` | `RateLimitStore` | Storage backend | [source](https://github.com/veryfront/veryfront-code/blob/main/src/middleware/builtin/security/rate-limit.ts#L75) | -| `keyGenerator?` | (req: Request) => string | Function to derive rate limit key from request | [source](https://github.com/veryfront/veryfront-code/blob/main/src/middleware/builtin/security/rate-limit.ts#L76) | -| `trustProxy?` | `boolean` | Trust proxy-set forwarding headers (X-Forwarded-For) for keying. Defaults to false so forwarded headers are ignored and cannot be used to evade limits. Enable only when a trusted proxy that appends the real client IP sits in front of this middleware. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/middleware/builtin/security/rate-limit.ts#L83) | +| `maxRequests?` | `number` | Max requests per window | [source](https://github.com/veryfront/veryfront-code/blob/main/src/middleware/builtin/security/rate-limit.ts#L142) | +| `windowMs?` | `number` | Time window (ms) | [source](https://github.com/veryfront/veryfront-code/blob/main/src/middleware/builtin/security/rate-limit.ts#L143) | +| `store?` | `RateLimitStore` | Storage backend | [source](https://github.com/veryfront/veryfront-code/blob/main/src/middleware/builtin/security/rate-limit.ts#L144) | +| `maxEntries?` | `number` | Capacity of the default in-memory store. It must exceed the peak distinct identities expected in one complete window plus burst headroom. Capacity exhaustion denies only previously unseen identities with HTTP 503. Cannot be combined with a caller-provided `store`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/middleware/builtin/security/rate-limit.ts#L151) | +| `keyGenerator?` | (req: Request) => string | Function to derive rate limit key from request | [source](https://github.com/veryfront/veryfront-code/blob/main/src/middleware/builtin/security/rate-limit.ts#L152) | +| `trustProxy?` | `boolean` | Trust proxy-set forwarding headers (X-Forwarded-For) for keying. Defaults to false so forwarded headers are ignored and cannot be used to evade limits. Enable only when a trusted proxy that appends the real client IP sits in front of this middleware. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/middleware/builtin/security/rate-limit.ts#L159) | + +### `MemoryRateLimitStoreOptions` + +Options accepted by the in-memory rate limit store. + +| Property | Type | Description | Source | +|----------|------|-------------|--------| +| `maxEntries?` | `number` | Maximum number of active identities retained by the store. Size this above the expected concurrent identities in one rate-limit window. New identities fail closed when all entries are active; existing identities remain tracked until their windows expire. When used through `rateLimit()`, capacity exhaustion logs `stage=store-increment`, `failureKind=capacity-exhausted`, and `capacity` set to the configured `maxEntries`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/middleware/builtin/security/rate-limit.ts#L137) | ### `LoggerOptions` @@ -142,13 +151,13 @@ Options accepted by timeout. | Name | Description | Source | |------|-------------|--------| -| `authRateLimit` | Pre-configured rate limiter for authentication endpoints (5 req/15min). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/middleware/builtin/security/rate-limit.ts#L140) | +| `authRateLimit` | Pre-configured rate limiter for authentication endpoints (5 req/15min). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/middleware/builtin/security/rate-limit.ts#L331) | | `cors` | Create CORS middleware. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/http/cors/middleware.ts#L10) | | `devLogger` | Create development request logging middleware. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/middleware/builtin/logger.ts#L244) | | `getTimeoutFromEnv` | Gets timeout from environment variable REQUEST_TIMEOUT_MS | [source](https://github.com/veryfront/veryfront-code/blob/main/src/middleware/builtin/timeout.ts#L94) | | `logger` | Create request logging middleware. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/middleware/builtin/logger.ts#L191) | | `prodLogger` | Create production request logging middleware. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/middleware/builtin/logger.ts#L249) | -| `rateLimit` | Create rate-limit middleware. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/middleware/builtin/security/rate-limit.ts#L106) | +| `rateLimit` | Create rate-limit middleware. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/middleware/builtin/security/rate-limit.ts#L224) | | `timeout` | Creates a middleware that enforces request timeouts. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/middleware/builtin/timeout.ts#L52) | | `timeoutFromEnv` | Creates a timeout middleware with configuration from environment | [source](https://github.com/veryfront/veryfront-code/blob/main/src/middleware/builtin/timeout.ts#L102) | @@ -156,26 +165,27 @@ Options accepted by timeout. | Name | Description | Source | |------|-------------|--------| -| `MemoryRateLimitStore` | Implement memory rate limit store. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/middleware/builtin/security/rate-limit.ts#L25) | +| `MemoryRateLimitStore` | Implement memory rate limit store. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/middleware/builtin/security/rate-limit.ts#L45) | | `MiddlewareContext` | Context for middleware. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/middleware/core/context.ts#L5) | | `MiddlewarePipeline` | Implement middleware pipeline. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/middleware/core/pipeline/pipeline.ts#L9) | -| `RedisRateLimitStore` | Implement redis rate limit store. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/middleware/builtin/security/redis-rate-limit.ts#L27) | +| `RedisRateLimitStore` | Redis rate-limit store backed by the registered Redis runtime provider. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/middleware/builtin/security/redis-rate-limit.ts#L39) | ### Types | Name | Description | Source | |------|-------------|--------| -| `AuthRateLimitOptions` | Options accepted by the authentication rate-limit preset. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/middleware/builtin/security/rate-limit.ts#L87) | +| `AuthRateLimitOptions` | Options accepted by the authentication rate-limit preset. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/middleware/builtin/security/rate-limit.ts#L163) | | `Context` | Context for context. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/middleware/core/types.ts#L8) | | `CorsOptions` | Options accepted by cors. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/middleware/builtin/types.ts#L26) | | `ExecutionContext` | Context for execution. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/middleware/core/types.ts#L2) | | `LogFormat` | Public API contract for log format. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/middleware/builtin/logger.ts#L14) | | `LoggerOptions` | Options accepted by logger. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/middleware/builtin/logger.ts#L17) | +| `MemoryRateLimitStoreOptions` | Options accepted by the in-memory rate limit store. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/middleware/builtin/security/rate-limit.ts#L128) | | `MiddlewareFactory` | Public API contract for middleware factory. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/middleware/core/types.ts#L32) | | `MiddlewareHandler` | Handler for middleware. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/middleware/core/types.ts#L26) | | `MiddlewarePipelineOptions` | Options accepted by middleware pipeline. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/middleware/core/pipeline/types.ts#L2) | | `Next` | Public API contract for next. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/middleware/core/types.ts#L23) | -| `RateLimitOptions` | Options accepted by rate limit. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/middleware/builtin/security/rate-limit.ts#L72) | +| `RateLimitOptions` | Options accepted by rate limit. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/middleware/builtin/security/rate-limit.ts#L141) | | `RateLimitStore` | Public API contract for rate limit store. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/middleware/builtin/security/types.ts#L32) | -| `RedisRateLimitOptions` | Options accepted by redis rate limit. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/middleware/builtin/security/redis-rate-limit.ts#L21) | +| `RedisRateLimitOptions` | Options accepted by the provider-backed Redis rate-limit store. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/middleware/builtin/security/redis-rate-limit.ts#L24) | | `TimeoutOptions` | Options accepted by timeout. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/middleware/builtin/timeout.ts#L17) | diff --git a/extensions/ext-redis/src/index.ts b/extensions/ext-redis/src/index.ts index e23c7b578b..9e06a5595a 100644 --- a/extensions/ext-redis/src/index.ts +++ b/extensions/ext-redis/src/index.ts @@ -54,7 +54,7 @@ export default extRedis; export { RedisMemory } from "./agent-memory.ts"; export { createRedisCacheAdministration } from "./cache-administration.ts"; export { RedisCacheBackend } from "./cache-backend.ts"; -export { RedisRateLimitStore } from "./rate-limit-store.ts"; +export { type RedisRateLimitOptions, RedisRateLimitStore } from "./rate-limit-store.ts"; export { RedisCacheStore } from "./render-cache-store.ts"; export { startProxyRoutingInvalidationBus } from "./routing-invalidation-bus.ts"; export { createRedisRuntimeProvider } from "./redis-runtime-provider.ts"; diff --git a/extensions/ext-redis/src/rate-limit-store.test.ts b/extensions/ext-redis/src/rate-limit-store.test.ts index edc9244610..05a2028b23 100644 --- a/extensions/ext-redis/src/rate-limit-store.test.ts +++ b/extensions/ext-redis/src/rate-limit-store.test.ts @@ -1,7 +1,10 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals, assertRejects, assertThrows } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; -import { type RedisRateLimitOptions, RedisRateLimitStore } from "./rate-limit-store.ts"; +import { isVeryfrontError, TIMEOUT_ERROR } from "veryfront/errors"; +import { MAX_RATE_LIMIT_KEY_LENGTH } from "veryfront/extensions/distributed/rate-limit-support"; +import { ClientClosedError } from "redis"; +import { type RedisRateLimitOptions, RedisRateLimitStore } from "./index.ts"; async function outcomeWithin( promise: Promise, @@ -137,6 +140,50 @@ function createStoreWithMock( return { rateStore, mockClient }; } +async function withTimeoutUnrefProbe(run: () => Promise): Promise<{ + result: T; + unrefCalls: number; +}> { + const runtime = globalThis as unknown as { + setTimeout: typeof setTimeout; + clearTimeout: typeof clearTimeout; + }; + const originalSetTimeout = runtime.setTimeout; + const originalClearTimeout = runtime.clearTimeout; + let unrefCalls = 0; + + runtime.setTimeout = ((handler: TimerHandler, timeout?: number, ...args: unknown[]) => { + const inner = originalSetTimeout(handler, timeout, ...args); + return { + inner, + unref() { + unrefCalls++; + }, + } as unknown as ReturnType; + }) as typeof setTimeout; + runtime.clearTimeout = ((id?: ReturnType) => { + const inner = (id as unknown as { inner?: ReturnType } | undefined) + ?.inner; + originalClearTimeout(inner ?? id); + }) as typeof clearTimeout; + + try { + return { result: await run(), unrefCalls }; + } finally { + runtime.setTimeout = originalSetTimeout; + runtime.clearTimeout = originalClearTimeout; + } +} + +async function withTimeoutRefGuard(run: () => Promise): Promise { + const keepAlive = setInterval(() => {}, 1_000); + try { + return await run(); + } finally { + clearInterval(keepAlive); + } +} + function assert_reset_at_is_future(resetAt: number): void { assertEquals(resetAt > Date.now() - 1000, true); } @@ -160,7 +207,7 @@ describe("middleware/builtin/security/redis-rate-limit", () => { assertThrows( () => new RedisRateLimitStore({ - keyPrefix: "x".repeat(1025), + keyPrefix: "x".repeat(MAX_RATE_LIMIT_KEY_LENGTH + 1), }), RangeError, "1024", @@ -253,7 +300,7 @@ describe("middleware/builtin/security/redis-rate-limit", () => { const { rateStore, mockClient } = createStoreWithMock(); await assertRejects( - () => rateStore.increment("x".repeat(1025), 1000), + () => rateStore.increment("x".repeat(MAX_RATE_LIMIT_KEY_LENGTH + 1), 1000), RangeError, "1024", ); @@ -289,16 +336,59 @@ describe("middleware/builtin/security/redis-rate-limit", () => { }); mockClient.eval = () => new Promise(() => {}); - const outcome = await outcomeWithin( - rateStore.increment("key", 1000), - 50, + const error = await withTimeoutRefGuard(() => + assertRejects( + () => rateStore.increment("key", 1000), + Error, + "timed out", + ) ); - assertEquals(outcome, "rejected"); + assertEquals(isVeryfrontError(error), true); + assertEquals(isVeryfrontError(error) ? error.slug : undefined, TIMEOUT_ERROR.slug); assertEquals(mockClient._disconnectCalls, 1); // deno-lint-ignore no-explicit-any assertEquals((rateStore as any).client, null); }); + + it("unrefs the operation timeout so it does not hold the process open", async () => { + const { rateStore, mockClient } = createStoreWithMock({ + operationTimeoutMs: 1, + }); + mockClient.eval = () => new Promise(() => {}); + + const { result: error, unrefCalls } = await withTimeoutUnrefProbe(() => + assertRejects( + () => rateStore.increment("key", 1000), + Error, + "timed out", + ) + ); + + assertEquals(isVeryfrontError(error), true); + assertEquals(unrefCalls, 1); + }); + + it("does not retire a client for an unrelated TimeoutError name", async () => { + const { rateStore, mockClient } = createStoreWithMock(); + mockClient.eval = () => { + const error = new Error("foreign timeout"); + error.name = "TimeoutError"; + return Promise.reject(error); + }; + + const error = await assertRejects( + () => rateStore.increment("key", 1000), + Error, + "foreign timeout", + ); + + if (!(error instanceof Error)) throw new Error("Expected Redis client error"); + assertEquals(error.name, "TimeoutError"); + assertEquals(mockClient._disconnectCalls, 0); + // deno-lint-ignore no-explicit-any + assertEquals((rateStore as any).client, mockClient); + }); }); describe("reset", () => { @@ -315,6 +405,23 @@ describe("middleware/builtin/security/redis-rate-limit", () => { const { rateStore } = createStoreWithMock(); await rateStore.reset("nonexistent"); }); + + it("should reject an invalid key before loading or connecting Redis", async () => { + const rateStore = new RedisRateLimitStore(); + let factoryLoads = 0; + // deno-lint-ignore no-explicit-any + (rateStore as any).loadClientFactory = () => { + factoryLoads++; + return Promise.resolve(() => createMockRedisClient()); + }; + + await assertRejects( + () => rateStore.reset("x".repeat(MAX_RATE_LIMIT_KEY_LENGTH + 1)), + RangeError, + "1024", + ); + assertEquals(factoryLoads, 0); + }); }); describe("destroy", () => { @@ -339,6 +446,22 @@ describe("middleware/builtin/security/redis-rate-limit", () => { assertEquals((rateStore as any).client, null); }); + it("should treat already-closed clients as destroyed", async () => { + const { rateStore, mockClient } = createStoreWithMock(); + let disconnectAttempts = 0; + mockClient.disconnect = () => { + disconnectAttempts++; + return Promise.reject(new ClientClosedError()); + }; + + await rateStore.destroy(); + await rateStore.destroy(); + + assertEquals(disconnectAttempts, 1); + // deno-lint-ignore no-explicit-any + assertEquals((rateStore as any).client, null); + }); + it("should retain a failed disconnect so shutdown can retry it", async () => { const { rateStore, mockClient } = createStoreWithMock(); let disconnectAttempts = 0; @@ -360,6 +483,24 @@ describe("middleware/builtin/security/redis-rate-limit", () => { }); }); + describe("reset", () => { + it("should reject invalid keys before connecting", async () => { + const rateStore = new RedisRateLimitStore(); + const mockClient = createMockRedisClient(); + + // deno-lint-ignore no-explicit-any + (rateStore as any).loadClientFactory = () => Promise.resolve(() => mockClient); + + await assertRejects( + () => rateStore.reset("x".repeat(MAX_RATE_LIMIT_KEY_LENGTH + 1)), + RangeError, + "1024", + ); + + assertEquals(mockClient._connectCalls, 0); + }); + }); + describe("ensureClient", () => { it("should reuse existing client", async () => { const { rateStore, mockClient } = createStoreWithMock(); @@ -564,6 +705,60 @@ describe("middleware/builtin/security/redis-rate-limit", () => { const outcome = await outcomeWithin(incrementPromise, 50); assertEquals(outcome, "rejected"); }); + + it("should attach pending rejection handling before destroy cancels it", async () => { + const rateStore = new RedisRateLimitStore(); + const mockClient = createMockRedisClient(); + let connectStarted = false; + mockClient.connect = () => { + connectStarted = true; + return new Promise(() => {}); + }; + + // deno-lint-ignore no-explicit-any + (rateStore as any).loadClientFactory = () => Promise.resolve(() => mockClient); + + const incrementPromise = rateStore.increment("pending", 1000); + for (let attempt = 0; attempt < 10 && !connectStarted; attempt++) { + await Promise.resolve(); + } + + // deno-lint-ignore no-explicit-any + const pending = (rateStore as any).clientPromise as Promise | null; + let cancelObserved = false; + let catchAttachedBeforeCancel = false; + if (pending) { + const originalCatch = pending.catch.bind(pending); + Object.defineProperty(pending, "catch", { + configurable: true, + value: (...args: Parameters["catch"]>) => { + if (!cancelObserved) catchAttachedBeforeCancel = true; + return originalCatch(...args); + }, + }); + } + // deno-lint-ignore no-explicit-any + const originalCancel = (rateStore as any).cancelPendingConnection as + | (() => void) + | null; + // deno-lint-ignore no-explicit-any + (rateStore as any).cancelPendingConnection = () => { + cancelObserved = true; + originalCancel?.(); + }; + + await rateStore.destroy(); + + if (!pending) throw new Error("Expected pending connection promise"); + assertEquals(catchAttachedBeforeCancel, true); + const pendingOutcome = await outcomeWithin(pending, 50); + await assertRejects( + () => incrementPromise, + Error, + "superseded", + ); + assertEquals(pendingOutcome, "rejected"); + }); }); }); }); diff --git a/extensions/ext-redis/src/rate-limit-store.ts b/extensions/ext-redis/src/rate-limit-store.ts index 4458ddc3e0..3747288c67 100644 --- a/extensions/ext-redis/src/rate-limit-store.ts +++ b/extensions/ext-redis/src/rate-limit-store.ts @@ -1,12 +1,14 @@ -import { createError, toError } from "veryfront/errors"; +import { createError, isVeryfrontError, TIMEOUT_ERROR, toError } from "veryfront/errors"; import { serverLogger } from "veryfront/utils/logger"; -import { createClient } from "redis"; +import { ClientClosedError, createClient } from "redis"; import { MAX_TIMER_DELAY_MS, type RateLimitEntry, type RateLimitStore, + REDIS_RATE_LIMIT_INCREMENT_WITH_TTL_SCRIPT, requireRateLimitKey, requireRateLimitWindowMs, + unrefTimer, } from "veryfront/extensions/distributed/rate-limit-support"; const logger = serverLogger.component("redis-ratelimit"); @@ -38,16 +40,6 @@ type RedisClientFactory = (options: RedisClientFactoryOptions) => RedisClient; const DEFAULT_REDIS_CONNECT_TIMEOUT_MS = 5_000; const DEFAULT_REDIS_OPERATION_TIMEOUT_MS = 5_000; -const INCREMENT_WITH_TTL_SCRIPT = ` -local count = redis.call("INCR", KEYS[1]) -local ttl = redis.call("PTTL", KEYS[1]) -if ttl < 0 then - redis.call("PEXPIRE", KEYS[1], ARGV[1]) - ttl = tonumber(ARGV[1]) -end -return { count, ttl } -`; - /** Options accepted by redis rate limit. */ export interface RedisRateLimitOptions { url?: string; @@ -157,6 +149,10 @@ export class RedisRateLimitStore implements RateLimitStore { try { await this.disconnectClient(client); } catch (error) { + if (isAlreadyClosedClientError(error)) { + this.markDisconnected(client); + return; + } logger.warn("client disconnect failed", { errorName: error instanceof Error ? error.name : typeof error, }); @@ -180,24 +176,38 @@ export class RedisRateLimitStore implements RateLimitStore { try { Promise.resolve(client.disconnect()).then( () => { - this.disconnectPromises.delete(client); - this.pendingDisconnectClients.delete(client); - this.disconnectedClients.add(client); + this.markDisconnected(client); resolveDisconnect(); }, (error) => { this.disconnectPromises.delete(client); + if (isAlreadyClosedClientError(error)) { + this.markDisconnected(client); + resolveDisconnect(); + return; + } rejectDisconnect(error); }, ); } catch (error) { this.disconnectPromises.delete(client); - rejectDisconnect(error); + if (isAlreadyClosedClientError(error)) { + this.markDisconnected(client); + resolveDisconnect(); + } else { + rejectDisconnect(error); + } } return pending; } + private markDisconnected(client: RedisClient): void { + this.disconnectPromises.delete(client); + this.pendingDisconnectClients.delete(client); + this.disconnectedClients.add(client); + } + private async withTimeout( operation: Promise, timeoutMs: number, @@ -209,6 +219,7 @@ export class RedisRateLimitStore implements RateLimitStore { timeoutId = setTimeout(() => { reject(createTimeoutError(operationName, timeoutMs)); }, timeoutMs); + unrefTimer(timeoutId); }); try { @@ -298,7 +309,7 @@ export class RedisRateLimitStore implements RateLimitStore { let result: unknown; try { result = await this.withTimeout( - client.eval(INCREMENT_WITH_TTL_SCRIPT, { + client.eval(REDIS_RATE_LIMIT_INCREMENT_WITH_TTL_SCRIPT, { keys: [redisKey], arguments: [String(normalizedWindowMs)], }), @@ -318,11 +329,12 @@ export class RedisRateLimitStore implements RateLimitStore { } async reset(key: string): Promise { + const normalizedKey = requireRateLimitKey(key); const client = await this.ensureClient(); const generation = this.clientGeneration; try { await this.withTimeout( - client.del(this.storageKey(requireRateLimitKey(key))), + client.del(this.storageKey(normalizedKey)), this.operationTimeoutMs, "reset", ); @@ -338,6 +350,9 @@ export class RedisRateLimitStore implements RateLimitStore { const client = this.client; const connectingClient = this.connectingClient; const pending = this.clientPromise; + // Mark the pending connection rejection as observed before cancellation; + // disconnect work below may otherwise leave an unhandled-rejection window. + pending?.catch(() => {}); const cancelPendingConnection = this.cancelPendingConnection; const clientsToDisconnect = new Set(this.pendingDisconnectClients); if (client) clientsToDisconnect.add(client); @@ -359,8 +374,6 @@ export class RedisRateLimitStore implements RateLimitStore { }) ), ); - pending?.catch(() => {}); - if (disconnectFailed) throw disconnectError; } } @@ -380,15 +393,17 @@ function requireTimeoutMs(value: unknown, name: string): number { } function createTimeoutError(operationName: string, timeoutMs: number): Error { - const error = new Error( - `Redis rate limit ${operationName} timed out after ${timeoutMs}ms`, - ); - error.name = "TimeoutError"; - return error; + return TIMEOUT_ERROR.create({ + detail: `Redis rate limit ${operationName} timed out after ${timeoutMs}ms`, + }); } function isTimeoutError(error: unknown): boolean { - return error instanceof Error && error.name === "TimeoutError"; + return isVeryfrontError(error) && error.slug === TIMEOUT_ERROR.slug; +} + +function isAlreadyClosedClientError(error: unknown): boolean { + return error instanceof ClientClosedError; } function parseIncrementResult(result: unknown): [number, number] { diff --git a/scripts/lint/audit-core-deps.test.ts b/scripts/lint/audit-core-deps.test.ts index b26d2a6851..066d9c250c 100644 --- a/scripts/lint/audit-core-deps.test.ts +++ b/scripts/lint/audit-core-deps.test.ts @@ -261,7 +261,7 @@ describe("findCoreThirdPartySourceImports", () => { { path: "src/cache/scoped-imports.ts", content: [ - 'const dependency = "npm:redis@5.11.0";', + 'const dependency = "npm:ioredis@5.8.2";', "{", ' const dependency = "./local.ts";', " await import(dependency);", @@ -275,7 +275,7 @@ describe("findCoreThirdPartySourceImports", () => { { path: "src/cache/scoped-imports.ts", line: 6, - specifier: "npm:redis@5.11.0", + specifier: "npm:ioredis@5.8.2", }, ]); }); @@ -285,7 +285,7 @@ describe("findCoreThirdPartySourceImports", () => { { path: "src/cache/default-parameter.ts", content: [ - 'const dependency = "npm:redis@5.11.0";', + 'const dependency = "npm:ioredis@5.8.2";', "function load(value = dependency) {", " return import(dependency);", "}", @@ -294,7 +294,7 @@ describe("findCoreThirdPartySourceImports", () => { { path: "src/cache/destructured-parameter.ts", content: [ - 'const dependency = "npm:redis@5.11.0";', + 'const dependency = "npm:ioredis@5.8.2";', "function load({ dependency: local }) {", " return import(dependency);", "}", @@ -303,7 +303,7 @@ describe("findCoreThirdPartySourceImports", () => { { path: "src/cache/loop-scope.ts", content: [ - 'const dependency = "npm:redis@5.11.0";', + 'const dependency = "npm:ioredis@5.8.2";', 'for (const dependency of ["./local.ts"]) {', " await import(dependency);", "}", @@ -313,7 +313,7 @@ describe("findCoreThirdPartySourceImports", () => { { path: "src/cache/var-scope.ts", content: [ - 'const dependency = "npm:redis@5.11.0";', + 'const dependency = "npm:ioredis@5.8.2";', "function load() {", " { var dependency = './local.ts'; }", " return import(dependency);", @@ -323,7 +323,7 @@ describe("findCoreThirdPartySourceImports", () => { { path: "src/cache/parameter-var-scope.ts", content: [ - 'const dependency = "npm:redis@5.11.0";', + 'const dependency = "npm:ioredis@5.8.2";', "function load(value = import(dependency)) {", " var dependency = './local.ts';", "}", @@ -332,7 +332,7 @@ describe("findCoreThirdPartySourceImports", () => { { path: "src/cache/static-block-scope.ts", content: [ - 'const dependency = "npm:redis@5.11.0";', + 'const dependency = "npm:ioredis@5.8.2";', "class Cache {", " static {", " var dependency = './local.ts';", @@ -345,7 +345,7 @@ describe("findCoreThirdPartySourceImports", () => { { path: "src/cache/named-class-expression.ts", content: [ - 'const dependency = "npm:redis@5.11.0";', + 'const dependency = "npm:ioredis@5.8.2";', "const Cache = class dependency {", " static { void import(dependency); }", "};", @@ -354,7 +354,7 @@ describe("findCoreThirdPartySourceImports", () => { { path: "src/cache/computed-class-method.ts", content: [ - 'const dependency = "npm:redis@5.11.0";', + 'const dependency = "npm:ioredis@5.8.2";', "class Cache {", " [import(dependency)](dependency: string) {}", "}", @@ -363,7 +363,7 @@ describe("findCoreThirdPartySourceImports", () => { { path: "src/cache/computed-object-method.ts", content: [ - 'const dependency = "npm:redis@5.11.0";', + 'const dependency = "npm:ioredis@5.8.2";', "const cache = {", " [import(dependency)](dependency: string) {}", "};", @@ -372,7 +372,7 @@ describe("findCoreThirdPartySourceImports", () => { { path: "src/cache/namespace-scope.ts", content: [ - 'const dependency = "npm:redis@5.11.0";', + 'const dependency = "npm:ioredis@5.8.2";', "namespace Cache {", ' const dependency = "./local.ts";', " void import(dependency);", @@ -386,42 +386,42 @@ describe("findCoreThirdPartySourceImports", () => { { path: "src/cache/default-parameter.ts", line: 3, - specifier: "npm:redis@5.11.0", + specifier: "npm:ioredis@5.8.2", }, { path: "src/cache/destructured-parameter.ts", line: 3, - specifier: "npm:redis@5.11.0", + specifier: "npm:ioredis@5.8.2", }, { path: "src/cache/loop-scope.ts", line: 5, - specifier: "npm:redis@5.11.0", + specifier: "npm:ioredis@5.8.2", }, { path: "src/cache/parameter-var-scope.ts", line: 2, - specifier: "npm:redis@5.11.0", + specifier: "npm:ioredis@5.8.2", }, { path: "src/cache/static-block-scope.ts", line: 8, - specifier: "npm:redis@5.11.0", + specifier: "npm:ioredis@5.8.2", }, { path: "src/cache/computed-class-method.ts", line: 3, - specifier: "npm:redis@5.11.0", + specifier: "npm:ioredis@5.8.2", }, { path: "src/cache/computed-object-method.ts", line: 3, - specifier: "npm:redis@5.11.0", + specifier: "npm:ioredis@5.8.2", }, { path: "src/cache/namespace-scope.ts", line: 6, - specifier: "npm:redis@5.11.0", + specifier: "npm:ioredis@5.8.2", }, ]); }); diff --git a/src/extensions/distributed/rate-limit-support.ts b/src/extensions/distributed/rate-limit-support.ts index 0e6fd54fac..c5b4bec44f 100644 --- a/src/extensions/distributed/rate-limit-support.ts +++ b/src/extensions/distributed/rate-limit-support.ts @@ -5,7 +5,12 @@ export type { RateLimitStore, } from "#veryfront/middleware/builtin/security/types.ts"; export { + MAX_RATE_LIMIT_KEY_LENGTH, requireRateLimitKey, requireRateLimitWindowMs, } from "#veryfront/middleware/builtin/security/rate-limit-validation.ts"; +export { + REDIS_RATE_LIMIT_INCREMENT_WITH_TTL_SCRIPT, +} from "#veryfront/middleware/builtin/security/redis-rate-limit-script.ts"; +export { unrefTimer } from "#veryfront/platform/compat/process.ts"; export { MAX_TIMER_DELAY_MS } from "#veryfront/utils/timer.ts"; diff --git a/src/middleware/builtin/index.ts b/src/middleware/builtin/index.ts index 83032b6fae..5adf9277d2 100644 --- a/src/middleware/builtin/index.ts +++ b/src/middleware/builtin/index.ts @@ -21,6 +21,7 @@ export { authRateLimit, type AuthRateLimitOptions, MemoryRateLimitStore, + type MemoryRateLimitStoreOptions, rateLimit, type RateLimitOptions, } from "./security/rate-limit.ts"; diff --git a/src/middleware/builtin/security/index.ts b/src/middleware/builtin/security/index.ts index d409db3cf3..fa88c51e03 100644 --- a/src/middleware/builtin/security/index.ts +++ b/src/middleware/builtin/security/index.ts @@ -17,7 +17,10 @@ export { csrfProtection } from "./csrf.ts"; export { authRateLimit, type AuthRateLimitOptions, + MemoryRateLimitStore, + type MemoryRateLimitStoreOptions, rateLimit, type RateLimitOptions, } from "./rate-limit.ts"; +export { type RedisRateLimitOptions, RedisRateLimitStore } from "./redis-rate-limit.ts"; export { securityHeaders } from "./security-headers.ts"; diff --git a/src/middleware/builtin/security/rate-limit.test.ts b/src/middleware/builtin/security/rate-limit.test.ts index 04b9fe34da..d428c1a3cf 100644 --- a/src/middleware/builtin/security/rate-limit.test.ts +++ b/src/middleware/builtin/security/rate-limit.test.ts @@ -1,10 +1,24 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals, assertExists } from "#veryfront/testing/assert.ts"; +import { + assertEquals, + assertExists, + assertRejects, + assertThrows, +} from "#veryfront/testing/assert.ts"; import { afterEach, beforeEach, describe, it } from "#veryfront/testing/bdd.ts"; import { delay } from "#std/async.ts"; import { scaleMs } from "#veryfront/testing/timing.ts"; +import { deleteEnv, getHostEnv, setEnv } from "#veryfront/platform/compat/process.ts"; +import { __subscribeLogRecordEmitter, type LogEntry } from "#veryfront/utils/logger/index.ts"; import { MiddlewareContext } from "../../core/context.ts"; -import { authRateLimit, MemoryRateLimitStore, rateLimit } from "./rate-limit.ts"; +import { MAX_RATE_LIMIT_KEY_LENGTH } from "./rate-limit-validation.ts"; +import { + authRateLimit, + MemoryRateLimitStore, + rateLimit, + type RedisRateLimitOptions, + RedisRateLimitStore, +} from "#veryfront/middleware"; (globalThis as Record).__vfDisableLruInterval = true; @@ -67,6 +81,87 @@ describe("MemoryRateLimitStore", () => { await store.reset("non-existent"); }); }); + + it("should reject new identities at capacity without evicting active limits", async () => { + const boundedStore = new MemoryRateLimitStore(60000, { maxEntries: 1 }); + + try { + await boundedStore.increment("existing", 60000); + + const error = await assertRejects( + () => boundedStore.increment("overflow", 60000), + Error, + "capacity", + ); + if (!(error instanceof Error)) throw new Error("Expected a capacity error"); + assertEquals(error.name, "MemoryRateLimitCapacityError"); + + const existing = await boundedStore.increment("existing", 60000); + assertEquals(existing.count, 2); + } finally { + boundedStore.destroy(); + } + }); + + it("rejects invalid store options with a stable error", () => { + assertThrows( + () => new MemoryRateLimitStore(60000, null as never), + TypeError, + "options", + ); + }); + + it("should release retained entries when destroyed", async () => { + const boundedStore = new MemoryRateLimitStore(60000, { maxEntries: 1 }); + await boundedStore.increment("first", 60000); + + boundedStore.destroy(); + + const replacement = await boundedStore.increment("second", 60000); + assertEquals(replacement.count, 1); + boundedStore.destroy(); + }); + + it("should honor the host cleanup-disable flag", () => { + const globals = globalThis as Record; + const previousGlobalFlag = globals.__vfDisableLruInterval; + const previousHostFlag = getHostEnv("VF_DISABLE_LRU_INTERVAL"); + globals.__vfDisableLruInterval = false; + setEnv("VF_DISABLE_LRU_INTERVAL", "1"); + + const disabledStore = new MemoryRateLimitStore(60000); + try { + const internals = disabledStore as unknown as { + cleanupInterval?: ReturnType; + }; + assertEquals(internals.cleanupInterval, undefined); + } finally { + disabledStore.destroy(); + if (previousGlobalFlag === undefined) { + delete globals.__vfDisableLruInterval; + } else { + globals.__vfDisableLruInterval = previousGlobalFlag; + } + if (previousHostFlag === undefined) { + deleteEnv("VF_DISABLE_LRU_INTERVAL"); + } else { + setEnv("VF_DISABLE_LRU_INTERVAL", previousHostFlag); + } + } + }); + + it("should reject invalid capacity and window configuration", () => { + for (const maxEntries of [0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY]) { + assertThrows( + () => new MemoryRateLimitStore(60000, { maxEntries }), + RangeError, + ); + } + + for (const windowMs of [0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY]) { + assertThrows(() => new MemoryRateLimitStore(windowMs), RangeError); + } + }); }); describe("rateLimit middleware", () => { @@ -122,6 +217,364 @@ describe("rateLimit middleware", () => { assertEquals(response?.status, 200); }); + it("should keep the documented default limits at 100 requests per 60s window", async () => { + const middleware = rateLimit(); + + for (let index = 0; index < 100; index++) { + const response = await middleware( + createContext(), + () => Promise.resolve(new Response("OK")), + ); + assertEquals(response?.status, 200); + } + + const blocked = await middleware( + createContext(), + () => Promise.resolve(new Response("OK")), + ); + + assertEquals(blocked?.status, 429); + const retryAfterSeconds = Number(blocked?.headers.get("Retry-After")); + assertEquals(Number.isSafeInteger(retryAfterSeconds), true); + assertEquals(retryAfterSeconds >= 1 && retryAfterSeconds <= 60, true); + }); + + it("should validate numeric configuration before creating middleware", () => { + for ( + const maxRequests of [ + -1, + 1.5, + Number.NaN, + Number.MAX_SAFE_INTEGER, + Number.POSITIVE_INFINITY, + ] + ) { + assertThrows( + () => rateLimit({ maxRequests }), + RangeError, + "between 0", + ); + } + + for (const windowMs of [0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY]) { + assertThrows( + () => rateLimit({ windowMs }), + RangeError, + ); + } + + assertThrows( + () => + rateLimit({ + maxEntries: 100, + store: { + increment: () => Promise.resolve({ count: 1, resetAt: Date.now() + 1_000 }), + reset: () => Promise.resolve(), + }, + }), + TypeError, + "maxEntries", + ); + }); + + it("keeps active identities available and fails closed for overflow identities", async () => { + const maxEntries = 256; + const middleware = rateLimit({ + maxRequests: 2, + windowMs: 60_000, + maxEntries, + trustProxy: true, + }); + + for (let index = 0; index < maxEntries; index++) { + const response = await middleware( + createContext(`198.51.100.${index}`), + () => Promise.resolve(new Response("OK")), + ); + assertEquals(response?.status, 200); + } + + const overflow = await middleware( + createContext("203.0.113.1"), + () => Promise.resolve(new Response("unexpected")), + ); + const existing = await middleware( + createContext("198.51.100.0"), + () => Promise.resolve(new Response("OK")), + ); + + assertEquals(overflow?.status, 503); + assertEquals(existing?.status, 200); + }); + + it("should fail closed when the rate-limit store is unavailable", async () => { + let nextCalled = false; + const middleware = rateLimit({ + store: { + increment: () => Promise.reject(new Error("backend unavailable")), + reset: () => Promise.resolve(), + }, + }); + + const response = await middleware(createContext(), () => { + nextCalled = true; + return Promise.resolve(new Response("OK")); + }); + + assertEquals(response?.status, 503); + assertEquals(response?.headers.get("Retry-After"), "60"); + assertEquals(response?.headers.get("Cache-Control"), "no-store"); + assertEquals(nextCalled, false); + }); + + it("should throttle repeated rate-limit store failure logs", async () => { + const originalConsoleError = console.error; + let loggedFailures = 0; + console.error = () => { + loggedFailures++; + }; + + try { + const middleware = rateLimit({ + store: { + increment: () => Promise.reject(new Error("backend unavailable")), + reset: () => Promise.resolve(), + }, + }); + + const first = await middleware( + createContext(), + () => Promise.resolve(new Response("OK")), + ); + const second = await middleware( + createContext(), + () => Promise.resolve(new Response("OK")), + ); + + assertEquals(first?.status, 503); + assertEquals(second?.status, 503); + assertEquals(loggedFailures, 1); + } finally { + console.error = originalConsoleError; + } + }); + + it("logs key, store, and capacity failures as distinct operational signals", async () => { + const originalConsoleError = console.error; + const logs: string[] = []; + console.error = (...values: unknown[]) => { + logs.push(values.map((value) => String(value)).join(" ")); + }; + + try { + const keyFailure = rateLimit({ + keyGenerator: () => "x".repeat(MAX_RATE_LIMIT_KEY_LENGTH + 1), + }); + const storeFailure = rateLimit({ + store: { + increment: () => Promise.reject(new Error("unavailable")), + reset: () => Promise.resolve(), + }, + }); + const capacityFailure = rateLimit({ maxEntries: 1, trustProxy: true }); + + await keyFailure(createContext(), () => Promise.resolve(new Response("unexpected"))); + await storeFailure(createContext(), () => Promise.resolve(new Response("unexpected"))); + await capacityFailure( + createContext("198.51.100.1"), + () => Promise.resolve(new Response("OK")), + ); + await capacityFailure( + createContext("198.51.100.2"), + () => Promise.resolve(new Response("unexpected")), + ); + + const output = logs.join("\n"); + assertEquals(output.includes("failureKind=key-resolution"), true); + assertEquals(output.includes("failureKind=store-unavailable"), true); + assertEquals(output.includes("failureKind=capacity-exhausted"), true); + assertEquals(output.includes("capacity=1"), true); + } finally { + console.error = originalConsoleError; + } + }); + + it("should fail closed when a store returns an invalid counter", async () => { + const middleware = rateLimit({ + store: { + increment: () => Promise.resolve({ count: Number.NaN, resetAt: Date.now() + 1000 }), + reset: () => Promise.resolve(), + }, + }); + + const response = await middleware( + createContext(), + () => Promise.resolve(new Response("OK")), + ); + + assertEquals(response?.status, 503); + }); + + it("should keep the legacy Redis rate-limit store export constructible", () => { + const options: RedisRateLimitOptions = { + keyPrefix: "compat:", + connectTimeoutMs: 1_000, + operationTimeoutMs: 1_000, + }; + const redisStore = new RedisRateLimitStore(options); + + assertEquals(typeof redisStore.increment, "function"); + assertEquals(typeof redisStore.reset, "function"); + }); + + it("should fail closed when custom keys are invalid without calling the store", async () => { + let incrementCalled = false; + const middleware = rateLimit({ + keyGenerator: () => "x".repeat(MAX_RATE_LIMIT_KEY_LENGTH + 1), + store: { + increment: () => { + incrementCalled = true; + return Promise.resolve({ count: 1, resetAt: Date.now() + 1000 }); + }, + reset: () => Promise.resolve(), + }, + }); + + const response = await middleware( + createContext(), + () => Promise.resolve(new Response("OK")), + ); + + assertEquals(response?.status, 503); + assertEquals(response?.headers.get("Retry-After"), "60"); + assertEquals(incrementCalled, false); + }); + + it("should fail closed when trusted proxy headers generate invalid keys", async () => { + let incrementCalled = false; + const middleware = rateLimit({ + trustProxy: true, + store: { + increment: () => { + incrementCalled = true; + return Promise.resolve({ count: 1, resetAt: Date.now() + 1000 }); + }, + reset: () => Promise.resolve(), + }, + }); + + const response = await middleware( + createContext("x".repeat(MAX_RATE_LIMIT_KEY_LENGTH + 1)), + () => Promise.resolve(new Response("OK")), + ); + + assertEquals(response?.status, 503); + assertEquals(response?.headers.get("Retry-After"), "60"); + assertEquals(incrementCalled, false); + }); + + it("should log key resolution failures separately from store failures", async () => { + const records: LogEntry[] = []; + const unsubscribe = __subscribeLogRecordEmitter((entry) => { + if (entry.component === "rate-limit") records.push(entry); + }); + + try { + const keyFailure = rateLimit({ + keyGenerator: () => { + throw new Error("custom key failure"); + }, + store: { + increment: () => Promise.resolve({ count: 1, resetAt: Date.now() + 1000 }), + reset: () => Promise.resolve(), + }, + }); + const storeFailure = rateLimit({ + store: { + increment: () => { + const error = new Error("backend unavailable"); + error.name = "BackendUnavailableError"; + return Promise.reject(error); + }, + reset: () => Promise.resolve(), + }, + }); + + assertEquals( + (await keyFailure(createContext(), () => Promise.resolve(new Response("OK")))) + ?.status, + 503, + ); + assertEquals( + (await storeFailure(createContext(), () => Promise.resolve(new Response("OK")))) + ?.status, + 503, + ); + } finally { + unsubscribe(); + } + + assertEquals(records.map((record) => record.message), [ + "Rate limit key resolution failed; request denied", + "Rate limit store failed; request denied", + ]); + assertEquals(records.map((record) => record.context?.stage), [ + "key-resolution", + "store-increment", + ]); + assertEquals(records.map((record) => record.context?.failureKind), [ + "key-resolution", + "store-unavailable", + ]); + assertEquals(records.map((record) => record.context?.errorName), [ + "Error", + "BackendUnavailableError", + ]); + }); + + it("should emit a capacity-specific store failure signal", async () => { + const records: LogEntry[] = []; + const unsubscribe = __subscribeLogRecordEmitter((entry) => { + if (entry.component === "rate-limit") records.push(entry); + }); + const store = new MemoryRateLimitStore(60000, { maxEntries: 1 }); + const middleware = rateLimit({ + maxRequests: 10, + windowMs: 60000, + store, + trustProxy: true, + }); + + try { + assertEquals( + (await middleware( + createContext("198.51.100.1"), + () => Promise.resolve(new Response("OK")), + ))?.status, + 200, + ); + assertEquals( + (await middleware( + createContext("198.51.100.2"), + () => Promise.resolve(new Response("OK")), + ))?.status, + 503, + ); + } finally { + unsubscribe(); + store.destroy(); + } + + assertEquals(records.length, 1); + assertEquals( + records[0]?.message, + "Rate limit store capacity exhausted; request denied", + ); + assertEquals(records[0]?.context?.stage, "store-increment"); + assertEquals(records[0]?.context?.failureKind, "capacity-exhausted"); + assertEquals(records[0]?.context?.capacity, 1); + }); + it("should use custom key generator", async () => { let capturedKey = ""; const middleware = rateLimit({ @@ -208,6 +661,17 @@ describe("rateLimit middleware", () => { } }); + it("should require direct auth preset stores to implement reset", () => { + assertThrows( + () => + authRateLimit({ + increment: () => Promise.resolve({ count: 1, resetAt: Date.now() + 1000 }), + } as never), + TypeError, + "increment() and reset()", + ); + }); + it("should separate trusted proxy clients in the auth preset", async () => { const middleware = authRateLimit({ trustProxy: true }); diff --git a/src/middleware/builtin/security/rate-limit.ts b/src/middleware/builtin/security/rate-limit.ts index 656921c878..1bdbbe7b38 100644 --- a/src/middleware/builtin/security/rate-limit.ts +++ b/src/middleware/builtin/security/rate-limit.ts @@ -3,18 +3,38 @@ import { getRequest } from "../types.ts"; import type { RateLimitEntry, RateLimitStore } from "./types.ts"; import { HTTP_TOO_MANY_REQUESTS, + HTTP_UNAVAILABLE, MS_PER_MINUTE, MS_PER_SECOND, } from "#veryfront/utils/constants/http.ts"; import { CLEANUP_INTERVAL_MULTIPLIER } from "#veryfront/utils/constants/cache.ts"; -import { unrefTimer } from "#veryfront/platform/compat/process.ts"; +import { getHostEnv, unrefTimer } from "#veryfront/platform/compat/process.ts"; import { resolveRateLimitClientKey } from "#veryfront/security/rate-limit/client-key.ts"; +import { MAX_TIMER_DELAY_MS } from "#veryfront/utils/timer.ts"; +import { serverLogger } from "#veryfront/utils"; +import { + requireRateLimitEntry, + requireRateLimitKey, + requireRateLimitWindowMs, +} from "./rate-limit-validation.ts"; const DEFAULT_RATE_LIMIT_REQUESTS = 100; const DEFAULT_RATE_LIMIT_WINDOW_MS = MS_PER_MINUTE; +const DEFAULT_MEMORY_RATE_LIMIT_MAX_ENTRIES = 10_000; +const STORE_FAILURE_RETRY_AFTER_SECONDS = 60; +const STORE_FAILURE_LOG_INTERVAL_MS = MS_PER_MINUTE; +const logger = serverLogger.component("rate-limit"); + +class MemoryRateLimitCapacityError extends Error { + override readonly name = "MemoryRateLimitCapacityError"; + + constructor(readonly capacity: number) { + super(`Memory rate limit store capacity of ${capacity} entries is exhausted`); + } +} -function createRateLimitEntry(windowMs: number): RateLimitEntry { - return { count: 1, resetAt: Date.now() + windowMs }; +function createRateLimitEntry(now: number, windowMs: number): RateLimitEntry { + return { count: 1, resetAt: now + windowMs }; } function defaultKeyGenerator(req: Request, trustProxy: boolean): string { @@ -25,54 +45,115 @@ function defaultKeyGenerator(req: Request, trustProxy: boolean): string { export class MemoryRateLimitStore implements RateLimitStore { private counts = new Map(); private cleanupInterval?: ReturnType; + private readonly maxEntries: number; + + constructor( + windowMs: number, + options: MemoryRateLimitStoreOptions = {}, + ) { + if (!options || typeof options !== "object" || Array.isArray(options)) { + throw new TypeError("Memory rate limit store options must be an object"); + } + const normalizedWindowMs = requireRateLimitWindowMs(windowMs); + const maxEntries = options.maxEntries ?? + DEFAULT_MEMORY_RATE_LIMIT_MAX_ENTRIES; + if (!Number.isSafeInteger(maxEntries) || maxEntries <= 0) { + throw new RangeError( + "Memory rate limit maxEntries must be a positive safe integer", + ); + } + this.maxEntries = maxEntries; - constructor(windowMs: number) { const shouldSkipInterval = - (globalThis as Record).__vfDisableLruInterval === true; + (globalThis as Record).__vfDisableLruInterval === true || + getHostEnv("VF_DISABLE_LRU_INTERVAL") === "1"; if (shouldSkipInterval) return; - this.cleanupInterval = setInterval(() => { - const now = Date.now(); - for (const [key, entry] of this.counts.entries()) { - if (entry.resetAt < now) this.counts.delete(key); - } - }, windowMs * CLEANUP_INTERVAL_MULTIPLIER); + this.cleanupInterval = setInterval( + () => { + this.removeExpired(Date.now()); + }, + Math.min( + normalizedWindowMs * CLEANUP_INTERVAL_MULTIPLIER, + MAX_TIMER_DELAY_MS, + ), + ); unrefTimer(this.cleanupInterval); } - increment(key: string, windowMs: number): Promise { - const existing = this.counts.get(key); + async increment(key: string, windowMs: number): Promise { + const normalizedKey = requireRateLimitKey(key); + const normalizedWindowMs = requireRateLimitWindowMs(windowMs); + const existing = this.counts.get(normalizedKey); const now = Date.now(); - if (!existing || existing.resetAt < now) { - const entry = createRateLimitEntry(windowMs); - this.counts.set(key, entry); - return Promise.resolve(entry); + if (!existing || existing.resetAt <= now) { + if (existing) this.counts.delete(normalizedKey); + + if (this.counts.size >= this.maxEntries) { + this.removeExpired(now); + } + if (this.counts.size >= this.maxEntries) { + throw new MemoryRateLimitCapacityError(this.maxEntries); + } + + const entry = createRateLimitEntry(now, normalizedWindowMs); + this.counts.set(normalizedKey, entry); + return { ...entry }; } - existing.count++; - return Promise.resolve(existing); + if (existing.count < Number.MAX_SAFE_INTEGER) existing.count++; + return { ...existing }; } - reset(key: string): Promise { - this.counts.delete(key); - return Promise.resolve(); + async reset(key: string): Promise { + this.counts.delete(requireRateLimitKey(key)); } destroy(): void { - if (!this.cleanupInterval) return; - clearInterval(this.cleanupInterval); - this.cleanupInterval = undefined; + this.counts.clear(); + if (this.cleanupInterval !== undefined) { + clearInterval(this.cleanupInterval); + this.cleanupInterval = undefined; + } + } + + private removeExpired(now: number): void { + for (const [key, entry] of this.counts) { + if (entry.resetAt <= now) this.counts.delete(key); + } } } +/** Options accepted by the in-memory rate limit store. */ +export interface MemoryRateLimitStoreOptions { + /** + * Maximum number of active identities retained by the store. + * + * Size this above the peak number of distinct identities expected during one + * complete rate-limit window, including burst headroom. New identities fail + * closed when every entry is active; active limits are never evicted because + * eviction would let identity-flooding attackers reset their quota. When + * used through `rateLimit()`, capacity exhaustion logs structured failure + * details for the middleware request path. + */ + maxEntries?: number; +} + /** Options accepted by rate limit. */ export interface RateLimitOptions { maxRequests?: number; windowMs?: number; store?: RateLimitStore; + /** + * Capacity of the default in-memory store. It must exceed the peak distinct + * identities expected in one complete window plus burst headroom. Capacity + * exhaustion denies only previously unseen identities with HTTP 503. + * Cannot be combined with a caller-provided `store`. + */ + maxEntries?: number; keyGenerator?: (req: Request) => string; /** * Trust proxy-set forwarding headers (X-Forwarded-For) for keying. Defaults to @@ -87,6 +168,8 @@ export interface RateLimitOptions { export interface AuthRateLimitOptions { /** Storage backend. Existing callers can also pass the store directly. */ store?: RateLimitStore; + /** Capacity of the default in-memory store; see `RateLimitOptions.maxEntries`. */ + maxEntries?: number; /** Function to derive a stable client key from the request. */ keyGenerator?: (req: Request) => string; /** @@ -96,10 +179,54 @@ export interface AuthRateLimitOptions { trustProxy?: boolean; } -function isRateLimitStore( - value: RateLimitStore | AuthRateLimitOptions, -): value is RateLimitStore { - return "increment" in value && typeof value.increment === "function"; +function isRateLimitStore(value: unknown): value is RateLimitStore { + return ( + value != null && + typeof value === "object" && + typeof (value as Partial).increment === "function" && + typeof (value as Partial).reset === "function" + ); +} + +function hasRateLimitStoreMethod(value: unknown): boolean { + return ( + value != null && + typeof value === "object" && + ("increment" in value || "reset" in value) + ); +} + +function requireRateLimitStore(value: unknown): RateLimitStore { + if (!isRateLimitStore(value)) { + throw new TypeError( + "Rate limit store must implement increment() and reset()", + ); + } + return value as RateLimitStore; +} + +function requireMaxRequests(value: unknown): number { + if ( + typeof value !== "number" || + !Number.isSafeInteger(value) || + value < 0 || + value >= Number.MAX_SAFE_INTEGER + ) { + throw new RangeError( + `Rate limit maxRequests must be an integer between 0 and ${Number.MAX_SAFE_INTEGER - 1}`, + ); + } + return value; +} + +function storeUnavailableResponse(): Response { + return new Response("Service temporarily unavailable", { + status: HTTP_UNAVAILABLE, + headers: { + "Cache-Control": "no-store", + "Retry-After": String(STORE_FAILURE_RETRY_AFTER_SECONDS), + }, + }); } /** Create rate-limit middleware. */ @@ -107,31 +234,100 @@ export function rateLimit( optionsOrMaxRequests?: number | RateLimitOptions, windowMsArg?: number, ): Middleware { + if ( + optionsOrMaxRequests !== undefined && + typeof optionsOrMaxRequests !== "number" && + (typeof optionsOrMaxRequests !== "object" || + optionsOrMaxRequests === null || + Array.isArray(optionsOrMaxRequests)) + ) { + throw new TypeError( + "Rate limit configuration must be a number or options object", + ); + } + const options: RateLimitOptions = typeof optionsOrMaxRequests === "number" ? { maxRequests: optionsOrMaxRequests, windowMs: windowMsArg } : optionsOrMaxRequests ?? {}; - const maxRequests = options.maxRequests ?? DEFAULT_RATE_LIMIT_REQUESTS; - const windowMs = options.windowMs ?? DEFAULT_RATE_LIMIT_WINDOW_MS; - const store = options.store ?? new MemoryRateLimitStore(windowMs); + const maxRequests = requireMaxRequests( + options.maxRequests ?? DEFAULT_RATE_LIMIT_REQUESTS, + ); + const windowMs = requireRateLimitWindowMs( + options.windowMs ?? DEFAULT_RATE_LIMIT_WINDOW_MS, + ); + if (options.store !== undefined && options.maxEntries !== undefined) { + throw new TypeError("Rate limit maxEntries cannot be combined with a custom store"); + } + const store = options.store === undefined + ? new MemoryRateLimitStore(windowMs, { maxEntries: options.maxEntries }) + : requireRateLimitStore(options.store); + if ( + options.trustProxy !== undefined && + typeof options.trustProxy !== "boolean" + ) { + throw new TypeError("Rate limit trustProxy must be a boolean"); + } const trustProxy = options.trustProxy ?? false; + if ( + options.keyGenerator !== undefined && + typeof options.keyGenerator !== "function" + ) { + throw new TypeError("Rate limit keyGenerator must be a function"); + } const keyGenerator = options.keyGenerator ?? ((req: Request) => defaultKeyGenerator(req, trustProxy)); + const lastFailureLogAt = new Map(); return async (ctx, next) => { const req = getRequest(ctx); - const key = keyGenerator(req); - const entry = await store.increment(key, windowMs); + let entry: RateLimitEntry; + let stage: "key-resolution" | "store-increment" = "key-resolution"; + try { + const key = requireRateLimitKey(keyGenerator(req)); + stage = "store-increment"; + entry = requireRateLimitEntry(await store.increment(key, windowMs)); + } catch (error) { + const failureKind = error instanceof MemoryRateLimitCapacityError + ? "capacity-exhausted" + : stage === "key-resolution" + ? "key-resolution" + : "store-unavailable"; + const now = performance.now(); + const lastLogAt = lastFailureLogAt.get(failureKind); + if ( + lastLogAt === undefined || + now - lastLogAt >= STORE_FAILURE_LOG_INTERVAL_MS + ) { + lastFailureLogAt.set(failureKind, now); + const message = failureKind === "capacity-exhausted" + ? "Rate limit store capacity exhausted; request denied" + : failureKind === "key-resolution" + ? "Rate limit key resolution failed; request denied" + : "Rate limit store failed; request denied"; + logger.error(message, { + failureKind, + stage, + errorName: error instanceof Error ? error.name : typeof error, + ...(error instanceof MemoryRateLimitCapacityError ? { capacity: error.capacity } : {}), + }); + } + return storeUnavailableResponse(); + } if (entry.count <= maxRequests) return next(); - const retryAfterSeconds = Math.ceil( - (entry.resetAt - Date.now()) / MS_PER_SECOND, + const retryAfterSeconds = Math.max( + 1, + Math.ceil((entry.resetAt - Date.now()) / MS_PER_SECOND), ); return new Response("Too Many Requests", { status: HTTP_TOO_MANY_REQUESTS, - headers: { "Retry-After": String(retryAfterSeconds) }, + headers: { + "Cache-Control": "no-store", + "Retry-After": String(retryAfterSeconds), + }, }); }; } @@ -144,6 +340,8 @@ export function authRateLimit( ? {} : isRateLimitStore(storeOrOptions) ? { store: storeOrOptions } + : hasRateLimitStoreMethod(storeOrOptions) + ? { store: requireRateLimitStore(storeOrOptions) } : storeOrOptions; return rateLimit({ diff --git a/src/middleware/builtin/security/redis-rate-limit-script.ts b/src/middleware/builtin/security/redis-rate-limit-script.ts new file mode 100644 index 0000000000..00ffe615f5 --- /dev/null +++ b/src/middleware/builtin/security/redis-rate-limit-script.ts @@ -0,0 +1,9 @@ +export const REDIS_RATE_LIMIT_INCREMENT_WITH_TTL_SCRIPT = ` +local count = redis.call("INCR", KEYS[1]) +local ttl = redis.call("PTTL", KEYS[1]) +if ttl < 0 then + redis.call("PEXPIRE", KEYS[1], ARGV[1]) + ttl = tonumber(ARGV[1]) +end +return { count, ttl } +`; diff --git a/src/middleware/builtin/security/redis-rate-limit.test.ts b/src/middleware/builtin/security/redis-rate-limit.test.ts index df45126d1a..5dd46da530 100644 --- a/src/middleware/builtin/security/redis-rate-limit.test.ts +++ b/src/middleware/builtin/security/redis-rate-limit.test.ts @@ -1,314 +1,377 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals, assertRejects, assertThrows } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; -import { RedisRateLimitStore } from "./redis-rate-limit.ts"; +import { isVeryfrontError, TIMEOUT_ERROR } from "#veryfront/errors"; +import { MAX_RATE_LIMIT_KEY_LENGTH } from "./rate-limit-validation.ts"; +import { type RedisRateLimitOptions, RedisRateLimitStore } from "./redis-rate-limit.ts"; -function createMockRedisClient(): { - connect: () => Promise; - disconnect: () => Promise; - eval: ( +interface MockRedisClient { + eval( script: string, options: { keys: string[]; arguments: string[] }, - ) => Promise<[number, number]>; - incr: (key: string) => Promise; - pExpire: (key: string, ms: number) => Promise; - pTTL: (key: string) => Promise; - del: (key: string) => Promise; - on: (event: string, listener: (...args: unknown[]) => void) => void; - _emit: (event: string, ...args: unknown[]) => void; + ): Promise; + del(key: string): Promise; _evalCalls: number; - _incrCalls: number; - _pExpireCalls: number; - _disconnectCalls: number; _delCalls: number; - _store: Map; -} { - const store = new Map(); - const listeners = new Map void>>(); + _lastKey?: string; + _lastWindow?: string; +} + +function createMockRedisClient( + result: unknown = [1, 60_000], +): MockRedisClient { let evalCalls = 0; - let incrCalls = 0; - let pExpireCalls = 0; - let disconnectCalls = 0; let delCalls = 0; - - return { - connect: () => Promise.resolve(), - disconnect: () => { - disconnectCalls++; - return Promise.resolve(); - }, - eval: (_script: string, options: { keys: string[]; arguments: string[] }) => { - evalCalls += 1; - const key = options.keys[0]; - if (!key) throw new Error("Expected eval key"); - const windowMs = Number(options.arguments[0]); - const entry = store.get(key) ?? { count: 0, ttl: -1 }; - entry.count += 1; - if (entry.ttl < 0) entry.ttl = windowMs; - store.set(key, entry); - return Promise.resolve([entry.count, entry.ttl]); - }, - incr: (key: string) => { - incrCalls += 1; - const entry = store.get(key) ?? { count: 0, ttl: -1 }; - entry.count += 1; - store.set(key, entry); - return Promise.resolve(entry.count); + const client: MockRedisClient = { + eval: (_script, options) => { + evalCalls++; + client._lastKey = options.keys[0]; + client._lastWindow = options.arguments[0]; + return Promise.resolve(result); }, - pExpire: (key: string, ms: number) => { - pExpireCalls += 1; - const entry = store.get(key); - if (entry) entry.ttl = ms; - return Promise.resolve(true); - }, - pTTL: (key: string) => { - const entry = store.get(key); - return Promise.resolve(entry?.ttl ?? -2); - }, - del: (key: string) => { - delCalls += 1; - const deleted = store.has(key) ? 1 : 0; - store.delete(key); - return Promise.resolve(deleted); - }, - on: (event: string, listener: (...args: unknown[]) => void) => { - const eventListeners = listeners.get(event) ?? []; - eventListeners.push(listener); - listeners.set(event, eventListeners); - }, - _emit: (event: string, ...args: unknown[]) => { - for (const listener of listeners.get(event) ?? []) listener(...args); + del: (key) => { + delCalls++; + client._lastKey = key; + return Promise.resolve(1); }, get _evalCalls() { return evalCalls; }, - get _incrCalls() { - return incrCalls; - }, - get _pExpireCalls() { - return pExpireCalls; - }, - get _disconnectCalls() { - return disconnectCalls; - }, get _delCalls() { return delCalls; }, - _store: store, }; + return client; } function createStoreWithMock( - options?: { keyPrefix?: string }, + options?: RedisRateLimitOptions, + client = createMockRedisClient(), ): { - rateStore: RedisRateLimitStore; - mockClient: ReturnType; + store: RedisRateLimitStore; + client: MockRedisClient; + getClientCalls: () => number; + closeCalls: () => number; } { - const rateStore = new RedisRateLimitStore(options); - const mockClient = createMockRedisClient(); + const store = new RedisRateLimitStore(options); + let getClientCalls = 0; + let closeCalls = 0; let closed = false; - - // deno-lint-ignore no-explicit-any - (rateStore as any).connection = { - getClient: () => Promise.resolve(mockClient), - close: async () => { - if (closed) return; - await mockClient.disconnect(); - closed = true; + (store as unknown as { + connection: { + getClient(): Promise; + close(): Promise; + }; + }).connection = { + getClient: () => { + getClientCalls++; + return Promise.resolve(client); + }, + close: () => { + if (!closed) { + closeCalls++; + closed = true; + } + return Promise.resolve(); }, }; + return { + store, + client, + getClientCalls: () => getClientCalls, + closeCalls: () => closeCalls, + }; +} - return { rateStore, mockClient }; +async function withTimeoutUnrefProbe(run: () => Promise): Promise<{ + result: T; + unrefCalls: number; +}> { + const runtime = globalThis as unknown as { + setTimeout: typeof setTimeout; + clearTimeout: typeof clearTimeout; + }; + const originalSetTimeout = runtime.setTimeout; + const originalClearTimeout = runtime.clearTimeout; + let unrefCalls = 0; + + runtime.setTimeout = ((handler: TimerHandler, timeout?: number, ...args: unknown[]) => { + const inner = originalSetTimeout(handler, timeout, ...args); + return { + inner, + unref() { + unrefCalls++; + }, + } as unknown as ReturnType; + }) as typeof setTimeout; + runtime.clearTimeout = ((id?: ReturnType) => { + const inner = (id as unknown as { inner?: ReturnType } | undefined) + ?.inner; + originalClearTimeout(inner ?? id); + }) as typeof clearTimeout; + + try { + return { result: await run(), unrefCalls }; + } finally { + runtime.setTimeout = originalSetTimeout; + runtime.clearTimeout = originalClearTimeout; + } } -function assert_reset_at_is_future(resetAt: number): void { - assertEquals(resetAt > Date.now() - 1000, true); +async function withTimeoutRefGuard(run: () => Promise): Promise { + const keepAlive = setInterval(() => {}, 1_000); + try { + return await run(); + } finally { + clearInterval(keepAlive); + } } -describe("middleware/builtin/security/redis-rate-limit", () => { - describe("RedisRateLimitStore", () => { - describe("constructor", () => { - it("should use default key prefix", () => { - const store = new RedisRateLimitStore(); - // deno-lint-ignore no-explicit-any - assertEquals((store as any).keyPrefix, "veryfront:ratelimit:"); - }); - - it("should accept custom key prefix", () => { - const store = new RedisRateLimitStore({ keyPrefix: "custom:" }); - // deno-lint-ignore no-explicit-any - assertEquals((store as any).keyPrefix, "custom:"); - }); - - it("should reject an invalid key prefix before connecting", () => { +describe("provider-backed RedisRateLimitStore", () => { + describe("constructor", () => { + it("uses the stable default key prefix", () => { + const store = new RedisRateLimitStore(); + assertEquals( + (store as unknown as { keyPrefix: string }).keyPrefix, + "veryfront:ratelimit:", + ); + }); + + it("accepts a custom key prefix", () => { + const store = new RedisRateLimitStore({ keyPrefix: "tenant:" }); + assertEquals( + (store as unknown as { keyPrefix: string }).keyPrefix, + "tenant:", + ); + }); + + it("rejects malformed options before opening a provider connection", () => { + assertThrows( + () => new RedisRateLimitStore(null as never), + TypeError, + "options", + ); + assertThrows( + () => new RedisRateLimitStore({ url: 42 as never }), + TypeError, + "url", + ); + assertThrows( + () => new RedisRateLimitStore({ keyPrefix: "x".repeat(MAX_RATE_LIMIT_KEY_LENGTH + 1) }), + RangeError, + "1024", + ); + for (const timeout of [0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY]) { assertThrows( - () => new RedisRateLimitStore({ keyPrefix: "x".repeat(1025) }), + () => new RedisRateLimitStore({ connectTimeoutMs: timeout }), RangeError, - "1024", + "connectTimeoutMs", ); - for (const invalidPrefix of ["", " \t ", "app\u0000:", "app\u0085:"]) { - assertThrows( - () => new RedisRateLimitStore({ keyPrefix: invalidPrefix }), - TypeError, - "visible text without control characters", - ); - } - }); + assertThrows( + () => new RedisRateLimitStore({ operationTimeoutMs: timeout }), + RangeError, + "operationTimeoutMs", + ); + } + }); + }); + + describe("increment", () => { + it("preserves the Redis key and window contract", async () => { + const { store, client } = createStoreWithMock({ keyPrefix: "custom:" }); + const entry = await store.increment("user-1", 30_000); + + assertEquals(entry.count, 1); + assertEquals(entry.resetAt > Date.now(), true); + assertEquals(client._lastKey, "custom:user-1"); + assertEquals(client._lastWindow, "30000"); + assertEquals(client._evalCalls, 1); }); - describe("increment", () => { - it("should increment count for a new key", async () => { - const { rateStore } = createStoreWithMock(); - const entry = await rateStore.increment("test-key", 60000); - assertEquals(entry.count, 1); - assert_reset_at_is_future(entry.resetAt); - }); - - it("should set expiry on first increment", async () => { - const { rateStore, mockClient } = createStoreWithMock(); - await rateStore.increment("key1", 60000); - const storedEntry = mockClient._store.get("veryfront:ratelimit:key1"); - assertEquals(storedEntry?.ttl, 60000); - }); - - it("should increment and set missing TTL in one Redis eval", async () => { - const { rateStore, mockClient } = createStoreWithMock(); - - const entry = await rateStore.increment("key1", 60000); - - assertEquals(entry.count, 1); - assertEquals(mockClient._evalCalls, 1); - assertEquals(mockClient._incrCalls, 0); - assertEquals(mockClient._pExpireCalls, 0); - }); - - it("should increment count for existing key", async () => { - const { rateStore } = createStoreWithMock(); - await rateStore.increment("key1", 60000); - const entry = await rateStore.increment("key1", 60000); - assertEquals(entry.count, 2); - }); - - it("should use custom key prefix", async () => { - const { rateStore, mockClient } = createStoreWithMock({ keyPrefix: "app:" }); - await rateStore.increment("user-1", 30000); - assertEquals(mockClient._store.has("app:user-1"), true); - }); - - it("should handle pTTL returning -1 by re-setting expiry", async () => { - const { rateStore, mockClient } = createStoreWithMock(); - - await rateStore.increment("key1", 60000); - - const stored = mockClient._store.get("veryfront:ratelimit:key1"); - if (!stored) throw new Error("Expected key to exist in mock store"); - stored.ttl = -1; - - const result = await rateStore.increment("key1", 60000); - assertEquals(result.count, 2); - - const updated = mockClient._store.get("veryfront:ratelimit:key1"); - if (!updated) throw new Error("Expected key to exist in mock store"); - assertEquals(updated.ttl, 60000); - }); - - it("should return resetAt based on pTTL", async () => { - const { rateStore } = createStoreWithMock(); - const before = Date.now(); - const entry = await rateStore.increment("key1", 60000); - const diff = entry.resetAt - before; - assertEquals(diff >= 59000 && diff <= 61000, true); - }); - - it("should reject invalid keys and windows before Redis evaluation", async () => { - const { rateStore, mockClient } = createStoreWithMock(); + it("uses the admitted Redis TTL for resetAt", async () => { + const before = Date.now(); + const { store } = createStoreWithMock(undefined, createMockRedisClient([2, 1_500])); + const entry = await store.increment("user", 30_000); - await assertRejects( - () => rateStore.increment("x".repeat(1025), 1000), - RangeError, - "1024", - ); - for ( - const invalidKey of ["", " \t ", "tenant\u0000member", "tenant\u0085member"] - ) { - await assertRejects( - () => rateStore.increment(invalidKey, 1000), - TypeError, - "visible text without control characters", - ); - } - for (const invalidWindow of [0, -1, 1.5, Number.NaN]) { - await assertRejects( - () => rateStore.increment("key", invalidWindow), - RangeError, - "windowMs", - ); - } - - assertEquals(mockClient._evalCalls, 0); - }); + assertEquals(entry.count, 2); + assertEquals(entry.resetAt >= before + 1_500, true); + assertEquals(entry.resetAt <= Date.now() + 1_500, true); }); - describe("reset", () => { - it("should delete the key from the store", async () => { - const { rateStore, mockClient } = createStoreWithMock(); - await rateStore.increment("key1", 60000); - assertEquals(mockClient._store.has("veryfront:ratelimit:key1"), true); + it("falls back to the configured window when Redis reports no TTL", async () => { + const before = Date.now(); + const { store } = createStoreWithMock(undefined, createMockRedisClient([1, -1])); + const entry = await store.increment("user", 2_000); - await rateStore.reset("key1"); - assertEquals(mockClient._store.has("veryfront:ratelimit:key1"), false); - }); + assertEquals(entry.resetAt >= before + 2_000, true); + assertEquals(entry.resetAt <= Date.now() + 2_000, true); + }); - it("should not throw when resetting non-existent key", async () => { - const { rateStore } = createStoreWithMock(); - await rateStore.reset("nonexistent"); - }); + it("validates keys and windows before opening a provider connection", async () => { + const { store, client, getClientCalls } = createStoreWithMock(); + + await assertRejects( + () => store.increment("x".repeat(MAX_RATE_LIMIT_KEY_LENGTH + 1), 1_000), + RangeError, + "1024", + ); + await assertRejects( + () => store.increment("key", 0), + RangeError, + "windowMs", + ); + assertEquals(getClientCalls(), 0); + assertEquals(client._evalCalls, 0); + }); - it("should reject an invalid key before deleting", async () => { - const { rateStore, mockClient } = createStoreWithMock(); + it("rejects malformed Redis eval envelopes", async () => { + for (const result of [null, {}, [], [1]]) { + const { store } = createStoreWithMock(undefined, createMockRedisClient(result)); + await assertRejects( + () => store.increment("key", 1_000), + Error, + "invalid result", + ); + } + }); + it("rejects non-positive or unsafe counters", async () => { + for (const count of [0, -1, 1.5, Number.NaN, Number.MAX_SAFE_INTEGER + 1]) { + const { store } = createStoreWithMock( + undefined, + createMockRedisClient([count, 1_000]), + ); await assertRejects( - () => rateStore.reset("x".repeat(1025)), - RangeError, - "1024", + () => store.increment("key", 1_000), + Error, + "invalid count", + ); + } + }); + + it("rejects unsafe TTL values", async () => { + for (const ttl of [1.5, Number.NaN, Number.MAX_SAFE_INTEGER + 1]) { + const { store } = createStoreWithMock( + undefined, + createMockRedisClient([1, ttl]), ); await assertRejects( - () => rateStore.reset("tenant\u0000member"), - TypeError, - "visible text without control characters", + () => store.increment("key", 1_000), + Error, + "invalid TTL", ); + } + }); + + it("bounds commands and retires a provider connection after timeout", async () => { + const client = createMockRedisClient(); + client.eval = () => new Promise(() => {}); + const { store, closeCalls } = createStoreWithMock( + { operationTimeoutMs: 1 }, + client, + ); + + const error = await withTimeoutRefGuard(() => + assertRejects( + () => store.increment("key", 1_000), + Error, + "timed out", + ) + ); + assertEquals(isVeryfrontError(error), true); + assertEquals(isVeryfrontError(error) ? error.slug : undefined, TIMEOUT_ERROR.slug); + assertEquals(closeCalls(), 1); + }); - assertEquals(mockClient._delCalls, 0); - }); + it("unrefs the operation timeout so it does not hold the process open", async () => { + const client = createMockRedisClient(); + client.eval = () => new Promise(() => {}); + const { store } = createStoreWithMock({ operationTimeoutMs: 1 }, client); + + const { result: error, unrefCalls } = await withTimeoutUnrefProbe(() => + assertRejects( + () => store.increment("key", 1_000), + Error, + "timed out", + ) + ); + + assertEquals(isVeryfrontError(error), true); + assertEquals(unrefCalls, 1); }); - describe("destroy", () => { - it("should disconnect the client", async () => { - const { rateStore, mockClient } = createStoreWithMock(); - await rateStore.destroy(); - assertEquals(mockClient._disconnectCalls, 1); - }); - - it("should be safe to call when no client exists", async () => { - const store = new RedisRateLimitStore(); - await store.destroy(); - }); - - it("should be safe to call multiple times", async () => { - const { rateStore, mockClient } = createStoreWithMock(); - await rateStore.destroy(); - await rateStore.destroy(); - assertEquals(mockClient._disconnectCalls, 1); - }); + it("does not retire a provider connection for an unrelated TimeoutError name", async () => { + const client = createMockRedisClient(); + client.eval = () => { + const error = new Error("foreign timeout"); + error.name = "TimeoutError"; + return Promise.reject(error); + }; + const { store, closeCalls } = createStoreWithMock(undefined, client); + + const error = await assertRejects( + () => store.increment("key", 1_000), + Error, + "foreign timeout", + ); + + if (!(error instanceof Error)) throw new Error("Expected Redis client error"); + assertEquals(error.name, "TimeoutError"); + assertEquals(closeCalls(), 0); + }); + }); + + describe("reset", () => { + it("deletes the prefixed key", async () => { + const { store, client } = createStoreWithMock({ keyPrefix: "custom:" }); + await store.reset("user-1"); + + assertEquals(client._lastKey, "custom:user-1"); + assertEquals(client._delCalls, 1); + }); + + it("validates the key before opening a provider connection", async () => { + const { store, client, getClientCalls } = createStoreWithMock(); + await assertRejects( + () => store.reset("tenant\u0000member"), + TypeError, + "control characters", + ); + assertEquals(getClientCalls(), 0); + assertEquals(client._delCalls, 0); + }); + + it("bounds delete commands and retires the connection after timeout", async () => { + const client = createMockRedisClient(); + client.del = () => new Promise(() => {}); + const { store, closeCalls } = createStoreWithMock( + { operationTimeoutMs: 1 }, + client, + ); + + await withTimeoutRefGuard(() => + assertRejects( + () => store.reset("key"), + Error, + "timed out", + ) + ); + assertEquals(closeCalls(), 1); + }); + }); + + describe("destroy", () => { + it("closes its provider-owned connection", async () => { + const { store, closeCalls } = createStoreWithMock(); + await store.destroy(); + assertEquals(closeCalls(), 1); }); - describe("ensureClient", () => { - it("should reuse existing client", async () => { - const { rateStore, mockClient } = createStoreWithMock(); - await rateStore.increment("a", 1000); - await rateStore.increment("b", 1000); - assertEquals(mockClient._evalCalls, 2); - }); + it("is idempotent at the store boundary", async () => { + const { store, closeCalls } = createStoreWithMock(); + await store.destroy(); + await store.destroy(); + assertEquals(closeCalls(), 1); }); }); }); diff --git a/src/middleware/builtin/security/redis-rate-limit.ts b/src/middleware/builtin/security/redis-rate-limit.ts index 7764763f29..00e08be816 100644 --- a/src/middleware/builtin/security/redis-rate-limit.ts +++ b/src/middleware/builtin/security/redis-rate-limit.ts @@ -1,46 +1,73 @@ -import { createError, toError } from "#veryfront/errors"; +import { createError, isVeryfrontError, TIMEOUT_ERROR, toError } from "#veryfront/errors"; import { OwnedRedisClientConnection } from "#veryfront/extensions/distributed/owned-redis-client.ts"; import type { RedisClient } from "#veryfront/extensions/distributed"; +import { unrefTimer } from "#veryfront/platform/compat/process.ts"; import { serverLogger } from "#veryfront/utils"; +import { MAX_TIMER_DELAY_MS } from "#veryfront/utils/timer.ts"; +import { REDIS_RATE_LIMIT_INCREMENT_WITH_TTL_SCRIPT } from "./redis-rate-limit-script.ts"; import { requireRateLimitKey, requireRateLimitWindowMs } from "./rate-limit-validation.ts"; import type { RateLimitEntry, RateLimitStore } from "./types.ts"; const logger = serverLogger.component("redis-ratelimit"); +const DEFAULT_REDIS_CONNECT_TIMEOUT_MS = 5_000; +const DEFAULT_REDIS_OPERATION_TIMEOUT_MS = 5_000; -const INCREMENT_WITH_TTL_SCRIPT = ` -local count = redis.call("INCR", KEYS[1]) -local ttl = redis.call("PTTL", KEYS[1]) -if ttl < 0 then - redis.call("PEXPIRE", KEYS[1], ARGV[1]) - ttl = tonumber(ARGV[1]) -end -return { count, ttl } -`; - -/** Options accepted by redis rate limit. */ +/** Options accepted by the provider-backed Redis rate-limit store. */ export interface RedisRateLimitOptions { url?: string; keyPrefix?: string; + /** Maximum time allowed for opening the extension-provided Redis client. */ + connectTimeoutMs?: number; + /** Maximum time allowed for an individual Redis command. */ + operationTimeoutMs?: number; } -/** Implement redis rate limit store. */ +/** + * Redis rate-limit store backed by the registered Redis runtime provider. + * + * Core owns only the stable rate-limit facade. The Redis extension owns the + * third-party client package, connections, and transport lifecycle. + */ export class RedisRateLimitStore implements RateLimitStore { private readonly connection: OwnedRedisClientConnection; private readonly keyPrefix: string; + private readonly operationTimeoutMs: number; constructor(options: RedisRateLimitOptions = {}) { + if (typeof options !== "object" || options === null || Array.isArray(options)) { + throw new TypeError("Redis rate limit options must be an object"); + } + if (options.url !== undefined && typeof options.url !== "string") { + throw new TypeError("Redis rate limit url must be a string"); + } + const connectTimeoutMs = requireTimeoutMs( + options.connectTimeoutMs ?? DEFAULT_REDIS_CONNECT_TIMEOUT_MS, + "connectTimeoutMs", + ); + this.operationTimeoutMs = requireTimeoutMs( + options.operationTimeoutMs ?? DEFAULT_REDIS_OPERATION_TIMEOUT_MS, + "operationTimeoutMs", + ); this.keyPrefix = requireRateLimitKey( options.keyPrefix ?? "veryfront:ratelimit:", "Redis rate limit keyPrefix", ); this.connection = new OwnedRedisClientConnection( - options.url === undefined ? {} : { url: options.url }, + { + ...(options.url === undefined ? {} : { url: options.url }), + connectTimeout: connectTimeoutMs, + autoReconnect: false, + }, { onError(error) { - logger.error("client error", error); + logger.error("client error", { + errorName: error instanceof Error ? error.name : typeof error, + }); }, onCloseError(error) { - logger.error("client close failed", error); + logger.error("client close failed", { + errorName: error instanceof Error ? error.name : typeof error, + }); }, }, ); @@ -54,6 +81,38 @@ export class RedisRateLimitStore implements RateLimitStore { return `${this.keyPrefix}${key}`; } + private async withOperationTimeout( + operation: Promise, + operationName: string, + ): Promise { + let timeoutId: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timeoutId = setTimeout( + () => reject(createTimeoutError(operationName, this.operationTimeoutMs)), + this.operationTimeoutMs, + ); + unrefTimer(timeoutId); + }); + + try { + return await Promise.race([operation, timeout]); + } catch (error) { + if (isTimeoutError(error)) { + // Retire the timed-out provider-owned connection before another + // operation can reuse it. A close failure stays observable on the next + // getClient()/destroy() attempt instead of silently reopening. + void this.connection.close().catch((closeError) => { + logger.error("timed-out client close failed", { + errorName: closeError instanceof Error ? closeError.name : typeof closeError, + }); + }); + } + throw error; + } finally { + if (timeoutId !== undefined) clearTimeout(timeoutId); + } + } + async increment(key: string, windowMs: number): Promise { const normalizedKey = requireRateLimitKey(key); const normalizedWindowMs = requireRateLimitWindowMs(windowMs); @@ -61,19 +120,25 @@ export class RedisRateLimitStore implements RateLimitStore { const redisKey = this.storageKey(normalizedKey); const [count, pttl] = parseIncrementResult( - await client.eval(INCREMENT_WITH_TTL_SCRIPT, { - keys: [redisKey], - arguments: [String(normalizedWindowMs)], - }), + await this.withOperationTimeout( + client.eval(REDIS_RATE_LIMIT_INCREMENT_WITH_TTL_SCRIPT, { + keys: [redisKey], + arguments: [String(normalizedWindowMs)], + }), + "increment", + ), ); - const ttl = pttl > 0 ? pttl : normalizedWindowMs; + const ttl = pttl > 0 ? requireRateLimitWindowMs(pttl) : normalizedWindowMs; return { count, resetAt: Date.now() + ttl }; } async reset(key: string): Promise { const normalizedKey = requireRateLimitKey(key); const client = await this.ensureClient(); - await client.del(this.storageKey(normalizedKey)); + await this.withOperationTimeout( + client.del(this.storageKey(normalizedKey)).then(() => undefined), + "reset", + ); } async destroy(): Promise { @@ -81,6 +146,30 @@ export class RedisRateLimitStore implements RateLimitStore { } } +function requireTimeoutMs(value: unknown, name: string): number { + if ( + typeof value !== "number" || + !Number.isSafeInteger(value) || + value <= 0 || + value > MAX_TIMER_DELAY_MS + ) { + throw new RangeError( + `Redis rate limit ${name} must be an integer between 1 and ${MAX_TIMER_DELAY_MS}`, + ); + } + return value; +} + +function createTimeoutError(operationName: string, timeoutMs: number): Error { + return TIMEOUT_ERROR.create({ + detail: `Redis rate limit ${operationName} timed out after ${timeoutMs}ms`, + }); +} + +function isTimeoutError(error: unknown): boolean { + return isVeryfrontError(error) && error.slug === TIMEOUT_ERROR.slug; +} + function parseIncrementResult(result: unknown): [number, number] { if (!Array.isArray(result) || result.length < 2) { throw toError( @@ -94,11 +183,19 @@ function parseIncrementResult(result: unknown): [number, number] { const count = Number(result[0]); const ttl = Number(result[1]); - if (!Number.isFinite(count) || !Number.isFinite(ttl)) { + if (!Number.isSafeInteger(count) || count < 1) { + throw toError( + createError({ + type: "config", + message: "Redis rate limit eval returned an invalid count.", + }), + ); + } + if (!Number.isSafeInteger(ttl)) { throw toError( createError({ type: "config", - message: "Redis rate limit eval returned non-numeric values.", + message: "Redis rate limit eval returned an invalid TTL.", }), ); } diff --git a/src/middleware/index.ts b/src/middleware/index.ts index 348096e6c7..6855a1abca 100644 --- a/src/middleware/index.ts +++ b/src/middleware/index.ts @@ -42,6 +42,7 @@ export { authRateLimit, type AuthRateLimitOptions, MemoryRateLimitStore, + type MemoryRateLimitStoreOptions, rateLimit, type RateLimitOptions, } from "./builtin/security/rate-limit.ts"; diff --git a/src/modules/server/rate-limiter.test.ts b/src/modules/server/rate-limiter.test.ts index 89c789ada1..63b2460189 100644 --- a/src/modules/server/rate-limiter.test.ts +++ b/src/modules/server/rate-limiter.test.ts @@ -1,5 +1,5 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals } from "#veryfront/testing/assert.ts"; +import { assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { RateLimiter } from "./rate-limiter.ts"; @@ -53,5 +53,66 @@ describe("modules/server/rate-limiter", () => { assertEquals(limiter.check(socket), true); }); + + it("rejects invalid message limits", () => { + for (const maxMessages of [0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY]) { + assertThrows( + () => new RateLimiter(maxMessages), + RangeError, + "maxMessages", + ); + } + }); + + it("rejects invalid window durations", () => { + for (const windowMs of [0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY]) { + assertThrows( + () => new RateLimiter(1, { windowMs }), + RangeError, + "windowMs", + ); + } + }); + + it("rejects invalid options with a stable error", () => { + assertThrows( + () => new RateLimiter(1, null as never), + TypeError, + "options", + ); + }); + + it("fails closed when the clock returns a non-finite value", () => { + const limiter = new RateLimiter(1, { now: () => Number.NaN }); + assertEquals(limiter.check(mockSocket()), false); + }); + + it("opens a new window at the exact boundary", () => { + let now = 100; + const limiter = new RateLimiter(1, { + windowMs: 10, + now: () => now, + }); + const socket = mockSocket(); + + assertEquals(limiter.check(socket), true); + assertEquals(limiter.check(socket), false); + now = 110; + assertEquals(limiter.check(socket), true); + }); + + it("recovers safely if an injected clock moves backwards", () => { + let now = 100; + const limiter = new RateLimiter(1, { + windowMs: 10, + now: () => now, + }); + const socket = mockSocket(); + + assertEquals(limiter.check(socket), true); + assertEquals(limiter.check(socket), false); + now = 90; + assertEquals(limiter.check(socket), true); + }); }); }); diff --git a/src/modules/server/rate-limiter.ts b/src/modules/server/rate-limiter.ts index a581166c77..1f13b54ba0 100644 --- a/src/modules/server/rate-limiter.ts +++ b/src/modules/server/rate-limiter.ts @@ -1,26 +1,64 @@ import { HMR_RATE_LIMIT_WINDOW_MS } from "#veryfront/utils"; +import { MAX_TIMER_DELAY_MS } from "#veryfront/utils/timer.ts"; import type { WebSocketConnection } from "#veryfront/platform/adapters/base.ts"; +export interface RateLimiterOptions { + windowMs?: number; + now?: () => number; +} + +interface RateLimitRecord { + count: number; + windowStart: number; + resetTime: number; +} + export class RateLimiter { - private readonly messageCounts = new Map< - WebSocketConnection, - { count: number; resetTime: number } - >(); - private readonly windowMs = HMR_RATE_LIMIT_WINDOW_MS; + private readonly messageCounts = new WeakMap(); + private readonly maxMessages: number; + private readonly windowMs: number; + private readonly now: () => number; + + constructor(maxMessages: number, options: RateLimiterOptions = {}) { + if (!options || typeof options !== "object" || Array.isArray(options)) { + throw new TypeError("Rate limiter options must be an object"); + } + if (!Number.isSafeInteger(maxMessages) || maxMessages <= 0) { + throw new RangeError("maxMessages must be a positive safe integer"); + } + const windowMs = options.windowMs ?? HMR_RATE_LIMIT_WINDOW_MS; + if ( + !Number.isSafeInteger(windowMs) || + windowMs <= 0 || + windowMs > MAX_TIMER_DELAY_MS + ) { + throw new RangeError( + `windowMs must be an integer between 1 and ${MAX_TIMER_DELAY_MS}`, + ); + } + if (options.now !== undefined && typeof options.now !== "function") { + throw new TypeError("now must be a function"); + } - constructor(private readonly maxMessages: number) {} + this.maxMessages = maxMessages; + this.windowMs = windowMs; + this.now = options.now ?? (() => performance.now()); + } check(socket: WebSocketConnection): boolean { - const now = Date.now(); + const now = this.now(); + if (!Number.isFinite(now)) return false; const record = this.messageCounts.get(socket); - if (record && now <= record.resetTime) { + if (record && now >= record.windowStart && now < record.resetTime) { if (record.count >= this.maxMessages) return false; record.count++; return true; } - this.messageCounts.set(socket, { count: 1, resetTime: now + this.windowMs }); + const resetTime = now + this.windowMs; + if (!Number.isFinite(resetTime)) return false; + this.messageCounts.set(socket, { count: 1, windowStart: now, resetTime }); return true; } From e2a93fc70189ad3269b04cf7a4a76a7382d308d4 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 14:48:15 +0200 Subject: [PATCH 14/26] Reject ambiguous SBOM lock argument The SBOM generator accepted --lock without a value and let Deno's file APIs report an empty-path failure. Validate the lock path before any file reads so CI and maintainers see a usage error tied to the bad flag. Constraint: PR #3280 remains draft behind the proxy rollout gate. Rejected: Rely on Deno's empty-path error | it hides the offending --lock flag and exits through the runtime-error path. Confidence: high Scope-risk: narrow Directive: Keep CLI flag validation before reading deno.json or the selected lockfile. Tested: npx --yes deno@2.7.7 test --config=scripts/test.deno.json --no-check --allow-read --allow-write --allow-run scripts/build/generate-sbom.test.ts Tested: npx --yes deno@2.7.7 fmt --config=scripts/test.deno.json --check scripts/build/generate-sbom.ts scripts/build/generate-sbom.test.ts Tested: npx --yes deno@2.7.7 lint --config=scripts/test.deno.json scripts/build/generate-sbom.ts scripts/build/generate-sbom.test.ts Tested: npx --yes deno@2.7.7 check --config=scripts/test.deno.json scripts/build/generate-sbom.ts scripts/build/generate-sbom.test.ts Tested: git diff --check Not-tested: Full repository pre-push hook not yet run before this commit; normal push will run it. --- scripts/build/generate-sbom.test.ts | 21 +++++++++++++++++++++ scripts/build/generate-sbom.ts | 22 +++++++++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/scripts/build/generate-sbom.test.ts b/scripts/build/generate-sbom.test.ts index e8d6495477..d163558016 100644 --- a/scripts/build/generate-sbom.test.ts +++ b/scripts/build/generate-sbom.test.ts @@ -441,3 +441,24 @@ describe("componentsFromLock", () => { ); }); }); + +Deno.test("generate-sbom CLI rejects --lock without a value as a usage error", async () => { + const command = new Deno.Command(Deno.execPath(), { + args: [ + "run", + "--allow-read", + "--allow-write", + "scripts/build/generate-sbom.ts", + "--lock", + ], + stdout: "piped", + stderr: "piped", + }); + + const result = await command.output(); + const stderr = new TextDecoder().decode(result.stderr); + + assertEquals(result.code, 2); + assertStringIncludes(stderr, "--lock requires a non-empty path"); + assertStringIncludes(stderr, "Usage:"); +}); diff --git a/scripts/build/generate-sbom.ts b/scripts/build/generate-sbom.ts index 4181192331..a898a97f81 100644 --- a/scripts/build/generate-sbom.ts +++ b/scripts/build/generate-sbom.ts @@ -611,6 +611,25 @@ async function writeTextOutput( await Deno.writeTextFile(outputPath, text); } +const GENERATE_SBOM_USAGE = [ + "Usage: deno run --allow-read --allow-write scripts/build/generate-sbom.ts [--lock path] [--output path]", + " deno run --allow-read --allow-write scripts/build/generate-sbom.ts --all-manifests --output-dir dist/sbom", + " deno run --allow-read --allow-write scripts/build/generate-sbom.ts --manifest extensions/ext-sandbox-shell-tools/deno.json --output dist/sbom-ext-sandbox-shell-tools.json", +].join("\n"); + +function exitUsage(message: string): never { + console.error(`Error: ${message}`); + console.error(GENERATE_SBOM_USAGE); + Deno.exit(2); +} + +function requireNonEmptyPath(value: unknown, flag: string): string { + if (typeof value !== "string" || value.trim().length === 0) { + exitUsage(`${flag} requires a non-empty path`); + } + return value; +} + if (import.meta.main) { const args = parseArgs(Deno.args, { boolean: ["all-manifests"], @@ -622,8 +641,9 @@ if (import.meta.main) { }, }); + const lockPath = requireNonEmptyPath(args.lock, "--lock"); const denoConfig = JSON.parse(await Deno.readTextFile("deno.json")); - const lockText = await Deno.readTextFile(args.lock); + const lockText = await Deno.readTextFile(lockPath); if (args["all-manifests"]) { const workspaceMembers = workspaceMembersFromDenoConfig(denoConfig); From f63646c4558a320cf373f1bd5ebca317122e4130 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 15:19:26 +0200 Subject: [PATCH 15/26] fix(agent): close alias pollution, unverified project lookups, and false-success exits (#3287) * fix(agent): stop resolving prototype members as model aliases The legacy model alias table was a plain object, so a configured model name that collides with an Object.prototype member resolved through the prototype chain instead of missing. `resolveConfiguredAgentModel("constructor")` returned the Object constructor function, violating the declared string return type and propagating a non-string into runtime model resolution. Switch the table to a Map so lookups only see own entries. Alias entries are unchanged; a new test pins every Veryfront Cloud catalog model id to its provider model so the table cannot silently drift. * fix(agent): bind resolved project identity to the requested reference The hosted project-reference resolver trusted whatever identity the API returned: any well-formed `{ id }` was accepted even when it named a different project than the one requested, so a lookup response could silently retarget a child fork at another project. The reference itself was also unvalidated, letting untrimmed, control-character, or unbounded values reach the request path, and a non-ok response leaked its body. Validate the reference before it reaches fetch, read the response through own-data property descriptors rather than direct access, require the returned id or normalized slug to match what was asked for, and cancel the body on lookup failure. The child fork tool schema applies the same canonical-identifier policy so bad references fail at parse time. The helpers are kept local to the resolver; the upstream branch routes them through an agent project-context module that does not exist here. * fix(agent): bound tool error text and fail closed on hostile values stringifyToolError is the last step before a tool failure reaches logs, clients, and models, but it trusted the value it was handed. Serializing a circular object produced "[object Object]"; `undefined` and symbols returned a non-string, violating the declared return type; a value with a `toJSON` hook had that hook invoked and its output returned verbatim; and a revoked proxy made the stringifier itself throw. Output was also unbounded, so one oversized message could carry 8KB of text downstream. Read the message as an own data property, add a native DOMException message path so abort and timeout reasons survive, serialize through the existing bounded JSON snapshot helper, and truncate on a UTF-8 byte budget without splitting surrogate pairs. Every remaining path resolves to a stable "Unknown error" instead of leaking coercion output. * fix(workflow): stop reporting success for runs with no durable outcome Both worker entrypoints derive their exit code from `backend.getRun()` after execution, which returns null when the run was deleted or never persisted. That null fell through to the default branch and exited 0, so a run that vanished mid-execution reported success to the orchestrator. Cancelled runs and runs still sitting in pending or running did the same. Map cancelled, pending, running, and a missing run to the failure exit code, and keep waiting on success since that is a deliberate pause. The missing-run case logs distinctly so it is not confused with an unexpected status. * fix(agent): detach failed project lookup cleanup * fix(agent): avoid proxy traps in tool error formatting * fix(agent): fail closed on edge proxy errors * fix(agent): harden structured error snapshots * fix(agent): close snapshot hook gaps * Keep provider diagnostics bounded without Proxy guarantees Review feedback showed two remaining unsafe edges in diagnostic serialization. The tool-error path now applies the documented byte cap after JSON text is produced, and JSON snapshotting fails closed before object reflection when the runtime cannot identify Proxy values without traps. Constraint: Edge and browser runtimes can import the module without node:util/types Proxy brand checks. Rejected: Trust descriptor inspection when Proxy detection is unavailable | proxy traps can run during prototype or descriptor reflection. Confidence: high Scope-risk: narrow Tested: npx --yes deno@2.7.7 test --no-check --allow-all src/agent/runtime/error-utils.test.ts src/provider/runtime-loader-helpers.test.ts Tested: npx --yes deno@2.7.7 task lint Tested: npx --yes deno@2.7.7 task typecheck Tested: git diff --check * Keep extension request builders inside the full lint gate The branch-level push hook lints extension packages in addition to the root lint task. Two imports made obsolete by prior request-builder changes blocked the push, so this removes only the dead specifiers. Constraint: The repository pre-push hook runs a broader lint surface than deno task lint on this branch. Confidence: high Scope-risk: narrow Tested: npx --yes deno@2.7.7 lint src/ cli/ react/ extensions/ Tested: npx --yes deno@2.7.7 test --no-check --allow-all extensions/ext-llm-anthropic/src/anthropic-request-builder.test.ts extensions/ext-llm-openai/src/openai-responses-request-builder.test.ts * Fail closed on hostile project lookup payloads Hosted project-reference resolution reads response JSON from an untrusted control-plane boundary. The resolver now uses the shared no-hook data-property reader and normalizes unreadable or proxy payloads into the existing unconfirmed identity failure. Child fork warning metadata keeps runId while distinguishing parent and child run ids, and child project-reference validation reuses the shared public message. Constraint: Hosted resolver must not execute Proxy traps or leak private response errors Rejected: Continue local descriptor inspection | active proxies can run traps and leak private failures Confidence: high Scope-risk: narrow Tested: npx --yes deno@2.7.7 test --no-check --allow-all src/agent/hosted/project-reference-resolver.test.ts src/agent/hosted/child-pending-tool-lifecycle.test.ts src/agent/hosted/child-fork-run-context.test.ts src/agent/hosted/child-lifecycle.test.ts src/agent/runtime/model-resolution.test.ts src/agent/hosted/child-tool-input.test.ts Tested: npx --yes deno@2.7.7 check touched hosted/runtime files Tested: npx --yes deno@2.7.7 fmt --check touched hosted/runtime files Tested: npx --yes deno@2.7.7 lint touched hosted/runtime files Tested: npx --yes deno@2.7.7 task verify:quick Tested: npx --yes deno@2.7.7 task lint:test-typecheck Tested: npx --yes deno@2.7.7 task test:unit Not-tested: Playwright pre-push smoke; scripts/hooks/pre-push references missing tests/e2e/playwright.config.ts, and the existing tests/e2e/playwright.config.cjs fails before tests when local Node resolution picks up a user-level veryfront package missing veryfront/platform/path exports * fix(agent): fail closed without proxy brand checks * Fail closed when hosted lookup cannot identify proxies Hosted project lookup reads untrusted API payloads through no-hook data descriptors. Review found that a runtime without native Proxy brand checks would treat ordinary and proxied objects the same and could still reach descriptor traps. This change makes the lookup data predicate reject every object when that capability is unavailable, preserving the existing data-read path only where Proxy identification is available. Constraint: Preserve no-hook guarantees for untrusted hosted lookup responses across runtimes. Rejected: Let readRuntimeOwnDataProperty catch descriptor traps | the boundary should avoid invoking hostile traps, not recover after them. Confidence: high Scope-risk: narrow Tested: npx --yes deno@2.7.7 test --no-check --allow-all src/agent/hosted/project-reference-resolver.test.ts Tested: npx --yes deno@2.7.7 fmt --check src/agent/hosted/project-reference-resolver.ts src/agent/hosted/project-reference-resolver.test.ts Tested: npx --yes deno@2.7.7 lint src/agent/hosted/project-reference-resolver.ts src/agent/hosted/project-reference-resolver.test.ts Tested: npx --yes deno@2.7.7 check src/agent/hosted/project-reference-resolver.ts src/agent/hosted/project-reference-resolver.test.ts Tested: git diff --check Not-tested: Full repository suite before commit; pre-push hook runs the broader gate. * fix(agent): address runtime hardening review * Keep child lifecycle assertions aligned with run ownership The hosted child fork context routes lifecycle warnings through the parent run when both parent and child identifiers exist. Align the integration assertion with that ownership contract so the focused suite catches actual regressions instead of contradicting the logger. Constraint: Preserve the parent-first runId contract already covered by the lifecycle logger tests. Confidence: high Scope-risk: narrow Tested: Focused hosted child fork and pending lifecycle tests, format, lint, typecheck, and git diff check. * Preserve run diagnostics around child workflow edge states Hosted child project references are model-provided input, so validation trims surrounding whitespace before applying the canonical opaque-id limit while keeping a separate raw input cap. Workflow worker final-state warnings now include a terminal-sanitized run id for pending and running outcomes so failed run records remain searchable. Constraint: Review follow-up for PR #3287 required exact child-tool input and workflow warning fixes only. Rejected: Change declarative evaluator worker startup accounting | the exact failed CI test passed locally in isolation and no branch code implicated it. Confidence: high Scope-risk: narrow Reversibility: clean Tested: focused child-tool/workflow shared tests; exact declarative evaluator worker test; verify:quick; lint:test-typecheck; test:unit assertions passed with Deno pending-promise exit; isolated component-loader rerun Not-tested: fresh GitHub CI after push * Preserve safe diagnostics in review warning paths Copilot flagged two review-only hardening gaps: final workflow warning branches still used raw run ids outside the pending/running path, and best-effort diagnostic arrays could inherit Array.prototype.toJSON during JSON serialization. The fix reuses one sanitized run id for every warning branch that includes it and gives best-effort arrays their own inert toJSON guard before JSON.stringify sees them. Constraint: Review findings require terminal-facing diagnostics to avoid control-sequence injection and no-hook diagnostics to avoid serialization hooks. Rejected: Sanitizing only the newly flagged branches separately | a single sanitized value keeps the warning switch consistent. Confidence: high Scope-risk: narrow Directive: Do not return diagnostic arrays from no-hook serialization paths without an own inert toJSON property. Tested: RED then GREEN focused workflow and runtime error-utils tests; touched-file fmt, lint, check; affected hosted/runtime/workflow suite; verify:quick; lint:test-typecheck; test:unit attempted and isolated unrelated failure passed. Not-tested: End-to-end workflow worker process execution with a crafted run id. * Fail closed on edge MCP config reflection MCP tool config snapshotting used the no-hook Proxy brand check, but runtimes without native Proxy brand checks report that capability as unavailable. In that environment, continuing into ownKeys or descriptor reads lets a hostile config Proxy run traps. The factory now rejects object MCP configs before reflection when that no-hook brand check is unavailable. Constraint: Edge-like hosts cannot distinguish Proxy values without invoking traps. Rejected: Catch descriptor trap errors only | that still enters attacker-controlled traps before failing. Confidence: high Scope-risk: narrow Tested: npx --yes deno@2.7.7 test --no-check --allow-all src/tool/factory.test.ts Tested: npx --yes deno@2.7.7 fmt --check --config=deno.json src/tool/factory.ts src/tool/factory.test.ts Tested: npx --yes deno@2.7.7 lint --config=deno.json src/tool/factory.ts src/tool/factory.test.ts Tested: npx --yes deno@2.7.7 check --config=deno.json --frozen src/tool/factory.ts src/tool/factory.test.ts Tested: git diff --check * Prevent provider snapshot option traps before inspection Snapshot options cross a provider boundary before JSON value traversal, so options need the same no-hook Proxy guard as payload objects. The guard now rejects Proxy options before descriptor reads and falls back to structured clone on hosts without native Proxy brand checks, while keeping the existing own-data option validation. Constraint: Provider snapshotting must fail closed without evaluating caller Proxy traps Rejected: Catch descriptor trap failures after reflection | the trap has already executed by then Confidence: high Scope-risk: narrow Directive: Keep option validation ahead of descriptor reads when extending JsonSnapshotOptions Tested: npx --yes deno@2.7.7 test --no-check --allow-all src/provider/runtime-loader/json-snapshot.test.ts Tested: npx --yes deno@2.7.7 test --no-check --allow-all src/provider/runtime-loader/json-snapshot.test.ts src/provider/runtime-loader/provider-usage-merge.test.ts Tested: npx --yes deno@2.7.7 fmt --check src/provider/runtime-loader/json-snapshot.ts src/provider/runtime-loader/json-snapshot.test.ts Tested: npx --yes deno@2.7.7 lint src/provider/runtime-loader/json-snapshot.ts src/provider/runtime-loader/json-snapshot.test.ts Tested: npx --yes deno@2.7.7 check src/provider/runtime-loader/json-snapshot.ts src/provider/runtime-loader/json-snapshot.test.ts Tested: git diff --check * Keep edge MCP tools loadable without Proxy brands MCP tool metadata is constructed in hosts that may not expose no-hook Proxy detection, so the no-brand path now crosses the captured structured-clone boundary before descriptor validation. Workflow final-state logging now uses the same sanitized run id for success, failure, and waiting messages as for non-final states. Constraint: Edge runtimes must still load plain MCP-configured tools without evaluating caller Proxy traps Rejected: Keep rejecting all MCP config when Proxy brands are unavailable | this breaks Cloudflare-style runtimes for ordinary metadata Confidence: high Scope-risk: narrow Directive: Keep MCP/provider JSON clone-boundary behavior aligned when no-hook Proxy detection is unavailable Tested: npx --yes deno@2.7.7 test --no-check --allow-all src/tool/factory.test.ts src/workflow/worker/shared.test.ts Tested: npx --yes deno@2.7.7 fmt --check src/tool/factory.ts src/tool/factory.test.ts src/workflow/worker/shared.ts src/workflow/worker/shared.test.ts Tested: npx --yes deno@2.7.7 lint src/tool/factory.ts src/tool/factory.test.ts src/workflow/worker/shared.ts src/workflow/worker/shared.test.ts Tested: npx --yes deno@2.7.7 check src/tool/factory.ts src/tool/factory.test.ts src/workflow/worker/shared.ts src/workflow/worker/shared.test.ts Tested: git diff --check * fix(agent): close remaining diagnostic reflection gaps * Keep provider replay metadata working on edge runtimes Provider exact-replay metadata previously used the strict JSON snapshot helper directly. On hosts without no-hook Proxy identification that helper rejects ordinary object graphs, so OpenAI raw response replay, Anthropic raw assistant replay, and Google thought-signature or grounding replay could fail even for plain provider-owned metadata. Route those provider metadata snapshots through the provider-boundary snapshot helper exported by the shared provider surface. Also tighten MCP descriptor validation with an own-property check so polluted descriptor prototypes cannot turn accessors into apparent data properties. Constraint: Edge runtimes can lack native no-hook Proxy brand checks while still supporting structuredClone for provider-owned JSON boundaries. Rejected: Loosen strict snapshotJsonValue globally | it must remain fail-closed for boundaries that require zero untrusted reflection. Confidence: high Scope-risk: narrow Directive: Provider replay metadata should use snapshotProviderJsonValue when the source is provider-owned JSON that may cross an edge structured-clone boundary. Tested: npx --yes deno@2.7.7 test --no-check --allow-all extensions/ext-llm-openai/src/openai-web-search.test.ts extensions/ext-llm-anthropic/src/anthropic-native-content.test.ts extensions/ext-llm-google/src/google-request-builder.test.ts src/tool/factory.test.ts Tested: npx --yes deno@2.7.7 fmt --check and lint on touched files Tested: npx --yes deno@2.7.7 check on focused test files Not-tested: Full CI beyond local pre-push hook before push --- .../src/anthropic-native-content.test.ts | 65 +++ .../src/anthropic-native-content.ts | 8 +- .../src/google-request-builder.test.ts | 74 +++ .../src/google-thought-signatures.ts | 8 +- .../src/openai-web-search.test.ts | 53 ++ .../ext-llm-openai/src/openai-web-search.ts | 8 +- .../hosted/child-fork-run-context.test.ts | 3 + src/agent/hosted/child-fork-run-context.ts | 5 +- src/agent/hosted/child-lifecycle.test.ts | 29 + .../child-pending-tool-lifecycle.test.ts | 33 ++ .../hosted/child-pending-tool-lifecycle.ts | 12 +- src/agent/hosted/child-tool-input.test.ts | 78 +++ src/agent/hosted/child-tool-input.ts | 27 +- .../hosted/project-reference-resolver.test.ts | 294 ++++++++++ .../hosted/project-reference-resolver.ts | 81 ++- src/agent/runtime/error-utils.test.ts | 502 +++++++++++++++++- src/agent/runtime/error-utils.ts | 357 ++++++++++++- src/agent/runtime/model-resolution.test.ts | 28 + src/agent/runtime/model-resolution.ts | 72 +-- src/platform/compat/error-introspection.ts | 49 +- src/provider/runtime-loader-helpers.test.ts | 145 +++++ src/provider/runtime-loader.ts | 12 +- .../runtime-loader/json-snapshot.test.ts | 33 ++ src/provider/runtime-loader/json-snapshot.ts | 290 ++++++++-- src/provider/shared/index.ts | 1 + src/tool/factory.test.ts | 150 ++++++ src/tool/factory.ts | 54 +- src/workflow/worker/shared.test.ts | 126 ++++- src/workflow/worker/shared.ts | 28 +- 29 files changed, 2477 insertions(+), 148 deletions(-) create mode 100644 src/agent/hosted/project-reference-resolver.test.ts create mode 100644 src/provider/runtime-loader/json-snapshot.test.ts diff --git a/extensions/ext-llm-anthropic/src/anthropic-native-content.test.ts b/extensions/ext-llm-anthropic/src/anthropic-native-content.test.ts index e728b314fa..c4a303f377 100644 --- a/extensions/ext-llm-anthropic/src/anthropic-native-content.test.ts +++ b/extensions/ext-llm-anthropic/src/anthropic-native-content.test.ts @@ -9,6 +9,18 @@ import { validateAnthropicRawAssistantMessages, } from "./anthropic-native-content.ts"; +async function runNoBrandEval(script: string): Promise { + const output = await new Deno.Command(Deno.execPath(), { + args: ["eval", "--config=deno.json", script], + cwd: new URL("../../../", import.meta.url), + stdout: "piped", + stderr: "piped", + }).output(); + const stderr = new TextDecoder().decode(output.stderr); + assertEquals(output.code, 0, stderr); + return JSON.parse(new TextDecoder().decode(output.stdout)); +} + describe("Anthropic provider-native content normalization", () => { it("owns exact replay metadata and rejects executable object behavior", () => { const rawMessages = [[{ @@ -79,6 +91,59 @@ describe("Anthropic provider-native content normalization", () => { ); }); + it("accepts plain raw assistant metadata without Proxy detection", async () => { + const result = await runNoBrandEval(` + Object.defineProperty(globalThis, "caches", { + configurable: true, + value: {}, + }); + Object.defineProperty(globalThis, "WebSocketPair", { + configurable: true, + value: function WebSocketPair() {}, + }); + + const { canIdentifyProxyWithoutHooks } = await import( + "./src/platform/compat/error-introspection.ts" + ); + const { validateAnthropicRawAssistantMessages } = await import( + "./extensions/ext-llm-anthropic/src/anthropic-native-content.ts" + ); + + console.log(JSON.stringify({ + canIdentifyProxyWithoutHooks, + messages: validateAnthropicRawAssistantMessages([[ + { + type: "thinking", + thinking: "valid", + signature: "sig_edge", + }, + { + type: "tool_use", + id: "tool_edge", + name: "lookup", + input: { query: "Veryfront" }, + }, + ]]), + })); + `); + assertEquals(result, { + canIdentifyProxyWithoutHooks: false, + messages: [[ + { + type: "thinking", + thinking: "valid", + signature: "sig_edge", + }, + { + type: "tool_use", + id: "tool_edge", + name: "lookup", + input: { query: "Veryfront" }, + }, + ]], + }); + }); + it("keeps ordinary client tool_use blocks out of the provider-executed path", () => { assertEquals( parseAnthropicProviderToolUse({ diff --git a/extensions/ext-llm-anthropic/src/anthropic-native-content.ts b/extensions/ext-llm-anthropic/src/anthropic-native-content.ts index 550be30db1..524746df4d 100644 --- a/extensions/ext-llm-anthropic/src/anthropic-native-content.ts +++ b/extensions/ext-llm-anthropic/src/anthropic-native-content.ts @@ -1,4 +1,8 @@ -import { type JsonSnapshotValue, readRecord, snapshotJsonValue } from "veryfront/provider/shared"; +import { + type JsonSnapshotValue, + readRecord, + snapshotProviderJsonValue, +} from "veryfront/provider/shared"; export type AnthropicProviderToolNameRegistry = Map; @@ -109,7 +113,7 @@ export function snapshotAnthropicRawAssistantMetadata( value: unknown, ): JsonSnapshotValue { try { - return snapshotJsonValue(value, { + return snapshotProviderJsonValue(value, { maxBytes: MAX_ANTHROPIC_RAW_ASSISTANT_METADATA_BYTES, maxDepth: MAX_ANTHROPIC_RAW_ASSISTANT_METADATA_DEPTH, maxNodes: MAX_ANTHROPIC_RAW_ASSISTANT_METADATA_NODES, diff --git a/extensions/ext-llm-google/src/google-request-builder.test.ts b/extensions/ext-llm-google/src/google-request-builder.test.ts index f2b223d1c1..0cf1548386 100644 --- a/extensions/ext-llm-google/src/google-request-builder.test.ts +++ b/extensions/ext-llm-google/src/google-request-builder.test.ts @@ -70,6 +70,18 @@ function assertJsonEquals(actual: unknown, expected: unknown): void { ); } +async function runNoBrandEval(script: string): Promise { + const output = await new Deno.Command(Deno.execPath(), { + args: ["eval", "--config=deno.json", script], + cwd: new URL("../../../", import.meta.url), + stdout: "piped", + stderr: "piped", + }).output(); + const stderr = new TextDecoder().decode(output.stderr); + assertEquals(output.code, 0, stderr); + return JSON.parse(new TextDecoder().decode(output.stdout)); +} + describe("ext-llm-google/google-request-builder", () => { it("preserves generateContent request shaping, provider option merge order, and warnings", () => { const prompt: RuntimePromptMessage[] = [ @@ -1327,6 +1339,68 @@ describe("ext-llm-google/google-request-builder", () => { assertEquals(googleGetterReads, 0); }); + it("accepts plain thought-signature and grounding metadata without Proxy detection", async () => { + const result = await runNoBrandEval(` + Object.defineProperty(globalThis, "caches", { + configurable: true, + value: {}, + }); + Object.defineProperty(globalThis, "WebSocketPair", { + configurable: true, + value: function WebSocketPair() {}, + }); + + const { canIdentifyProxyWithoutHooks } = await import( + "./src/platform/compat/error-introspection.ts" + ); + const { buildGoogleGenerateContentRequest } = await import( + "./extensions/ext-llm-google/src/google-request-builder.ts" + ); + const { createGoogleProviderMetadata } = await import( + "./extensions/ext-llm-google/src/google-thought-signatures.ts" + ); + + const metadata = createGoogleProviderMetadata( + [{ text: "Private thought.", thought: true, thoughtSignature: "sig_edge" }], + { source: "google-search" }, + ); + const body = buildGoogleGenerateContentRequest( + "google", + { + prompt: [{ + role: "assistant", + content: [{ type: "reasoning", text: "Private thought." }], + providerMetadata: metadata, + }], + }, + { push() {}, drain() { return []; } }, + ); + console.log(JSON.stringify({ + canIdentifyProxyWithoutHooks, + metadata, + parts: body.contents[0].parts, + })); + `); + assertEquals(result, { + canIdentifyProxyWithoutHooks: false, + metadata: { + google: { + rawAssistantParts: [{ + text: "Private thought.", + thought: true, + thoughtSignature: "sig_edge", + }], + groundingMetadata: { source: "google-search" }, + }, + }, + parts: [{ + text: "Private thought.", + thought: true, + thoughtSignature: "sig_edge", + }], + }); + }); + it("enforces the exact Google raw-part count boundary", () => { const atLimit = Array.from( { length: 4_096 }, diff --git a/extensions/ext-llm-google/src/google-thought-signatures.ts b/extensions/ext-llm-google/src/google-thought-signatures.ts index ada43377b2..dd5de76b07 100644 --- a/extensions/ext-llm-google/src/google-thought-signatures.ts +++ b/extensions/ext-llm-google/src/google-thought-signatures.ts @@ -1,4 +1,8 @@ -import { type JsonSnapshotValue, readRecord, snapshotJsonValue } from "veryfront/provider/shared"; +import { + type JsonSnapshotValue, + readRecord, + snapshotProviderJsonValue, +} from "veryfront/provider/shared"; import { createGoogleToolCallCorrelationRegistry, type GoogleSupportedPartDataField, @@ -19,7 +23,7 @@ const GOOGLE_PROVIDER_METADATA_SNAPSHOT_OPTIONS = { } as const; function snapshotGoogleProviderMetadata(value: unknown): JsonSnapshotValue { - return snapshotJsonValue(value, GOOGLE_PROVIDER_METADATA_SNAPSHOT_OPTIONS); + return snapshotProviderJsonValue(value, GOOGLE_PROVIDER_METADATA_SNAPSHOT_OPTIONS); } function asSnapshotRecord(value: JsonSnapshotValue): Record | undefined { diff --git a/extensions/ext-llm-openai/src/openai-web-search.test.ts b/extensions/ext-llm-openai/src/openai-web-search.test.ts index 084dfccc51..e95d11f9de 100644 --- a/extensions/ext-llm-openai/src/openai-web-search.test.ts +++ b/extensions/ext-llm-openai/src/openai-web-search.test.ts @@ -35,6 +35,18 @@ function captureThrownError( throw new Error("Expected function to throw"); } +async function runNoBrandEval(script: string): Promise { + const output = await new Deno.Command(Deno.execPath(), { + args: ["eval", "--config=deno.json", script], + cwd: new URL("../../../", import.meta.url), + stdout: "piped", + stderr: "piped", + }).output(); + const stderr = new TextDecoder().decode(output.stderr); + assertEquals(output.code, 0, stderr); + return JSON.parse(new TextDecoder().decode(output.stdout)); +} + describe("ext-llm-openai/openai-web-search", () => { it("maps the four supported provider tool revisions and preserves the runtime name", () => { for ( @@ -538,4 +550,45 @@ describe("ext-llm-openai/openai-web-search", () => { assertEquals(proxyPropertyReads, 0); assertEquals(proxyError.cause, undefined); }); + + it("accepts plain raw response metadata without Proxy detection", async () => { + const result = await runNoBrandEval(` + Object.defineProperty(globalThis, "caches", { + configurable: true, + value: {}, + }); + Object.defineProperty(globalThis, "WebSocketPair", { + configurable: true, + value: function WebSocketPair() {}, + }); + + const { canIdentifyProxyWithoutHooks } = await import( + "./src/platform/compat/error-introspection.ts" + ); + const { + createOpenAIRawResponseMetadata, + readOpenAIRawResponseOutputItems, + } = await import("./extensions/ext-llm-openai/src/openai-web-search.ts"); + + const metadata = createOpenAIRawResponseMetadata([{ + id: "ws_edge", + type: "web_search_call", + status: "completed", + action: { type: "search", query: "Veryfront" }, + }]); + console.log(JSON.stringify({ + canIdentifyProxyWithoutHooks, + items: readOpenAIRawResponseOutputItems(metadata), + })); + `); + assertEquals(result, { + canIdentifyProxyWithoutHooks: false, + items: [{ + id: "ws_edge", + type: "web_search_call", + status: "completed", + action: { type: "search", query: "Veryfront" }, + }], + }); + }); }); diff --git a/extensions/ext-llm-openai/src/openai-web-search.ts b/extensions/ext-llm-openai/src/openai-web-search.ts index e7e99c63ae..e19410ce93 100644 --- a/extensions/ext-llm-openai/src/openai-web-search.ts +++ b/extensions/ext-llm-openai/src/openai-web-search.ts @@ -1,4 +1,8 @@ -import { readRecord, snapshotJsonValue, stringifyJsonValue } from "veryfront/provider/shared"; +import { + readRecord, + snapshotProviderJsonValue, + stringifyJsonValue, +} from "veryfront/provider/shared"; import { isBoundedOpenAIStreamString, MAX_OPENAI_STREAM_IDENTIFIER_BYTES, @@ -310,7 +314,7 @@ function snapshotOpenAIRawResponseOutputItems( ): Array> { let snapshot: unknown; try { - snapshot = snapshotJsonValue(value, { + snapshot = snapshotProviderJsonValue(value, { maxBytes: MAX_OPENAI_RAW_RESPONSE_METADATA_BYTES, }); } catch (error) { diff --git a/src/agent/hosted/child-fork-run-context.test.ts b/src/agent/hosted/child-fork-run-context.test.ts index e234ce2a43..6ff9aedaf5 100644 --- a/src/agent/hosted/child-fork-run-context.test.ts +++ b/src/agent/hosted/child-fork-run-context.test.ts @@ -234,6 +234,7 @@ Deno.test("createHostedChildForkRunContext closes pending tool calls with host l pendingToolLogContext: { conversationId: "conversation-1", parentRunId: "run-1", + childRunId: "child-run-1", description: "Check the app", }, pendingToolLogWriter: { @@ -257,6 +258,8 @@ Deno.test("createHostedChildForkRunContext closes pending tool calls with host l assertEquals(warnings[0]?.context, { conversationId: "conversation-1", runId: "run-1", + parentRunId: "run-1", + childRunId: "child-run-1", description: "Check the app", reason: "aborted", toolCallIds: ["tool-call-1"], diff --git a/src/agent/hosted/child-fork-run-context.ts b/src/agent/hosted/child-fork-run-context.ts index 8ceeaa439e..93ad95bee7 100644 --- a/src/agent/hosted/child-fork-run-context.ts +++ b/src/agent/hosted/child-fork-run-context.ts @@ -196,7 +196,10 @@ export function createHostedDurableChildForkRunContext( ...createHostedChildForkRunContext({ mirror: durableRunMirror, messageId: input.durableChildRun?.childMessageId ?? null, - pendingToolLogContext: input.pendingToolLogContext, + pendingToolLogContext: { + ...input.pendingToolLogContext, + childRunId: input.durableChildRun?.childRunId, + }, pendingToolLogWriter: input.pendingToolLogWriter, }), }; diff --git a/src/agent/hosted/child-lifecycle.test.ts b/src/agent/hosted/child-lifecycle.test.ts index 106df7baab..da58f83cbf 100644 --- a/src/agent/hosted/child-lifecycle.test.ts +++ b/src/agent/hosted/child-lifecycle.test.ts @@ -360,4 +360,33 @@ describe("agent/hosted-child-lifecycle", () => { assertEquals(result.terminalState.status, "completed"); assertEquals(result.terminalState.terminalErrorCode, "DURABLE_CHILD_COMPLETED_EXTERNALLY"); }); + + it("returns externally completed terminal states without rethrowing unexpected final status", async () => { + const result = await runHostedChildExecutionLifecycle({ + adapter: { + completed: () => { + throw new Error("completed terminal persistence must be skipped"); + }, + }, + executionFailedCode: "INVOKE_AGENT_FAILED", + execute: () => { + throw new HostedChildTerminalStateError("completed", { + childConversationId: "conversation-1", + childRunId: "run-1", + childMessageId: "message-1", + latestEventId: 1, + latestExternalEventSequence: 1, + }); + }, + getExecutionSnapshot: () => null, + }); + + assertEquals(result.status, "failed"); + assertEquals(result.terminalState, { + status: "completed", + terminalErrorCode: "DURABLE_CHILD_COMPLETED_EXTERNALLY", + terminalErrorMessage: + "Hosted child run run-1 became completed before local execution finished", + }); + }); }); diff --git a/src/agent/hosted/child-pending-tool-lifecycle.test.ts b/src/agent/hosted/child-pending-tool-lifecycle.test.ts index d3bfad8224..708fe94ab7 100644 --- a/src/agent/hosted/child-pending-tool-lifecycle.test.ts +++ b/src/agent/hosted/child-pending-tool-lifecycle.test.ts @@ -154,6 +154,7 @@ Deno.test("createHostedChildPendingToolLifecycleLogger writes host context for p context: { conversationId: "conv-1", runId: "run-1", + parentRunId: "run-1", description: "Summarize docs", reason: "error", toolCallIds: ["tool-1", "tool-2"], @@ -165,6 +166,7 @@ Deno.test("createHostedChildPendingToolLifecycleLogger writes host context for p context: { conversationId: "conv-1", runId: "run-1", + parentRunId: "run-1", description: "Summarize docs", toolCallId: "tool-1", phase: "input_streaming", @@ -174,3 +176,34 @@ Deno.test("createHostedChildPendingToolLifecycleLogger writes host context for p }, ]); }); + +Deno.test("createHostedChildPendingToolLifecycleLogger preserves the parent run as runId", () => { + const warnings: Array<{ message: string; context: Record }> = []; + const logger = createHostedChildPendingToolLifecycleLogger( + { + parentRunId: "run-parent", + childRunId: "run-child", + description: "Summarize docs", + }, + { + warn: (message, context) => warnings.push({ message, context }), + }, + ); + + logger.warnIncompleteToolLifecycles?.({ + reason: "ended", + toolCallIds: ["tool-1"], + errorMessage: null, + }); + + assertEquals(warnings[0]?.context, { + conversationId: undefined, + runId: "run-parent", + parentRunId: "run-parent", + childRunId: "run-child", + description: "Summarize docs", + reason: "ended", + toolCallIds: ["tool-1"], + errorMessage: null, + }); +}); diff --git a/src/agent/hosted/child-pending-tool-lifecycle.ts b/src/agent/hosted/child-pending-tool-lifecycle.ts index d802c0686a..15481ff26b 100644 --- a/src/agent/hosted/child-pending-tool-lifecycle.ts +++ b/src/agent/hosted/child-pending-tool-lifecycle.ts @@ -42,6 +42,7 @@ export interface HostedChildPendingToolLifecycleLogger { export interface HostedChildPendingToolLifecycleLogContext { conversationId?: string; parentRunId?: string; + childRunId?: string; description: string; } @@ -55,11 +56,18 @@ export function createHostedChildPendingToolLifecycleLogger( context: HostedChildPendingToolLifecycleLogContext, writer: HostedChildPendingToolLifecycleLogWriter, ): HostedChildPendingToolLifecycleLogger { + const runId = context.parentRunId ?? context.childRunId; + const runContext = { + ...(runId ? { runId } : {}), + ...(context.parentRunId ? { parentRunId: context.parentRunId } : {}), + ...(context.childRunId ? { childRunId: context.childRunId } : {}), + }; + return { warnIncompleteToolLifecycles: (log) => { writer.warn("Closing incomplete child fork tool lifecycles", { conversationId: context.conversationId, - runId: context.parentRunId, + ...runContext, description: context.description, reason: log.reason, toolCallIds: log.toolCallIds, @@ -69,7 +77,7 @@ export function createHostedChildPendingToolLifecycleLogger( warnUnknownToolIdentity: (log) => { writer.warn("Closing child fork tool lifecycle without recoverable tool identity", { conversationId: context.conversationId, - runId: context.parentRunId, + ...runContext, description: context.description, toolCallId: log.toolCallId, phase: log.phase, diff --git a/src/agent/hosted/child-tool-input.test.ts b/src/agent/hosted/child-tool-input.test.ts index bc9c65a765..02572086a3 100644 --- a/src/agent/hosted/child-tool-input.test.ts +++ b/src/agent/hosted/child-tool-input.test.ts @@ -2,12 +2,17 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals } from "#veryfront/testing/assert.ts"; import { DEFAULT_HOSTED_CHILD_AGENT_ID, + getHostedChildForkToolInputSchema, hostedChildForkToolInputSchema, MAX_HOSTED_CHILD_DELEGATION_DEPTH, + MAX_HOSTED_CHILD_PROJECT_REFERENCE_INPUT_CODE_UNITS, resolveHostedChildForkRuntimeConfig, resolveHostedChildForkThinkingOverride, withHostedChildInvocationContext, } from "./child-tool-input.ts"; +import { schemaToJsonSchema } from "#veryfront/schemas/index.ts"; +import { INVALID_AGENT_PROJECT_REFERENCE_MESSAGE } from "../project/context.ts"; +import { MAX_OPAQUE_ID_CODE_UNITS } from "#veryfront/utils/project-identity.ts"; Deno.test("hostedChildForkToolInputSchema accepts the hosted child fork fields", () => { const parsed = hostedChildForkToolInputSchema.parse({ @@ -143,6 +148,79 @@ Deno.test("hostedChildForkToolInputSchema rejects negative thinking budgets", () assertEquals(result.success, false); }); +Deno.test("hostedChildForkToolInputSchema rejects unsafe project references", () => { + for ( + const projectReference of [ + "", + "project-\n123", + "p".repeat(MAX_OPAQUE_ID_CODE_UNITS + 1), + ] + ) { + const result = hostedChildForkToolInputSchema.safeParse({ + description: "invalid project", + prompt: "Try an invalid project reference.", + context: {}, + project_reference: projectReference, + }); + + assertEquals(result.success, false); + if (result.success) { + throw new Error("Expected invalid hosted child fork input"); + } + const error = result.error as { issues?: Array<{ message?: string }> }; + assertEquals( + error.issues?.[0]?.message, + projectReference.length > MAX_OPAQUE_ID_CODE_UNITS + ? `Too big: expected string to have <=${MAX_OPAQUE_ID_CODE_UNITS} characters` + : INVALID_AGENT_PROJECT_REFERENCE_MESSAGE, + ); + } +}); + +Deno.test("hostedChildForkToolInputSchema trims project references", () => { + const result = hostedChildForkToolInputSchema.parse({ + description: "switch project", + prompt: "Open the requested project.", + project_reference: " project-123 ", + }); + + assertEquals(result.project_reference, "project-123"); +}); + +Deno.test("hostedChildForkToolInputSchema trims project references before enforcing canonical length", () => { + const canonicalReference = "p".repeat(MAX_OPAQUE_ID_CODE_UNITS); + const result = hostedChildForkToolInputSchema.parse({ + description: "switch project", + prompt: "Open the requested project.", + project_reference: ` ${canonicalReference} `, + }); + + assertEquals(result.project_reference, canonicalReference); +}); + +Deno.test("hostedChildForkToolInputSchema rejects raw project-reference payloads beyond the DoS bound", () => { + const result = hostedChildForkToolInputSchema.safeParse({ + description: "switch project", + prompt: "Open the requested project.", + project_reference: `${ + " ".repeat(MAX_HOSTED_CHILD_PROJECT_REFERENCE_INPUT_CODE_UNITS) + }project-123`, + }); + + assertEquals(result.success, false); +}); + +Deno.test("hosted child fork JSON Schema exposes project-reference input bounds", () => { + const schema = schemaToJsonSchema(getHostedChildForkToolInputSchema()); + const projectReference = schema.properties?.project_reference as + | Record + | undefined; + + assertEquals(projectReference?.type, "string"); + assertEquals(projectReference?.minLength, 1); + assertEquals(projectReference?.maxLength, MAX_HOSTED_CHILD_PROJECT_REFERENCE_INPUT_CODE_UNITS); +}); + Deno.test("DEFAULT_HOSTED_CHILD_AGENT_ID names the hosted child runtime agent", () => { assertEquals(DEFAULT_HOSTED_CHILD_AGENT_ID, "invoke-agent-child"); }); diff --git a/src/agent/hosted/child-tool-input.ts b/src/agent/hosted/child-tool-input.ts index d28beae475..fcbc3edecd 100644 --- a/src/agent/hosted/child-tool-input.ts +++ b/src/agent/hosted/child-tool-input.ts @@ -2,11 +2,17 @@ import { defineSchema, getJsonValueSchema, lazySchema } from "#veryfront/schemas import type { InferSchema } from "#veryfront/extensions/schema/index.ts"; import { withDefaultResearchArtifactPath } from "../artifacts/default-research-artifact-policy.ts"; import type { ChildRunResultMode } from "../child-run/result-summary.ts"; +import { INVALID_AGENT_PROJECT_REFERENCE_MESSAGE } from "../project/context.ts"; import type { RuntimeAgentThinkingConfig } from "../runtime/agent-definition.ts"; +import { + isCanonicalOpaqueProjectIdentifier, + MAX_OPAQUE_ID_CODE_UNITS, +} from "#veryfront/utils/project-identity.ts"; /** Default value for hosted child agent ID. */ export const DEFAULT_HOSTED_CHILD_AGENT_ID = "invoke-agent-child"; export const MAX_HOSTED_CHILD_DELEGATION_DEPTH = 8; +export const MAX_HOSTED_CHILD_PROJECT_REFERENCE_INPUT_CODE_UNITS = 8_192; const HOSTED_CHILD_FORK_RESULT_MODES = ["summary", "full", "structured"] as const; /** Hosted child fork result return mode. */ @@ -19,9 +25,24 @@ export const getHostedChildForkToolInputSchema = defineSchema((v) => context: v.record(v.string(), getJsonValueSchema()).default({}).describe( "Structured data payload for the child task. Use this for critical facts, records, ids, decisions, and values the child must act on. Defaults to {} when the delegation has no record or evidence payload.", ), - project_reference: v.string().optional().describe( - "Override project context by UUID or slug. Use after studio_open_project.", - ), + project_reference: v + .string() + .min(1, INVALID_AGENT_PROJECT_REFERENCE_MESSAGE) + .max(MAX_HOSTED_CHILD_PROJECT_REFERENCE_INPUT_CODE_UNITS) + .transform((value) => value.trim()) + .pipe( + v.string() + .min(1, INVALID_AGENT_PROJECT_REFERENCE_MESSAGE) + .max(MAX_OPAQUE_ID_CODE_UNITS) + .refine( + isCanonicalOpaqueProjectIdentifier, + INVALID_AGENT_PROJECT_REFERENCE_MESSAGE, + ), + ) + .optional() + .describe( + "Override project context by UUID or slug. Use after studio_open_project.", + ), tools: v.array(v.string()).optional().describe( "Tool subset for this fork. Omit = inherit all parent tools.", ), diff --git a/src/agent/hosted/project-reference-resolver.test.ts b/src/agent/hosted/project-reference-resolver.test.ts new file mode 100644 index 0000000000..b2f752e280 --- /dev/null +++ b/src/agent/hosted/project-reference-resolver.test.ts @@ -0,0 +1,294 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; +import { canIdentifyProxyWithoutHooks } from "#veryfront/platform/compat/error-introspection.ts"; +import { MAX_OPAQUE_ID_CODE_UNITS } from "#veryfront/utils/project-identity.ts"; +import { withMockFetch } from "#veryfront/testing/mock-fetch.ts"; +import { + canReadHostedProjectLookupDataProperties, + resolveHostedProjectReference, +} from "./project-reference-resolver.ts"; + +const LOOKUP_INPUT = { + projectReference: "target-project", + authToken: "token-1", + apiUrl: "https://api.example.test", +}; + +async function assertLookupFailure(mockFetch: typeof globalThis.fetch): Promise { + await withMockFetch(mockFetch, () => + assertRejects( + () => resolveHostedProjectReference(LOOKUP_INPUT), + Error, + "Project lookup failed (502)", + )); +} + +Deno.test("resolveHostedProjectReference returns a matching normalized API identity", async () => { + const requests: Array<{ url: string; authorization: string | null }> = []; + + await withMockFetch( + (input, init) => { + requests.push({ + url: String(input), + authorization: new Headers(init?.headers).get("authorization"), + }); + return Promise.resolve( + Response.json({ + id: "11111111-1111-4111-8111-111111111111", + slug: " target-project ", + }), + ); + }, + async () => { + assertEquals(await resolveHostedProjectReference(LOOKUP_INPUT), { + projectId: "11111111-1111-4111-8111-111111111111", + slug: "target-project", + }); + }, + ); + + assertEquals(requests, [{ + url: "https://api.example.test/projects/target-project", + authorization: "Bearer token-1", + }]); +}); + +Deno.test("resolveHostedProjectReference rejects an API identity that does not match the request", async () => { + await withMockFetch( + () => + Promise.resolve( + Response.json({ + id: "22222222-2222-4222-8222-222222222222", + slug: "different-project", + }), + ), + async () => { + await assertRejects( + () => resolveHostedProjectReference(LOOKUP_INPUT), + Error, + "Project lookup response did not confirm the requested project identity", + ); + }, + ); +}); + +Deno.test("resolveHostedProjectReference ignores accessors and inherited descriptor values", async () => { + const defineProperty = Object.defineProperty; + const deleteProperty = Reflect.deleteProperty; + const previousValue = Object.getOwnPropertyDescriptor(Object.prototype, "value"); + let payloadAccessorCalls = 0; + let inheritedValueCalls = 0; + const payload = defineProperty({}, "id", { + configurable: true, + enumerable: true, + get() { + payloadAccessorCalls += 1; + return "target-project"; + }, + }); + let failure: unknown; + + try { + defineProperty(Object.prototype, "value", { + configurable: true, + get() { + inheritedValueCalls += 1; + return "target-project"; + }, + }); + await withMockFetch( + () => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(payload), + } as unknown as Response), + async () => { + try { + await resolveHostedProjectReference(LOOKUP_INPUT); + } catch (error) { + failure = error; + } + }, + ); + } finally { + if (previousValue) { + defineProperty(Object.prototype, "value", previousValue); + } else { + deleteProperty(Object.prototype, "value"); + } + } + + assertEquals( + failure instanceof Error ? failure.message : undefined, + "Project lookup response did not confirm the requested project identity", + ); + assertEquals(payloadAccessorCalls, 0); + assertEquals(inheritedValueCalls, 0); +}); + +Deno.test("resolveHostedProjectReference rejects active response proxies without invoking traps", async () => { + assertEquals(canIdentifyProxyWithoutHooks, true); + let trapCalls = 0; + const payload = new Proxy({ id: "target-project", slug: "target-project" }, { + getOwnPropertyDescriptor() { + trapCalls += 1; + throw new Error("private descriptor failure"); + }, + }); + + await withMockFetch( + () => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(payload), + } as unknown as Response), + async () => { + await assertRejects( + () => resolveHostedProjectReference(LOOKUP_INPUT), + Error, + "Project lookup response did not confirm the requested project identity", + ); + }, + ); + + assertEquals(trapCalls, 0); +}); + +Deno.test("project lookup data reads fail closed when proxy detection is unavailable", () => { + assertEquals( + canReadHostedProjectLookupDataProperties( + { id: "target-project", slug: "target-project" }, + false, + ), + false, + ); +}); + +Deno.test("resolveHostedProjectReference rejects revoked response proxies without leaking revocation errors", async () => { + const revocable = Proxy.revocable({ id: "target-project", slug: "target-project" }, {}); + revocable.revoke(); + + await withMockFetch( + () => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(revocable.proxy), + } as unknown as Response), + async () => { + await assertRejects( + () => resolveHostedProjectReference(LOOKUP_INPUT), + Error, + "Project lookup response did not confirm the requested project identity", + ); + }, + ); +}); + +Deno.test("resolveHostedProjectReference rejects unsafe references before fetch", async () => { + let fetchCount = 0; + + await withMockFetch( + () => { + fetchCount += 1; + throw new Error("invalid project references must not reach fetch"); + }, + async () => { + for ( + const projectReference of [ + "", + " target-project", + "target-\nproject", + "p".repeat(MAX_OPAQUE_ID_CODE_UNITS + 1), + ] + ) { + await assertRejects( + () => resolveHostedProjectReference({ ...LOOKUP_INPUT, projectReference }), + TypeError, + "Project reference must be a trimmed non-empty bounded identifier without control characters", + ); + } + }, + ); + + assertEquals(fetchCount, 0); +}); + +Deno.test("resolveHostedProjectReference preserves lookup failure when cancellation rejects", async () => { + let cancellationCount = 0; + + await assertLookupFailure(() => + Promise.resolve( + new Response( + new ReadableStream({ + cancel() { + cancellationCount += 1; + return Promise.reject(new Error("cleanup failed")); + }, + }), + { status: 502 }, + ), + ) + ); + await Promise.resolve(); + + assertEquals(cancellationCount, 1); +}); + +Deno.test("resolveHostedProjectReference preserves lookup failure when cancellation throws", async () => { + let cancellationCount = 0; + + await assertLookupFailure(() => + Promise.resolve({ + ok: false, + status: 502, + body: { + cancel() { + cancellationCount += 1; + throw new Error("cleanup failed"); + }, + }, + } as unknown as Response) + ); + + assertEquals(cancellationCount, 1); +}); + +Deno.test("resolveHostedProjectReference does not await stalled error-body cancellation", async () => { + let cancellationStarted = false; + let timeoutId: ReturnType | undefined; + + await withMockFetch( + () => + Promise.resolve( + new Response( + new ReadableStream({ + cancel() { + cancellationStarted = true; + return new Promise(() => {}); + }, + }), + { status: 502 }, + ), + ), + async () => { + const timeout = new Promise<"timed-out">((resolve) => { + timeoutId = setTimeout(() => resolve("timed-out"), 100); + }); + + try { + const outcome = await Promise.race([ + resolveHostedProjectReference(LOOKUP_INPUT).then( + () => "resolved", + (error) => error instanceof Error ? error.message : "non-error rejection", + ), + timeout, + ]); + + assertEquals(outcome, "Project lookup failed (502)"); + assertEquals(cancellationStarted, true); + } finally { + if (timeoutId !== undefined) clearTimeout(timeoutId); + } + }, + ); +}); diff --git a/src/agent/hosted/project-reference-resolver.ts b/src/agent/hosted/project-reference-resolver.ts index 1b0bed8ed1..7f248e60d1 100644 --- a/src/agent/hosted/project-reference-resolver.ts +++ b/src/agent/hosted/project-reference-resolver.ts @@ -1,8 +1,57 @@ import { type ConfirmedAgentProjectContextSwitch, + getConfirmedAgentProjectIdentity, getConfirmedResolvedAgentProjectIdentity, + INVALID_AGENT_PROJECT_REFERENCE_MESSAGE, + normalizeAgentProjectReference, UNCONFIRMED_AGENT_PROJECT_IDENTITY_MESSAGE, } from "../project/context.ts"; +import { readOwnDataProperty as readRuntimeOwnDataProperty } from "../runtime/data-property-descriptor.ts"; +import { + canIdentifyProxyWithoutHooks, + isProxyWithoutHooks, +} from "#veryfront/platform/compat/error-introspection.ts"; + +const ArrayIsArray = Array.isArray; + +/** + * Read an own data property from untrusted JSON without invoking accessors or + * walking the prototype chain. + */ +export function canReadHostedProjectLookupDataProperties( + source: unknown, + proxyDetectionAvailable = canIdentifyProxyWithoutHooks, +): source is object { + if (typeof source !== "object" || source === null || ArrayIsArray(source)) { + return false; + } + if (!proxyDetectionAvailable) { + return false; + } + return !isProxyWithoutHooks(source); +} + +function readOwnDataProperty(source: unknown, key: string): unknown { + if (!canReadHostedProjectLookupDataProperties(source)) { + return undefined; + } + + try { + return readRuntimeOwnDataProperty(source, key, "project lookup response", false); + } catch { + return undefined; + } +} + +function cancelResponseBodyWithoutWaiting(response: Response): void { + let cancellation: Promise | undefined; + try { + cancellation = response.body?.cancel(); + } catch { + // Preserve the primary lookup failure when connection cleanup also fails. + } + void cancellation?.catch(() => undefined); +} /** Resolver for public project references used by hosted agent tools. */ export type HostedProjectReferenceResolver = (input: { @@ -34,29 +83,47 @@ export async function resolveHostedProjectReference(input: { apiUrl: string; abortSignal?: AbortSignal; }): Promise<{ projectId: string; slug?: string | null }> { + const projectReference = normalizeAgentProjectReference(input.projectReference); + if (!projectReference) { + throw new TypeError(INVALID_AGENT_PROJECT_REFERENCE_MESSAGE); + } + const response = await fetch( - new URL(`/projects/${encodeURIComponent(input.projectReference)}`, input.apiUrl), + new URL(`/projects/${encodeURIComponent(projectReference)}`, input.apiUrl), { headers: { Authorization: `Bearer ${input.authToken}` }, signal: input.abortSignal, }, ); if (!response.ok) { + cancelResponseBodyWithoutWaiting(response); throw new Error(`Project lookup failed (${response.status})`); } - const data = await response.json() as { id?: unknown; slug?: unknown }; - if (typeof data.id !== "string" || data.id.length === 0) { - throw new Error("Project lookup response did not include project id"); + let data: unknown; + try { + data = await response.json(); + } catch { + throw new Error("Project lookup response did not confirm the requested project identity"); + } + const identity = getConfirmedAgentProjectIdentity({ + projectId: readOwnDataProperty(data, "id"), + projectSlug: readOwnDataProperty(data, "slug"), + requestedProjectReference: projectReference, + }); + // The response must name the project that was asked for; a lookup that + // answers with a different identity must not silently retarget the caller. + if (!identity) { + throw new Error("Project lookup response did not confirm the requested project identity"); } const resolution = { - projectId: data.id, - slug: typeof data.slug === "string" ? data.slug : null, + projectId: identity.projectId, + slug: identity.projectSlug ?? null, }; const confirmed = requireConfirmedHostedProjectReference( resolution, - input.projectReference, + projectReference, ); return { projectId: confirmed.projectId, diff --git a/src/agent/runtime/error-utils.test.ts b/src/agent/runtime/error-utils.test.ts index 7050f11169..973b525834 100644 --- a/src/agent/runtime/error-utils.test.ts +++ b/src/agent/runtime/error-utils.test.ts @@ -1,7 +1,17 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals, assertStrictEquals, assertThrows } from "#veryfront/testing/assert.ts"; +import { + assertEquals, + assertStrictEquals, + assertStringIncludes, + assertThrows, +} from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; -import { createAbortError, stringifyToolError, throwIfAborted } from "./error-utils.ts"; +import { + createAbortError, + MAX_TOOL_ERROR_TEXT_BYTES, + stringifyToolError, + throwIfAborted, +} from "./error-utils.ts"; describe("agent/runtime/error-utils", () => { describe("createAbortError", () => { @@ -52,6 +62,17 @@ describe("agent/runtime/error-utils", () => { assertEquals(stringifyToolError(new Error("tool exploded")), "tool exploded"); }); + it("preserves native abort and timeout DOMException messages", () => { + assertEquals( + stringifyToolError(new DOMException("client disconnected", "AbortError")), + "client disconnected", + ); + assertEquals( + stringifyToolError(new DOMException("provider timed out", "TimeoutError")), + "provider timed out", + ); + }); + it("stringifies structured values as JSON", () => { assertEquals( stringifyToolError({ code: "E_TOOL", retryable: true }), @@ -59,11 +80,484 @@ describe("agent/runtime/error-utils", () => { ); }); - it("falls back to String() when JSON serialization fails", () => { + it("retains safe fields when structured diagnostics contain unsupported values", () => { + assertEquals( + stringifyToolError({ + code: "E_TOOL", + detail: undefined, + occurredAt: new Date("2026-08-03T00:00:00.000Z"), + }), + '{"code":"E_TOOL","occurredAt":"2026-08-03T00:00:00.000Z"}', + ); + }); + + it("bounds serialized structured values after JSON escaping", () => { + const value = { details: "\u0000".repeat(MAX_TOOL_ERROR_TEXT_BYTES) }; + const result = stringifyToolError(value); + + assertEquals( + new TextEncoder().encode(result).byteLength <= MAX_TOOL_ERROR_TEXT_BYTES, + true, + ); + assertStringIncludes(result, '"details"'); + }); + + it("bounds best-effort string leaves before JSON serialization", () => { + const result = stringifyToolError({ + code: "E_TOOL", + detail: "x".repeat(MAX_TOOL_ERROR_TEXT_BYTES * 1_024), + unsupported: undefined, + }); + + assertEquals(new TextEncoder().encode(result).byteLength <= MAX_TOOL_ERROR_TEXT_BYTES, true); + assertStringIncludes(result, '"code":"E_TOOL"'); + assertStringIncludes(result, "…"); + }); + + it("uses a stable fallback when safe JSON serialization fails", () => { const circular: Record = {}; circular.self = circular; - assertEquals(stringifyToolError(circular), "[object Object]"); + assertEquals(stringifyToolError(circular), "Unknown error"); + }); + + it("does not invoke getters or custom serialization and coercion hooks", () => { + let calls = 0; + const hostile = { + get message(): string { + calls += 1; + return "getter executed"; + }, + toJSON(): string { + calls += 1; + return "serializer executed"; + }, + [Symbol.toPrimitive](): string { + calls += 1; + return "coercion executed"; + }, + }; + + assertEquals(stringifyToolError(hostile), "Unknown error"); + assertEquals(calls, 0); + }); + + it("retains safe siblings without invoking unsupported diagnostic branches", () => { + let calls = 0; + const diagnostic = Object.defineProperty({ code: "E_TOOL" }, "detail", { + enumerable: true, + get() { + calls += 1; + return "private"; + }, + }); + + assertEquals(stringifyToolError(diagnostic), '{"code":"E_TOOL"}'); + assertEquals(calls, 0); + }); + + it("shadows inherited array toJSON in best-effort diagnostics", () => { + const defineProperty = Object.defineProperty; + const deleteProperty = Reflect.deleteProperty; + const original = Object.getOwnPropertyDescriptor(Array.prototype, "toJSON"); + let calls = 0; + let result: string | undefined; + + try { + defineProperty(Array.prototype, "toJSON", { + configurable: true, + value() { + calls += 1; + return "mutated-array"; + }, + writable: true, + }); + + result = stringifyToolError({ + code: "E_TOOL", + details: ["safe", 1], + skipped: undefined, + }); + } finally { + if (original) { + defineProperty(Array.prototype, "toJSON", original); + } else { + deleteProperty(Array.prototype, "toJSON"); + } + } + + assertEquals(result, '{"code":"E_TOOL","details":["safe",1]}'); + assertEquals(calls, 0); + }); + + it("fails closed for revoked proxies", () => { + const { proxy, revoke } = Proxy.revocable({}, {}); + revoke(); + + assertEquals(stringifyToolError(proxy), "Unknown error"); + }); + + it("fails closed for active proxies without invoking traps", () => { + let calls = 0; + const proxy = new Proxy({}, { + get(target, property, receiver) { + calls += 1; + return Reflect.get(target, property, receiver); + }, + getOwnPropertyDescriptor(target, property) { + calls += 1; + return Reflect.getOwnPropertyDescriptor(target, property); + }, + getPrototypeOf(target) { + calls += 1; + return Reflect.getPrototypeOf(target); + }, + ownKeys(target) { + calls += 1; + return Reflect.ownKeys(target); + }, + }); + + assertEquals(stringifyToolError(proxy), "Unknown error"); + assertEquals(calls, 0); + }); + + it("fails closed without Proxy introspection in a fresh Cloudflare process", async () => { + const script = ` + Object.defineProperty(globalThis, "caches", { + configurable: true, + value: {}, + }); + Object.defineProperty(globalThis, "WebSocketPair", { + configurable: true, + value: function WebSocketPair() {}, + }); + + const { runtimeKind } = await import("./src/platform/compat/runtime.ts"); + const { + canIdentifyProxyWithoutHooks, + isNativeErrorWithoutHooks, + } = await import("./src/platform/compat/error-introspection.ts"); + const { stringifyToolError } = await import("./src/agent/runtime/error-utils.ts"); + + let calls = 0; + const handler = { + get(target, property, receiver) { + calls += 1; + return Reflect.get(target, property, receiver); + }, + getOwnPropertyDescriptor(target, property) { + calls += 1; + return Reflect.getOwnPropertyDescriptor(target, property); + }, + getPrototypeOf(target) { + calls += 1; + return Reflect.getPrototypeOf(target); + }, + ownKeys(target) { + calls += 1; + return Reflect.ownKeys(target); + }, + }; + const proxy = new Proxy({}, handler); + const errorProxy = new Proxy(new Error("private"), handler); + + const result = { + runtimeKind, + canIdentifyProxyWithoutHooks, + proxy: stringifyToolError(proxy), + errorProxy: stringifyToolError(errorProxy), + calls, + nativeError: isNativeErrorWithoutHooks(new Error("native")), + error: stringifyToolError(new Error("tool exploded")), + domException: stringifyToolError( + new DOMException("provider timed out", "TimeoutError"), + ), + object: stringifyToolError({ code: "E_TOOL" }), + callable: stringifyToolError(() => undefined), + nullValue: stringifyToolError(null), + booleanValue: stringifyToolError(true), + numberValue: stringifyToolError(42), + undefinedValue: stringifyToolError(undefined), + bigintValue: stringifyToolError(1n), + symbolValue: stringifyToolError(Symbol("private")), + }; + console.log(JSON.stringify(result)); + `; + const output = await new Deno.Command(Deno.execPath(), { + args: ["eval", "--config=deno.json", script], + cwd: new URL("../../../", import.meta.url), + stdout: "piped", + stderr: "piped", + }).output(); + const stderr = new TextDecoder().decode(output.stderr); + assertEquals(output.code, 0, stderr); + + const result = JSON.parse(new TextDecoder().decode(output.stdout)); + assertEquals(result, { + runtimeKind: "cloudflare", + canIdentifyProxyWithoutHooks: false, + proxy: "Unknown error", + errorProxy: "Unknown error", + calls: 0, + nativeError: true, + error: "tool exploded", + domException: "provider timed out", + object: "Unknown error", + callable: "Unknown error", + nullValue: "null", + booleanValue: "true", + numberValue: "42", + undefinedValue: "undefined", + bigintValue: "bigint", + symbolValue: "symbol", + }); + }); + + it("uses captured primordials for native and primitive diagnostics", () => { + const defineProperty = Object.defineProperty; + const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; + const descriptors = { + apply: getOwnPropertyDescriptor(Reflect, "apply")!, + charCodeAt: getOwnPropertyDescriptor(String.prototype, "charCodeAt")!, + domMessage: getOwnPropertyDescriptor(DOMException.prototype, "message")!, + getOwnPropertyDescriptor: getOwnPropertyDescriptor( + Object, + "getOwnPropertyDescriptor", + )!, + hasOwnProperty: getOwnPropertyDescriptor( + Object.prototype, + "hasOwnProperty", + )!, + jsonStringify: getOwnPropertyDescriptor(JSON, "stringify")!, + slice: getOwnPropertyDescriptor(String.prototype, "slice")!, + }; + const domException = new DOMException("provider timed out", "TimeoutError"); + const oversized = "é".repeat(MAX_TOOL_ERROR_TEXT_BYTES); + let hookCalls = 0; + const hostile = () => { + hookCalls += 1; + throw new Error("mutable primordial must not run"); + }; + let result: + | { + bounded: string; + domException: string; + error: string; + nullValue: string; + } + | undefined; + + try { + for ( + const [owner, key] of [ + [Reflect, "apply"], + [String.prototype, "charCodeAt"], + [DOMException.prototype, "message"], + [Object, "getOwnPropertyDescriptor"], + [Object.prototype, "hasOwnProperty"], + [JSON, "stringify"], + [String.prototype, "slice"], + ] as const + ) { + defineProperty(owner, key, { + configurable: true, + value: hostile, + writable: true, + }); + } + + result = { + bounded: stringifyToolError(oversized), + domException: stringifyToolError(domException), + error: stringifyToolError(new Error("tool exploded")), + nullValue: stringifyToolError(null), + }; + } finally { + defineProperty(Reflect, "apply", descriptors.apply); + defineProperty(String.prototype, "charCodeAt", descriptors.charCodeAt); + defineProperty(DOMException.prototype, "message", descriptors.domMessage); + defineProperty( + Object, + "getOwnPropertyDescriptor", + descriptors.getOwnPropertyDescriptor, + ); + defineProperty( + Object.prototype, + "hasOwnProperty", + descriptors.hasOwnProperty, + ); + defineProperty(JSON, "stringify", descriptors.jsonStringify); + defineProperty(String.prototype, "slice", descriptors.slice); + } + + assertEquals(result?.error, "tool exploded"); + assertEquals(result?.domException, "provider timed out"); + assertEquals(result?.nullValue, "null"); + assertEquals( + new TextEncoder().encode(result?.bounded).byteLength <= MAX_TOOL_ERROR_TEXT_BYTES, + true, + ); + assertEquals(hookCalls, 0); + }); + + it("does not consult mutable primordials while snapshotting structured errors", () => { + const defineProperty = Object.defineProperty; + const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; + const targets: ReadonlyArray = [ + [Array, "isArray"], + [Array.prototype, "push"], + [Array.prototype, "sort"], + [JSON, "stringify"], + [Number, "isFinite"], + [Number, "isInteger"], + [Number, "isSafeInteger"], + [Object, "create"], + [Object, "defineProperty"], + [Object, "freeze"], + [Object, "getOwnPropertyDescriptor"], + [Object, "getPrototypeOf"], + [Object, "is"], + [Object, "keys"], + [Object.prototype, "hasOwnProperty"], + [Reflect, "apply"], + [Reflect, "ownKeys"], + [String.prototype, "charCodeAt"], + [WeakSet.prototype, "add"], + [WeakSet.prototype, "delete"], + [WeakSet.prototype, "has"], + [globalThis, "Array"], + [globalThis, "Number"], + [globalThis, "String"], + [globalThis, "TypeError"], + [globalThis, "WeakSet"], + ]; + const originals = targets.map(([owner, key]) => ({ + key, + owner, + descriptor: getOwnPropertyDescriptor(owner, key)!, + })); + const structuredError = { + retryable: true, + details: ["safe", 1], + code: "E_TOOL", + }; + let hookCalls = 0; + let result: string | undefined; + + try { + for (let index = 0; index < originals.length; index += 1) { + const { owner, key } = originals[index]!; + const label = typeof key === "string" ? key : "Symbol.iterator"; + defineProperty(owner, key, { + configurable: true, + value: () => { + hookCalls += 1; + throw new Error(`mutable primordial ${label} must not run`); + }, + writable: true, + }); + } + result = stringifyToolError(structuredError); + } finally { + for (let index = 0; index < originals.length; index += 1) { + const { owner, key, descriptor } = originals[index]!; + defineProperty(owner, key, descriptor); + } + } + + assertEquals( + result, + '{"code":"E_TOOL","details":["safe",1],"retryable":true}', + ); + assertEquals(hookCalls, 0); + }); + + it("avoids Array iterators and inherited numeric setters in a fresh process", async () => { + const script = ` + const { stringifyToolError } = await import( + "./src/agent/runtime/error-utils.ts" + ); + const defineProperty = Object.defineProperty; + const deleteProperty = Reflect.deleteProperty; + const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; + const iteratorDescriptor = getOwnPropertyDescriptor( + Array.prototype, + Symbol.iterator, + ); + const indexDescriptor = getOwnPropertyDescriptor(Array.prototype, "0"); + const structuredError = { + retryable: true, + details: ["safe", 1], + code: "E_TOOL", + }; + let iteratorCalls = 0; + let inheritedSetterCalls = 0; + let result; + + try { + defineProperty(Array.prototype, Symbol.iterator, { + configurable: true, + value() { + iteratorCalls += 1; + throw new Error("Array iterator must not run"); + }, + writable: true, + }); + defineProperty(Array.prototype, "0", { + configurable: true, + set() { + inheritedSetterCalls += 1; + }, + }); + result = stringifyToolError(structuredError); + } finally { + if (iteratorDescriptor) { + defineProperty(Array.prototype, Symbol.iterator, iteratorDescriptor); + } + if (indexDescriptor) { + defineProperty(Array.prototype, "0", indexDescriptor); + } else { + deleteProperty(Array.prototype, "0"); + } + } + + console.log(JSON.stringify({ + inheritedSetterCalls, + iteratorCalls, + result, + })); + `; + const output = await new Deno.Command(Deno.execPath(), { + args: ["eval", "--config=deno.json", script], + cwd: new URL("../../../", import.meta.url), + stdout: "piped", + stderr: "piped", + }).output(); + const stderr = new TextDecoder().decode(output.stderr); + assertEquals(output.code, 0, stderr); + assertEquals( + JSON.parse(new TextDecoder().decode(output.stdout)), + { + inheritedSetterCalls: 0, + iteratorCalls: 0, + result: '{"code":"E_TOOL","details":["safe",1],"retryable":true}', + }, + ); + }); + + it("bounds direct and Error diagnostic text by UTF-8 byte length", () => { + const oversized = "é".repeat(MAX_TOOL_ERROR_TEXT_BYTES); + const direct = stringifyToolError(oversized); + const fromError = stringifyToolError(new Error(oversized)); + + assertEquals(new TextEncoder().encode(direct).byteLength <= MAX_TOOL_ERROR_TEXT_BYTES, true); + assertEquals( + new TextEncoder().encode(fromError).byteLength <= MAX_TOOL_ERROR_TEXT_BYTES, + true, + ); + assertStringIncludes(direct, "…"); + assertStringIncludes(fromError, "…"); }); }); }); diff --git a/src/agent/runtime/error-utils.ts b/src/agent/runtime/error-utils.ts index b925985e40..979c5ddf7c 100644 --- a/src/agent/runtime/error-utils.ts +++ b/src/agent/runtime/error-utils.ts @@ -1,17 +1,364 @@ +import { snapshotJsonValue } from "#veryfront/provider/runtime-loader/json-snapshot.ts"; +import { + canIdentifyProxyWithoutHooks, + isNativeErrorWithoutHooks, + isProxyWithoutHooks, +} from "#veryfront/platform/compat/error-introspection.ts"; + export { createAbortError, throwIfAborted } from "#veryfront/utils/abort.ts"; +/** Maximum UTF-8 size of tool failure text forwarded to logs, clients, or models. */ +export const MAX_TOOL_ERROR_TEXT_BYTES = 4_096; + +const TOOL_ERROR_TEXT_TRUNCATION_SUFFIX = "…"; +const TOOL_ERROR_TEXT_TRUNCATION_SUFFIX_BYTES = 3; +const UNKNOWN_TOOL_ERROR_TEXT = "Unknown error"; +const apply = Reflect.apply; +const ArrayIsArray = Array.isArray; +const dateToISOString = Date.prototype.toISOString; +const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const getPrototypeOf = Object.getPrototypeOf; +const objectCreate = Object.create; +const objectDefineProperty = Object.defineProperty; +const objectHasOwnProperty = Object.prototype.hasOwnProperty; +const ownKeys = Reflect.ownKeys; +const stringCharCodeAt = String.prototype.charCodeAt; +const stringSlice = String.prototype.slice; +const jsonStringify = JSON.stringify; +const mathMin = Math.min; +const NativeArrayPrototype = Array.prototype; +const NativeDatePrototype = Date.prototype; +const NativeObjectPrototype = Object.prototype; +const NativeWeakSet = WeakSet; +const numberIsFinite = Number.isFinite; +const numberIsSafeInteger = Number.isSafeInteger; +const stringFromValue = String; +const weakSetAdd = WeakSet.prototype.add; +const weakSetDelete = WeakSet.prototype.delete; +const weakSetHas = WeakSet.prototype.has; +const MAX_BEST_EFFORT_DEPTH = 8; +const MAX_BEST_EFFORT_NODES = 256; +const OMIT_DIAGNOSTIC_VALUE = Symbol("omit-diagnostic-value"); + +function hasOwn(object: object, key: PropertyKey): boolean { + return apply(objectHasOwnProperty, object, [key]) as boolean; +} + +function hasWeakSetValue(set: WeakSet, value: object): boolean { + return apply(weakSetHas, set, [value]) as boolean; +} + +function addWeakSetValue(set: WeakSet, value: object): void { + apply(weakSetAdd, set, [value]); +} + +function deleteWeakSetValue(set: WeakSet, value: object): void { + apply(weakSetDelete, set, [value]); +} + +function readOwnGetter(object: object, key: PropertyKey): (() => unknown) | undefined { + const descriptor = getOwnPropertyDescriptor(object, key); + return descriptor && hasOwn(descriptor, "get") && typeof descriptor.get === "function" + ? descriptor.get + : undefined; +} + +const DOM_EXCEPTION_MESSAGE_GETTER = typeof DOMException === "function" + ? readOwnGetter(DOMException.prototype, "message") + : undefined; + +function charCodeAt(value: string, index: number): number { + return apply(stringCharCodeAt, value, [index]); +} + +function slice(value: string, start: number, end?: number): string { + return apply(stringSlice, value, end === undefined ? [start] : [start, end]); +} + +function codePointUtf8Width(value: string, index: number): { bytes: number; codeUnits: number } { + const codeUnit = charCodeAt(value, index); + if (codeUnit <= 0x7f) return { bytes: 1, codeUnits: 1 }; + if (codeUnit <= 0x7ff) return { bytes: 2, codeUnits: 1 }; + if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { + const next = charCodeAt(value, index + 1); + if (next >= 0xdc00 && next <= 0xdfff) { + return { bytes: 4, codeUnits: 2 }; + } + } + // TextEncoder replaces lone surrogates with the three-byte U+FFFD sequence. + return { bytes: 3, codeUnits: 1 }; +} + +function boundToolErrorText(value: string): string { + let bytes = 0; + let index = 0; + let suffixSafeIndex = 0; + + while (index < value.length) { + const width = codePointUtf8Width(value, index); + if (width.bytes > MAX_TOOL_ERROR_TEXT_BYTES - bytes) { + return slice(value, 0, suffixSafeIndex) + TOOL_ERROR_TEXT_TRUNCATION_SUFFIX; + } + bytes += width.bytes; + index += width.codeUnits; + if (bytes <= MAX_TOOL_ERROR_TEXT_BYTES - TOOL_ERROR_TEXT_TRUNCATION_SUFFIX_BYTES) { + suffixSafeIndex = index; + } + } + + return value; +} + +function readOwnMessage(error: unknown): string | undefined { + if ( + (typeof error !== "object" || error === null) && + typeof error !== "function" + ) { + return undefined; + } + + try { + const descriptor = getOwnPropertyDescriptor(error, "message"); + return descriptor && hasOwn(descriptor, "value") && + typeof descriptor.value === "string" + ? descriptor.value + : undefined; + } catch { + return undefined; + } +} + +function readNativeDomExceptionMessage(error: unknown): string | undefined { + if ( + !DOM_EXCEPTION_MESSAGE_GETTER || + ((typeof error !== "object" || error === null) && typeof error !== "function") + ) { + return undefined; + } + + try { + const message = apply(DOM_EXCEPTION_MESSAGE_GETTER, error, []); + return typeof message === "string" ? message : undefined; + } catch { + return undefined; + } +} + +type BestEffortDiagnosticValue = + | null + | boolean + | number + | string + | BestEffortDiagnosticValue[] + | { [key: string]: BestEffortDiagnosticValue }; + +interface BestEffortDiagnosticState { + ancestors: WeakSet; + nodes: number; +} + +function inspectOwnDescriptor( + value: object, + key: PropertyKey, +): PropertyDescriptor | undefined { + try { + return getOwnPropertyDescriptor(value, key); + } catch { + return undefined; + } +} + +function defineDiagnosticProperty( + target: object, + key: PropertyKey, + value: BestEffortDiagnosticValue, +): void { + objectDefineProperty(target, key, { + configurable: true, + enumerable: true, + value, + writable: true, + }); +} + +function defineDiagnosticSerializationGuard(target: object): void { + objectDefineProperty(target, "toJSON", { + configurable: false, + enumerable: false, + value: undefined, + writable: false, + }); +} + +/** + * Build a partial diagnostic after the strict JSON snapshot rejects one + * branch. This path never evaluates accessors, coercion hooks, or Proxy traps: + * unsafe branches are omitted (or represented as null in arrays) while safe + * siblings remain useful to operators and models. + */ +function snapshotBestEffortDiagnostic( + value: unknown, + depth: number, + state: BestEffortDiagnosticState, +): BestEffortDiagnosticValue | typeof OMIT_DIAGNOSTIC_VALUE { + if (state.nodes >= MAX_BEST_EFFORT_NODES || depth > MAX_BEST_EFFORT_DEPTH) { + return OMIT_DIAGNOSTIC_VALUE; + } + state.nodes += 1; + + if (value === null) return null; + if (typeof value === "string") return boundToolErrorText(value); + if (typeof value === "boolean") return value; + if (typeof value === "number") return numberIsFinite(value) ? value : OMIT_DIAGNOSTIC_VALUE; + if (typeof value !== "object") return OMIT_DIAGNOSTIC_VALUE; + + if (isProxyWithoutHooks(value) || hasWeakSetValue(state.ancestors, value)) { + return OMIT_DIAGNOSTIC_VALUE; + } + + let prototype: object | null; + try { + prototype = getPrototypeOf(value); + } catch { + return OMIT_DIAGNOSTIC_VALUE; + } + + if (prototype === NativeDatePrototype) { + try { + return apply(dateToISOString, value, []) as string; + } catch { + return OMIT_DIAGNOSTIC_VALUE; + } + } + + const isArray = ArrayIsArray(value); + if ( + (isArray && prototype !== NativeArrayPrototype) || + (!isArray && prototype !== NativeObjectPrototype && prototype !== null) + ) { + return OMIT_DIAGNOSTIC_VALUE; + } + + addWeakSetValue(state.ancestors, value); + try { + if (isArray) { + const lengthDescriptor = inspectOwnDescriptor(value, "length"); + if ( + !lengthDescriptor || !hasOwn(lengthDescriptor, "value") || + !numberIsSafeInteger(lengthDescriptor.value) || lengthDescriptor.value < 0 + ) { + return OMIT_DIAGNOSTIC_VALUE; + } + const length = mathMin( + lengthDescriptor.value as number, + MAX_BEST_EFFORT_NODES - state.nodes, + ); + const result: BestEffortDiagnosticValue[] = []; + for (let index = 0; index < length; index += 1) { + const descriptor = inspectOwnDescriptor(value, stringFromValue(index)); + const child = descriptor && descriptor.enumerable === true && hasOwn(descriptor, "value") + ? snapshotBestEffortDiagnostic(descriptor.value, depth + 1, state) + : OMIT_DIAGNOSTIC_VALUE; + defineDiagnosticProperty( + result, + index, + child === OMIT_DIAGNOSTIC_VALUE ? null : child, + ); + } + defineDiagnosticSerializationGuard(result); + return result; + } + + let keys: (string | symbol)[]; + try { + keys = ownKeys(value); + } catch { + return OMIT_DIAGNOSTIC_VALUE; + } + const result = objectCreate(null) as Record; + let retainedProperties = 0; + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]!; + if (typeof key !== "string") continue; + const descriptor = inspectOwnDescriptor(value, key); + if (!descriptor || descriptor.enumerable !== true || !hasOwn(descriptor, "value")) continue; + const child = snapshotBestEffortDiagnostic(descriptor.value, depth + 1, state); + if (child !== OMIT_DIAGNOSTIC_VALUE) { + defineDiagnosticProperty(result, key, child); + retainedProperties += 1; + } + if (state.nodes >= MAX_BEST_EFFORT_NODES) break; + } + return retainedProperties > 0 ? result : OMIT_DIAGNOSTIC_VALUE; + } finally { + deleteWeakSetValue(state.ancestors, value); + } +} + +function stringifyBestEffortDiagnostic(error: unknown): string | undefined { + if (!canIdentifyProxyWithoutHooks) return undefined; + const snapshot = snapshotBestEffortDiagnostic(error, 0, { + ancestors: new NativeWeakSet(), + nodes: 0, + }); + if (snapshot === OMIT_DIAGNOSTIC_VALUE) return undefined; + try { + const serialized = jsonStringify(snapshot); + return typeof serialized === "string" && serialized.length > 0 + ? boundToolErrorText(serialized) + : undefined; + } catch { + return undefined; + } +} + export function stringifyToolError(error: unknown): string { if (typeof error === "string" && error.length > 0) { - return error; + return boundToolErrorText(error); + } + + const objectLike = (typeof error === "object" && error !== null) || typeof error === "function"; + if (objectLike) { + if (canIdentifyProxyWithoutHooks) { + if (isProxyWithoutHooks(error)) return UNKNOWN_TOOL_ERROR_TEXT; + } else if (!isNativeErrorWithoutHooks(error)) { + // Without a no-hook Proxy brand check, ordinary objects and functions + // cannot be distinguished safely from Proxy values. Native Error brands + // remain readable through the captured Error.isError primitive. + return UNKNOWN_TOOL_ERROR_TEXT; + } + } + + const ownMessage = readOwnMessage(error); + if (ownMessage !== undefined && ownMessage.length > 0) { + return boundToolErrorText(ownMessage); + } + + const domExceptionMessage = readNativeDomExceptionMessage(error); + if (domExceptionMessage !== undefined && domExceptionMessage.length > 0) { + return boundToolErrorText(domExceptionMessage); } - if (error instanceof Error && typeof error.message === "string" && error.message.length > 0) { - return error.message; + // A genuine Error without a readable message is already exhausted here. + // Do not pass it into object reflection when Proxy detection is unavailable. + if (objectLike && !canIdentifyProxyWithoutHooks) { + return UNKNOWN_TOOL_ERROR_TEXT; } try { - return JSON.stringify(error); + const snapshot = snapshotJsonValue(error, { + maxBytes: MAX_TOOL_ERROR_TEXT_BYTES, + maxDepth: 16, + maxNodes: 1_024, + }); + const serialized = jsonStringify(snapshot); + return typeof serialized === "string" && serialized.length > 0 + ? boundToolErrorText(serialized) + : UNKNOWN_TOOL_ERROR_TEXT; } catch { - return String(error); + if (error === undefined) return "undefined"; + if (typeof error === "bigint") return "bigint"; + if (typeof error === "symbol") return "symbol"; + return stringifyBestEffortDiagnostic(error) ?? UNKNOWN_TOOL_ERROR_TEXT; } } diff --git a/src/agent/runtime/model-resolution.test.ts b/src/agent/runtime/model-resolution.test.ts index e50b72c85a..000b18aff2 100644 --- a/src/agent/runtime/model-resolution.test.ts +++ b/src/agent/runtime/model-resolution.test.ts @@ -2,6 +2,7 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; import { deleteEnv, setEnv } from "#veryfront/compat/process.ts"; import { afterEach, describe, it } from "#veryfront/testing/bdd.ts"; +import { VERYFRONT_CLOUD_CHAT_MODELS } from "#veryfront/provider/veryfront-cloud/model-catalog.ts"; import { AUTO_AGENT_MODEL, DEFAULT_AGENT_MODEL, @@ -102,6 +103,33 @@ describe("agent/runtime/model-resolution", () => { ); }); + it("aliases every Veryfront Cloud catalog model id to its provider model", () => { + for (const model of VERYFRONT_CLOUD_CHAT_MODELS) { + assertEquals(resolveConfiguredAgentModel(model.id), model.modelId); + } + }); + + it("does not resolve Object.prototype members as model aliases", () => { + for ( + const inherited of [ + "constructor", + "toString", + "valueOf", + "hasOwnProperty", + "isPrototypeOf", + "propertyIsEnumerable", + "toLocaleString", + "__proto__", + "__defineGetter__", + "__defineSetter__", + "__lookupGetter__", + "__lookupSetter__", + ] + ) { + assertEquals(resolveConfiguredAgentModel(inherited), inherited); + } + }); + it("uses the default model through Veryfront Cloud when cloud bootstrap is available", () => { setEnv("VERYFRONT_API_TOKEN", "vf_test_runtime"); setEnv("VERYFRONT_PROJECT_SLUG", "demo-project"); diff --git a/src/agent/runtime/model-resolution.ts b/src/agent/runtime/model-resolution.ts index e7f7db88d1..8da1336b90 100644 --- a/src/agent/runtime/model-resolution.ts +++ b/src/agent/runtime/model-resolution.ts @@ -22,45 +22,45 @@ const HOSTED_PROVIDER_NAMES = new Set([ "moonshotai", "openai", ]); -const DIRECT_CREDENTIAL_PROVIDER_ALIASES: Record = { - "google-ai-studio": "google", -}; -const DIRECT_RUNTIME_PROVIDER_ALIASES: Record = { - "google-ai-studio": "google", -}; +const DIRECT_CREDENTIAL_PROVIDER_ALIASES = new Map([ + ["google-ai-studio", "google"], +]); +const DIRECT_RUNTIME_PROVIDER_ALIASES = new Map([ + ["google-ai-studio", "google"], +]); const DIRECT_AUTO_MODEL_DEFAULTS: Array<{ provider: string; modelId: string }> = [ { provider: "openai", modelId: "gpt-5.4-nano" }, { provider: "anthropic", modelId: "claude-sonnet-4-6" }, { provider: "google-ai-studio", modelId: "gemini-3.5-flash" }, { provider: "mistral", modelId: "mistral-large-2512" }, ]; -const LEGACY_MODEL_ALIASES: Record = { - opus: "anthropic/claude-opus-4-8", - sonnet: "anthropic/claude-sonnet-4-6", - haiku: "anthropic/claude-haiku-4-5-20251001", - "claude-opus-4-8": "anthropic/claude-opus-4-8", - "claude-opus-4-6": "anthropic/claude-opus-4-6", - "claude-sonnet-4-6": "anthropic/claude-sonnet-4-6", - "claude-haiku-4-5-20251001": "anthropic/claude-haiku-4-5-20251001", - "gpt-5.5": "openai/gpt-5.5", - "gpt-5.2": "openai/gpt-5.2", - "gpt-5.4": "openai/gpt-5.4", - "gpt-5.4-mini": "openai/gpt-5.4-mini", - "gpt-5.4-nano": "openai/gpt-5.4-nano", - "o3-pro": "openai/o3-pro", - "o4-mini": "openai/o4-mini", - "gemini-3.1-pro": "google-ai-studio/gemini-3.1-pro-preview", - "gemini-3.1-pro-preview": "google-ai-studio/gemini-3.1-pro-preview", - "gemini-3.5-flash": "google-ai-studio/gemini-3.5-flash", - "gemini-3-flash-preview": "google-ai-studio/gemini-3-flash-preview", - "gemini-3.1-flash-lite": "google-ai-studio/gemini-3.1-flash-lite", - "gemini-2.5-pro": "google-ai-studio/gemini-2.5-pro", - "gemini-2.5-flash": "google-ai-studio/gemini-2.5-flash", - "mistral-large": "mistral/mistral-large-2512", - "mistral-large-2512": "mistral/mistral-large-2512", - "kimi-k2.6": "moonshotai/kimi-k2.6", - "kimi-k2.5": "moonshotai/kimi-k2.5", -}; +const LEGACY_MODEL_ALIASES = new Map([ + ["opus", "anthropic/claude-opus-4-8"], + ["sonnet", "anthropic/claude-sonnet-4-6"], + ["haiku", "anthropic/claude-haiku-4-5-20251001"], + ["claude-opus-4-8", "anthropic/claude-opus-4-8"], + ["claude-opus-4-6", "anthropic/claude-opus-4-6"], + ["claude-sonnet-4-6", "anthropic/claude-sonnet-4-6"], + ["claude-haiku-4-5-20251001", "anthropic/claude-haiku-4-5-20251001"], + ["gpt-5.5", "openai/gpt-5.5"], + ["gpt-5.2", "openai/gpt-5.2"], + ["gpt-5.4", "openai/gpt-5.4"], + ["gpt-5.4-mini", "openai/gpt-5.4-mini"], + ["gpt-5.4-nano", "openai/gpt-5.4-nano"], + ["o3-pro", "openai/o3-pro"], + ["o4-mini", "openai/o4-mini"], + ["gemini-3.1-pro", "google-ai-studio/gemini-3.1-pro-preview"], + ["gemini-3.1-pro-preview", "google-ai-studio/gemini-3.1-pro-preview"], + ["gemini-3.5-flash", "google-ai-studio/gemini-3.5-flash"], + ["gemini-3-flash-preview", "google-ai-studio/gemini-3-flash-preview"], + ["gemini-3.1-flash-lite", "google-ai-studio/gemini-3.1-flash-lite"], + ["gemini-2.5-pro", "google-ai-studio/gemini-2.5-pro"], + ["gemini-2.5-flash", "google-ai-studio/gemini-2.5-flash"], + ["mistral-large", "mistral/mistral-large-2512"], + ["mistral-large-2512", "mistral/mistral-large-2512"], + ["kimi-k2.6", "moonshotai/kimi-k2.6"], + ["kimi-k2.5", "moonshotai/kimi-k2.5"], +]); export function normalizeAgentModelConfig(model?: string): string { if (model === undefined) return DEFAULT_AGENT_MODEL; @@ -79,11 +79,11 @@ export function resolveConfiguredAgentModel(model?: string): string { return normalized; } - return LEGACY_MODEL_ALIASES[normalized] ?? normalized; + return LEGACY_MODEL_ALIASES.get(normalized) ?? normalized; } function hasDirectProviderCredentials(provider: string): boolean { - switch (DIRECT_CREDENTIAL_PROVIDER_ALIASES[provider] ?? provider) { + switch (DIRECT_CREDENTIAL_PROVIDER_ALIASES.get(provider) ?? provider) { case "anthropic": return Boolean(getAnthropicEnvConfig().apiKey); case "google": @@ -114,7 +114,7 @@ function normalizeVeryfrontCloudRuntimeModel(modelId: string): string { } function toDirectRuntimeModel(provider: string, modelId: string): string { - const runtimeProvider = DIRECT_RUNTIME_PROVIDER_ALIASES[provider] ?? provider; + const runtimeProvider = DIRECT_RUNTIME_PROVIDER_ALIASES.get(provider) ?? provider; return `${runtimeProvider}/${modelId}`; } diff --git a/src/platform/compat/error-introspection.ts b/src/platform/compat/error-introspection.ts index 4d44c45ad0..478cb7cbee 100644 --- a/src/platform/compat/error-introspection.ts +++ b/src/platform/compat/error-introspection.ts @@ -18,16 +18,50 @@ const NativeError = Error; const NativeAsyncFunctionPrototype = getPrototypeOf(async function () {}); const toStringTagSymbol = Symbol.toStringTag; +function hasOwn(object: object, key: PropertyKey): boolean { + return apply(objectHasOwnProperty, object, [key]) as boolean; +} + +type ErrorBrandCheck = (value: unknown) => boolean; + +/** + * Capture the portable Error brand primitive during trusted framework + * bootstrap, before tenant code can replace mutable globals. Edge runtimes do + * not expose an immutable host-module equivalent, so this capture boundary is + * the authority for later no-hook Error checks. + */ +function captureErrorIsError(): ErrorBrandCheck | undefined { + const descriptor = getOwnPropertyDescriptor(NativeError, "isError"); + return descriptor && hasOwn(descriptor, "value") && + typeof descriptor.value === "function" + ? descriptor.value as ErrorBrandCheck + : undefined; +} + +const capturedErrorIsError = captureErrorIsError(); const unavailableBrandCheck = (_value: unknown): boolean => false; + +function portableErrorBrandCheck(value: unknown): boolean { + if (!capturedErrorIsError) return false; + try { + return apply(capturedErrorIsError, NativeError, [value]) === true; + } catch (_) { + return false; + } +} + const nativeAsyncFunctionBrandCheck = nativeBrandChecks?.isAsyncFunction ?? unavailableBrandCheck; -const nativeErrorBrandCheck = nativeBrandChecks?.isNativeError ?? unavailableBrandCheck; +const nativeErrorBrandCheck = nativeBrandChecks?.isNativeError ?? portableErrorBrandCheck; const nativePromiseBrandCheck = nativeBrandChecks?.isPromise ?? unavailableBrandCheck; const nativeProxyBrandCheck = nativeBrandChecks?.isProxy ?? unavailableBrandCheck; const nativeUint8ArrayBrandCheck = nativeBrandChecks?.isUint8Array ?? unavailableBrandCheck; -function hasOwn(object: object, key: PropertyKey): boolean { - return apply(objectHasOwnProperty, object, [key]) as boolean; -} +/** + * Whether this runtime can distinguish Proxy values without evaluating a trap. + * Callers that need a fail-closed guarantee must not treat a `false` result + * from {@link isProxyWithoutHooks} as proof when this capability is absent. + */ +export const canIdentifyProxyWithoutHooks = nativeBrandChecks !== undefined; function createDataDescriptor(value: unknown): PropertyDescriptor { const descriptor = createObject(null) as PropertyDescriptor; @@ -249,7 +283,12 @@ export function readNativeErrorNameWithoutHooks(error: Error): string { } } -/** Identify a Proxy without evaluating any trap on the proxied value. */ +/** + * Identify a Proxy without evaluating any trap on the proxied value. + * + * Returns `false` without inspecting `value` when + * {@link canIdentifyProxyWithoutHooks} is false. + */ export function isProxyWithoutHooks(value: unknown): boolean { return nativeProxyBrandCheck(value); } diff --git a/src/provider/runtime-loader-helpers.test.ts b/src/provider/runtime-loader-helpers.test.ts index 1878cf8e16..2ec30f0872 100644 --- a/src/provider/runtime-loader-helpers.test.ts +++ b/src/provider/runtime-loader-helpers.test.ts @@ -149,6 +149,106 @@ describe("provider/runtime-loader helpers", () => { ); }); + it("fails closed for object snapshots without Proxy detection", async () => { + const script = ` + Object.defineProperty(globalThis, "caches", { + configurable: true, + value: {}, + }); + Object.defineProperty(globalThis, "WebSocketPair", { + configurable: true, + value: function WebSocketPair() {}, + }); + + const { + canIdentifyProxyWithoutHooks, + } = await import("./src/platform/compat/error-introspection.ts"); + const { jsonValuesEqual, snapshotJsonValue, stringifyJsonValue } = await import( + "./src/provider/runtime-loader.ts" + ); + + let calls = 0; + let getterCalls = 0; + const accessor = Object.defineProperty({}, "safe", { + enumerable: true, + get() { + getterCalls += 1; + return true; + }, + }); + const proxy = new Proxy({ safe: true }, { + getPrototypeOf(target) { + calls += 1; + return Reflect.getPrototypeOf(target); + }, + getOwnPropertyDescriptor(target, property) { + calls += 1; + return Reflect.getOwnPropertyDescriptor(target, property); + }, + ownKeys(target) { + calls += 1; + return Reflect.ownKeys(target); + }, + }); + + const result = { + canIdentifyProxyWithoutHooks, + primitive: snapshotJsonValue("safe"), + calls, + plainObject: "", + providerObject: "", + providerAccessor: stringifyJsonValue(accessor), + providerEquality: jsonValuesEqual('{"safe":true}', { safe: true }, true), + getterCalls, + proxy: "", + providerProxy: "", + }; + try { + snapshotJsonValue({ safe: true }); + } catch (error) { + result.plainObject = error instanceof Error ? error.message : String(error); + } + try { + result.providerObject = stringifyJsonValue({ safe: true }); + } catch (error) { + result.providerObject = error instanceof Error ? error.message : String(error); + } + try { + snapshotJsonValue(proxy); + } catch (error) { + result.proxy = error instanceof Error ? error.message : String(error); + } + try { + stringifyJsonValue({ nested: proxy }); + } catch (error) { + result.providerProxy = error instanceof Error ? error.message : String(error); + } + console.log(JSON.stringify(result)); + `; + const output = await new Deno.Command(Deno.execPath(), { + args: ["eval", "--config=deno.json", script], + cwd: new URL("../../", import.meta.url), + stdout: "piped", + stderr: "piped", + }).output(); + const stderr = new TextDecoder().decode(output.stderr); + assertEquals(output.code, 0, stderr); + + const result = JSON.parse(new TextDecoder().decode(output.stdout)); + assertEquals(result, { + canIdentifyProxyWithoutHooks: false, + primitive: "safe", + calls: 0, + plainObject: "Provider JSON snapshot cannot inspect object values without Proxy detection", + providerObject: '{"safe":true}', + providerAccessor: '{"safe":true}', + providerEquality: true, + getterCalls: 1, + proxy: "Provider JSON snapshot cannot inspect object values without Proxy detection", + providerProxy: "Provider tool value must be JSON-serializable", + }); + }); + it("serializes only owned snapshots without invoking getters or toJSON", () => { let getterCalls = 0; let toJsonCalls = 0; @@ -503,6 +603,51 @@ describe("provider/runtime-loader helpers", () => { } }); + it("reads snapshot options only from own data properties", () => { + const optionKeys = [ + "maxDepth", + "maxNodes", + "maxBytes", + "sortObjectKeys", + ] as const; + + for (let index = 0; index < optionKeys.length; index += 1) { + const key = optionKeys[index]!; + let getterCalls = 0; + const options = Object.defineProperty({}, key, { + configurable: true, + get() { + getterCalls += 1; + return key === "sortObjectKeys" ? false : 1; + }, + }); + + assertThrows( + () => snapshotJsonValue(null, options), + TypeError, + "options must use own data properties", + ); + assertEquals(getterCalls, 0); + } + + const inheritedOptions = Object.create({ + maxDepth: 0, + maxNodes: 1, + maxBytes: 1, + sortObjectKeys: false, + }); + assertEquals( + Object.keys(snapshotJsonValue({ b: 2, a: 1 }, inheritedOptions) as object), + ["a", "b"], + ); + assertEquals( + Object.keys( + snapshotJsonValue({ b: 2, a: 1 }, { sortObjectKeys: false }) as object, + ), + ["b", "a"], + ); + }); + it("makes JSON equality fail closed without invoking accessors or serialization hooks", () => { let getterCalls = 0; const accessor = Object.defineProperty({}, "value", { diff --git a/src/provider/runtime-loader.ts b/src/provider/runtime-loader.ts index 005d2f1399..0b66fb9d85 100644 --- a/src/provider/runtime-loader.ts +++ b/src/provider/runtime-loader.ts @@ -18,9 +18,13 @@ import { TOOL_INPUT_PENDING_THRESHOLD_MS, withToolInputStatusTransitions, } from "./runtime-loader/tool-input-status.ts"; -import { snapshotJsonValue } from "./runtime-loader/json-snapshot.ts"; +import { snapshotProviderJsonValue } from "./runtime-loader/json-snapshot.ts"; -export { jsonValuesEqual, snapshotJsonValue } from "./runtime-loader/json-snapshot.ts"; +export { + jsonValuesEqual, + snapshotJsonValue, + snapshotProviderJsonValue, +} from "./runtime-loader/json-snapshot.ts"; export type { JsonSnapshotOptions, JsonSnapshotValue } from "./runtime-loader/json-snapshot.ts"; export { ProviderError, @@ -146,7 +150,9 @@ export function createWarningCollector(): WarningCollector { /** Serialize a JSON-compatible value. */ export function stringifyJsonValue(value: unknown): string { try { - const serialized = JSON.stringify(snapshotJsonValue(value, { sortObjectKeys: false })); + const serialized = JSON.stringify( + snapshotProviderJsonValue(value, { sortObjectKeys: false }), + ); if (serialized === undefined) { throw new TypeError("value has no JSON representation"); } diff --git a/src/provider/runtime-loader/json-snapshot.test.ts b/src/provider/runtime-loader/json-snapshot.test.ts new file mode 100644 index 0000000000..b893f320c9 --- /dev/null +++ b/src/provider/runtime-loader/json-snapshot.test.ts @@ -0,0 +1,33 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { snapshotJsonValue } from "./json-snapshot.ts"; + +describe("provider/runtime-loader/json-snapshot", () => { + it("rejects proxy options before descriptor inspection", () => { + let descriptorReads = 0; + const options = new Proxy( + { maxDepth: 1 }, + { + getOwnPropertyDescriptor() { + descriptorReads += 1; + throw new TypeError("options descriptor trap must not run"); + }, + }, + ); + + assertThrows( + () => snapshotJsonValue(null, options), + TypeError, + "Provider JSON snapshot options could not be inspected", + ); + assertEquals(descriptorReads, 0); + }); + + it("preserves plain own-data options", () => { + assertEquals( + snapshotJsonValue({ b: 1, a: 2 }, { sortObjectKeys: false }), + { b: 1, a: 2 }, + ); + }); +}); diff --git a/src/provider/runtime-loader/json-snapshot.ts b/src/provider/runtime-loader/json-snapshot.ts index f0b41b5925..d7d1007c2d 100644 --- a/src/provider/runtime-loader/json-snapshot.ts +++ b/src/provider/runtime-loader/json-snapshot.ts @@ -1,9 +1,76 @@ -import { isProxyWithoutHooks } from "#veryfront/platform/compat/error-introspection.ts"; +import { + canIdentifyProxyWithoutHooks, + isProxyWithoutHooks, +} from "#veryfront/platform/compat/error-introspection.ts"; + +/** + * Security-sensitive primordials are captured during trusted framework + * bootstrap, before tenant code runs. Snapshotting must not consult mutable + * global constructors or prototype methods after that boundary: callers use + * this module while handling values supplied by project and provider code. + */ +const apply = Reflect.apply; +const ArrayIsArray = Array.isArray; +const arraySort = Array.prototype.sort; +const NativeArray = Array; +const NativeTypeError = TypeError; +const NativeWeakSet = WeakSet; +const numberIsFinite = Number.isFinite; +const numberIsInteger = Number.isInteger; +const numberIsSafeInteger = Number.isSafeInteger; +const numberFromValue = Number; +const objectCreate = Object.create; +const objectDefineProperty = Object.defineProperty; +const objectFreeze = Object.freeze; +const objectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const objectGetPrototypeOf = Object.getPrototypeOf; +const objectHasOwnProperty = Object.prototype.hasOwnProperty; +const objectIs = Object.is; +const objectKeys = Object.keys; +const ownKeys = Reflect.ownKeys; +const jsonParse = JSON.parse; +const structuredCloneValue = globalThis.structuredClone; +const stringCharCodeAt = String.prototype.charCodeAt; +const stringFromValue = String; +const weakSetAdd = WeakSet.prototype.add; +const weakSetDelete = WeakSet.prototype.delete; +const weakSetHas = WeakSet.prototype.has; +const NativeArrayPrototype = Array.prototype; +const NativeObjectPrototype = Object.prototype; + +function hasOwn(object: object, key: PropertyKey): boolean { + return apply(objectHasOwnProperty, object, [key]) as boolean; +} + +function charCodeAt(value: string, index: number): number { + return apply(stringCharCodeAt, value, [index]); +} + +function hasWeakSetValue(set: WeakSet, value: object): boolean { + return apply(weakSetHas, set, [value]) as boolean; +} + +function addWeakSetValue(set: WeakSet, value: object): void { + apply(weakSetAdd, set, [value]); +} + +function deleteWeakSetValue(set: WeakSet, value: object): void { + apply(weakSetDelete, set, [value]); +} + +function defineArrayElement(array: T[], index: number, value: T): void { + objectDefineProperty(array, index, { + configurable: true, + enumerable: true, + value, + writable: true, + }); +} const DEFAULT_MAX_DEPTH = 64; const DEFAULT_MAX_NODES = 65_536; const DEFAULT_MAX_BYTES = 8 * 1024 * 1024; -const OWNED_ARRAY_SNAPSHOTS = new WeakSet(); +const OWNED_ARRAY_SNAPSHOTS = new NativeWeakSet(); /** * A deeply owned JSON value returned by {@link snapshotJsonValue}. @@ -55,39 +122,90 @@ type SnapshotState = ResolvedJsonSnapshotOptions & { nodes: number; bytes: number; ancestors: WeakSet; + valuesAreOwned: boolean; }; function invalidValue(reason: string): never { - throw new TypeError(`Provider JSON snapshot ${reason}`); + throw new NativeTypeError(`Provider JSON snapshot ${reason}`); } function readLimit( - value: number | undefined, + value: unknown, fallback: number, name: keyof JsonSnapshotOptions, minimum: number, ): number { const resolved = value ?? fallback; if ( - !Number.isSafeInteger(resolved) || + typeof resolved !== "number" || + !numberIsSafeInteger(resolved) || resolved < minimum ) { - throw new TypeError( + throw new NativeTypeError( `Provider JSON snapshot ${name} must be a safe integer no less than ${minimum}`, ); } return resolved; } +function readOwnOption( + options: JsonSnapshotOptions, + key: keyof JsonSnapshotOptions, +): unknown { + if ((typeof options !== "object" && typeof options !== "function") || options === null) { + throw new NativeTypeError("Provider JSON snapshot options must be an object"); + } + + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = objectGetOwnPropertyDescriptor(options, key); + } catch { + throw new NativeTypeError("Provider JSON snapshot options could not be inspected"); + } + if (!descriptor) return undefined; + if (!hasOwn(descriptor, "value")) { + throw new NativeTypeError("Provider JSON snapshot options must use own data properties"); + } + return descriptor.value; +} + +function prepareOptionsForInspection(options: JsonSnapshotOptions): JsonSnapshotOptions { + if ((typeof options !== "object" && typeof options !== "function") || options === null) { + throw new NativeTypeError("Provider JSON snapshot options must be an object"); + } + + if (canIdentifyProxyWithoutHooks) { + if (isProxyWithoutHooks(options)) { + throw new NativeTypeError("Provider JSON snapshot options could not be inspected"); + } + return options; + } + + if (typeof structuredCloneValue !== "function") { + throw new NativeTypeError("Provider JSON snapshot options could not be inspected"); + } + + try { + return apply(structuredCloneValue, globalThis, [options]) as JsonSnapshotOptions; + } catch { + throw new NativeTypeError("Provider JSON snapshot options could not be inspected"); + } +} + function resolveOptions(options: JsonSnapshotOptions): ResolvedJsonSnapshotOptions { - if (options.sortObjectKeys !== undefined && typeof options.sortObjectKeys !== "boolean") { - throw new TypeError("Provider JSON snapshot sortObjectKeys must be a boolean"); + const inspectedOptions = prepareOptionsForInspection(options); + const maxDepth = readOwnOption(inspectedOptions, "maxDepth"); + const maxNodes = readOwnOption(inspectedOptions, "maxNodes"); + const maxBytes = readOwnOption(inspectedOptions, "maxBytes"); + const sortObjectKeys = readOwnOption(inspectedOptions, "sortObjectKeys"); + if (sortObjectKeys !== undefined && typeof sortObjectKeys !== "boolean") { + throw new NativeTypeError("Provider JSON snapshot sortObjectKeys must be a boolean"); } return { - maxDepth: readLimit(options.maxDepth, DEFAULT_MAX_DEPTH, "maxDepth", 0), - maxNodes: readLimit(options.maxNodes, DEFAULT_MAX_NODES, "maxNodes", 1), - maxBytes: readLimit(options.maxBytes, DEFAULT_MAX_BYTES, "maxBytes", 1), - sortObjectKeys: options.sortObjectKeys ?? true, + maxDepth: readLimit(maxDepth, DEFAULT_MAX_DEPTH, "maxDepth", 0), + maxNodes: readLimit(maxNodes, DEFAULT_MAX_NODES, "maxNodes", 1), + maxBytes: readLimit(maxBytes, DEFAULT_MAX_BYTES, "maxBytes", 1), + sortObjectKeys: sortObjectKeys ?? true, }; } @@ -106,7 +224,7 @@ function addJsonStringBytes(state: SnapshotState, value: string): void { addBytes(state, 2); // Opening and closing quotation marks. for (let index = 0; index < value.length; index += 1) { - const codeUnit = value.charCodeAt(index); + const codeUnit = charCodeAt(value, index); if (codeUnit === 0x22 || codeUnit === 0x5c) { addBytes(state, 2); @@ -134,7 +252,7 @@ function addJsonStringBytes(state: SnapshotState, value: string): void { continue; } if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { - const next = value.charCodeAt(index + 1); + const next = charCodeAt(value, index + 1); if (next >= 0xdc00 && next <= 0xdfff) { addBytes(state, 4); index += 1; @@ -162,7 +280,7 @@ function assertRawJsonTextWithinByteLimit(value: string, maxBytes: number): void }; for (let index = 0; index < value.length; index += 1) { - const codeUnit = value.charCodeAt(index); + const codeUnit = charCodeAt(value, index); if (codeUnit <= 0x7f) { add(1); continue; @@ -172,7 +290,7 @@ function assertRawJsonTextWithinByteLimit(value: string, maxBytes: number): void continue; } if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { - const next = value.charCodeAt(index + 1); + const next = charCodeAt(value, index + 1); if (next >= 0xdc00 && next <= 0xdfff) { add(4); index += 1; @@ -188,7 +306,7 @@ function assertRawJsonTextWithinByteLimit(value: string, maxBytes: number): void function inspectPrototype(value: object): object | null { try { - return Object.getPrototypeOf(value); + return objectGetPrototypeOf(value); } catch { invalidValue("could not inspect a value"); } @@ -196,7 +314,7 @@ function inspectPrototype(value: object): object | null { function inspectOwnKeys(value: object): (string | symbol)[] { try { - return Reflect.ownKeys(value); + return ownKeys(value); } catch { invalidValue("could not inspect a value"); } @@ -207,7 +325,7 @@ function inspectOwnDescriptor( key: string | symbol, ): PropertyDescriptor { try { - const descriptor = Object.getOwnPropertyDescriptor(value, key); + const descriptor = objectGetOwnPropertyDescriptor(value, key); if (descriptor === undefined) { invalidValue("changed while it was being inspected"); } @@ -224,7 +342,7 @@ function readDataProperty( ): unknown { const descriptor = inspectOwnDescriptor(value, key); if ( - !Object.hasOwn(descriptor, "value") || + !hasOwn(descriptor, "value") || (requireEnumerable && descriptor.enumerable !== true) ) { invalidValue("must contain only enumerable data properties"); @@ -247,16 +365,16 @@ function snapshotArray( depth: number, state: SnapshotState, ): readonly JsonSnapshotValue[] { - if (inspectPrototype(value) !== Array.prototype) { + if (inspectPrototype(value) !== NativeArrayPrototype) { invalidValue("arrays must use the intrinsic Array prototype"); } const lengthDescriptor = inspectOwnDescriptor(value, "length"); if ( - !Object.hasOwn(lengthDescriptor, "value") || + !hasOwn(lengthDescriptor, "value") || lengthDescriptor.enumerable !== false || lengthDescriptor.configurable !== false || - !Number.isSafeInteger(lengthDescriptor.value) || + !numberIsSafeInteger(lengthDescriptor.value) || lengthDescriptor.value < 0 ) { invalidValue("contained an invalid array length"); @@ -270,20 +388,21 @@ function snapshotArray( } const keys = inspectOwnKeys(value); - const ownedSnapshot = OWNED_ARRAY_SNAPSHOTS.has(value); + const ownedSnapshot = hasWeakSetValue(OWNED_ARRAY_SNAPSHOTS, value); if (keys.length !== length + 1 + (ownedSnapshot ? 1 : 0)) { invalidValue("arrays must be dense and contain no extra properties"); } - const elementValues: unknown[] = new Array(length); - for (const key of keys) { + const elementValues: unknown[] = new NativeArray(length); + for (let keyIndex = 0; keyIndex < keys.length; keyIndex += 1) { + const key = keys[keyIndex]!; if (key === "length") { continue; } if (key === "toJSON" && ownedSnapshot) { const descriptor = inspectOwnDescriptor(value, key); if ( - !Object.hasOwn(descriptor, "value") || + !hasOwn(descriptor, "value") || descriptor.value !== undefined || descriptor.configurable !== false || descriptor.enumerable !== false || @@ -296,16 +415,16 @@ function snapshotArray( if (typeof key !== "string") { invalidValue("must not contain symbol properties"); } - const index = Number(key); + const index = numberFromValue(key); if ( - !Number.isInteger(index) || + !numberIsInteger(index) || index < 0 || index >= length || - String(index) !== key + stringFromValue(index) !== key ) { invalidValue("arrays must be dense and contain no extra properties"); } - elementValues[index] = readDataProperty(value, key, true); + defineArrayElement(elementValues, index, readDataProperty(value, key, true)); } addBytes(state, 1); @@ -314,17 +433,21 @@ function snapshotArray( if (index > 0) { addBytes(state, 1); } - snapshot[index] = snapshotValue(elementValues[index], depth + 1, state); + defineArrayElement( + snapshot, + index, + snapshotValue(elementValues[index], depth + 1, state), + ); } addBytes(state, 1); - Object.defineProperty(snapshot, "toJSON", { + objectDefineProperty(snapshot, "toJSON", { configurable: false, enumerable: false, value: undefined, writable: false, }); - OWNED_ARRAY_SNAPSHOTS.add(snapshot); - return Object.freeze(snapshot); + addWeakSetValue(OWNED_ARRAY_SNAPSHOTS, snapshot); + return objectFreeze(snapshot); } function snapshotObject( @@ -333,7 +456,7 @@ function snapshotObject( state: SnapshotState, ): { readonly [key: string]: JsonSnapshotValue } { const prototype = inspectPrototype(value); - if (prototype !== Object.prototype && prototype !== null) { + if (prototype !== NativeObjectPrototype && prototype !== null) { invalidValue("objects must have a plain or null prototype"); } @@ -342,21 +465,25 @@ function snapshotObject( invalidValue(`exceeded ${state.maxNodes} nodes`); } const entries: { key: string; value: unknown }[] = []; - for (const key of keys) { + for (let keyIndex = 0; keyIndex < keys.length; keyIndex += 1) { + const key = keys[keyIndex]!; if (typeof key !== "string") { invalidValue("must not contain symbol properties"); } - entries.push({ + defineArrayElement(entries, entries.length, { key, value: readDataProperty(value, key, true), }); } if (state.sortObjectKeys) { - entries.sort((left, right) => left.key < right.key ? -1 : left.key > right.key ? 1 : 0); + apply(arraySort, entries, [ + (left: { key: string }, right: { key: string }) => + left.key < right.key ? -1 : left.key > right.key ? 1 : 0, + ]); } addBytes(state, 1); - const snapshot = Object.create(null) as Record; + const snapshot = objectCreate(null) as Record; for (let index = 0; index < entries.length; index += 1) { if (index > 0) { addBytes(state, 1); @@ -364,7 +491,7 @@ function snapshotObject( const entry = entries[index]!; addJsonStringBytes(state, entry.key); addBytes(state, 1); - Object.defineProperty(snapshot, entry.key, { + objectDefineProperty(snapshot, entry.key, { configurable: false, enumerable: true, value: snapshotValue(entry.value, depth + 1, state), @@ -372,7 +499,7 @@ function snapshotObject( }); } addBytes(state, 1); - return Object.freeze(snapshot); + return objectFreeze(snapshot); } function snapshotValue( @@ -395,26 +522,31 @@ function snapshotValue( addJsonStringBytes(state, value); return value; case "number": - if (!Number.isFinite(value)) { + if (!numberIsFinite(value)) { invalidValue("numbers must be finite"); } - if (Object.is(value, -0)) { + if (objectIs(value, -0)) { addBytes(state, 1); return 0; } - addBytes(state, String(value).length); + addBytes(state, stringFromValue(value).length); return value; case "object": { const objectValue = value as object; - if (isProxyWithoutHooks(objectValue)) { - invalidValue("must not contain Proxy values"); + if (!state.valuesAreOwned) { + if (!canIdentifyProxyWithoutHooks) { + invalidValue("cannot inspect object values without Proxy detection"); + } + if (isProxyWithoutHooks(objectValue)) { + invalidValue("must not contain Proxy values"); + } } - if (state.ancestors.has(objectValue)) { + if (hasWeakSetValue(state.ancestors, objectValue)) { invalidValue("must not contain cycles"); } - state.ancestors.add(objectValue); + addWeakSetValue(state.ancestors, objectValue); try { - if (Array.isArray(value)) { + if (ArrayIsArray(value)) { return snapshotArray(value, depth, state); } return snapshotObject( @@ -423,7 +555,7 @@ function snapshotValue( state, ); } finally { - state.ancestors.delete(objectValue); + deleteWeakSetValue(state.ancestors, objectValue); } } default: @@ -445,8 +577,8 @@ function snapshotsEqual( return false; } - const leftIsArray = Array.isArray(left); - if (leftIsArray !== Array.isArray(right)) { + const leftIsArray = ArrayIsArray(left); + if (leftIsArray !== ArrayIsArray(right)) { return false; } if (leftIsArray) { @@ -465,8 +597,8 @@ function snapshotsEqual( const leftObject = left as { readonly [key: string]: JsonSnapshotValue }; const rightObject = right as { readonly [key: string]: JsonSnapshotValue }; - const leftKeys = Object.keys(leftObject); - const rightKeys = Object.keys(rightObject); + const leftKeys = objectKeys(leftObject); + const rightKeys = objectKeys(rightObject); if (leftKeys.length !== rightKeys.length) { return false; } @@ -497,9 +629,49 @@ export function snapshotJsonValue( const resolved = resolveOptions(options); return snapshotValue(value, 0, { ...resolved, - ancestors: new WeakSet(), + ancestors: new NativeWeakSet(), + bytes: 0, + nodes: 0, + valuesAreOwned: false, + }); +} + +/** + * Create the provider-boundary snapshot used by request builders. + * + * Node-compatible runtimes use the strict descriptor walk above. Edge hosts + * cannot distinguish Proxy objects before reflection, so they first cross the + * captured structured-clone boundary. The host rejects Proxy values (including + * nested Proxies) without running Proxy traps and returns a newly owned graph. + * Ordinary accessors follow the host's structured-clone semantics; this is an + * explicit edge-runtime compatibility trade-off because those hosts expose no + * no-hook Proxy brand primitive. + */ +export function snapshotProviderJsonValue( + value: unknown, + options: JsonSnapshotOptions = {}, +): JsonSnapshotValue { + if (canIdentifyProxyWithoutHooks) { + return snapshotJsonValue(value, options); + } + if (typeof structuredCloneValue !== "function") { + invalidValue("cannot inspect object values without Proxy detection or structured clone"); + } + + const resolved = resolveOptions(options); + let cloned: unknown; + try { + cloned = apply(structuredCloneValue, globalThis, [value]); + } catch { + invalidValue("could not cross the edge-runtime structured-clone boundary"); + } + + return snapshotValue(cloned, 0, { + ...resolved, + ancestors: new NativeWeakSet(), bytes: 0, nodes: 0, + valuesAreOwned: true, }); } @@ -522,7 +694,7 @@ export function jsonValuesEqual( } assertRawJsonTextWithinByteLimit(value, DEFAULT_MAX_BYTES); try { - return JSON.parse(value); + return apply(jsonParse, undefined, [value]); } catch { // Preserve non-JSON text as a string for backwards compatibility. return value; @@ -531,8 +703,8 @@ export function jsonValuesEqual( try { return snapshotsEqual( - snapshotJsonValue(normalize(left)), - snapshotJsonValue(normalize(right)), + snapshotProviderJsonValue(normalize(left)), + snapshotProviderJsonValue(normalize(right)), ); } catch { return false; diff --git a/src/provider/shared/index.ts b/src/provider/shared/index.ts index b5aff3bd7c..5a562ba286 100644 --- a/src/provider/shared/index.ts +++ b/src/provider/shared/index.ts @@ -59,6 +59,7 @@ export { requestJson, requestStream, snapshotJsonValue, + snapshotProviderJsonValue, stringifyJsonValue, stringifyToolArguments, stringifyToolResultValue, diff --git a/src/tool/factory.test.ts b/src/tool/factory.test.ts index 20f68c0692..5511fa6aad 100644 --- a/src/tool/factory.test.ts +++ b/src/tool/factory.test.ts @@ -289,6 +289,156 @@ describe("tool factory", () => { ); }); + it("rejects MCP accessors despite descriptor prototype pollution", () => { + const originalValue = Object.getOwnPropertyDescriptor(Object.prototype, "value"); + let accessorCalls = 0; + const mcp = Object.defineProperty({}, "enabled", { + enumerable: true, + get() { + accessorCalls += 1; + throw new Error("MCP accessors must not run"); + }, + }); + let thrown: unknown; + + try { + Object.defineProperty(Object.prototype, "value", { + configurable: true, + value: true, + writable: true, + }); + + try { + tool({ + id: "accessor-mcp-config", + description: "desc", + inputSchema: { type: "object" }, + execute: async () => null, + mcp, + }); + } catch (error) { + thrown = error; + } + } finally { + if (originalValue) { + Object.defineProperty(Object.prototype, "value", originalValue); + } else { + Reflect.deleteProperty(Object.prototype, "value"); + } + } + + assertEquals(thrown instanceof Error, true); + assertEquals(accessorCalls, 0); + }); + + it("copies special MCP property names without changing object prototypes", () => { + const mcp = Object.create(null) as Record; + Object.defineProperty(mcp, "__proto__", { + enumerable: true, + value: { polluted: true }, + }); + Object.defineProperty(mcp, "enabled", { + enumerable: true, + value: true, + }); + + const result = tool({ + id: "special-key-mcp-config", + description: "desc", + inputSchema: defineSchema((v) => v.object({}))(), + execute: async () => null, + mcp, + }).mcp as Record; + + assertEquals(Object.getPrototypeOf(result), Object.prototype); + assertEquals(Object.getOwnPropertyDescriptor(result, "__proto__")?.value, { + polluted: true, + }); + assertEquals(({} as { polluted?: boolean }).polluted, undefined); + }); + + it("loads MCP config through structured clone when proxy detection is unavailable", async () => { + const script = ` + Object.defineProperty(globalThis, "caches", { + configurable: true, + value: {}, + }); + Object.defineProperty(globalThis, "WebSocketPair", { + configurable: true, + value: function WebSocketPair() {}, + }); + + const { + canIdentifyProxyWithoutHooks, + } = await import("./src/platform/compat/error-introspection.ts"); + const { tool } = await import("./src/tool/factory.ts"); + + let trapCalls = 0; + const plainMcp = { + enabled: true, + title: "Edge tool", + annotations: { readOnlyHint: true }, + }; + const proxiedMcp = new Proxy({ enabled: true }, { + ownKeys() { + trapCalls += 1; + throw new Error("ownKeys trap must not escape"); + }, + getOwnPropertyDescriptor() { + trapCalls += 1; + throw new Error("descriptor trap must not escape"); + }, + }); + const result = { + canIdentifyProxyWithoutHooks, + trapCalls, + plainMcp: undefined, + proxyMessage: "", + }; + result.plainMcp = tool({ + id: "edge-mcp-config", + description: "desc", + inputSchema: { type: "object" }, + execute: async () => null, + mcp: plainMcp, + }).mcp; + try { + tool({ + id: "edge-mcp-config-proxy", + description: "desc", + inputSchema: { type: "object" }, + execute: async () => null, + mcp: proxiedMcp, + }); + } catch (error) { + result.proxyMessage = error instanceof Error ? error.message : String(error); + result.trapCalls = trapCalls; + } + console.log(JSON.stringify(result)); + `; + const output = await new Deno.Command(Deno.execPath(), { + args: ["eval", "--config=deno.json", script], + cwd: new URL("../../", import.meta.url), + stdout: "piped", + stderr: "piped", + }).output(); + const stderr = new TextDecoder().decode(output.stderr); + assertEquals(output.code, 0, stderr); + + const result = JSON.parse(new TextDecoder().decode(output.stdout)); + assertEquals(result, { + canIdentifyProxyWithoutHooks: false, + trapCalls: 0, + plainMcp: { + enabled: true, + title: "Edge tool", + annotations: { readOnlyHint: true }, + }, + proxyMessage: + 'Tool "edge-mcp-config-proxy" MCP configuration must contain only data properties', + }); + }); + it("snapshots raw schemas and metadata at construction", () => { const inputSchema: JsonSchema = { type: "object", diff --git a/src/tool/factory.ts b/src/tool/factory.ts index d355a6e560..30d5adf418 100644 --- a/src/tool/factory.ts +++ b/src/tool/factory.ts @@ -4,7 +4,22 @@ import { zodToJsonSchema } from "./schema/zod-json-schema.ts"; import { agentLogger } from "#veryfront/utils"; import { createError, getErrorMessage, INVALID_ARGUMENT, toError } from "#veryfront/errors"; import { snapshotBoundedJsonValue } from "#veryfront/schemas/json-value.ts"; -import { isProxyWithoutHooks } from "#veryfront/platform/compat/error-introspection.ts"; +import { + canIdentifyProxyWithoutHooks, + isProxyWithoutHooks, +} from "#veryfront/platform/compat/error-introspection.ts"; + +const apply = Reflect.apply; +const arrayIsArray = Array.isArray; +const objectCreate = Object.create; +const objectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const objectHasOwnProperty = Object.prototype.hasOwnProperty; +const ownKeys = Reflect.ownKeys; +const structuredCloneValue = globalThis.structuredClone; + +function hasOwn(object: object, key: PropertyKey): boolean { + return apply(objectHasOwnProperty, object, [key]) as boolean; +} interface ContractSchemaShape { __zod?: unknown; @@ -203,34 +218,53 @@ function snapshotMcpConfig( if (typeof value !== "object" || value === null) { schemaError(toolId, "MCP configuration must be a bounded JSON object"); } - if (isProxyWithoutHooks(value)) { - schemaError(toolId, "MCP configuration must contain only data properties"); + let inspectedValue: object = value; + if (canIdentifyProxyWithoutHooks) { + if (isProxyWithoutHooks(value)) { + schemaError(toolId, "MCP configuration must contain only data properties"); + } + } else { + if (typeof structuredCloneValue !== "function") { + schemaError(toolId, "MCP configuration must contain only data properties"); + } + // Local MCP metadata crosses the same edge-runtime trust boundary as + // provider-bound JSON: hosts without no-hook Proxy detection use the + // captured structured clone primitive to reject Proxies before reflection + // and then validate the owned clone. + try { + inspectedValue = apply(structuredCloneValue, globalThis, [value]) as object; + } catch { + schemaError(toolId, "MCP configuration must contain only data properties"); + } } - if (Array.isArray(value)) { + if (arrayIsArray(inspectedValue)) { schemaError(toolId, "MCP configuration must be a bounded JSON object"); } - const canonicalInput: Record = {}; + const canonicalInput = objectCreate(null) as Record; let keys: PropertyKey[]; try { - keys = Reflect.ownKeys(value); + keys = ownKeys(inspectedValue); } catch { schemaError(toolId, "MCP configuration must be a bounded JSON object"); } - for (const key of keys) { + for (let index = 0; index < keys.length; index++) { + const key = keys[index]!; if (typeof key !== "string") { schemaError(toolId, "MCP configuration must be a bounded JSON object"); } let descriptor: PropertyDescriptor | undefined; try { - descriptor = Object.getOwnPropertyDescriptor(value, key); + descriptor = objectGetOwnPropertyDescriptor(inspectedValue, key); } catch { schemaError(toolId, "MCP configuration must contain only data properties"); } - if (!descriptor || !("value" in descriptor)) { + if (!descriptor || !hasOwn(descriptor, "value")) { schemaError(toolId, "MCP configuration must contain only data properties"); } if (descriptor.enumerable && descriptor.value !== undefined) { + // A null prototype makes assignment safe even for `__proto__` and other + // special names without consulting inherited setters. canonicalInput[key] = descriptor.value; } } @@ -240,7 +274,7 @@ function snapshotMcpConfig( !snapshot.success || typeof snapshot.value !== "object" || snapshot.value === null || - Array.isArray(snapshot.value) + arrayIsArray(snapshot.value) ) { schemaError(toolId, "MCP configuration must be a bounded JSON object"); } diff --git a/src/workflow/worker/shared.test.ts b/src/workflow/worker/shared.test.ts index d53028da3e..7f4bcd0bb3 100644 --- a/src/workflow/worker/shared.test.ts +++ b/src/workflow/worker/shared.test.ts @@ -58,6 +58,28 @@ function createLogger() { }; } +function createCapturingLogger() { + const errors: string[] = []; + const infos: string[] = []; + const warnings: string[] = []; + return { + logger: { + error: (message: string) => { + errors.push(message); + }, + info: (message: string) => { + infos.push(message); + }, + warn: (message: string) => { + warnings.push(message); + }, + }, + errors, + infos, + warnings, + }; +} + function createRun(id: string, status: WorkflowRun["status"], workerId?: string): WorkflowRun { return { id, @@ -132,7 +154,7 @@ describe("workflow worker shared helpers", () => { ); }); - it("maps waiting and unexpected statuses to success exit codes", () => { + it("maps a paused waiting run to the success exit code", () => { const logger = createLogger(); const exitCodes = { SUCCESS: 0, WORKFLOW_FAILED: 1 }; @@ -140,7 +162,6 @@ describe("workflow worker shared helpers", () => { getFinalRunExitCode(logger, exitCodes, "run-1", { status: "waiting" } as never, false), 0, ); - assertEquals(getFinalRunExitCode(logger, exitCodes, "run-1", null, false), 0); }); it("maps failed runs to the failure exit code", () => { @@ -153,6 +174,107 @@ describe("workflow worker shared helpers", () => { ); }); + it("does not report success for runs that never reached a durable final state", () => { + const logger = createLogger(); + const exitCodes = { SUCCESS: 0, WORKFLOW_FAILED: 1 }; + + assertEquals(getFinalRunExitCode(logger, exitCodes, "run-1", null, false), 1); + assertEquals( + getFinalRunExitCode(logger, exitCodes, "run-1", { status: "cancelled" } as never, false), + 1, + ); + assertEquals( + getFinalRunExitCode(logger, exitCodes, "run-1", { status: "pending" } as never, false), + 1, + ); + assertEquals( + getFinalRunExitCode(logger, exitCodes, "run-1", { status: "running" } as never, false), + 1, + ); + }); + + it("logs sanitized run ids for runs that never reached a durable final state", () => { + const { logger, warnings } = createCapturingLogger(); + const exitCodes = { SUCCESS: 0, WORKFLOW_FAILED: 1 }; + const runId = "run-\x1b[2Jtoken=secret"; + + assertEquals(getFinalRunExitCode(logger, exitCodes, runId, null, false), 1); + assertEquals( + getFinalRunExitCode( + logger, + exitCodes, + runId, + { status: "cancelled" } as never, + false, + ), + 1, + ); + + assertEquals( + getFinalRunExitCode( + logger, + exitCodes, + runId, + { status: "pending" } as never, + false, + ), + 1, + ); + assertEquals( + getFinalRunExitCode( + logger, + exitCodes, + runId, + { status: "running" } as never, + false, + ), + 1, + ); + assertEquals( + getFinalRunExitCode( + logger, + exitCodes, + runId, + { status: "unexpected" } as never, + false, + ), + 1, + ); + + assertEquals(warnings, [ + "Workflow run was not found after execution: run-token=secret", + "Workflow was cancelled: run-token=secret", + "Workflow did not reach a durable final state: pending (runId: run-token=secret)", + "Workflow did not reach a durable final state: running (runId: run-token=secret)", + "Unexpected final status: unexpected (runId: run-token=secret)", + ]); + }); + + it("logs sanitized run ids for completed, failed, and waiting runs", () => { + const { errors, infos, logger } = createCapturingLogger(); + const exitCodes = { SUCCESS: 0, WORKFLOW_FAILED: 1 }; + const runId = "run-\x1b[2Jtoken=secret"; + + assertEquals( + getFinalRunExitCode(logger, exitCodes, runId, { status: "completed" } as never, true), + 0, + ); + assertEquals( + getFinalRunExitCode(logger, exitCodes, runId, { status: "failed" } as never, false), + 1, + ); + assertEquals( + getFinalRunExitCode(logger, exitCodes, runId, { status: "waiting" } as never, true), + 0, + ); + + assertEquals(infos, [ + "Workflow completed successfully: run-token=secret", + "Workflow paused (waiting): run-token=secret", + ]); + assertEquals(errors, ["Workflow failed: run-token=secret"]); + }); + it("persists approvals before an isolated executor returns a waiting run", async () => { const backend = new MemoryBackend(); const workerId = "run-execution:approval-owner"; diff --git a/src/workflow/worker/shared.ts b/src/workflow/worker/shared.ts index 99c538810e..f20e509926 100644 --- a/src/workflow/worker/shared.ts +++ b/src/workflow/worker/shared.ts @@ -1,4 +1,5 @@ import { env as getProcessEnv } from "#veryfront/compat/process.ts"; +import { sanitizeTerminalDiagnosticText } from "#veryfront/errors/safe-diagnostics.ts"; import { runWithRequestContext } from "#veryfront/platform/adapters/fs/veryfront/multi-project-adapter.ts"; import { getEnv } from "#veryfront/platform/compat/process.ts"; import { mergeInjectedWorkflowEnv } from "#veryfront/runs/runtime-env.ts"; @@ -99,26 +100,43 @@ export function getFinalRunExitCode( finalRun: WorkflowRun | null, debug = false, ): number { + const sanitizedRunId = sanitizeTerminalDiagnosticText(runId); + switch (finalRun?.status) { case "completed": if (debug) { - logger.info(`Workflow completed successfully: ${runId}`); + logger.info(`Workflow completed successfully: ${sanitizedRunId}`); } return exitCodes.SUCCESS; case "failed": - logger.error(`Workflow failed: ${runId}`, finalRun.error); + logger.error(`Workflow failed: ${sanitizedRunId}`, finalRun.error); return exitCodes.WORKFLOW_FAILED; case "waiting": if (debug) { - logger.info(`Workflow paused (waiting): ${runId}`); + logger.info(`Workflow paused (waiting): ${sanitizedRunId}`); } return exitCodes.SUCCESS; + case "cancelled": + logger.warn(`Workflow was cancelled: ${sanitizedRunId}`); + return exitCodes.WORKFLOW_FAILED; + + case "pending": + case "running": + logger.warn( + `Workflow did not reach a durable final state: ${finalRun.status} (runId: ${sanitizedRunId})`, + ); + return exitCodes.WORKFLOW_FAILED; + default: - logger.warn(`Unexpected final status: ${finalRun?.status}`); - return exitCodes.SUCCESS; + logger.warn( + finalRun + ? `Unexpected final status: ${finalRun.status} (runId: ${sanitizedRunId})` + : `Workflow run was not found after execution: ${sanitizedRunId}`, + ); + return exitCodes.WORKFLOW_FAILED; } } From 37e26805b9d2d0b9b30e99e27db5104b293462f3 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 15:40:28 +0200 Subject: [PATCH 16/26] fix(utils): repair hash, id, extension, sleep, namespace, and memoize correctness defects (#3326) * fix(utils): repair hash, id, extension, sleep, namespace, and memoize correctness defects - fnv1aHash: iterate UTF-16 code units instead of code points so astral characters no longer collide in blocks of 1024 (stale MDX cache modules) - id: rejection-sample random bytes to remove the 256 % 62 modulo bias and validate generator sizes - path-utils: getExtension/getExtensionName stop treating dots in directory names as extensions (/docs/v1.2/intro no longer yields "2/intro") - sleep: normalize durations via normalizeTimerDurationMs so delays beyond the 32-bit timer range reject instead of resolving immediately - cache-namespace: sort schema keys by code units instead of localeCompare so namespaces are locale-independent (rotates existing cache keys once) - memoize: length-prefix and type-tag simpleHash segments so argument boundaries cannot collapse into colliding cache keys * docs(utils): clarify sleep validation contract * Align sleep regression names with assertions Two suppressed review notes called out test names that implied timing guarantees not asserted by the tests. The behavior was already covered by the timer utility tests, so this narrows the names to the actual sleep contract being exercised. Constraint: Address suppressed PR review comments without changing runtime behavior Rejected: Add timing assertions here | timing semantics are already covered in src/utils/timer.test.ts and would make this sleep test more brittle Confidence: high Scope-risk: narrow Tested: npx --yes deno@2.7.7 test --no-check --allow-all src/utils/sleep.test.ts Tested: npx --yes deno@2.7.7 check src/utils/sleep.ts src/utils/sleep.test.ts Tested: npx --yes deno@2.7.7 fmt --check src/utils/sleep.test.ts Tested: git diff --check * fix(utils): avoid ambient typed-array iterators for IDs --- src/utils/cache-namespace.test.ts | 12 +++++++ src/utils/cache-namespace.ts | 6 +++- src/utils/hash-utils.test.ts | 8 ++++- src/utils/hash-utils.ts | 4 +-- src/utils/id.test.ts | 56 +++++++++++++++++++++++++++++++ src/utils/id.ts | 33 +++++++++++++++--- src/utils/memoize.test.ts | 5 +++ src/utils/memoize.ts | 22 +++++++++--- src/utils/path-utils.test.ts | 10 ++++++ src/utils/path-utils.ts | 6 ++-- src/utils/sleep.test.ts | 27 +++++++++++++++ src/utils/sleep.ts | 13 +++++-- 12 files changed, 185 insertions(+), 17 deletions(-) create mode 100644 src/utils/sleep.test.ts diff --git a/src/utils/cache-namespace.test.ts b/src/utils/cache-namespace.test.ts index 954fa4bb50..d6927141eb 100644 --- a/src/utils/cache-namespace.test.ts +++ b/src/utils/cache-namespace.test.ts @@ -2,6 +2,7 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { createCacheNamespace } from "./cache-namespace.ts"; +import { fnv1aHash } from "./hash-utils.ts"; describe("utils/cache-namespace", () => { it("is stable for equivalent objects with different key order", () => { @@ -25,4 +26,15 @@ describe("utils/cache-namespace", () => { assertEquals(left === right, false); }); + + it("sorts object keys by locale-independent code-unit order", () => { + // Code-unit order puts "z" (0x7a) before "ä" (0xe4); localeCompare in most + // locales would sort "ä" first and derive a different namespace per locale. + const serialized = '{"z":2,"ä":1}'; + + assertEquals( + createCacheNamespace("demo", { ä: 1, z: 2 }), + `demo-${fnv1aHash(serialized)}`, + ); + }); }); diff --git a/src/utils/cache-namespace.ts b/src/utils/cache-namespace.ts index 03527232c7..929a41c127 100644 --- a/src/utils/cache-namespace.ts +++ b/src/utils/cache-namespace.ts @@ -16,7 +16,11 @@ function serializeCacheNamespaceValue(value: CacheNamespaceValue): string { } if (typeof value === "object") { - const entries = Object.entries(value).sort(([left], [right]) => left.localeCompare(right)); + // Sort by UTF-16 code units so the namespace is identical in every locale; + // localeCompare would derive different cache keys per server locale. + const entries = Object.entries(value).sort(([left], [right]) => + left < right ? -1 : left > right ? 1 : 0 + ); return `{${ entries .map(([key, entry]) => `${JSON.stringify(key)}:${serializeCacheNamespaceValue(entry)}`) diff --git a/src/utils/hash-utils.test.ts b/src/utils/hash-utils.test.ts index b832d5bc38..4f777d1f4d 100644 --- a/src/utils/hash-utils.test.ts +++ b/src/utils/hash-utils.test.ts @@ -1,7 +1,7 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals, assertNotEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; -import { computeCodeHash, computeHash, shortHash, simpleHash } from "./hash-utils.ts"; +import { computeCodeHash, computeHash, fnv1aHash, shortHash, simpleHash } from "./hash-utils.ts"; describe("hash-utils", () => { describe("computeHash", () => { @@ -116,4 +116,10 @@ describe("hash-utils", () => { assertNotEquals(await shortHash("content 1"), await shortHash("content 2")); }); }); + + describe("fnv1aHash", () => { + it("includes every UTF-16 code unit for non-BMP characters", () => { + assertNotEquals(fnv1aHash("😀"), fnv1aHash("😁")); + }); + }); }); diff --git a/src/utils/hash-utils.ts b/src/utils/hash-utils.ts index 40456af148..06036e54a7 100644 --- a/src/utils/hash-utils.ts +++ b/src/utils/hash-utils.ts @@ -57,8 +57,8 @@ export async function shortHash(content: string): Promise { export function fnv1aHash(input: string): string { let hash = HASH_SEED_FNV1A >>> 0; - for (const char of input) { - hash ^= char.charCodeAt(0); + for (let index = 0; index < input.length; index++) { + hash ^= input.charCodeAt(index); hash = Math.imul(hash, FNV1A_PRIME_32); } diff --git a/src/utils/id.test.ts b/src/utils/id.test.ts index 723add5ff3..38166ca92f 100644 --- a/src/utils/id.test.ts +++ b/src/utils/id.test.ts @@ -52,9 +52,65 @@ describe("id", () => { assertEquals(ids.size, 100); }); + + it("should draw alphabet characters without modulo bias", () => { + // With `byte % 62`, the first 8 alphabet characters ("0"-"7") are drawn + // at 5/256 each (8 * 5/256 ~= 0.1563 combined) instead of the uniform + // 8/62 ~= 0.1290. The 0.145 midpoint threshold sits ~17 standard + // deviations from both distributions at this sample size. + let firstEightCount = 0; + let totalCount = 0; + + for (let i = 0; i < 20_000; i++) { + for (const char of generateId()) { + totalCount++; + if (char >= "0" && char <= "7") firstEightCount++; + } + } + + assertEquals(firstEightCount / totalCount < 0.145, true); + }); + + it("should not invoke a typed-array iterator replaced after module import", async () => { + const idModuleUrl = new URL("./id.ts", import.meta.url).href; + const source = ` + import { generateId } from ${JSON.stringify(idModuleUrl)}; + + Uint8Array.prototype[Symbol.iterator] = function () { + throw new Error("poisoned typed-array iterator"); + }; + console.log(generateId()); + `; + const command = new Deno.Command(Deno.execPath(), { + args: ["eval", "--no-check", "--frozen", "--config=deno.json", source], + stdout: "piped", + stderr: "piped", + }); + + const result = await command.output(); + const stderr = new TextDecoder().decode(result.stderr); + assertEquals(result.success, true, stderr); + assertMatch(new TextDecoder().decode(result.stdout).trim(), /^[0-9a-zA-Z]{16}$/); + }); }); describe("createIdGenerator", () => { + it("should reject invalid sizes", () => { + for ( + const size of [ + 0, + -1, + 1.5, + Number.NaN, + Number.POSITIVE_INFINITY, + Number.MAX_SAFE_INTEGER + 1, + 1_025, + ] + ) { + assertThrows(() => createIdGenerator({ size }), RangeError); + } + }); + it("should create generator with prefix", () => { const generate = createIdGenerator({ prefix: "test" }); assertMatch(generate(), /^test-[0-9a-zA-Z]{16}$/); diff --git a/src/utils/id.ts b/src/utils/id.ts index f033e6a32b..ce673acb8c 100644 --- a/src/utils/id.ts +++ b/src/utils/id.ts @@ -1,14 +1,38 @@ /** ID generation utilities (16-char alphanumeric with optional prefix) */ const ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; +const MAX_UNBIASED_BYTE = Math.floor(256 / ALPHABET.length) * ALPHABET.length; +const MAX_ID_SIZE = 1_024; + +function requireIdSize(size: number): number { + if (!Number.isSafeInteger(size) || size <= 0 || size > MAX_ID_SIZE) { + throw new RangeError(`ID size must be an integer between 1 and ${MAX_ID_SIZE}`); + } + return size; +} function randomString(length: number): string { - const bytes = new Uint8Array(length); - crypto.getRandomValues(bytes); + requireIdSize(length); let result = ""; - for (let i = 0; i < length; i++) { - result += ALPHABET[(bytes[i] ?? 0) % ALPHABET.length]; + while (result.length < length) { + const remaining = length - result.length; + const batchSize = Math.min( + 65_536, + Math.max(32, Math.ceil((remaining * 256) / MAX_UNBIASED_BYTE)), + ); + const bytes = new Uint8Array(batchSize); + crypto.getRandomValues(bytes); + + // Iterate the allocated batch directly. A project can replace the ambient + // typed-array iterator after framework modules load; ID generation must not + // execute or depend on that mutable hook. + for (let index = 0; index < batchSize; index++) { + const byte = bytes[index] ?? 0; + if (byte >= MAX_UNBIASED_BYTE) continue; + result += ALPHABET[byte % ALPHABET.length]; + if (result.length === length) break; + } } return result; } @@ -57,6 +81,7 @@ export function createIdGenerator(options: { size?: number; }): () => string { const { prefix, separator = "-", size = 16 } = options; + requireIdSize(size); return function generate(): string { const id = randomString(size); diff --git a/src/utils/memoize.test.ts b/src/utils/memoize.test.ts index c3a0e6a7d4..2dc28c8253 100644 --- a/src/utils/memoize.test.ts +++ b/src/utils/memoize.test.ts @@ -137,6 +137,11 @@ describe("memoize", () => { assertNotEquals(simpleHash("a", "b"), simpleHash("b", "a")); }); + it("distinguishes argument boundaries and primitive types", () => { + assertNotEquals(simpleHash("ab", "c"), simpleHash("a", "bc")); + assertNotEquals(simpleHash("1"), simpleHash(1)); + }); + it("should handle non-string values", () => { assertEquals(simpleHash(123, true, null), simpleHash(123, true, null)); }); diff --git a/src/utils/memoize.ts b/src/utils/memoize.ts index 4e98fb59f4..d98cd7f472 100644 --- a/src/utils/memoize.ts +++ b/src/utils/memoize.ts @@ -84,19 +84,31 @@ export function memoize( } /** - * FNV-1a hash algorithm for fast cache key generation. + * FNV-1a hash algorithm for fast, framed cache key generation. * 10-15x faster than JSON.stringify() and uses 70-80% less memory. */ export function simpleHash(...values: unknown[]): string { let hash = HASH_SEED_FNV1A; - for (const value of values) { - const str = typeof value === "string" ? value : String(value); + const mix = (text: string): void => { + // Length-prefix every segment so argument boundaries cannot collapse + // (`["ab", "c"]` must not hash as `["a", "bc"]`). + const length = text.length >>> 0; + for (let shift = 0; shift < 32; shift += 8) { + hash ^= (length >>> shift) & 0xff; + hash = Math.imul(hash, FNV1A_PRIME_32); + } - for (let i = 0; i < str.length; i++) { - hash ^= str.charCodeAt(i); + for (let i = 0; i < text.length; i++) { + hash ^= text.charCodeAt(i); hash = Math.imul(hash, FNV1A_PRIME_32); } + }; + + for (const value of values) { + const str = typeof value === "string" ? value : String(value); + mix(value === null ? "null" : typeof value); + mix(str); } return (hash >>> 0).toString(36); diff --git a/src/utils/path-utils.test.ts b/src/utils/path-utils.test.ts index 98a384176c..84f27bf33a 100644 --- a/src/utils/path-utils.test.ts +++ b/src/utils/path-utils.test.ts @@ -115,6 +115,11 @@ describe("path-utils", () => { it("should handle paths with directories", () => { assertEquals(getExtension("/path/to/file.tsx"), ".tsx"); }); + + it("should ignore dots that occur only in directory names", () => { + assertEquals(getExtension("/path.with.dot/file"), ""); + assertEquals(getExtension("C:\\path.with.dot\\file"), ""); + }); }); describe("getExtensionName", () => { @@ -129,6 +134,11 @@ describe("path-utils", () => { it("should return empty for trailing dot", () => { assertEquals(getExtensionName("file."), ""); }); + + it("should ignore dots that occur only in directory names", () => { + assertEquals(getExtensionName("/path.with.dot/file"), ""); + assertEquals(getExtensionName("C:\\path.with.dot\\file"), ""); + }); }); describe("getDirectory", () => { diff --git a/src/utils/path-utils.ts b/src/utils/path-utils.ts index 976867d22c..8c51059f2a 100644 --- a/src/utils/path-utils.ts +++ b/src/utils/path-utils.ts @@ -33,7 +33,8 @@ export function isWithinDirectory(root: string, target: string): boolean { */ export function getExtension(path: string): string { const lastDot = path.lastIndexOf("."); - if (lastDot === -1 || lastDot === path.length - 1) return ""; + const lastSeparator = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); + if (lastDot <= lastSeparator || lastDot === path.length - 1) return ""; return path.slice(lastDot); } @@ -43,7 +44,8 @@ export function getExtension(path: string): string { */ export function getExtensionName(path: string): string { const lastDot = path.lastIndexOf("."); - if (lastDot === -1 || lastDot === path.length - 1) return ""; + const lastSeparator = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); + if (lastDot <= lastSeparator || lastDot === path.length - 1) return ""; return path.slice(lastDot + 1).toLowerCase(); } diff --git a/src/utils/sleep.test.ts b/src/utils/sleep.test.ts new file mode 100644 index 0000000000..de2b1803ef --- /dev/null +++ b/src/utils/sleep.test.ts @@ -0,0 +1,27 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { sleep } from "./sleep.ts"; + +describe("sleep", () => { + it("throws for delays unsupported by JavaScript timers", () => { + for ( + const delayMs of [ + -1, + Number.NaN, + Number.POSITIVE_INFINITY, + 2_147_483_648, + ] + ) { + assertThrows(() => sleep(delayMs), RangeError); + } + }); + + it("resolves zero-delay sleeps", async () => { + assertEquals(await sleep(0), undefined); + }); + + it("resolves fractional retry jitter delays", async () => { + assertEquals(await sleep(0.1), undefined); + }); +}); diff --git a/src/utils/sleep.ts b/src/utils/sleep.ts index 3b6ed40cbc..bce1544f51 100644 --- a/src/utils/sleep.ts +++ b/src/utils/sleep.ts @@ -1,11 +1,20 @@ -/** Resolve after `ms` milliseconds; rejects with `abortSignal.reason` if aborted first. */ +import { normalizeTimerDurationMs } from "./timer.ts"; + +/** + * Return a promise that resolves after `ms` milliseconds. + * + * Throws `RangeError` synchronously when `ms` is negative, non-finite, or + * exceeds the portable JavaScript timer range. The returned promise rejects + * with `abortSignal.reason` if the signal is aborted first. + */ export function sleep(ms: number, abortSignal?: AbortSignal): Promise { + const durationMs = normalizeTimerDurationMs(ms, "Sleep duration"); abortSignal?.throwIfAborted(); return new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { abortSignal?.removeEventListener("abort", onAbort); resolve(); - }, ms); + }, durationMs); const onAbort = () => { clearTimeout(timeoutId); abortSignal?.removeEventListener("abort", onAbort); From c196ffc06ce244502ee1e13b2c13ecf8c1d2418c Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 15:42:29 +0200 Subject: [PATCH 17/26] fix(cli): harden deploy polling and route verification (#3311) * fix(cli): harden deploy polling and restore a failing-capable polling test Rework port of the deploy-polling improvements from codex/module-reconcile-20260723 onto main, without that branch's reverts or its neutered test. Polling hardening in waitForReleaseAssetManifest: - read the manifest state via own-data-property lookup so hostile accessor-backed responses are rejected without executing accessors - validate the state string (non-empty, bounded length) and fail closed on partial, superseded, and unknown states instead of silently polling until the generic timeout - parse ready responses with parseReadyReleaseAssetManifestResponse so legacy-schema or release-mismatched manifests are rejected instead of deployed Control plane: - getReleaseAssetManifest returns the raw untrusted response; only an HTTP 404 maps to polling absence, and a successful null body is an error instead of being treated as "not built yet" Deploy pipeline ordering: - collect expected page routes during verify-source, before the release is created, and propagate non-not-found route directory inspection errors instead of swallowing them All new tests were verified red against the previous implementation before the port and are green after it. No existing test names were removed. * chore: drop unused stringifyJsonValue imports to unblock pre-push lint * Keep release manifest guards CodeQL-clean The release manifest helper already rejected null before descriptor access, but the check order produced duplicate CodeQL inconvertible-type threads. Reorder the guard without changing behavior so the static analyzer can follow the same fail-closed boundary. Constraint: GitHub Advanced Security comments target the exact helper guard, not runtime behavior. Rejected: Broaden parser changes | unnecessary for a one-line static-analysis finding. Confidence: high Scope-risk: narrow Tested: npx --yes deno@2.7.7 test --no-check --allow-all cli/shared/deployment/deploy-project.test.ts cli/shared/deployment/control-plane.test.ts Tested: npx --yes deno@2.7.7 lint cli/shared/deployment/deploy-project.ts Tested: git diff --check Not-tested: full verify * test(cli): cover release asset polling progression * Keep deploy polling failures diagnosable The deploy asset-manifest boundary already failed closed on malformed states, but an empty successful response could still cross the control-plane interface as absence and superseded or unknown states were too generic for field diagnosis. This patch keeps the polling sentinel explicit and makes terminal state failures actionable without broadening deploy behavior. Constraint: Review feedback asked for small diagnostics and type-contract fixes on the existing deploy hardening PR. Rejected: Model all manifest responses with the strict ready schema | queued, building, failed, superseded, and partial states intentionally carry non-ready bodies. Confidence: high Scope-risk: narrow Tested: npx --yes deno@2.7.7 test --no-check --allow-all --parallel cli/shared/deployment/control-plane.test.ts cli/shared/deployment/deploy-project.test.ts Tested: npx --yes deno@2.7.7 check cli/shared/deployment/control-plane.ts cli/shared/deployment/deploy-project.ts cli/test-utils/deploy-test-support.ts cli/shared/deployment/control-plane.test.ts cli/shared/deployment/deploy-project.test.ts Tested: npx --yes deno@2.7.7 fmt --check cli/shared/deployment/control-plane.ts cli/shared/deployment/deploy-project.ts cli/test-utils/deploy-test-support.ts cli/shared/deployment/control-plane.test.ts cli/shared/deployment/deploy-project.test.ts Not-tested: Full repository suite after this narrow diagnostics patch * Keep deploy polling regression deterministic The production read-back test waits for twenty source reads under a fake clock. A forty-tick guard could expire under parallel repository load before the expected read count, so the test keeps the same behavioral assertion with a wider deterministic scheduling allowance. Constraint: Preserve the twenty-read behavioral assertion while avoiding scheduler-sensitive failures. Rejected: Lower the expected read count | would weaken the deploy polling regression. Confidence: high Scope-risk: narrow Reversibility: clean Tested: focused integration test passed before the remote review-fix reconciliation Not-tested: full repository suite after the newest remote commit * Keep deploy polling resilient to transient asset-manifest failures Release asset manifest polling now treats transient gateway errors like the existing missing-manifest sentinel and continues until the normal deadline, while still failing closed for malformed or terminal manifest states. The production read-back regression keeps its fixed read budget and now documents why the fake-clock guard is wider than main after the pre-mutation verification work added in this PR. Constraint: Review required either retrying transient control-plane failures or documenting abort-on-transient as intentional. Rejected: Restore the fake-clock guard to 40 | the integration test reproduced 19/20 reads locally, so 40 was not enough for the current async chain under load. Confidence: high Scope-risk: narrow Tested: npx --yes deno@2.7.7 test --no-check --allow-all cli/commands/deploy/command.integration.test.ts Tested: npx --yes deno@2.7.7 test --no-check --allow-all --parallel cli/shared/deployment/control-plane.test.ts cli/shared/deployment/deploy-project.test.ts Tested: npx --yes deno@2.7.7 fmt --check cli/shared/deployment/deploy-project.ts cli/shared/deployment/deploy-project.test.ts cli/commands/deploy/command.integration.test.ts Tested: npx --yes deno@2.7.7 lint cli/shared/deployment/deploy-project.ts cli/shared/deployment/deploy-project.test.ts cli/commands/deploy/command.integration.test.ts Tested: npx --yes deno@2.7.7 check cli/shared/deployment/deploy-project.ts cli/shared/deployment/deploy-project.test.ts cli/commands/deploy/command.integration.test.ts Tested: git diff --check * fix(cli): bound transient deploy polling retries * Deduplicate untrusted manifest property reads and disambiguate empty-body errors Address the remaining review finding on release asset polling: - Export readUntrustedOwnDataProperty from the release-assets manifest schema and reuse it in waitForReleaseAssetManifest instead of the duplicated readReleaseAssetResponseDataProperty helper, keeping the accessor-safe property hardening in one place. - Export isSafeBoundedText and use it for the polling state check so the bounded-text rules (length, trim, control characters) match the schema module instead of an ad-hoc length test. - Give the control-plane empty successful body its own error message ("returned an empty manifest response") so it no longer shares the "invalid state response" wording with the polling loop's malformed state failure. Co-Authored-By: Claude Fable 5 * Keep retry documentation aligned with status precedence The idempotent retry classifier now treats a structured HTTP status as authoritative, so the nearby comment must describe connection retries as status-less instead of implying that any connection-shaped error retries. Constraint: Suppressed exact-head review feedback requires code comments to match classifier behavior. Rejected: Change retry behavior again | current tests intentionally pin authoritative HTTP status precedence. Confidence: high Scope-risk: narrow Tested: deno 2.7.7 fmt, lint, check, and git diff --check on cli/shared/config.ts * Mirror partial release asset manifest states The deploy manifest parser already treats partial manifests as a known incomplete state, but the exported state type omitted that value. Mirror the runtime state set so callers and tests compile against the actual control-plane contract. Constraint: Pull request review requested type parity without changing deploy polling behavior. Scope-risk: narrow Confidence: high Tested: npx --yes deno@2.7.7 test --no-check --allow-all --parallel cli/shared/config.test.ts cli/shared/deployment/control-plane.test.ts cli/shared/deployment/deploy-project.test.ts Tested: npx --yes deno@2.7.7 fmt --check src/release-assets/manifest-schema.ts src/release-assets/index.ts cli/shared/config.ts cli/shared/config.test.ts cli/shared/deployment/control-plane.ts cli/shared/deployment/control-plane.test.ts cli/shared/deployment/deploy-project.ts cli/shared/deployment/deploy-project.test.ts Tested: npx --yes deno@2.7.7 lint src/release-assets/manifest-schema.ts src/release-assets/index.ts cli/shared/config.ts cli/shared/config.test.ts cli/shared/deployment/control-plane.ts cli/shared/deployment/control-plane.test.ts cli/shared/deployment/deploy-project.ts cli/shared/deployment/deploy-project.test.ts Tested: npx --yes deno@2.7.7 check src/release-assets/manifest-schema.ts src/release-assets/index.ts cli/shared/config.ts cli/shared/config.test.ts cli/shared/deployment/control-plane.ts cli/shared/deployment/control-plane.test.ts cli/shared/deployment/deploy-project.ts cli/shared/deployment/deploy-project.test.ts Tested: git diff --check * fix(cli): enforce deploy polling deadlines --------- Co-authored-by: Kentaro Wakayama Co-authored-by: Claude Fable 5 --- .../deploy/command.integration.test.ts | 38 +- cli/shared/config.test.ts | 21 + cli/shared/config.ts | 55 ++- cli/shared/deployment/control-plane.test.ts | 52 +++ cli/shared/deployment/control-plane.ts | 25 +- cli/shared/deployment/deploy-project.test.ts | 407 +++++++++++++++++- cli/shared/deployment/deploy-project.ts | 150 +++++-- src/platform/index.ts | 1 + src/release-assets/index.ts | 2 + src/release-assets/manifest-schema.ts | 22 +- 10 files changed, 710 insertions(+), 63 deletions(-) diff --git a/cli/commands/deploy/command.integration.test.ts b/cli/commands/deploy/command.integration.test.ts index b4ded35f78..cf9f83145c 100644 --- a/cli/commands/deploy/command.integration.test.ts +++ b/cli/commands/deploy/command.integration.test.ts @@ -13,6 +13,7 @@ import type { DeploymentRoutingConvergence } from "../../shared/deployment/contr import { FakeTime } from "#std/testing/time"; import { stripAnsi } from "../../ui/ansi.ts"; import { setVerboseMode } from "../../utils/index.ts"; +import { RELEASE_ASSET_MANIFEST_SCHEMA_VERSION } from "veryfront/release-assets"; /** * The real Deploy Execution module with test-bounded polling: these suites @@ -209,20 +210,20 @@ function createDeployFetchHandler(options: { state: "ready", manifest_version: 1, manifest: { - schemaVersion: 1, + schemaVersion: RELEASE_ASSET_MANIFEST_SCHEMA_VERSION, projectId: PROJECT_ID, releaseId: RELEASE_ID, releaseVersion: 41, manifestVersion: 1, builderVersion: "test", - sourceContentHash: options.sourceDigest, + sourceContentHash: options.sourceDigest.slice("sha256:".length), createdAt: "2026-07-10T09:20:00.000Z", assetBasePath: "/_vf/assets", modules: {}, css: [], routes: {}, + dependencyMode: "source", dependencies: {}, - fallback: { mode: "jit", gaps: [] }, }, }); } @@ -838,13 +839,13 @@ it("uses canonical production read-back in human and JSON modes", async () => { state: "ready", manifest_version: 1, manifest: { - schemaVersion: 1, + schemaVersion: RELEASE_ASSET_MANIFEST_SCHEMA_VERSION, projectId: PROJECT_ID, releaseId: RELEASE_ID, releaseVersion: 41, manifestVersion: 1, builderVersion: "test", - sourceContentHash: sourceDigest, + sourceContentHash: sourceDigest.slice("sha256:".length), createdAt: "2026-07-10T09:20:00.000Z", assetBasePath: "/_vf/assets", modules: { @@ -858,10 +859,11 @@ it("uses canonical production read-back in human and JSON modes", async () => { routes: { "/dashboard": { modules: ["pages/dashboard.tsx"], + css: [], }, }, + dependencyMode: "source", dependencies: {}, - fallback: { mode: "jit", gaps: [] }, }, }); } @@ -1064,7 +1066,10 @@ it("uses canonical production read-back in human and JSON modes", async () => { await time.tickAsync(0); for ( let tick = 0; - releaseSourceReads < 20 && tick < 40; + // The deploy flow now does more pre-mutation verification before this + // poll starts. Keep the read budget fixed at 20, but allow enough fake + // clock ticks for the async chain to issue all reads under load. + releaseSourceReads < 20 && tick < 60; tick++ ) { await time.tickAsync(500); @@ -1208,13 +1213,13 @@ it("deploys production from a dirty worktree when the pushed digest matches the state: "ready", manifest_version: 1, manifest: { - schemaVersion: 1, + schemaVersion: RELEASE_ASSET_MANIFEST_SCHEMA_VERSION, projectId: PROJECT_ID, releaseId: RELEASE_ID, releaseVersion: 41, manifestVersion: 1, builderVersion: "test", - sourceContentHash: sourceDigest, + sourceContentHash: sourceDigest.slice("sha256:".length), createdAt: "2026-07-10T09:20:00.000Z", assetBasePath: "/_vf/assets", modules: { @@ -1228,10 +1233,11 @@ it("deploys production from a dirty worktree when the pushed digest matches the routes: { "/dashboard": { modules: ["pages/dashboard.tsx"], + css: [], }, }, + dependencyMode: "source", dependencies: {}, - fallback: { mode: "jit", gaps: [] }, }, }); } @@ -1569,20 +1575,20 @@ it("uses an alternative slug when inferred first deploy project creation conflic state: "ready", manifest_version: 1, manifest: { - schemaVersion: 1, + schemaVersion: RELEASE_ASSET_MANIFEST_SCHEMA_VERSION, projectId: PROJECT_ID, releaseId: RELEASE_ID, releaseVersion: 41, manifestVersion: 1, builderVersion: "test", - sourceContentHash: sourceDigest, + sourceContentHash: sourceDigest.slice("sha256:".length), createdAt: "2026-07-10T09:20:00.000Z", assetBasePath: "/_vf/assets", modules: {}, css: [], routes: {}, + dependencyMode: "source", dependencies: {}, - fallback: { mode: "jit", gaps: [] }, }, }); } @@ -1754,20 +1760,20 @@ it("collects configured app and pages routes when projectDir has a trailing slas state: "ready", manifest_version: 1, manifest: { - schemaVersion: 1, + schemaVersion: RELEASE_ASSET_MANIFEST_SCHEMA_VERSION, projectId: PROJECT_ID, releaseId: RELEASE_ID, releaseVersion: 41, manifestVersion: 1, builderVersion: "test", - sourceContentHash: sourceDigest, + sourceContentHash: sourceDigest.slice("sha256:".length), createdAt: "2026-07-10T09:20:00.000Z", assetBasePath: "/_vf/assets", modules: {}, css: [], routes: {}, + dependencyMode: "source", dependencies: {}, - fallback: { mode: "jit", gaps: [] }, }, })); } diff --git a/cli/shared/config.test.ts b/cli/shared/config.test.ts index 9bbe5af153..43c01e8988 100644 --- a/cli/shared/config.test.ts +++ b/cli/shared/config.test.ts @@ -8,6 +8,7 @@ import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { createApiClient, + isRetryableApiReadError, readConfigFile, resolveConfig, resolveConfigWithAuth, @@ -19,6 +20,26 @@ import { join } from "veryfront/platform/path"; import { __resetEnvLoaderForTests, loadEnv } from "veryfront/utils/env-loader"; import { deleteToken, saveToken } from "../auth/token-store.ts"; +describe("isRetryableApiReadError", () => { + it("retries gateway and connection failures but not authoritative client statuses", () => { + assertEquals(isRetryableApiReadError({ status: 503 }), true); + assertEquals( + isRetryableApiReadError(Object.assign(new Error("connection reset"), { + code: "ECONNRESET", + })), + true, + ); + assertEquals( + isRetryableApiReadError(Object.assign(new Error("unauthorized"), { + cause: Object.assign(new Error("connection reset"), { code: "ECONNRESET" }), + status: 401, + })), + false, + ); + assertEquals(isRetryableApiReadError(new DOMException("cancelled", "AbortError")), false); + }); +}); + function createMockEnv(overrides: Partial = {}): EnvironmentConfig { return { apiUrl: overrides.apiUrl, diff --git a/cli/shared/config.ts b/cli/shared/config.ts index 852364b982..3eebaca49c 100644 --- a/cli/shared/config.ts +++ b/cli/shared/config.ts @@ -27,6 +27,20 @@ function isTransientStatus(status: number): boolean { return status === 502 || status === 503 || status === 504; } +/** + * Classify failures from an idempotent API read conservatively. + * + * A structured HTTP status is authoritative: authentication, validation, and + * other client failures must not become retryable merely because an attached + * cause resembles a connection error. + */ +export function isRetryableApiReadError(error: unknown): boolean { + const status = typeof error === "object" && error !== null + ? (error as { status?: unknown }).status + : undefined; + return typeof status === "number" ? isTransientStatus(status) : isRetryableConnectionError(error); +} + /** Sleep for `ms` milliseconds plus a random jitter up to 20% of `ms`. */ function sleepWithJitter(ms: number): Promise { const jitter = Math.floor(ms * 0.2 * Math.random()); @@ -343,8 +357,19 @@ function resolveConfigByMode( return resolveConfigBase(projectDir, env ?? getEnvironmentConfig(), interactive); } +export interface ApiReadOptions { + /** Abort the in-flight HTTP request when this signal fires. */ + signal?: AbortSignal; + /** Use `none` when a higher-level polling loop owns retry timing. */ + retryPolicy?: "default" | "none"; +} + export interface ApiClient { - get(path: string, params?: Record): Promise; + get( + path: string, + params?: Record, + options?: ApiReadOptions, + ): Promise; post(path: string, body?: unknown): Promise; put(path: string, body?: unknown): Promise; patch(path: string, body?: unknown): Promise; @@ -384,9 +409,11 @@ export function createApiClient(config: ResolvedConfig): ApiClient { method: string, url: string, body?: unknown, + signal?: AbortSignal, ): Promise { const response = await fetch(url, { method, + ...(signal ? { signal } : {}), headers: { Authorization: `Bearer ${apiToken}`, "Content-Type": "application/json", @@ -429,6 +456,7 @@ export function createApiClient(config: ResolvedConfig): ApiClient { path: string, body?: unknown, params?: Record, + options: ApiReadOptions = {}, ): Promise { const url = new URL(`${apiUrl}${path}`); @@ -438,26 +466,21 @@ export function createApiClient(config: ResolvedConfig): ApiClient { const urlStr = url.toString(); let lastError: unknown; + const maxAttempts = options.retryPolicy === "none" ? 1 : API_MAX_RETRIES; - for (let attempt = 0; attempt < API_MAX_RETRIES; attempt++) { + for (let attempt = 0; attempt < maxAttempts; attempt++) { try { - return await requestOnce(method, urlStr, body); + return await requestOnce(method, urlStr, body, options.signal); } catch (error) { lastError = error; - const status = (error as { status?: number }).status; - const isTransient = status !== undefined - ? isTransientStatus(status) - : isRetryableConnectionError(error); const isRefused = isConnectionRefusedError(error); - // Idempotent: retry on transient HTTP status or any retryable connection error. + // Idempotent: retry on transient HTTP status or status-less retryable connection errors. // Non-idempotent: retry only on connection-refused (request never reached server). - const shouldRetry = isIdempotent(method) - ? (isTransient || isRetryableConnectionError(error)) - : isRefused; + const shouldRetry = isIdempotent(method) ? isRetryableApiReadError(error) : isRefused; - if (!shouldRetry || attempt >= API_MAX_RETRIES - 1) { + if (!shouldRetry || attempt >= maxAttempts - 1) { throw error; } @@ -473,8 +496,12 @@ export function createApiClient(config: ResolvedConfig): ApiClient { } return { - get(path: string, params?: Record): Promise { - return request("GET", path, undefined, params); + get( + path: string, + params?: Record, + options?: ApiReadOptions, + ): Promise { + return request("GET", path, undefined, params, options); }, post(path: string, body?: unknown): Promise { return request("POST", path, body); diff --git a/cli/shared/deployment/control-plane.test.ts b/cli/shared/deployment/control-plane.test.ts index 897060ddef..0c261d1b47 100644 --- a/cli/shared/deployment/control-plane.test.ts +++ b/cli/shared/deployment/control-plane.test.ts @@ -39,6 +39,58 @@ async function collectReleaseFiles(files: AsyncIterable) { } describe("createHttpDeployControlPlane", () => { + it("treats only not-found release asset manifests as polling absence", async () => { + const notFound = { status: 404 }; + const forbidden = { status: 403 }; + let error: unknown = notFound; + const controlPlane = createHttpDeployControlPlane( + config, + mockClientReturning({ + get: () => Promise.reject(error), + }), + ); + + assertEquals( + await controlPlane.getReleaseAssetManifest("my-project", "release-1"), + null, + ); + + error = forbidden; + await assertRejects( + () => controlPlane.getReleaseAssetManifest("my-project", "release-1"), + ); + }); + + it("does not treat a successful null manifest response as polling absence", async () => { + const controlPlane = createHttpDeployControlPlane( + config, + mockClientReturning({ + get: () => Promise.resolve(null), + }), + ); + + await assertRejects( + () => controlPlane.getReleaseAssetManifest("my-project", "release-1"), + Error, + "empty manifest response", + ); + }); + + it("does not treat an empty successful manifest response as polling absence", async () => { + const controlPlane = createHttpDeployControlPlane( + config, + mockClientReturning({ + get: () => Promise.resolve(undefined), + }), + ); + + await assertRejects( + () => controlPlane.getReleaseAssetManifest("my-project", "release-1"), + Error, + "empty manifest response", + ); + }); + it("normalizes legacy deployment references before returning them", async () => { const controlPlane = createHttpDeployControlPlane( config, diff --git a/cli/shared/deployment/control-plane.ts b/cli/shared/deployment/control-plane.ts index 84a6130597..0e68daf447 100644 --- a/cli/shared/deployment/control-plane.ts +++ b/cli/shared/deployment/control-plane.ts @@ -5,7 +5,6 @@ import { normalizeControlPlane, type ProjectTarget, } from "../deployment-provenance.ts"; -import type { ReleaseAssetManifestResponse } from "veryfront/release-assets"; import { DEPLOYMENT_ERROR } from "veryfront/errors"; export interface DeployProjectRecord { @@ -49,6 +48,12 @@ export interface DeployReleaseFile { content: string; } +export type DeployReleaseAssetManifestBody = object | string | number | boolean; + +export interface DeployReleaseAssetManifestReadOptions { + signal?: AbortSignal; +} + export interface DeployControlPlane { readonly controlPlane: string; getProject(reference: string): Promise; @@ -62,10 +67,15 @@ export interface DeployControlPlane { reference: string, releaseId: string, ): AsyncIterable; + /** + * Performs one manifest read and returns null only while it does not exist. + * The caller owns polling and retry timing. + */ getReleaseAssetManifest( projectSlug: string, releaseId: string, - ): Promise; + options?: DeployReleaseAssetManifestReadOptions, + ): Promise; createDeployment( reference: string, input: { releaseId: string; environmentId: string }, @@ -285,15 +295,22 @@ export function createHttpDeployControlPlane( } while (cursor); }, - async getReleaseAssetManifest(projectSlug, releaseId) { + async getReleaseAssetManifest(projectSlug, releaseId, options) { + let response: unknown; try { - return await client.get( + response = await client.get( `/projects/${projectSlug}/releases/${releaseId}/asset-manifest`, + undefined, + { retryPolicy: "none", signal: options?.signal }, ); } catch (error) { if (getErrorStatus(error) === 404) return null; throw error; } + if (response == null) { + throw new Error(`Release assets for ${releaseId} returned an empty manifest response`); + } + return response as DeployReleaseAssetManifestBody; }, async createDeployment(reference, input) { diff --git a/cli/shared/deployment/deploy-project.test.ts b/cli/shared/deployment/deploy-project.test.ts index 5a5c237747..1eb81ae2da 100644 --- a/cli/shared/deployment/deploy-project.test.ts +++ b/cli/shared/deployment/deploy-project.test.ts @@ -7,6 +7,7 @@ import { assertStrictEquals, } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; +import { FakeTime } from "#std/testing/time"; import { DEPLOYMENT_ERROR, ENVIRONMENT_NOT_FOUND, @@ -14,9 +15,14 @@ import { SOURCE_DIGEST_MISMATCH, VeryfrontError, } from "veryfront/errors"; -import { withMockFetch } from "#veryfront/testing/mock-fetch.ts"; +import { observeFetchRequestInit, withMockFetch } from "#veryfront/testing/mock-fetch.ts"; +import { createApiClient } from "../config.ts"; import { computeSourceDigest, writePushReceipt } from "../deployment-provenance.ts"; -import type { DeployControlPlane } from "./control-plane.ts"; +import { + createHttpDeployControlPlane, + type DeployControlPlane, + type DeployReleaseAssetManifestBody, +} from "./control-plane.ts"; import { assertProjectOwnership, createDeployProject, @@ -426,6 +432,29 @@ describe("DeployProject", () => { }); }); + it("propagates non-not-found route directory inspection errors before deployment mutation", async () => { + await withDeployEnv(async () => { + const { projectDir } = await createPushedProject(); + const controlPlane = new InMemoryDeployControlPlane(); + try { + await Deno.writeTextFile( + `${projectDir}/veryfront.config.ts`, + 'export default { directories: { app: "app\\0" } };\n', + ); + + await assertRejects( + () => executeApply(projectDir, controlPlane), + TypeError, + "unexpected NUL byte", + ); + assertEquals(controlPlane.createdReleases, []); + assertEquals(controlPlane.createdDeployments, []); + } finally { + await Deno.remove(projectDir, { recursive: true }); + } + }); + }); + it("emits routing convergence warnings after deployment verification", async () => { await withDeployEnv(async () => { const { projectDir } = await createPushedProject(); @@ -1213,6 +1242,380 @@ describe("deployment verification", () => { }); describe("release asset manifest", () => { + function manifestControlPlane(response: DeployReleaseAssetManifestBody): DeployControlPlane { + return helperControlPlane({ + getReleaseAssetManifest: () => Promise.resolve(response), + }); + } + + const polling = { + expectedRoutes: ["/"], + pollIntervalMs: 100, + timeoutMs: 100, + }; + + it("parses a valid ready response for the requested release", async () => { + const result = await waitForReleaseAssetManifest( + manifestControlPlane(readyManifest()), + PROJECT_SLUG, + "release-1", + polling, + ); + + assertEquals(result.state, "ready"); + assertEquals(result.manifest.releaseId, "release-1"); + }); + + it("continues polling through a missing manifest and building state", async () => { + using time = new FakeTime(); + const responses = [null, { state: "building" }, readyManifest()]; + let reads = 0; + const controlPlane = helperControlPlane({ + getReleaseAssetManifest: () => { + const response = responses[Math.min(reads, responses.length - 1)]!; + reads++; + return Promise.resolve(response); + }, + }); + + const pending = waitForReleaseAssetManifest( + controlPlane, + PROJECT_SLUG, + "release-1", + { ...polling, timeoutMs: 500 }, + ); + await time.tickAsync(0); + await time.tickAsync(100); + await time.tickAsync(100); + + const result = await pending; + assertEquals(result.state, "ready"); + assertEquals(reads, 3); + }); + + it("reports the last state after the polling deadline", async () => { + using time = new FakeTime(); + let reads = 0; + const controlPlane = helperControlPlane({ + getReleaseAssetManifest: () => { + reads++; + return Promise.resolve({ state: "building" }); + }, + }); + + const rejection = assertRejects( + () => + waitForReleaseAssetManifest(controlPlane, PROJECT_SLUG, "release-1", { + ...polling, + timeoutMs: 250, + }), + Error, + "last state: building", + ); + await time.tickAsync(0); + await time.tickAsync(100); + await time.tickAsync(100); + await time.tickAsync(50); + + await rejection; + assertEquals(reads, 3); + }); + + it("reports missing when a manifest disappears after building", async () => { + using time = new FakeTime(); + const responses = [{ state: "building" }, null]; + let reads = 0; + const controlPlane = helperControlPlane({ + getReleaseAssetManifest: () => { + const response = responses[Math.min(reads, responses.length - 1)]!; + reads++; + return Promise.resolve(response); + }, + }); + + const rejection = assertRejects( + () => + waitForReleaseAssetManifest(controlPlane, PROJECT_SLUG, "release-1", { + ...polling, + timeoutMs: 250, + }), + Error, + "last state: missing", + ); + await time.tickAsync(0); + await time.tickAsync(100); + await time.tickAsync(100); + await time.tickAsync(50); + + await rejection; + assertEquals(reads, 3); + }); + + it("recovers from transient control-plane failures within the polling deadline", async () => { + using time = new FakeTime(); + const unavailable = Object.assign(new Error("service unavailable"), { status: 503 }); + const connectionReset = Object.assign(new Error("connection reset"), { + code: "ECONNRESET", + }); + const responses: Array = [ + unavailable, + connectionReset, + { state: "building" }, + readyManifest(), + ]; + let reads = 0; + const controlPlane = helperControlPlane({ + getReleaseAssetManifest: () => { + const response = responses[Math.min(reads, responses.length - 1)]!; + reads++; + return response instanceof Error ? Promise.reject(response) : Promise.resolve(response); + }, + }); + + const pending = waitForReleaseAssetManifest( + controlPlane, + PROJECT_SLUG, + "release-1", + { ...polling, timeoutMs: 500 }, + ); + await time.tickAsync(0); + await time.tickAsync(100); + await time.tickAsync(100); + await time.tickAsync(100); + + const result = await pending; + assertEquals(result.state, "ready"); + assertEquals(reads, 4); + }); + + it("bounds transient control-plane retries by the original polling deadline", async () => { + using time = new FakeTime(); + let reads = 0; + const controlPlane = helperControlPlane({ + getReleaseAssetManifest: () => { + reads++; + return Promise.reject(Object.assign(new Error("service unavailable"), { status: 503 })); + }, + }); + + const rejection = assertRejects( + () => + waitForReleaseAssetManifest(controlPlane, PROJECT_SLUG, "release-1", { + ...polling, + timeoutMs: 250, + }), + Error, + "last control-plane failure: HTTP 503", + ); + await time.tickAsync(0); + await time.tickAsync(100); + await time.tickAsync(100); + await time.tickAsync(50); + + await rejection; + assertEquals(reads, 3); + }); + + it("enforces the polling deadline through the production HTTP adapter", async () => { + using time = new FakeTime(); + let reads = 0; + const signals: AbortSignal[] = []; + + await withMockFetch( + ((_input: string | URL | Request, init?: RequestInit) => { + reads++; + const signal = observeFetchRequestInit(init).signal; + if (!signal) return Promise.reject(new Error("asset manifest read has no deadline signal")); + signals.push(signal); + + if (reads === 1) { + return Promise.resolve( + new Response("{}", { status: 503, statusText: "Service Unavailable" }), + ); + } + + return new Promise((_resolve, reject) => { + if (signal.aborted) { + reject(signal.reason); + return; + } + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); + }); + }) as typeof fetch, + async () => { + const config = { + apiUrl: "https://control.example.test/api", + apiToken: "", + projectSlug: PROJECT_SLUG, + }; + const controlPlane = createHttpDeployControlPlane(config, createApiClient(config)); + const rejection = assertRejects( + () => + waitForReleaseAssetManifest(controlPlane, PROJECT_SLUG, "release-1", { + ...polling, + timeoutMs: 250, + }), + Error, + "last control-plane failure: HTTP 503", + ); + + await time.tickAsync(0); + assertEquals(reads, 1); + await time.tickAsync(100); + assertEquals(reads, 2); + await time.tickAsync(150); + + await rejection; + assertEquals(reads, 2); + assertEquals(signals.length, 2); + assertEquals(signals[0]?.aborted, false); + assertEquals(signals[1]?.aborted, true); + }, + ); + }); + + it("does not retry authentication, validation, or cancellation failures", async () => { + for ( + const error of [ + Object.assign(new Error("unauthorized"), { status: 401 }), + Object.assign(new Error("invalid request"), { status: 422 }), + new DOMException("cancelled", "AbortError"), + ] + ) { + let reads = 0; + const controlPlane = helperControlPlane({ + getReleaseAssetManifest: () => { + reads++; + return Promise.reject(error); + }, + }); + + await assertRejects( + () => waitForReleaseAssetManifest(controlPlane, PROJECT_SLUG, "release-1", polling), + error.constructor as ErrorConstructor, + error.message, + ); + assertEquals(reads, 1, `${error.name} must fail without another polling attempt`); + } + }); + + it("rejects legacy ready manifests", async () => { + const current = readyManifest(); + await assertRejects( + () => + waitForReleaseAssetManifest( + manifestControlPlane({ + ...current, + manifest: { ...current.manifest!, schemaVersion: 1 }, + }), + PROJECT_SLUG, + "release-1", + polling, + ), + Error, + "invalid or mismatched ready manifest", + ); + }); + + it("rejects ready manifests for another release", async () => { + const current = readyManifest(); + await assertRejects( + () => + waitForReleaseAssetManifest( + manifestControlPlane({ + ...current, + manifest: { ...current.manifest!, releaseId: "release-other" }, + }), + PROJECT_SLUG, + "release-1", + polling, + ), + Error, + "invalid or mismatched ready manifest", + ); + }); + + it("rejects accessor-backed states without executing accessors", async () => { + let accessorCalls = 0; + const hostileResponse: Record = {}; + Object.defineProperty(hostileResponse, "state", { + enumerable: true, + get() { + accessorCalls++; + return "ready"; + }, + }); + + await assertRejects( + () => + waitForReleaseAssetManifest( + manifestControlPlane(hostileResponse), + PROJECT_SLUG, + "release-1", + polling, + ), + Error, + "invalid state response", + ); + assertEquals(accessorCalls, 0); + }); + + it("rejects oversized manifest states", async () => { + await assertRejects( + () => + waitForReleaseAssetManifest( + manifestControlPlane({ state: "q".repeat(65) }), + PROJECT_SLUG, + "release-1", + polling, + ), + Error, + "invalid state response", + ); + }); + + it("fails closed for partial manifests", async () => { + await assertRejects( + () => + waitForReleaseAssetManifest( + manifestControlPlane({ state: "partial" }), + PROJECT_SLUG, + "release-1", + polling, + ), + Error, + "unsupported partial manifest", + ); + }); + + it("fails closed for superseded manifests", async () => { + await assertRejects( + () => + waitForReleaseAssetManifest( + manifestControlPlane({ state: "superseded" }), + PROJECT_SLUG, + "release-1", + polling, + ), + Error, + "Release assets for release-1 were superseded", + ); + }); + + it("fails closed for unsupported manifest states", async () => { + await assertRejects( + () => + waitForReleaseAssetManifest( + manifestControlPlane({ state: "unexpected" }), + PROJECT_SLUG, + "release-1", + polling, + ), + Error, + "unsupported state response: unexpected", + ); + }); + it("rejects ready empty manifests before deployment", async () => { const controlPlane = helperControlPlane({ getReleaseAssetManifest: () => Promise.resolve(readyManifest({})), diff --git a/cli/shared/deployment/deploy-project.ts b/cli/shared/deployment/deploy-project.ts index fac737222d..bf43056bd9 100644 --- a/cli/shared/deployment/deploy-project.ts +++ b/cli/shared/deployment/deploy-project.ts @@ -1,9 +1,16 @@ import { type EnvironmentConfig, getConfig, getEnvironmentConfig } from "veryfront/config"; -import { createFileSystem, runtime } from "veryfront/platform"; +import { createFileSystem, isNotFoundError, runtime } from "veryfront/platform"; import { join, relative, resolve } from "veryfront/platform/path"; import { isWithinDirectory, normalizePath } from "veryfront/utils"; import { parseProjectDomain } from "veryfront/server"; -import { type ReleaseAssetManifestResponse, routeForPage } from "veryfront/release-assets"; +import { + isSafeBoundedText, + parseReadyReleaseAssetManifestResponse, + readUntrustedOwnDataProperty, + type ReadyReleaseAssetManifestResponse, + type ReleaseAssetManifestResponse, + routeForPage, +} from "veryfront/release-assets"; import { DEPLOYMENT_ERROR, ENVIRONMENT_NOT_FOUND, @@ -23,6 +30,7 @@ import { import { normalizeProjectSlug } from "../slug.ts"; import { reserveProjectSlug } from "../reserve-slug.ts"; import { + getErrorStatus, inferProjectSlugFromDirectory, projectApiReference, ProjectReferenceNotFoundError, @@ -32,6 +40,7 @@ import { shouldPersistProjectLink, } from "../project-resolution.ts"; import { + isRetryableApiReadError, type ProjectReferenceSource, resolveConfigWithAuth, resolveConfigWithAuthDetails, @@ -547,8 +556,9 @@ async function collectProjectPageRoutes(projectDir: string): Promise { try { if (!(await fs.exists(dir))) return; entries = await fs.readDir(dir); - } catch { - return; + } catch (error) { + if (isNotFoundError(error)) return; + throw error; } for await (const entry of entries) { @@ -604,6 +614,50 @@ function assertReadyManifestCoversPageRoutes( } } +/** Upper bound for a plausible manifest state value from the control plane. */ +const MAX_MANIFEST_STATE_LENGTH = 64; + +function releaseAssetPollingTimeoutError( + timeoutMs: number, + lastState: string, + lastTransientFailure: string | null, +): Error { + const timeoutSeconds = Math.ceil(timeoutMs / 1000); + return new Error( + `Release assets were not ready within ${timeoutSeconds}s (last state: ${lastState}${ + lastTransientFailure === null ? "" : `; last control-plane failure: ${lastTransientFailure}` + }). Check the release asset build and run deploy again.`, + ); +} + +async function readReleaseAssetManifestBeforeDeadline(options: { + controlPlane: DeployControlPlane; + projectSlug: string; + releaseId: string; + remainingMs: number; + timeoutError: Error; +}): Promise>> { + const abortController = new AbortController(); + let timeout: ReturnType | undefined; + const deadline = new Promise((_resolve, reject) => { + timeout = setTimeout(() => { + reject(options.timeoutError); + abortController.abort(); + }, options.remainingMs); + }); + + try { + return await Promise.race([ + options.controlPlane.getReleaseAssetManifest(options.projectSlug, options.releaseId, { + signal: abortController.signal, + }), + deadline, + ]); + } finally { + if (timeout !== undefined) clearTimeout(timeout); + } +} + export async function waitForReleaseAssetManifest( controlPlane: DeployControlPlane, projectSlug: string, @@ -613,36 +667,79 @@ export async function waitForReleaseAssetManifest( pollIntervalMs?: number; timeoutMs?: number; } = {}, -): Promise { +): Promise { const pollIntervalMs = Math.max(100, options.pollIntervalMs ?? 2_000); const timeoutMs = Math.max(pollIntervalMs, options.timeoutMs ?? 120_000); const expectedRoutes = options.expectedRoutes ?? []; const deadline = Date.now() + timeoutMs; let lastState = "missing"; + let lastTransientFailure: string | null = null; for (;;) { - const result = await controlPlane.getReleaseAssetManifest(projectSlug, releaseId); - if (result) { - lastState = result.state; + const remainingMs = deadline - Date.now(); + const timeoutError = releaseAssetPollingTimeoutError( + timeoutMs, + lastState, + lastTransientFailure, + ); + if (remainingMs <= 0) throw timeoutError; + + let raw: Awaited> = null; + try { + raw = await readReleaseAssetManifestBeforeDeadline({ + controlPlane, + projectSlug, + releaseId, + remainingMs, + timeoutError, + }); + lastTransientFailure = null; + if (raw === null) lastState = "missing"; + } catch (error) { + if (!isRetryableApiReadError(error)) throw error; + const status = getErrorStatus(error); + lastTransientFailure = status === undefined + ? "a transient connection failure" + : `HTTP ${status}`; + } + if (raw !== null) { + const state = readUntrustedOwnDataProperty(raw, "state"); + if (!isSafeBoundedText(state, MAX_MANIFEST_STATE_LENGTH)) { + throw new Error(`Release assets for ${releaseId} returned an invalid state response`); + } + lastState = state; - if (result.state === "ready") { + if (state === "ready") { + const result = parseReadyReleaseAssetManifestResponse(raw, releaseId); + if (!result) { + throw new Error( + `Release assets for ${releaseId} returned an invalid or mismatched ready manifest. Rebuild the release assets and run deploy again.`, + ); + } assertReadyManifestCoversPageRoutes(releaseId, result, expectedRoutes); return result; } - if (result.state === "failed") { + if (state === "partial") { + throw new Error( + `Release asset build produced an unsupported partial manifest for release ${releaseId}. Rebuild the release assets and run deploy again.`, + ); + } + if (state === "failed") { throw new Error(`Release asset build failed for release ${releaseId}`); } + if (state === "superseded") { + throw new Error( + `Release assets for ${releaseId} were superseded by a newer build. Run deploy again.`, + ); + } + if (state !== "queued" && state !== "building") { + throw new Error( + `Release assets for ${releaseId} returned an unsupported state response: ${state}`, + ); + } } - const remainingMs = deadline - Date.now(); - if (remainingMs <= 0) { - const timeoutSeconds = Math.ceil(timeoutMs / 1000); - throw new Error( - `Release assets were not ready within ${timeoutSeconds}s (last state: ${lastState}). Check the release asset build and run deploy again.`, - ); - } - - await wait(Math.min(pollIntervalMs, remainingMs)); + await wait(Math.min(pollIntervalMs, Math.max(0, deadline - Date.now()))); } } @@ -1038,14 +1135,17 @@ export function createDeployProject(options: { }; } - const source = await step(observer, "verify-source", async () => - resolvePushedSource({ + const { source, expectedPageRoutes } = await step(observer, "verify-source", async () => { + const source = await resolvePushedSource({ projectDir: request.projectDir, controlPlane: config.apiUrl, projectId: project!.id, projectSlug: project!.slug, branch, - })); + }); + const expectedPageRoutes = await collectProjectPageRoutes(request.projectDir); + return { source, expectedPageRoutes }; + }); const release = await step(observer, "create-release", async () => { const created = await controlPlane.createRelease(project!.id, { @@ -1071,14 +1171,12 @@ export function createDeployProject(options: { }), ); - const expectedPageRoutes = await step(observer, "wait-release-assets", async () => { - const routes = await collectProjectPageRoutes(request.projectDir); + await step(observer, "wait-release-assets", async () => { await waitForReleaseAssetManifest(controlPlane, project!.slug, release.id, { - expectedRoutes: routes, + expectedRoutes: expectedPageRoutes, pollIntervalMs: polling.assetManifestPollIntervalMs, timeoutMs: polling.assetManifestTimeoutMs, }); - return routes; }); const deployment = await step( diff --git a/src/platform/index.ts b/src/platform/index.ts index a87711f399..33064f5367 100644 --- a/src/platform/index.ts +++ b/src/platform/index.ts @@ -42,6 +42,7 @@ export { createFileSystem, exists, type FileSystem, + isNotFoundError, mkdir, readDir, readTextFile, diff --git a/src/release-assets/index.ts b/src/release-assets/index.ts index 729cd5e240..8ed94fad6a 100644 --- a/src/release-assets/index.ts +++ b/src/release-assets/index.ts @@ -50,8 +50,10 @@ export { getReleaseAssetManifestSchema, hasImmutableReleaseAssetDependencies, type ImmutableReleaseAssetManifest, + isSafeBoundedText, parseReadyReleaseAssetManifestResponse, parseReleaseAssetManifest, + readUntrustedOwnDataProperty, type ReadyReleaseAssetManifestResponse, type ReleaseAssetCssEntry, type ReleaseAssetDependencyMode, diff --git a/src/release-assets/manifest-schema.ts b/src/release-assets/manifest-schema.ts index 3ef9a727dd..19763099b9 100644 --- a/src/release-assets/manifest-schema.ts +++ b/src/release-assets/manifest-schema.ts @@ -73,7 +73,11 @@ const CSS_ENTRY_KEYS = new Set([ ]); const ROUTE_ENTRY_KEYS = new Set(["modules", "css"]); -function isSafeBoundedText(value: unknown, maxLength: number): value is string { +/** + * Check that an untrusted value is a non-empty, trimmed string within + * `maxLength` that contains no control characters. + */ +export function isSafeBoundedText(value: unknown, maxLength: number): value is string { return typeof value === "string" && value.length > 0 && value.length <= maxLength && @@ -287,6 +291,7 @@ export type ReleaseAssetManifestState = | "queued" | "building" | "ready" + | "partial" | "failed" | "superseded"; @@ -360,6 +365,21 @@ export function parseReadyReleaseAssetManifestResponse( } } +/** + * Read an own data property from an untrusted value without invoking accessors. + * + * Returns undefined for primitives, accessor-backed properties, and values + * whose property inspection throws (for example hostile proxies). + */ +export function readUntrustedOwnDataProperty(value: unknown, key: PropertyKey): unknown { + if (typeof value !== "object" || value === null) return undefined; + try { + return readOwnDataProperty(value, key); + } catch { + return undefined; + } +} + function parseReleaseAssetManifestImpl(value: unknown): ReleaseAssetManifest | null { const candidate = snapshotExactDataRecord(value, MANIFEST_KEYS); if (!candidate) return null; From 5d107f1fb66d9c9ed36b7da14e4d933e2868469f Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 15:48:37 +0200 Subject: [PATCH 18/26] fix(platform): fail closed on GitHub filesystem config errors (#3333) * fix(platform): fail closed on GitHub filesystem config errors Follow-up to #3323 closing the changes-requested review gap: a misconfigured GitHub adapter must never silently fall back to the host-local filesystem. - enhanceAdapterWithFS rethrows every CONFIG-category VeryfrontError instead of allowlisting the single config-validation-failed slug. - GitHubApiClient wraps invalid owner/repo identity as config-validation-failed instead of a bare TypeError that the integration layer would swallow. - createGitHubConfig raises registry CONFIG_INVALID errors instead of legacy plain Errors so empty token/owner/repo also fail closed. - The Veryfront path normalizer rejects C1 controls (U+0080-U+009F) like the GitHub normalizer, sharing one policy. - File-cache serialization regression gains a positive control proving the fake backend harness is live. All new regressions were proven red against the previous sources (3 suites, 7 failing steps) before the fixes were applied. * test(platform): narrow GitHub config error assertion * fix(platform): keep remote filesystems fail closed * docs(platform): align fail-closed rationale --- .../adapters/fs/cache/file-cache.test.ts | 6 + src/platform/adapters/fs/github/adapter.ts | 6 - .../fs/github/github-api-client.test.ts | 9 +- .../adapters/fs/github/github-api-client.ts | 15 +- src/platform/adapters/fs/github/types.ts | 32 ++-- src/platform/adapters/fs/integration.test.ts | 175 ++++++++++++++++-- src/platform/adapters/fs/integration.ts | 71 +++---- .../fs/veryfront/path-normalizer.test.ts | 12 ++ .../adapters/fs/veryfront/path-normalizer.ts | 8 +- 9 files changed, 244 insertions(+), 90 deletions(-) diff --git a/src/platform/adapters/fs/cache/file-cache.test.ts b/src/platform/adapters/fs/cache/file-cache.test.ts index b421f21ef5..56e832b822 100644 --- a/src/platform/adapters/fs/cache/file-cache.test.ts +++ b/src/platform/adapters/fs/cache/file-cache.test.ts @@ -367,6 +367,12 @@ describe("Distributed cache functions", () => { circular.self = circular; distributedCache.set("cyclic", circular); assertEquals(backendWrites, 0); + + // Positive control: a serializable entry must reach the fake backend, + // proving the harness is live and the zero-write assertion above is not + // vacuously passing because the backend was never wired up. + distributedCache.set("serializable", { ok: true }); + assertEquals(backendWrites, 1); }); it("should return boolean", async () => { diff --git a/src/platform/adapters/fs/github/adapter.ts b/src/platform/adapters/fs/github/adapter.ts index f8948096ad..9ff45d5f9c 100644 --- a/src/platform/adapters/fs/github/adapter.ts +++ b/src/platform/adapters/fs/github/adapter.ts @@ -48,12 +48,6 @@ export class GitHubFSAdapter implements FSAdapter { retry: githubConfig.retry, }; - if (!rawConfig.token) { - throw CONFIG_INVALID.create({ - detail: "GitHub adapter requires a token; set GITHUB_TOKEN or pass config.github.token", - }); - } - this.config = createGitHubConfig(rawConfig); this.client = new GitHubApiClient(this.config); diff --git a/src/platform/adapters/fs/github/github-api-client.test.ts b/src/platform/adapters/fs/github/github-api-client.test.ts index 691e719271..566d275872 100644 --- a/src/platform/adapters/fs/github/github-api-client.test.ts +++ b/src/platform/adapters/fs/github/github-api-client.test.ts @@ -2,11 +2,13 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals, assertExists, + assertInstanceOf, assertRejects, assertThrows, } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { withMockFetch } from "#veryfront/testing/mock-fetch.ts"; +import { VeryfrontError } from "#veryfront/errors/types.ts"; import { GitHubApiClient } from "./github-api-client.ts"; const mockConfig = { @@ -59,11 +61,14 @@ describe("GitHubApiClient", () => { ["repo", "r".repeat(257)], ] as const ) { - assertThrows( + // Repository identity failures retain stable CONFIG error semantics. + const error = assertThrows( () => new GitHubApiClient({ ...mockConfig, [field]: value }), - TypeError, + VeryfrontError, "GitHub", ); + assertInstanceOf(error, VeryfrontError); + assertEquals(error.slug, "config-validation-failed"); } }); }); diff --git a/src/platform/adapters/fs/github/github-api-client.ts b/src/platform/adapters/fs/github/github-api-client.ts index 27f1a05cd0..b9b726eb82 100644 --- a/src/platform/adapters/fs/github/github-api-client.ts +++ b/src/platform/adapters/fs/github/github-api-client.ts @@ -1,4 +1,5 @@ import { createError, retryWithBackoff, toError } from "#veryfront/errors"; +import { CONFIG_VALIDATION_FAILED } from "#veryfront/errors/error-registry/config.ts"; import { logger } from "#veryfront/utils"; import type { ResolvedGitHubConfig } from "./types.ts"; import { @@ -105,9 +106,17 @@ export class GitHubApiClient { private rateLimitInfo: RateLimitInfo | null = null; constructor(private readonly config: ResolvedGitHubConfig) { - const owner = encodeRepositorySegment(config.owner, "owner"); - const repo = encodeRepositorySegment(config.repo, "repository"); - this.repositoryEndpoint = `/repos/${owner}/${repo}`; + // Invalid repository identity remains a CONFIG-category boundary error. + try { + const owner = encodeRepositorySegment(config.owner, "owner"); + const repo = encodeRepositorySegment(config.repo, "repository"); + this.repositoryEndpoint = `/repos/${owner}/${repo}`; + } catch (cause) { + throw CONFIG_VALIDATION_FAILED.create({ + detail: cause instanceof Error ? cause.message : "GitHub repository identity is invalid", + cause, + }); + } } get repoId(): string { diff --git a/src/platform/adapters/fs/github/types.ts b/src/platform/adapters/fs/github/types.ts index a8b543a659..b6d4a68b35 100644 --- a/src/platform/adapters/fs/github/types.ts +++ b/src/platform/adapters/fs/github/types.ts @@ -1,4 +1,4 @@ -import { createError, toError } from "#veryfront/errors"; +import { CONFIG_INVALID } from "#veryfront/errors"; export type { DirectoryEntry } from "../shared-types.ts"; @@ -68,25 +68,23 @@ const DEFAULT_MAX_RETRIES = 3; const DEFAULT_INITIAL_RETRY_DELAY_MS = 1_000; const DEFAULT_MAX_RETRY_DELAY_MS = 10_000; +function isBlankConfigValue(value: unknown): boolean { + return typeof value !== "string" || value.trim().length === 0; +} + export function createGitHubConfig(config: GitHubConfig): ResolvedGitHubConfig { - if (!config.token) { - throw toError( - createError({ - type: "config", - message: - "GitHub adapter requires a token. Set GITHUB_TOKEN environment variable or provide token in config.", - }), - ); + if (isBlankConfigValue(config.token)) { + throw CONFIG_INVALID.create({ + detail: + "GitHub adapter requires a token. Set GITHUB_TOKEN environment variable or provide token in config.", + }); } - if (!config.owner || !config.repo) { - throw toError( - createError({ - type: "config", - message: - "GitHub adapter requires owner and repo. Provide them in config or via GITHUB_OWNER and GITHUB_REPO environment variables.", - }), - ); + if (isBlankConfigValue(config.owner) || isBlankConfigValue(config.repo)) { + throw CONFIG_INVALID.create({ + detail: + "GitHub adapter requires owner and repo. Provide them in config or via GITHUB_OWNER and GITHUB_REPO environment variables.", + }); } return { diff --git a/src/platform/adapters/fs/integration.test.ts b/src/platform/adapters/fs/integration.test.ts index b679b6afdf..b857033edd 100644 --- a/src/platform/adapters/fs/integration.test.ts +++ b/src/platform/adapters/fs/integration.test.ts @@ -4,8 +4,10 @@ import { assertExists, assertInstanceOf, assertRejects, + assertStrictEquals, } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; +import { withMockFetch } from "#veryfront/testing/mock-fetch.ts"; import { createFSAdapterFromConfig, enhanceAdapterWithFS, @@ -99,7 +101,7 @@ describe("integration.ts", () => { assertEquals(getFSAdapterType({ fs: {} }), "local"); }); - describe("enhanceAdapterWithFS error fallback", () => { + describe("enhanceAdapterWithFS error propagation", () => { it("should preserve invalid retry configuration instead of changing filesystems", async () => { let rejection: unknown; try { @@ -140,28 +142,165 @@ describe("integration.ts", () => { assertEquals(error.slug, "config-validation-failed"); }); - it("should fall back to original adapter for unsupported type", async () => { - const adapter = await enhanceAdapterWithFS(denoAdapter, { - fs: { type: "unsupported-type" as any }, - }); - assertEquals(adapter, denoAdapter); + it("should fail closed when GitHub repository identity is invalid", async () => { + const error = await assertRejects( + () => + enhanceAdapterWithFS(denoAdapter, { + fs: { + type: "github", + github: { token: "test-token", owner: "team/other", repo: "repo" }, + }, + }), + VeryfrontError, + "GitHub owner", + ); + assertInstanceOf(error, VeryfrontError); + assertEquals(error.slug, "config-validation-failed"); }); - it("should fall back to original adapter for github type without config", async () => { - const adapter = await enhanceAdapterWithFS(denoAdapter, { - fs: { type: "github" }, - }); - assertEquals(adapter, denoAdapter); + it("should fail closed when the GitHub adapter has no token", async () => { + // token: "" is explicit so the GITHUB_TOKEN environment variable cannot + // satisfy the requirement and mask the regression in CI. + const error = await assertRejects( + () => + enhanceAdapterWithFS(denoAdapter, { + fs: { + type: "github", + github: { token: "", owner: "owner", repo: "repo" }, + }, + }), + VeryfrontError, + "token", + ); + assertInstanceOf(error, VeryfrontError); + assertEquals(error.slug, "config-invalid"); }); - it("should pass projectDir to FSAdapter config", async () => { - // With an unsupported type, it will fail and fall back, but the branch is exercised - const adapter = await enhanceAdapterWithFS( - denoAdapter, - { fs: { type: "unknown-type" as any } }, - "/some/project/dir", + it("should fail closed when the GitHub token contains only whitespace", async () => { + let requests = 0; + const error = await withMockFetch( + () => { + requests += 1; + return Promise.resolve(new Response("Unauthorized", { status: 401 })); + }, + () => + assertRejects( + () => + enhanceAdapterWithFS(denoAdapter, { + fs: { + type: "github", + github: { token: " ", owner: "owner", repo: "repo" }, + }, + }), + VeryfrontError, + "token", + ), ); - assertEquals(adapter, denoAdapter); + assertInstanceOf(error, VeryfrontError); + assertEquals(error.slug, "config-invalid"); + assertEquals(requests, 0); + }); + + it("should propagate GitHub network initialization failures", async () => { + const networkFailure = new Error("simulated GitHub outage"); + const error = await withMockFetch( + () => Promise.reject(networkFailure), + () => + assertRejects(() => + enhanceAdapterWithFS(denoAdapter, { + fs: { + type: "github", + github: { + token: "test-token", + owner: "owner", + repo: "repo", + retry: { maxRetries: 1, initialDelay: 0, maxDelay: 0 }, + }, + }, + }) + ), + ); + assertStrictEquals(error, networkFailure); + }); + + it("should propagate GitHub authentication failures", async () => { + const error = await withMockFetch( + () => Promise.resolve(new Response("Unauthorized", { status: 401 })), + () => + assertRejects( + () => + enhanceAdapterWithFS(denoAdapter, { + fs: { + type: "github", + github: { + token: "invalid-token", + owner: "owner", + repo: "repo", + retry: { maxRetries: 1, initialDelay: 0, maxDelay: 0 }, + }, + }, + }), + Error, + "authentication", + ), + ); + assertInstanceOf(error, Error); + }); + + it("should propagate unsupported adapter failures", async () => { + await assertRejects( + () => + enhanceAdapterWithFS(denoAdapter, { + fs: { type: "unsupported-type" as any }, + }), + Error, + 'FSAdapter type "unsupported-type" is not implemented', + ); + }); + + it("should fail closed for github type without config", async () => { + await assertRejects( + () => + enhanceAdapterWithFS(denoAdapter, { + fs: { type: "github" }, + }), + Error, + "GitHub adapter requires github configuration", + ); + }); + + it("should not consult VeryfrontError Symbol.hasInstance while propagating", async () => { + const originalHasInstance = Object.getOwnPropertyDescriptor( + VeryfrontError, + Symbol.hasInstance, + ); + Object.defineProperty(VeryfrontError, Symbol.hasInstance, { + configurable: true, + value() { + throw new Error("poisoned VeryfrontError Symbol.hasInstance was used"); + }, + }); + + let caught: unknown; + try { + await enhanceAdapterWithFS(denoAdapter, { + fs: { + type: "github", + github: { token: "test-token", owner: "team/other", repo: "repo" }, + }, + }); + } catch (error) { + caught = error; + } finally { + if (originalHasInstance) { + Object.defineProperty(VeryfrontError, Symbol.hasInstance, originalHasInstance); + } else { + Reflect.deleteProperty(VeryfrontError, Symbol.hasInstance); + } + } + + assertInstanceOf(caught, VeryfrontError); + assertEquals(caught.slug, "config-validation-failed"); }); }); diff --git a/src/platform/adapters/fs/integration.ts b/src/platform/adapters/fs/integration.ts index f430241da4..e1b241ab12 100644 --- a/src/platform/adapters/fs/integration.ts +++ b/src/platform/adapters/fs/integration.ts @@ -4,7 +4,6 @@ import { createFSAdapter } from "./factory.ts"; import { wrapFSAdapter } from "./wrapper.ts"; import { logger as baseLogger } from "#veryfront/utils"; import { withSpan } from "#veryfront/observability/tracing/otlp-setup.ts"; -import { VeryfrontError } from "#veryfront/errors/types.ts"; const logger = baseLogger.component("fs-integration"); @@ -35,46 +34,36 @@ export function enhanceAdapterWithFS( return withSpan( "platform.fs.enhanceAdapterWithFS", async () => { - try { - logger.debug("Initializing FSAdapter", { - type: fsType, - projectSlug: config.fs?.veryfront?.projectSlug, - }); - - const fsAdapterConfig: FSAdapterConfig = { - ...config.fs, - projectDir, - }; - - const fsAdapter = await createFSAdapter(fsAdapterConfig); - const wrappedFS = wrapFSAdapter(fsAdapter); - - const enhancedAdapter: RuntimeAdapter = new Proxy(adapter, { - get(target, prop, receiver) { - if (prop === "fs") return wrappedFS; - - const value = Reflect.get(target, prop, receiver); - return typeof value === "function" ? value.bind(target) : value; - }, - }); - - logger.debug("FSAdapter initialized successfully", { - type: fsType, - }); - - return enhancedAdapter; - } catch (error) { - if (error instanceof VeryfrontError && error.slug === "config-validation-failed") { - throw error; - } - logger.error("Failed to initialize FSAdapter", { - error: error instanceof Error ? error.message : String(error), - type: fsType, - }); - - logger.warn("Falling back to local filesystem"); - return adapter; - } + logger.debug("Initializing FSAdapter", { + type: fsType, + projectSlug: config.fs?.veryfront?.projectSlug, + }); + + const fsAdapterConfig: FSAdapterConfig = { + ...config.fs, + projectDir, + }; + + // An explicitly selected remote filesystem is an authority boundary. + // Propagate every initialization failure so callers never continue with + // the host-local adapter and serve files from the wrong source. + const fsAdapter = await createFSAdapter(fsAdapterConfig); + const wrappedFS = wrapFSAdapter(fsAdapter); + + const enhancedAdapter: RuntimeAdapter = new Proxy(adapter, { + get(target, prop, receiver) { + if (prop === "fs") return wrappedFS; + + const value = Reflect.get(target, prop, receiver); + return typeof value === "function" ? value.bind(target) : value; + }, + }); + + logger.debug("FSAdapter initialized successfully", { + type: fsType, + }); + + return enhancedAdapter; }, { "fs.adapter.type": fsType }, ); diff --git a/src/platform/adapters/fs/veryfront/path-normalizer.test.ts b/src/platform/adapters/fs/veryfront/path-normalizer.test.ts index 05b0d1dba9..5da627575f 100644 --- a/src/platform/adapters/fs/veryfront/path-normalizer.test.ts +++ b/src/platform/adapters/fs/veryfront/path-normalizer.test.ts @@ -152,6 +152,18 @@ describe("PathNormalizer", () => { ); }); + it("should reject C1 control characters like the GitHub normalizer", () => { + const normalizer = new PathNormalizer(); + // U+0080 and U+009F bound the C1 range; NUL is the C0 control anchor. + for (const codeUnit of [0x00, 0x80, 0x9f]) { + assertThrows( + () => normalizer.normalize(`src/${String.fromCharCode(codeUnit)}secrets.ts`), + TypeError, + "must not contain control characters", + ); + } + }); + it("should reject unbounded paths", () => { const normalizer = new PathNormalizer(); assertThrows( diff --git a/src/platform/adapters/fs/veryfront/path-normalizer.ts b/src/platform/adapters/fs/veryfront/path-normalizer.ts index dbf07fe44f..f4d6e327c4 100644 --- a/src/platform/adapters/fs/veryfront/path-normalizer.ts +++ b/src/platform/adapters/fs/veryfront/path-normalizer.ts @@ -4,10 +4,12 @@ import { CONFIG_VALIDATION_FAILED } from "#veryfront/errors/error-registry/confi const logger = baseLogger.component("path-normalizer"); const MAX_PATH_CODE_UNITS = 4_096; -function hasAsciiControlCharacter(value: string): boolean { +// Rejects the full non-printable control range (C0, DEL, and C1), the same +// policy as the GitHub path normalizer in ../github/path-utils.ts. +function hasControlCharacter(value: string): boolean { for (let index = 0; index < value.length; index++) { const codeUnit = value.charCodeAt(index); - if (codeUnit <= 0x1f || codeUnit === 0x7f) return true; + if (codeUnit <= 0x1f || (codeUnit >= 0x7f && codeUnit <= 0x9f)) return true; } return false; } @@ -97,7 +99,7 @@ export class PathNormalizer { `Filesystem ${label} exceeds the ${MAX_PATH_CODE_UNITS}-character limit`, ); } - if (hasAsciiControlCharacter(path)) { + if (hasControlCharacter(path)) { throw new TypeError(`Filesystem ${label} must not contain control characters`); } if (path.includes("\\")) { From 0d20941691c67923786bbcbe8b9efcc4c6474ff4 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 16:09:40 +0200 Subject: [PATCH 19/26] Stabilize stuck worker admission regression (#3336) The main CI coverage shard exposed a race in the regression for stuck worker termination: a one millisecond deadline can fire before the fake endpoint is constructed, so the test observes zero terminations instead of proving cleanup after a stuck termination drain. The test now aborts from the fake endpoint after subscription, which deterministically exercises the same bounded termination cleanup and admission release contract without relying on wall-clock startup scheduling. Constraint: Main CI failed coverage shard 1/8 at src/config/declarative-evaluator-worker.test.ts:1031 with terminationCount 0 instead of 1. Rejected: Increase the 1 ms timeout | still leaves a timer/startup race under saturated runners. Confidence: high Scope-risk: narrow Directive: Keep this regression endpoint-start deterministic; do not reintroduce a tiny timeout as the trigger for stuck termination. Tested: npx --yes deno@2.7.7 test --no-check --allow-all --unstable-worker-options --unstable-net src/config/declarative-evaluator-worker.test.ts Tested: npx --yes deno@2.7.7 check src/config/declarative-evaluator-worker.test.ts Tested: npx --yes deno@2.7.7 lint src/config/declarative-evaluator-worker.test.ts Tested: npx --yes deno@2.7.7 fmt --check src/config/declarative-evaluator-worker.test.ts Tested: git diff --check Tested: repeated focused single-test run 5x Not-tested: Full local coverage shard completed the formerly failing worker test but later failed on a local SOCKS proxy error in cli/commands/schedule/handler.test.ts. --- src/config/declarative-evaluator-worker.test.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/config/declarative-evaluator-worker.test.ts b/src/config/declarative-evaluator-worker.test.ts index 563440bfeb..7589141e5b 100644 --- a/src/config/declarative-evaluator-worker.test.ts +++ b/src/config/declarative-evaluator-worker.test.ts @@ -1003,14 +1003,17 @@ Deno.test("declarative config worker releases admission after a stuck terminatio const payload = await createPayload("export default { ready: true };"); const admission = declarativeConfigWorkerRunnerInternals .createAdmissionController(1, 0); + const abort = new AbortController(); let terminationCount = 0; const first = declarativeConfigWorkerRunnerInternals .evaluateWithAdmissionController( payload, - { timeoutMs: 1 }, + { signal: abort.signal, timeoutMs: 100 }, async () => ({ - postMessage() {}, + postMessage() { + abort.abort(); + }, subscribe() { return () => {}; }, @@ -1023,11 +1026,11 @@ Deno.test("declarative config worker releases admission after a stuck terminatio 5, ); - const timeoutError = await assertRejects( + const abortError = await assertRejects( () => first, DeclarativeConfigEvaluationError, ) as DeclarativeConfigEvaluationError; - assertEquals(timeoutError.reason, "worker-timeout"); + assertEquals(abortError.reason, "worker-aborted"); assertEquals(terminationCount, 1); await waitForAdmissionState(admission, { active: 0, queued: 0 }); From 8c2b6e9ff07910cb6dbf26dd29dcb94c791e0633 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Mon, 3 Aug 2026 16:23:56 +0200 Subject: [PATCH 20/26] docs(security): document hosted identity rollout order and close #3290 review gaps (#3335) * docs(security): document hosted identity rollout order and close review gaps Address the remaining findings from the 85/100 critical review of #3290: - Document the safe deploy order for the hosted identity hardening (set VERYFRONT_TRUST_FORWARDED_HEADERS=1 first, proxy tier before runtime tier) in src/security/README.md and .env.example, and point the bootstrap fail-closed error at that runbook. - Document the purely topological proxy-to-runtime trust boundary and the mTLS / per-hop-secret follow-up; no warn-only compatibility mode is added because it would let unbound identity select tenant data. - Verify the canonical POST method for control-plane branch-binding signatures instead of the raw request casing. - Dispose the uncached filesystem adapter when initialize() fails so repeated failures cannot leak watchers or sockets, with a regression test. - Coerce non-Error abort reasons in the project environment cache into the typed CACHE_ERROR contract. - Explain why release/content-source/path headers are read outside the identity-trust gate in extractRequestHeaders to prevent drift. The residual host-token fallback in agent-stream.handler.ts flagged by the review was already removed on this branch; existing regression tests pin the request-scoped credential behaviour. Co-Authored-By: Claude Fable 5 * docs(security): use portable rollout punctuation --------- Co-authored-by: Claude Fable 5 Co-authored-by: Koji Wakayama --- .env.example | 5 +++ .../fs/veryfront/proxy-manager.test.ts | 37 +++++++++++++++++ .../adapters/fs/veryfront/proxy-manager.ts | 11 +++++ src/proxy/control-plane-signature.ts | 4 +- src/security/README.md | 40 +++++++++++++++++++ src/server/bootstrap.ts | 5 ++- src/server/project-env/cache.ts | 9 ++++- .../runtime-handler/project-resolution.ts | 7 ++++ 8 files changed, 114 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index 18083c4884..e92c13c27a 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,11 @@ REDIS_URL= # PROXY_MODE=1 # NODE_ENV=production # CHANNEL_DISPATCH_SIGNING_PUBLIC_KEY= +# Required (exactly "1") for hosted proxy mode; the runtime refuses to boot +# without it. Set it only when the process is private behind a sanitising +# edge. Rollout order for existing deployments: set this variable first, then +# deploy the proxy tier, then the runtime tier. See src/security/README.md +# ("Rollout ordering for hosted identity changes"). # VERYFRONT_TRUST_FORWARDED_HEADERS=1 # Host outbound network policy diff --git a/src/platform/adapters/fs/veryfront/proxy-manager.test.ts b/src/platform/adapters/fs/veryfront/proxy-manager.test.ts index 51353daecd..4d9877393c 100644 --- a/src/platform/adapters/fs/veryfront/proxy-manager.test.ts +++ b/src/platform/adapters/fs/veryfront/proxy-manager.test.ts @@ -99,6 +99,43 @@ describe("ProxyFSAdapterManager", () => { manager.dispose(); } }); + + it("disposes the uncached adapter when initialization fails", async () => { + let disposeCalls = 0; + const manager = createManager({ + adapterFactory: (config) => { + const adapter = new VeryfrontFSAdapter(config); + adapter.initialize = () => Promise.reject(new Error("init failed")); + const originalDispose = adapter.dispose.bind(adapter); + adapter.dispose = () => { + disposeCalls++; + originalDispose(); + }; + return adapter; + }, + }); + + try { + await assertRejects( + () => + manager.getAdapter( + "my-project", + "test-token", + undefined, + false, + null, + null, + "main", + ), + Error, + "init failed", + ); + assertEquals(disposeCalls, 1); + assertEquals(manager.hasAdapter("my-project", false, null, "main"), false); + } finally { + manager.dispose(); + } + }); }); describe("exact production source", () => { diff --git a/src/platform/adapters/fs/veryfront/proxy-manager.ts b/src/platform/adapters/fs/veryfront/proxy-manager.ts index db50098e89..4e9d5076f2 100644 --- a/src/platform/adapters/fs/veryfront/proxy-manager.ts +++ b/src/platform/adapters/fs/veryfront/proxy-manager.ts @@ -512,6 +512,17 @@ export class ProxyFSAdapterManager { error: error instanceof Error ? error.message : String(error), }); + // The failed adapter is never cached, so nothing else releases the + // resources it may have allocated before initialize() threw. + try { + adapter.dispose(); + } catch (disposeError) { + logger.debug("Adapter dispose after failed initialization threw", { + cacheKey: diagnosticCacheKey, + error: disposeError instanceof Error ? disposeError.message : String(disposeError), + }); + } + throw error; } finally { projectAdapter.initializing = undefined; diff --git a/src/proxy/control-plane-signature.ts b/src/proxy/control-plane-signature.ts index f9d83c532a..57f9be769e 100644 --- a/src/proxy/control-plane-signature.ts +++ b/src/proxy/control-plane-signature.ts @@ -239,7 +239,9 @@ export async function resolveVerifiedControlPlaneBranchBinding( maxAgeSeconds: MAX_SIGNATURE_AGE_SECONDS, audience: binding.audience, expectedProjectId: binding.expectedProjectId, - requestMethod: req.method, + // The route gate above admits only POST (case-insensitively); verify the + // canonical method the control plane signs rather than the raw casing. + requestMethod: "POST", requestPath: url.pathname, }); if (!verified) { diff --git a/src/security/README.md b/src/security/README.md index a9c1e7450c..d7b3a0935a 100644 --- a/src/security/README.md +++ b/src/security/README.md @@ -97,6 +97,46 @@ endpoint is an error; there is no compatibility fallback to masked management values. Leave both internal credential variables unset when that endpoint is not deployed. +#### Trust boundary and residual risk + +With `VERYFRONT_TRUST_FORWARDED_HEADERS=1` set, identity headers are trusted +purely on network topology: any peer that can reach the runtime origin can +assert project, environment, and branch identity on routes outside the signed +control-plane path. There is no per-request cryptographic binding on the +proxy-to-runtime hop, so the design has no defence in depth if pod network +privacy fails. Operators must keep the runtime origin unreachable except from +the proxy (private service plus network policy). Planned follow-up: an +authenticated proxy-to-runtime hop (mTLS or a per-hop shared secret) so +identity headers are honoured only on an authenticated channel. Agent-run +dispatch is already independent of this hop; its branch and environment +binding comes from the signed control-plane request body. + +#### Rollout ordering for hosted identity changes + +The hosted runtime fails closed at boot without +`VERYFRONT_TRUST_FORWARDED_HEADERS=1`, and hosted agent runs fail closed +without the branch identity that only the current proxy derives from the +signed control-plane body. Upgrading an existing deployment is safe in this +order: + +1. Set `VERYFRONT_TRUST_FORWARDED_HEADERS=1` (and ensure + `CHANNEL_DISPATCH_SIGNING_PUBLIC_KEY` is set) on the runtime environment + while it still runs the previous version. Earlier runtimes already accept + the variable as an explicit operator trust opt-in, so this step is + behaviour-preserving inside the required private topology. +2. Deploy the proxy tier. Earlier runtimes ignore the added + `x-default-branch-name` header, and the `vf-utf8:` branch-name encoding is + applied only to values an earlier proxy could not forward at all. +3. Deploy the runtime tier. A new runtime booted without step 1 crash-loops + intentionally; a new runtime behind an old proxy rejects hosted + preview-branch and non-default-branch agent runs with `PERMISSION_DENIED` + because branch identity must come from the verified control-plane binding. + +Roll back in the reverse order (runtime first, then proxy). The trust variable +can remain set during rollback. There is deliberately no warn-only +compatibility mode for a missing trust declaration or missing branch binding: +either one would let unbound identity select tenant data. + ### Input validation Each standalone body, form, and query parser applies the same snapshotted diff --git a/src/server/bootstrap.ts b/src/server/bootstrap.ts index 17f55427d2..20ceb63d5f 100644 --- a/src/server/bootstrap.ts +++ b/src/server/bootstrap.ts @@ -615,7 +615,10 @@ function validateProductionEnvironment(): void { if (!isProxyTopologyTrusted()) { logger.error( "[Bootstrap:Prod] CRITICAL: proxy mode does not trust its upstream topology. " + - "Set VERYFRONT_TRUST_FORWARDED_HEADERS=1 only when this process is private behind a sanitising edge.", + "Set VERYFRONT_TRUST_FORWARDED_HEADERS=1 only when this process is private behind a sanitising edge. " + + "Existing hosted deployments must set this variable on the runtime environment before rolling out " + + "this version, and must upgrade the proxy tier before (or together with) the runtime tier. " + + "See src/security/README.md, 'Rollout ordering for hosted identity changes'.", ); throw INVALID_ARGUMENT.create({ detail: diff --git a/src/server/project-env/cache.ts b/src/server/project-env/cache.ts index ebd0a892d4..253382b292 100644 --- a/src/server/project-env/cache.ts +++ b/src/server/project-env/cache.ts @@ -127,9 +127,14 @@ function nonNegativeInteger(value: number, name: string): number { return value; } -function abortReason(signal: AbortSignal): unknown { - return signal.reason ?? CACHE_ERROR.create({ +function abortReason(signal: AbortSignal): Error { + const reason = signal.reason; + // Every internal abort passes a typed Error, but the typed error contract + // must hold even for an unexpected non-Error reason. + if (reason instanceof Error) return reason; + return CACHE_ERROR.create({ detail: "Project environment fetch was cancelled", + ...(reason === undefined || reason === null ? {} : { cause: reason }), }); } diff --git a/src/server/runtime-handler/project-resolution.ts b/src/server/runtime-handler/project-resolution.ts index ced4a029ef..427e1d8860 100644 --- a/src/server/runtime-handler/project-resolution.ts +++ b/src/server/runtime-handler/project-resolution.ts @@ -113,6 +113,13 @@ export function extractRequestHeaders( ? req.headers.get("x-environment") ?? url.searchParams.get("x-environment") ?? undefined : undefined; + // `x-release-id`, `x-content-source-id`, and `x-project-path` are read + // without the identity-trust gate deliberately: local single-project and + // eval flows supply them directly, `x-project-path` is re-guarded by the + // adapter factory behind the same proxy trust check, and proxy mode rejects + // every untrusted request outright in createProxyGuard before these values + // can select tenant identity. Do not add tenant-identity headers here + // without gating them on identityHeadersTrusted. return { projectSlug: projectSlugHeader ?? parsedDomain.slug ?? undefined, projectId: identityHeadersTrusted ? req.headers.get("x-project-id") ?? undefined : undefined, From c6d924e3dd4b51f50899fd4dd063407b5226524f Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 16:55:59 +0200 Subject: [PATCH 21/26] Keep proxy packaging from inheriting renderer memory Record the approved dedicated-artifact boundary, the measured OOM evidence, and the release and staging acceptance gates before completing the existing proxy producer. Constraint: Existing universal release artifacts and CLI behavior must remain compatible. Rejected: Increase the proxy memory limit only | preserves the packaging regression. Rejected: Slim the universal CLI entrypoint | risks public runtime and CLI capability loss. Confidence: high Scope-risk: narrow Directive: Keep the 1536 MiB three-start cgroup gate even while staging temporarily uses a 2 GiB limit. Tested: git diff --cached --check; forbidden path and punctuation scan. Not-tested: Implementation and compiled binary behavior are covered by the follow-on plan. --- ...026-08-03-proxy-memory-footprint-design.md | 178 ++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-03-proxy-memory-footprint-design.md diff --git a/docs/superpowers/specs/2026-08-03-proxy-memory-footprint-design.md b/docs/superpowers/specs/2026-08-03-proxy-memory-footprint-design.md new file mode 100644 index 0000000000..8dde4cd79f --- /dev/null +++ b/docs/superpowers/specs/2026-08-03-proxy-memory-footprint-design.md @@ -0,0 +1,178 @@ +# Dedicated proxy memory footprint design + +## Context + +The `veryfront-staging` proxy rollout of framework v0.1.1186 repeatedly exits with code 137 and Kubernetes records `OOMKilled`. The failing container has a 1536 MiB memory limit. Older v0.1.1185 proxy replicas remain healthy, and renderer replicas using the same v0.1.1186 universal binary survive only because they have a 4 GiB limit. + +Artifact and cgroup profiling isolates the regression to the compiled universal framework binary: + +- The Linux x64 universal binary grew from 887,756,293 bytes in v0.1.1185 to 951,154,705 bytes in v0.1.1186. +- A minimal `--version` invocation reproduces the failure before proxy traffic, Redis, Sentry, or renderer work starts. +- Commit `339367a6dd85` added explicit compiled-binary includes for Sharp, Lightning CSS, and PurgeCSS extensions. That change added about 78 MB to an ARM64 binary and about 234 MB to startup peak memory in the profiling environment. +- Reducing V8 old-space or disabling Sentry does not prevent the OOM because the dominant cost is the embedded module and dependency archive in the monolithic binary, not the JavaScript old heap. + +Draft veryfront-code PR #3280 already establishes the correct producer seam: a dedicated proxy entrypoint, a graph-specific Deno lock, explicit proxy provider composition, a proxy release asset, an exact proxy SBOM, and provider smoke tests. The compatible veryfront-server consumer work is already on its main branch: it resolves and verifies a separate proxy asset while retaining a universal-binary fallback. The remaining work is to complete the approved release contract and exercise the dedicated binary in the actual container entrypoint. + +## Goals + +- Preserve every existing universal `veryfront-*` release asset and its public CLI/runtime behavior. +- Publish dedicated `veryfront-proxy-linux-x64` and `veryfront-proxy-linux-arm64` assets. +- Keep proxy behavior shared between the universal CLI and dedicated binary so the optimized artifact does not create a second proxy implementation. +- Prove the x64 dedicated proxy starts successfully three consecutive times under the existing 1536 MiB cgroup limit. +- Make veryfront-server select the dedicated binary only for proxy mode, while retaining the universal binary for renderer mode and as a legacy fallback. +- Restore staging with a temporary 1 GiB request and 2 GiB limit, then right-size from measured startup and steady-state usage. + +## Non-goals + +- Slim or otherwise change the existing universal binary. +- Remove optional extensions, renderer/RSC support, build support, or CLI commands from public release assets. +- Redesign proxy request handling, caching semantics, observability, or shutdown behavior. +- Treat a larger memory limit as the root fix. +- Add a new third-party dependency. + +## Considered approaches + +### Dedicated entrypoint and release assets + +Compile a proxy-only static graph with the providers used by hosted proxy deployments. Publish separate Linux artifacts and let veryfront-server select them in proxy mode. + +This is the selected approach. It creates a clear deployment boundary, preserves the universal binary contract, and removes unrelated renderer, build, document, image, and CSS dependency graphs from proxy startup. + +### Reduced include list with the universal CLI entrypoint + +Compile `cli/main.ts` with fewer explicit includes. This retains the general CLI router, environment bootstrap, esbuild initialization, and broad command graph. Its memory reduction is less reliable and future CLI imports can silently grow the proxy again. + +This approach is rejected because the artifact would still carry code outside the proxy responsibility and lacks a durable dependency boundary. + +### Resource increase only + +Raise the proxy limit until the universal binary starts. This can restore availability, but it leaves the packaging regression intact and makes future universal-binary growth a deployment risk. + +This approach is retained only as temporary rollout headroom, not as the fix. + +## veryfront-code design + +### Entrypoint and lifecycle + +`cli/proxy-main.ts` is the dedicated compiled entrypoint. It statically anchors only the first-party providers required by hosted proxy deployments and then delegates to the shared standalone proxy runtime. + +The shared runtime owns: + +- logger initialization and startup output; +- `PORT` and `HOST` resolution; +- cache, Redis, Sentry, and OpenTelemetry provider activation; +- proxy module loading; +- extension teardown and shutdown integration; +- the compiled-process keep-alive lifecycle. + +The existing `veryfront serve --mode=proxy` path must use the same runtime wrapper. This keeps functional behavior and shutdown semantics aligned across universal and dedicated binaries. + +The dedicated entrypoint accepts the existing server invocation shape. Extra CLI words such as `serve --mode=proxy` are tolerated for rollout compatibility, while `PORT` and `HOST` remain the authoritative hosted configuration. + +### Compile profiles + +`scripts/build/compile-binary.ts` keeps the universal profile as the default. The proxy profile uses: + +- `cli/proxy-main.ts` as the entrypoint; +- only runtime-resolved proxy files as explicit includes; +- `--node-modules-dir=none`; +- a frozen graph-specific `scripts/build/proxy-deno.lock`. + +The graph-specific lock is required because a compiled Deno binary embeds locked npm packages. Reusing the workspace lock would pull unrelated packages such as Sharp and esbuild back into the proxy even when their source modules are not imported. + +CI regenerates the proxy lock and fails if the committed lock differs. The proxy SBOM is generated from that exact lock. + +### Release assets + +The release matrix adds: + +- `veryfront-proxy-linux-x64`, built and smoke-tested on Linux; +- `veryfront-proxy-linux-arm64`, cross-compiled on Linux and released for architecture parity. + +Existing artifact names and contents remain unchanged. The x64 artifact keeps a deterministic size ceiling as a fast packaging-regression guard. The cgroup smoke test is the behavioral memory guard. + +### Memory regression gate + +The Linux x64 proxy binary must cold-start and answer its health endpoint three consecutive times inside a Docker cgroup with a 1536 MiB memory limit. Each attempt must: + +1. start the exact compiled release binary with the memory cache; +2. answer the proxy health endpoint; +3. terminate cleanly; +4. report no container OOM termination. + +The test uses the existing limit rather than the temporary 2 GiB rollout limit so CI retains measurable safety headroom. The existing provider smoke suite continues to cover Redis cache selection, ambient Redis registration, OpenTelemetry, and Sentry separately. + +## veryfront-server design + +### Asset resolution + +The Docker build continues to download and checksum the universal Linux x64 binary. When the release contains `veryfront-proxy-linux-x64`, it also downloads and checksums that artifact. For older releases, `/usr/local/bin/veryfront-proxy` remains a hard link to the universal binary. + +Repository-dispatch builds from a new framework release require the dedicated proxy asset. Pull-request and ordinary compatibility builds may use the legacy fallback so older release fixtures remain testable. + +### Runtime selection + +The container command selects binaries by `VERYFRONT_MODE`: + +- `proxy`: execute `/usr/local/bin/veryfront-proxy serve --mode=proxy ...` when executable, otherwise execute the universal fallback; +- `production` or the existing default: execute `/usr/local/bin/veryfront serve --mode=production ...`. + +The selected runtime must become PID 1 so Kubernetes signals reach it directly. The container entrypoint test must exercise the Dockerfile's real default command for renderer, dedicated proxy, and legacy fallback cases rather than injecting an equivalent shell fragment only in the test. + +### Staging resources + +Set the staging proxy request to 1 GiB and limit to 2 GiB for the first dedicated-binary rollout. The request reflects the known historical steady-state footprint while the limit protects rollout availability during measurement. + +After the dedicated artifact is live, record cold-start peak and steady-state working set from the container cgroup and Kubernetes metrics. Reduce the request or limit only after observed peaks show adequate margin. The intended end state is not to normalize multi-gigabyte proxy memory. + +## Error handling and compatibility + +- A missing or malformed proxy checksum fails release-driven server builds instead of silently deploying an unverified binary. +- Legacy releases may fall back to the universal binary only in compatibility paths that explicitly allow fallback. +- Invalid cache modes fail with a direct configuration error. +- Provider activation failures run registered teardown and preserve the original startup failure. +- Existing proxy environment variables, routes, health endpoint, observability behavior, and signal semantics remain unchanged. +- Existing universal release assets remain byte-for-byte governed by the universal build profile. + +## Verification + +### veryfront-code + +- Focused tests for compile-profile arguments, lock freshness, excluded dependency classes, proxy runtime lifecycle, and provider composition. +- Bash syntax checks for smoke scripts. +- Proxy x64 compile and provider smoke suite. +- Three cold starts under a 1536 MiB Docker memory limit. +- Proxy ARM64 cross-compile. +- Format, lint, typecheck, and the repository's standard test gates. +- Release workflow assertions proving both proxy assets are uploaded and the proxy SBOM uses the proxy lock. + +### veryfront-server + +- Asset resolver tests for dedicated, fallback, missing, and malformed digest cases. +- Docker build test using a dedicated proxy fixture. +- Default-entrypoint tests for renderer, dedicated proxy, and legacy fallback, including PID 1 and SIGTERM behavior. +- Helm/schema validation for the 1 GiB request and 2 GiB limit. + +### Staging rollout + +- Verify the new proxy pod uses the dedicated artifact image. +- Verify readiness and health responses. +- Verify zero `OOMKilled` events and zero restarts through repeated cold starts or a rollout observation window. +- Record peak and steady-state memory before changing the temporary resource envelope. + +## Rollout order + +1. Complete and merge the dedicated proxy producer in veryfront-code. +2. Publish a framework release containing both proxy assets and their digests. +3. Let the existing veryfront-server release dispatch resolve the x64 proxy asset and build the server image. +4. Land the real runtime selector and staging resource values in veryfront-server before deploying that image. +5. Deploy staging and verify health, restarts, OOM events, and memory measurements. +6. Keep the legacy fallback until all supported server release paths require the dedicated artifact. + +## Risks + +- A dynamically loaded provider can be omitted from the proxy graph. Static provider anchors, the frozen proxy lock, provider smoke tests, and exact SBOM make this failure visible. +- Dedicated and universal proxy behavior can drift. A shared runtime wrapper and shared extension composition keep the behavioral code path singular. +- A size-only gate can miss high runtime allocation. The cgroup cold-start test covers the actual failure mode. +- Cross-compiled ARM64 cannot be executed on the x64 CI runner. It is compile-validated there; x64 remains the deployed and memory-gated server artifact. +- Temporary 2 GiB headroom can become permanent without measurement. The staging acceptance report must include observed peak and steady-state memory and a follow-up sizing recommendation. From 81d739ebf622333f43510dd69899ce0d3b3ddf43 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 17:00:51 +0200 Subject: [PATCH 22/26] Turn the proxy memory fix into verifiable slices Define the exact red-green sequence for reconciling the existing producer, publishing both Linux architectures, enforcing the cgroup ceiling, and completing the server runtime and staging resource contracts. Constraint: Implementation must remain isolated across the veryfront-code and veryfront-server worktrees. Rejected: Reimplement the draft proxy producer | duplicates a green and reviewed change set. Confidence: high Scope-risk: narrow Directive: Do not claim the memory regression fixed without three successful 1536 MiB cgroup starts. Tested: git diff --cached --check; design-to-plan seam and acceptance review. Not-tested: Commands in the plan execute in the following implementation commits. --- .../2026-08-03-proxy-memory-footprint.md | 195 ++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-03-proxy-memory-footprint.md diff --git a/docs/superpowers/plans/2026-08-03-proxy-memory-footprint.md b/docs/superpowers/plans/2026-08-03-proxy-memory-footprint.md new file mode 100644 index 0000000000..afc56be4ec --- /dev/null +++ b/docs/superpowers/plans/2026-08-03-proxy-memory-footprint.md @@ -0,0 +1,195 @@ +# Dedicated Proxy Memory Footprint Implementation Plan + +> **For implementing agents:** Execute each task as a vertical red-green slice. Do not edit either repository's main checkout. + +**Goal:** Ship a dedicated Linux proxy artifact that preserves the universal Veryfront binaries, starts reliably below the existing 1536 MiB cap, and is selected by the staging server image with temporary rollout headroom. + +**Architecture:** Reconcile the tested producer from draft veryfront-code PR #3280 onto current main, then add the missing ARM64 release leg and cgroup memory gate. In veryfront-server, complete the already-landed asset consumer by selecting the proxy executable in proxy mode, retain the universal fallback, and encode the staging resource envelope. + +**Tech stack:** Deno, TypeScript, Bash, GitHub Actions, Docker cgroups, Helm, Kubernetes. + +## Confirmed test seams + +- Build/release seam: `scripts/build/compile-binary.test.ts` observes compile arguments, release asset names, lock/SBOM contracts, and CI gate wiring. +- Runtime seam: the exact compiled Linux x64 proxy answers `/_proxy/health` inside a 1536 MiB Docker cgroup. +- Container seam: the built veryfront-server image selects the correct PID 1 executable from `VERYFRONT_MODE` and preserves SIGTERM delivery. +- Configuration seam: a rendered staging Helm chart contains the approved proxy memory request and limit. + +## Constraints + +- Work only in isolated worktrees. +- Preserve all existing universal binary names, contents, commands, and public behavior. +- Add no dependencies. +- Reuse draft PR #3280 instead of rebuilding the proxy lifecycle. +- Keep each new behavior change red before green. +- Use Lore commit messages and record exact verification in `Tested:` trailers. +- Do not publish, merge, or mutate staging during local implementation verification. + +## Task 1: Reconcile the existing proxy producer + +**Files imported from PR #3280:** + +- `.github/workflows/cicd.yml` +- `cli/commands/serve/command.ts` +- `cli/commands/serve/proxy-extension-composition.ts` +- `cli/commands/serve/proxy-extension-composition.test.ts` +- `cli/commands/serve/proxy-runtime.ts` +- `cli/commands/serve/proxy-runtime.test.ts` +- `cli/proxy-main.ts` +- `deno.json` +- `scripts/build/build-all.js` +- `scripts/build/compile-binary.ts` +- `scripts/build/compile-binary.test.ts` +- `scripts/build/generate-sbom.ts` +- `scripts/build/generate-sbom.test.ts` +- `scripts/build/proxy-deno.lock` +- `scripts/build/smoke-proxy-binary.sh` + +- [ ] Record `origin/main`, `origin/codex/proxy-specific-binary`, and worktree status. +- [ ] Merge the draft producer into this implementation branch. +- [ ] Resolve conflicts by retaining current-main BDD/import conventions plus the proxy profile, graph lock, shared runtime, exact SBOM, and current test task inventory. +- [ ] Run the imported producer tests: + +```bash +deno test --config=scripts/test.deno.json --no-check --allow-read --allow-write --allow-run scripts/build/compile-binary.test.ts scripts/build/generate-sbom.test.ts +deno test --no-check --allow-all cli/commands/serve/proxy-runtime.test.ts cli/commands/serve/proxy-extension-composition.test.ts +bash -n scripts/build/smoke-proxy-binary.sh +git diff --check origin/main...HEAD +``` + +- [ ] Commit the reconciliation with a Lore merge commit. + +## Task 2: Publish both Linux proxy architectures + +**Files:** `scripts/build/compile-binary.test.ts`, `.github/workflows/cicd.yml` + +- [ ] Add a failing release-contract assertion requiring both `veryfront-proxy-linux-x64` and `veryfront-proxy-linux-arm64`. +- [ ] Assert the ARM64 asset uses `aarch64-unknown-linux-gnu`, `cli/proxy-main.ts`, and the proxy profile. +- [ ] Run RED: + +```bash +deno test --config=scripts/test.deno.json --no-check --allow-read --allow-write --allow-run scripts/build/compile-binary.test.ts +``` + +Expected: failure because only the x64 proxy asset exists. + +- [ ] Add the ARM64 proxy matrix leg. Keep executable smoke tests restricted to native x64. +- [ ] Run GREEN, `deno fmt --check scripts/build/compile-binary.test.ts`, and `git diff --check`. +- [ ] Commit test and workflow together with a Lore message. + +## Task 3: Gate cold-start memory under 1536 MiB + +**Files:** `scripts/build/compile-binary.test.ts`, new `scripts/build/smoke-proxy-memory.sh`, `.github/workflows/cicd.yml` + +- [ ] Add a failing contract requiring the memory script, PR-job invocation, x64 release invocation, a 1536 MiB Docker limit, and three attempts. +- [ ] Run RED with the focused compile-binary test. +- [ ] Implement `smoke-proxy-memory.sh`. For each attempt it must: + + - run the exact mounted binary in `debian:trixie-slim` with `--memory=1536m`; + - use `CACHE_TYPE=memory` and a published loopback port; + - poll `/_proxy/health` with bounded host `curl` calls; + - capture logs on failure; + - inspect `.State.OOMKilled` before removal; + - stop cleanly and remove its exact container through a trap. + +- [ ] Wire the script after provider smoke in the PR job and the x64 main release leg. Do not execute ARM64 on x64. +- [ ] Run static GREEN: + +```bash +deno test --config=scripts/test.deno.json --no-check --allow-read --allow-write --allow-run scripts/build/compile-binary.test.ts +bash -n scripts/build/smoke-proxy-memory.sh +git diff --check +``` + +- [ ] Build and run the behavioral gates: + +```bash +deno task build:prepare +deno task build:proxy-lock +git diff --exit-code -- scripts/build/proxy-deno.lock +deno run -A scripts/build/compile-binary.ts --entrypoint cli/proxy-main.ts --profile proxy --target x86_64-unknown-linux-gnu --output /tmp/veryfront-proxy-linux-x64 +bash scripts/build/smoke-proxy-binary.sh /tmp/veryfront-proxy-linux-x64 +bash scripts/build/smoke-proxy-memory.sh /tmp/veryfront-proxy-linux-x64 +deno run -A scripts/build/compile-binary.ts --entrypoint cli/proxy-main.ts --profile proxy --target aarch64-unknown-linux-gnu --output /tmp/veryfront-proxy-linux-arm64 +``` + +- [ ] Commit the memory gate with binary sizes and smoke outcomes in `Tested:`. + +## Task 4: Complete the veryfront-server runtime selector + +**Worktree:** `/private/tmp/veryfront-server-proxy-memory-footprint`, branch `fix/proxy-memory-footprint`, based on server `origin/main`. + +**Files:** `scripts/test-container-entrypoint.sh`, `Dockerfile` + +- [ ] Create the isolated server worktree and confirm it is clean. +- [ ] Change the container test first so production, dedicated proxy, and unavailable-proxy fallback all use the image's real default command. +- [ ] Preserve PID 1 and SIGTERM marker assertions for every case. Remove the test-only selector fragment. +- [ ] Run RED against `veryfront-server:entrypoint-test`. Expected: proxy mode still selects the universal binary. +- [ ] Update the Docker command: + + - proxy mode executes `/usr/local/bin/veryfront-proxy` when executable; + - proxy mode falls back to `/usr/local/bin/veryfront` otherwise; + - production/default mode always executes `/usr/local/bin/veryfront`; + - every path uses `exec` and preserves the existing arguments. + +- [ ] Rebuild the fixture image and run GREEN: + +```bash +bash -n scripts/test-container-entrypoint.sh +./scripts/test-container-entrypoint.sh veryfront-server:entrypoint-test +``` + +- [ ] Commit test and Dockerfile together with a Lore message. + +## Task 5: Encode staging resource headroom + +**Files:** new `scripts/test-staging-proxy-resources.sh`, `.github/workflows/cicd.yml`, `chart/values-staging.yaml` + +- [ ] Add a rendered-chart test requiring proxy memory request `1Gi` and limit `2Gi`. +- [ ] Add it to the validate job after Helm setup. +- [ ] Run RED: + +```bash +bash -n scripts/test-staging-proxy-resources.sh +./scripts/test-staging-proxy-resources.sh +``` + +Expected: current staging values render `768Mi` and `1536Mi`. + +- [ ] Change only the staging proxy memory request and limit to `1Gi` and `2Gi`. +- [ ] Run GREEN: + +```bash +helm lint ./chart +./scripts/test-staging-proxy-resources.sh +git diff --check +``` + +- [ ] Commit the test, CI invocation, and values together. Record that final right-sizing depends on observed staging peaks. + +## Task 6: Final verification + +Run in veryfront-code: + +```bash +deno test --config=scripts/test.deno.json --no-check --allow-read --allow-write --allow-run scripts/build/compile-binary.test.ts scripts/build/generate-sbom.test.ts +deno test --no-check --allow-all cli/commands/serve/proxy-runtime.test.ts cli/commands/serve/proxy-extension-composition.test.ts +deno fmt --check cli/proxy-main.ts cli/commands/serve scripts/build/compile-binary.ts scripts/build/compile-binary.test.ts scripts/build/generate-sbom.ts scripts/build/generate-sbom.test.ts +deno lint cli/proxy-main.ts cli/commands/serve/proxy-runtime.ts cli/commands/serve/proxy-runtime.test.ts cli/commands/serve/proxy-extension-composition.ts cli/commands/serve/proxy-extension-composition.test.ts +deno check cli/proxy-main.ts cli/commands/serve/proxy-runtime.ts cli/commands/serve/proxy-runtime.test.ts +bash -n scripts/build/smoke-proxy-binary.sh scripts/build/smoke-proxy-memory.sh +git diff --check origin/main...HEAD +``` + +Run in veryfront-server: + +```bash +bash -n scripts/test-container-entrypoint.sh scripts/test-resolve-framework-assets.sh scripts/test-staging-proxy-resources.sh +./scripts/test-resolve-framework-assets.sh +./scripts/test-staging-proxy-resources.sh +helm lint ./chart +./scripts/test-container-entrypoint.sh veryfront-server:entrypoint-test +git diff --check origin/main...HEAD +``` + +Report both branches and commits, x64 and ARM64 binary sizes, all three cgroup attempts with `OOMKilled=false`, container PID 1/SIGTERM results, and rendered staging resources. State explicitly that merge, release, image publication, and staging rollout remain follow-up actions. From 162b8ffd4fac785ddac9c0dd205b0268bad6879f Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 17:02:35 +0200 Subject: [PATCH 23/26] Keep proxy releases available on both Linux architectures Extend the dedicated proxy matrix and its release contract to ARM64 while retaining executable smoke tests on native x64. Constraint: Existing universal artifacts remain unchanged and ARM64 is cross-compiled on the Linux runner. Confidence: high Scope-risk: narrow Directive: Keep runtime smoke conditions scoped to veryfront-proxy-linux-x64 unless an ARM64 runner is introduced. Tested: RED failed on missing veryfront-proxy-linux-arm64; GREEN passed 11 compile-binary tests, Deno formatting, and diff checks. --- .github/workflows/cicd.yml | 5 +++++ scripts/build/compile-binary.test.ts | 24 +++++++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 781d1d6dce..4301896e5f 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -461,6 +461,11 @@ jobs: name: veryfront-proxy-linux-x64 entrypoint: cli/proxy-main.ts profile: proxy + - os: ubuntu-latest + target: aarch64-unknown-linux-gnu + name: veryfront-proxy-linux-arm64 + entrypoint: cli/proxy-main.ts + profile: proxy - os: windows-2022 target: x86_64-pc-windows-msvc name: veryfront-windows-x64.exe diff --git a/scripts/build/compile-binary.test.ts b/scripts/build/compile-binary.test.ts index 877d46e56b..3a6210afe8 100644 --- a/scripts/build/compile-binary.test.ts +++ b/scripts/build/compile-binary.test.ts @@ -200,6 +200,28 @@ it("proxy release verifies lock freshness and publishes an exact SBOM", async () false, "proxy lock refresh must use Deno's default mutable lock mode", ); + + for ( + const { artifact, target } of [ + { + artifact: "veryfront-proxy-linux-x64", + target: "x86_64-unknown-linux-gnu", + }, + { + artifact: "veryfront-proxy-linux-arm64", + target: "aarch64-unknown-linux-gnu", + }, + ] + ) { + assertEquals( + new RegExp( + String + .raw`target: ${target}[\s\S]*?name: ${artifact}[\s\S]*?entrypoint: cli/proxy-main\.ts[\s\S]*?profile: proxy`, + ).test(workflow), + true, + `release matrix must publish ${artifact} from the proxy profile`, + ); + } }); it("compiled proxy smoke covers cache and observability providers", async () => { @@ -248,7 +270,7 @@ it("compiled proxy smoke covers cache and observability providers", async () => "compiled proxy smoke must enforce a defensible artifact size ceiling", ); assertEquals( - smoke.includes('${TMPDIR:-/tmp}/veryfront-proxy-smoke.XXXXXX'), + smoke.includes("${TMPDIR:-/tmp}/veryfront-proxy-smoke.XXXXXX"), true, "smoke temp directory must use a portable mktemp template", ); From 52c6c6cdbd8d59d2a1c8f29858017d13d9acbafc Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 17:04:22 +0200 Subject: [PATCH 24/26] Keep proxy rollout guidance actionable The prior removal marker referenced an unrelated issue, which could send future maintainers to the wrong operational context. Keep the rollout condition explicit without a false tracker. Constraint: The compatibility shim must remain until hosted charts stop emitting CACHE_TYPE=redis Confidence: high Scope-risk: narrow Tested: Deno 2.7.7 format check and git diff check --- cli/commands/serve/proxy-extension-composition.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/commands/serve/proxy-extension-composition.ts b/cli/commands/serve/proxy-extension-composition.ts index c970a260d6..20d0283057 100644 --- a/cli/commands/serve/proxy-extension-composition.ts +++ b/cli/commands/serve/proxy-extension-composition.ts @@ -102,8 +102,8 @@ async function activateStandaloneProxyExtensionsInternal(): Promise Date: Mon, 3 Aug 2026 17:08:44 +0200 Subject: [PATCH 25/26] Reject proxy artifacts that recreate the staging OOM Run the exact Linux x64 proxy release artifact three times in a 1536 MiB Docker cgroup and fail on missing health, OOM termination, or unclean shutdown. Keep provider smoke native to x64 and support explicit Docker platforms for cross-host verification. Constraint: CI must test below the temporary 2 GiB staging rollout limit. Rejected: Artifact size gate alone | cannot detect high runtime allocation. Confidence: high Scope-risk: narrow Directive: Do not raise the 1536 MiB gate to match staging headroom. Tested: RED failed on missing memory gate and missing platform override; GREEN passed 12 build-contract tests and Bash checks. x64 177719560 bytes and ARM64 171256016 bytes compiled. x64 and ARM64 each passed three 1536m starts with OOMKilled=false; native provider smoke passed. --- .github/workflows/cicd.yml | 9 ++- scripts/build/compile-binary.test.ts | 38 ++++++++++ scripts/build/smoke-proxy-memory.sh | 100 +++++++++++++++++++++++++++ 3 files changed, 146 insertions(+), 1 deletion(-) create mode 100755 scripts/build/smoke-proxy-memory.sh diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 4301896e5f..9d5dce58c4 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -427,6 +427,8 @@ jobs: --output veryfront-proxy-linux-x64 - name: Smoke test proxy binary run: bash scripts/build/smoke-proxy-binary.sh ./veryfront-proxy-linux-x64 + - name: Enforce proxy memory limit + run: bash scripts/build/smoke-proxy-memory.sh ./veryfront-proxy-linux-x64 build-binaries: if: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && github.ref == 'refs/heads/main' }} @@ -494,10 +496,15 @@ jobs: --output ${{ matrix.name }} - name: Smoke test proxy binary - if: matrix.profile == 'proxy' + if: matrix.name == 'veryfront-proxy-linux-x64' shell: bash run: bash scripts/build/smoke-proxy-binary.sh ./${{ matrix.name }} + - name: Enforce proxy memory limit + if: matrix.name == 'veryfront-proxy-linux-x64' + shell: bash + run: bash scripts/build/smoke-proxy-memory.sh ./veryfront-proxy-linux-x64 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ${{ matrix.name }} diff --git a/scripts/build/compile-binary.test.ts b/scripts/build/compile-binary.test.ts index 3a6210afe8..66fea77bcd 100644 --- a/scripts/build/compile-binary.test.ts +++ b/scripts/build/compile-binary.test.ts @@ -276,6 +276,44 @@ it("compiled proxy smoke covers cache and observability providers", async () => ); }); +it("proxy release enforces the cold-start cgroup budget", async () => { + const workflow = await Deno.readTextFile(".github/workflows/cicd.yml"); + const invocation = + "bash scripts/build/smoke-proxy-memory.sh ./veryfront-proxy-linux-x64"; + + assertEquals( + workflow.split(invocation).length - 1, + 2, + "pull-request and main-release jobs must both enforce proxy memory", + ); + assertEquals( + workflow.split("if: matrix.name == 'veryfront-proxy-linux-x64'").length - + 1, + 2, + "provider and memory smoke must execute only for the native x64 proxy", + ); + + const smoke = await Deno.readTextFile( + "scripts/build/smoke-proxy-memory.sh", + ); + for ( + const contract of [ + 'memory_limit="${PROXY_MEMORY_LIMIT:-1536m}"', + 'attempts="${PROXY_MEMORY_ATTEMPTS:-3}"', + 'container_platform="${PROXY_MEMORY_PLATFORM:-}"', + '--memory "$memory_limit"', + "{{.State.OOMKilled}}", + '"/_proxy/health"', + ] + ) { + assertEquals( + smoke.includes(contract), + true, + `missing proxy memory contract ${contract}`, + ); + } +}); + it("proxy binary smoke runs only for same-repository pull requests", async () => { const workflow = await Deno.readTextFile(".github/workflows/cicd.yml"); diff --git a/scripts/build/smoke-proxy-memory.sh b/scripts/build/smoke-proxy-memory.sh new file mode 100755 index 0000000000..4eb0267797 --- /dev/null +++ b/scripts/build/smoke-proxy-memory.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +set -euo pipefail + +binary="${1:?usage: smoke-proxy-memory.sh [base-port]}" +base_port="${2:-18180}" +memory_limit="${PROXY_MEMORY_LIMIT:-1536m}" +attempts="${PROXY_MEMORY_ATTEMPTS:-3}" +container_image="${PROXY_MEMORY_IMAGE:-debian:trixie-slim}" +container_platform="${PROXY_MEMORY_PLATFORM:-}" +health_path="/_proxy/health" +container="" +platform_args=() + +if [ -n "$container_platform" ]; then + platform_args=(--platform "$container_platform") +fi + +if [ ! -f "$binary" ]; then + echo "proxy binary not found: $binary" >&2 + exit 1 +fi + +if ! [[ "$attempts" =~ ^[1-9][0-9]*$ ]]; then + echo "PROXY_MEMORY_ATTEMPTS must be a positive integer" >&2 + exit 1 +fi + +binary_dir="$(cd "$(dirname "$binary")" && pwd)" +binary_name="$(basename "$binary")" + +cleanup() { + if [ -n "$container" ]; then + docker rm -f "$container" >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +for ((attempt = 1; attempt <= attempts; attempt++)); do + port=$((base_port + attempt - 1)) + container="veryfront-proxy-memory-${RANDOM}-$$-${attempt}" + + docker run --detach \ + "${platform_args[@]}" \ + --name "$container" \ + --memory "$memory_limit" \ + --publish "127.0.0.1:${port}:${port}" \ + --volume "${binary_dir}/${binary_name}:/usr/local/bin/veryfront-proxy:ro" \ + --env CACHE_TYPE=memory \ + --env HOME=/tmp \ + --env HOST=0.0.0.0 \ + --env NODE_ENV=development \ + --env PORT="$port" \ + --entrypoint /usr/local/bin/veryfront-proxy \ + "$container_image" >/dev/null + + ready=false + for ((probe = 1; probe <= 60; probe++)); do + if curl --connect-timeout 1 --max-time 2 -fsS \ + "http://127.0.0.1:${port}${health_path}" 2>/dev/null \ + | grep -Fq '"status":"ok"'; then + ready=true + break + fi + + if [ "$(docker inspect --format '{{.State.Running}}' "$container")" != "true" ]; then + break + fi + sleep 1 + done + + read -r oom_killed exit_code < <( + docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}}' "$container" + ) + + if [ "$ready" != "true" ]; then + docker logs "$container" >&2 || true + echo "proxy memory smoke attempt ${attempt} failed before health; OOMKilled=${oom_killed}, exit=${exit_code}" >&2 + exit 1 + fi + + if [ "$oom_killed" != "false" ]; then + docker logs "$container" >&2 || true + echo "proxy memory smoke attempt ${attempt} was OOM killed" >&2 + exit 1 + fi + + docker stop --time 5 "$container" >/dev/null + read -r oom_killed exit_code < <( + docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}}' "$container" + ) + if [ "$oom_killed" != "false" ] || [ "$exit_code" != "0" ]; then + docker logs "$container" >&2 || true + echo "proxy memory smoke attempt ${attempt} did not stop cleanly; OOMKilled=${oom_killed}, exit=${exit_code}" >&2 + exit 1 + fi + + docker rm "$container" >/dev/null + container="" + echo "proxy memory smoke attempt ${attempt}/${attempts} passed (${memory_limit}, OOMKilled=false)" +done From 4d9596ad72a82c2136a05dcbb2ba3cc49aae2914 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Mon, 3 Aug 2026 17:23:20 +0200 Subject: [PATCH 26/26] Make the proxy profile safe without caller conventions The proxy profile now owns its dedicated entrypoint, provider smoke isolates Sentry from OpenTelemetry, and the new CLI path follows repository output and test conventions. This closes independent standards and spec review findings without changing the universal profile. Constraint: Universal compilation must continue to default to cli/main.ts. Rejected: Depend on every CI caller passing --entrypoint | leaves local and future proxy builds able to produce the wrong graph. Confidence: high Scope-risk: narrow Reversibility: clean Tested: 17 build and SBOM tests, 4 proxy runtime steps, formatting, shell syntax, and five provider startup cases on the compiled macOS ARM64 proxy. --- cli/commands/serve/proxy-runtime.test.ts | 18 ++++++++++++++++++ cli/commands/serve/proxy-runtime.ts | 6 ++++-- cli/proxy-main.ts | 3 +-- .../2026-08-03-proxy-memory-footprint.md | 11 ++++++----- scripts/build/compile-binary.test.ts | 19 ++++++++++++++++--- scripts/build/compile-binary.ts | 14 ++++++++------ scripts/build/generate-sbom.test.ts | 2 +- scripts/build/smoke-proxy-binary.sh | 7 +++++-- 8 files changed, 59 insertions(+), 21 deletions(-) diff --git a/cli/commands/serve/proxy-runtime.test.ts b/cli/commands/serve/proxy-runtime.test.ts index 410c081761..2ef1008dc7 100644 --- a/cli/commands/serve/proxy-runtime.test.ts +++ b/cli/commands/serve/proxy-runtime.test.ts @@ -36,6 +36,24 @@ describe("standalone proxy runtime", () => { assertEquals(observed, ["127.0.0.2:4321"]); }); + it("preserves the existing proxy CLI header format", async () => { + const originalLog = console.log; + const lines: string[] = []; + console.log = (...values: unknown[]) => lines.push(values.join(" ")); + try { + await runStandaloneProxyRuntime({}, { + activateExtensions: async () => null, + registerTeardown: async () => async () => undefined, + loadProxy: async () => undefined, + keepAlive: async () => undefined, + }); + } finally { + console.log = originalLog; + } + + assertEquals(lines[0]?.startsWith("Veryfront "), true); + }); + it("tears down activated extensions when proxy startup fails", async () => { let teardownCount = 0; diff --git a/cli/commands/serve/proxy-runtime.ts b/cli/commands/serve/proxy-runtime.ts index 1e643869cd..7781f09aab 100644 --- a/cli/commands/serve/proxy-runtime.ts +++ b/cli/commands/serve/proxy-runtime.ts @@ -1,8 +1,8 @@ +import { getEnv, setEnv } from "veryfront/platform/env"; +import { cliLogger } from "veryfront/utils/logger"; import denoConfig from "../../../deno.json" with { type: "json" }; import { isJsonMode } from "../../shared/json-output.ts"; import { bold, brand, dim } from "../../ui/colors.ts"; -import { getEnv, setEnv } from "veryfront/platform/env"; -import { cliLogger } from "veryfront/utils/logger"; import { activateStandaloneProxyExtensions, registerStandaloneProxyExtensionTeardown, @@ -32,6 +32,8 @@ export function createStandaloneProxyKeepAlivePromise(): Promise { function showProxyHeader(): void { if (isJsonMode()) return; const version = typeof denoConfig.version === "string" ? denoConfig.version : "0.0.0"; + // Preserve the existing `veryfront serve --mode=proxy` output while the + // dedicated binary shares this runtime with the universal CLI. console.log(`${bold(brand("Veryfront"))} ${dim(`(v${version})`)}`); console.log(); } diff --git a/cli/proxy-main.ts b/cli/proxy-main.ts index f4aedb52fc..1b814e8893 100644 --- a/cli/proxy-main.ts +++ b/cli/proxy-main.ts @@ -1,5 +1,6 @@ /** Dedicated compiled proxy entrypoint. Optional CLI arguments are ignored. */ +import { setLoggerPreset } from "veryfront/utils/logger"; import "./commands/serve/proxy-runtime.ts"; // Keep the proxy's runtime-selected providers in the compile graph. Using @@ -11,8 +12,6 @@ import "../extensions/ext-observability-opentelemetry/src/index.ts"; import "../extensions/ext-observability-sentry/src/index.ts"; import "../extensions/ext-redis/src/index.ts"; -import { setLoggerPreset } from "veryfront/utils/logger"; - setLoggerPreset("cli"); const { runStandaloneProxyRuntime } = await import( diff --git a/docs/superpowers/plans/2026-08-03-proxy-memory-footprint.md b/docs/superpowers/plans/2026-08-03-proxy-memory-footprint.md index afc56be4ec..c5711f777f 100644 --- a/docs/superpowers/plans/2026-08-03-proxy-memory-footprint.md +++ b/docs/superpowers/plans/2026-08-03-proxy-memory-footprint.md @@ -107,17 +107,18 @@ git diff --check deno task build:prepare deno task build:proxy-lock git diff --exit-code -- scripts/build/proxy-deno.lock -deno run -A scripts/build/compile-binary.ts --entrypoint cli/proxy-main.ts --profile proxy --target x86_64-unknown-linux-gnu --output /tmp/veryfront-proxy-linux-x64 -bash scripts/build/smoke-proxy-binary.sh /tmp/veryfront-proxy-linux-x64 -bash scripts/build/smoke-proxy-memory.sh /tmp/veryfront-proxy-linux-x64 -deno run -A scripts/build/compile-binary.ts --entrypoint cli/proxy-main.ts --profile proxy --target aarch64-unknown-linux-gnu --output /tmp/veryfront-proxy-linux-arm64 +proxy_artifact_dir="$(mktemp -d)" +deno run -A scripts/build/compile-binary.ts --profile proxy --target x86_64-unknown-linux-gnu --output "${proxy_artifact_dir}/veryfront-proxy-linux-x64" +bash scripts/build/smoke-proxy-binary.sh "${proxy_artifact_dir}/veryfront-proxy-linux-x64" +bash scripts/build/smoke-proxy-memory.sh "${proxy_artifact_dir}/veryfront-proxy-linux-x64" +deno run -A scripts/build/compile-binary.ts --profile proxy --target aarch64-unknown-linux-gnu --output "${proxy_artifact_dir}/veryfront-proxy-linux-arm64" ``` - [ ] Commit the memory gate with binary sizes and smoke outcomes in `Tested:`. ## Task 4: Complete the veryfront-server runtime selector -**Worktree:** `/private/tmp/veryfront-server-proxy-memory-footprint`, branch `fix/proxy-memory-footprint`, based on server `origin/main`. +**Worktree:** isolated server branch `fix/proxy-memory-footprint`, based on server `origin/main`. **Files:** `scripts/test-container-entrypoint.sh`, `Dockerfile` diff --git a/scripts/build/compile-binary.test.ts b/scripts/build/compile-binary.test.ts index 66fea77bcd..1058230ba3 100644 --- a/scripts/build/compile-binary.test.ts +++ b/scripts/build/compile-binary.test.ts @@ -12,7 +12,7 @@ it("compiled CLI embeds the default Node WebSocket extension for HMR", () => { const args = createCompileArgs({ entrypoint: "cli/main.ts", extraIncludes: [], - output: "/tmp/veryfront", + output: "veryfront", }); assertEquals( @@ -107,7 +107,7 @@ it("proxy binary embeds only the runtime-resolved proxy entrypoint", async () => const args = createCompileArgs({ entrypoint: "cli/proxy-main.ts", extraIncludes: [], - output: "/tmp/veryfront-proxy", + output: "veryfront-proxy", profile: "proxy", }); @@ -224,6 +224,16 @@ it("proxy release verifies lock freshness and publishes an exact SBOM", async () } }); +it("proxy profile defaults to the dedicated proxy entrypoint", () => { + const args = createCompileArgs({ + extraIncludes: [], + output: "veryfront-proxy", + profile: "proxy", + }); + + assertEquals(args.at(-1), "cli/proxy-main.ts"); +}); + it("compiled proxy smoke covers cache and observability providers", async () => { const smoke = await Deno.readTextFile("scripts/build/smoke-proxy-binary.sh"); @@ -237,6 +247,9 @@ it("compiled proxy smoke covers cache and observability providers", async () => "[ext-redis] RedisRuntimeProvider registered", "OTEL_TRACES_EXPORTER=otlp", "[otel] Initialized", + "run_smoke otel", + "run_smoke sentry", + "VERYFRONT_ERROR_REPORTER=sentry", "SENTRY_DSN=https://public@example.com/1", ] ) { @@ -329,7 +342,7 @@ it("full binary remains the default compile profile", () => { const args = createCompileArgs({ entrypoint: "cli/main.ts", extraIncludes: [], - output: "/tmp/veryfront", + output: "veryfront", }); assertEquals(args.includes("extensions/ext-image-sharp/src/index.ts"), true); diff --git a/scripts/build/compile-binary.ts b/scripts/build/compile-binary.ts index f9f5de77be..ba57874005 100644 --- a/scripts/build/compile-binary.ts +++ b/scripts/build/compile-binary.ts @@ -59,7 +59,7 @@ export const PROXY_INCLUDES = [ export type CompileBinaryProfile = "full" | "proxy"; interface CompileBinaryOptions { - entrypoint: string; + entrypoint?: string; extraIncludes: string[]; output: string; profile?: CompileBinaryProfile; @@ -71,6 +71,7 @@ function includesForProfile(profile: CompileBinaryProfile): string[] { } export function createCompileArgs(options: CompileBinaryOptions): string[] { + const profile = options.profile ?? "full"; const args = [ "compile", "--allow-all", @@ -78,7 +79,7 @@ export function createCompileArgs(options: CompileBinaryOptions): string[] { "--unstable-worker-options", ]; - if (options.profile === "proxy") { + if (profile === "proxy") { // The workspace lock contains every framework dependency, and Deno embeds // every locked npm package in a compiled binary. Use the graph-specific // frozen lock so the proxy carries only its statically anchored providers. @@ -92,7 +93,7 @@ export function createCompileArgs(options: CompileBinaryOptions): string[] { } for (const include of [ - ...includesForProfile(options.profile ?? "full"), + ...includesForProfile(profile), ...options.extraIncludes, ]) { args.push("--include", include); @@ -102,7 +103,9 @@ export function createCompileArgs(options: CompileBinaryOptions): string[] { args.push("--target", options.target); } - args.push("--output", options.output, options.entrypoint); + const entrypoint = options.entrypoint ?? + (profile === "proxy" ? "cli/proxy-main.ts" : "cli/main.ts"); + args.push("--output", options.output, entrypoint); return args; } @@ -127,7 +130,6 @@ if (import.meta.main) { const args = parseArgs(Deno.args, { string: ["entrypoint", "include", "output", "profile", "target"], collect: ["include"], - default: { entrypoint: "cli/main.ts" }, }); if (typeof args.output !== "string" || !args.output) { @@ -142,7 +144,7 @@ if (import.meta.main) { try { await compileBinary({ - entrypoint: String(args.entrypoint), + entrypoint: typeof args.entrypoint === "string" ? args.entrypoint : undefined, extraIncludes, output: normalizeOutputPath(args.output), profile, diff --git a/scripts/build/generate-sbom.test.ts b/scripts/build/generate-sbom.test.ts index d163558016..f12b9db2a3 100644 --- a/scripts/build/generate-sbom.test.ts +++ b/scripts/build/generate-sbom.test.ts @@ -442,7 +442,7 @@ describe("componentsFromLock", () => { }); }); -Deno.test("generate-sbom CLI rejects --lock without a value as a usage error", async () => { +it("generate-sbom CLI rejects --lock without a value as a usage error", async () => { const command = new Deno.Command(Deno.execPath(), { args: [ "run", diff --git a/scripts/build/smoke-proxy-binary.sh b/scripts/build/smoke-proxy-binary.sh index 003c7fd01d..be53f4fbb0 100644 --- a/scripts/build/smoke-proxy-binary.sh +++ b/scripts/build/smoke-proxy-binary.sh @@ -59,10 +59,13 @@ run_smoke redis "$((base_port + 1))" "TokenCacheStore registered" \ CACHE_TYPE=redis REDIS_URL=redis://127.0.0.1:1 run_smoke ambient-redis "$((base_port + 2))" "[ext-redis] RedisRuntimeProvider registered" \ CACHE_TYPE=memory REDIS_URL=redis://127.0.0.1:1 -run_smoke observability "$((base_port + 3))" "[otel] Initialized" \ +run_smoke otel "$((base_port + 3))" "[otel] Initialized" \ CACHE_TYPE=memory \ OTEL_TRACES_ENABLED=true \ OTEL_TRACES_EXPORTER=otlp \ - OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318 \ + OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318 +run_smoke sentry "$((base_port + 4))" "" \ + CACHE_TYPE=memory \ + VERYFRONT_ERROR_REPORTER=sentry \ SENTRY_ENABLED=true \ SENTRY_DSN=https://public@example.com/1