From f24eb13500a550c09a1b5d136a4036e31349b73f Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Tue, 4 Aug 2026 14:08:54 +0200 Subject: [PATCH 1/3] fix: forward the API and content-gateway config to the browser engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browser worker built the WASM EngineHandle with every config argument hardcoded to undefined, so the engine ran against an empty API base and cold start skipped identity login entirely. Thread apiBaseUrl through EngineHostConfig, the worker bootstrap, and EngineHost, and refuse an absent or blank base at the WASM boundary rather than coming up unauthenticated. Add the read-accelerator and public-gateway surface alongside it, with no default: an unconfigured build reads nothing and the engine reports it as unavailable, never falling back to an endpoint nobody chose. The accelerator bearer is deferred — it is a session credential and a VITE_ variable ships in the public bundle. Plumb VITE_WEB3AUTH_VERIFIER through every workflow that builds the web app, and fail a deployment build whose login-critical environment is unset so the gap surfaces in CI rather than in the browser at first login. Closes #986 Closes #987 Closes #970 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Db7mRD4qUCPxkdc8JHqjfo --- .github/workflows/deploy-staging.yml | 4 + .github/workflows/desktop-staging-release.yml | 3 + .github/workflows/web-e2e.yml | 3 + apps/web/src/engine/config.test.ts | 57 +++++++++++- apps/web/src/engine/config.ts | 37 +++++++- apps/web/src/vite-env.d.ts | 4 + apps/web/vite.config.ts | 26 +++++- crates/engine/src/facade.rs | 9 +- crates/wasm/src/host.rs | 51 ++++++++--- packages/client/src/spawnEngineWorker.test.ts | 4 + packages/client/src/spawnEngineWorker.ts | 10 +++ packages/client/src/worker/engineHost.test.ts | 66 ++++++++++++++ packages/client/src/worker/engineHost.ts | 29 +++++-- packages/client/src/worker/engineWorker.ts | 12 ++- packages/client/src/worker/serve.test.ts | 2 +- packages/client/test/browser/engine.worker.ts | 5 +- packages/client/test/browser/mockAuth.ts | 86 +++++++++++++++++++ packages/client/test/browser/vite.config.ts | 10 ++- 18 files changed, 382 insertions(+), 36 deletions(-) create mode 100644 packages/client/src/worker/engineHost.test.ts create mode 100644 packages/client/test/browser/mockAuth.ts diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index 37d406412..9e11b8a46 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -100,6 +100,7 @@ jobs: run: pnpm --filter @cipherbox/web build:bundle env: VITE_WEB3AUTH_CLIENT_ID: ${{ vars.VITE_WEB3AUTH_CLIENT_ID }} + VITE_WEB3AUTH_VERIFIER: ${{ vars.VITE_WEB3AUTH_VERIFIER }} VITE_API_URL: ${{ vars.STAGING_API_URL }} VITE_ENVIRONMENT: staging VITE_GOOGLE_CLIENT_ID: ${{ vars.GOOGLE_CLIENT_ID }} @@ -177,6 +178,7 @@ jobs: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} VITE_WEB3AUTH_CLIENT_ID: ${{ vars.VITE_WEB3AUTH_CLIENT_ID }} + VITE_WEB3AUTH_VERIFIER: ${{ vars.VITE_WEB3AUTH_VERIFIER }} VITE_API_URL: ${{ vars.STAGING_API_URL }} VITE_ENVIRONMENT: staging VITE_GOOGLE_CLIENT_ID: ${{ vars.GOOGLE_CLIENT_ID }} @@ -270,6 +272,7 @@ jobs: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} VITE_WEB3AUTH_CLIENT_ID: ${{ vars.VITE_WEB3AUTH_CLIENT_ID }} + VITE_WEB3AUTH_VERIFIER: ${{ vars.VITE_WEB3AUTH_VERIFIER }} VITE_API_URL: ${{ vars.STAGING_API_URL }} VITE_ENVIRONMENT: staging VITE_GOOGLE_CLIENT_ID: ${{ vars.GOOGLE_CLIENT_ID }} @@ -345,6 +348,7 @@ jobs: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} VITE_WEB3AUTH_CLIENT_ID: ${{ vars.VITE_WEB3AUTH_CLIENT_ID }} + VITE_WEB3AUTH_VERIFIER: ${{ vars.VITE_WEB3AUTH_VERIFIER }} VITE_API_URL: ${{ vars.STAGING_API_URL }} VITE_ENVIRONMENT: staging VITE_GOOGLE_CLIENT_ID: ${{ vars.GOOGLE_CLIENT_ID }} diff --git a/.github/workflows/desktop-staging-release.yml b/.github/workflows/desktop-staging-release.yml index 4d8016cbc..4d5ad3e9b 100644 --- a/.github/workflows/desktop-staging-release.yml +++ b/.github/workflows/desktop-staging-release.yml @@ -74,6 +74,7 @@ jobs: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} VITE_WEB3AUTH_CLIENT_ID: ${{ vars.VITE_WEB3AUTH_CLIENT_ID }} + VITE_WEB3AUTH_VERIFIER: ${{ vars.VITE_WEB3AUTH_VERIFIER }} VITE_API_URL: ${{ vars.STAGING_API_URL }} VITE_ENVIRONMENT: staging VITE_GOOGLE_CLIENT_ID: ${{ vars.GOOGLE_CLIENT_ID }} @@ -160,6 +161,7 @@ jobs: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} VITE_WEB3AUTH_CLIENT_ID: ${{ vars.VITE_WEB3AUTH_CLIENT_ID }} + VITE_WEB3AUTH_VERIFIER: ${{ vars.VITE_WEB3AUTH_VERIFIER }} VITE_API_URL: ${{ vars.STAGING_API_URL }} VITE_ENVIRONMENT: staging VITE_GOOGLE_CLIENT_ID: ${{ vars.GOOGLE_CLIENT_ID }} @@ -234,6 +236,7 @@ jobs: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} VITE_WEB3AUTH_CLIENT_ID: ${{ vars.VITE_WEB3AUTH_CLIENT_ID }} + VITE_WEB3AUTH_VERIFIER: ${{ vars.VITE_WEB3AUTH_VERIFIER }} VITE_API_URL: ${{ vars.STAGING_API_URL }} VITE_ENVIRONMENT: staging VITE_GOOGLE_CLIENT_ID: ${{ vars.GOOGLE_CLIENT_ID }} diff --git a/.github/workflows/web-e2e.yml b/.github/workflows/web-e2e.yml index 0ca6692a6..e8f747e89 100644 --- a/.github/workflows/web-e2e.yml +++ b/.github/workflows/web-e2e.yml @@ -117,12 +117,14 @@ jobs: pnpm --filter @cipherbox/web build env: VITE_WEB3AUTH_CLIENT_ID: ${{ vars.VITE_WEB3AUTH_CLIENT_ID }} + VITE_WEB3AUTH_VERIFIER: ${{ vars.VITE_WEB3AUTH_VERIFIER }} VITE_API_URL: http://localhost:3000 - name: Create .env files for Web E2E run: | # Web app .env (for Vite dev server) echo "VITE_WEB3AUTH_CLIENT_ID=${{ vars.VITE_WEB3AUTH_CLIENT_ID }}" > apps/web/.env + echo "VITE_WEB3AUTH_VERIFIER=${{ vars.VITE_WEB3AUTH_VERIFIER }}" >> apps/web/.env echo "VITE_API_URL=http://localhost:3000" >> apps/web/.env # API .env (for NestJS) echo "NODE_ENV=test" > apps/api/.env @@ -167,6 +169,7 @@ jobs: REDIS_PORT: 6379 # Web app configuration (needed for Web3Auth initialization) VITE_WEB3AUTH_CLIENT_ID: ${{ vars.VITE_WEB3AUTH_CLIENT_ID }} + VITE_WEB3AUTH_VERIFIER: ${{ vars.VITE_WEB3AUTH_VERIFIER }} VITE_API_URL: http://localhost:3000 # Test-login endpoint (bypasses Core Kit, uses deterministic keypair) TEST_LOGIN_SECRET: e2e-test-secret-ci-only diff --git a/apps/web/src/engine/config.test.ts b/apps/web/src/engine/config.test.ts index 619ca99d2..3d0d50617 100644 --- a/apps/web/src/engine/config.test.ts +++ b/apps/web/src/engine/config.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { engineHostConfig, environment } from './config'; +import { engineHostConfig, environment, missingDeployEnv } from './config'; const artifact = { wasmModuleUrl: '/assets/cipherbox_wasm-deadbeef.js', @@ -45,6 +45,61 @@ describe('engineHostConfig', () => { 'http://localhost:3000' ); }); + + it('carries the read accelerator and the public gateway fallbacks through', () => { + const config = engineHostConfig( + { + VITE_READ_ACCELERATOR_URL: 'https://accelerator.example.test', + VITE_PUBLIC_GATEWAYS: ' https://a.example.test , https://b.example.test ', + }, + artifact + ); + expect(config.acceleratorBaseUrl).toBe('https://accelerator.example.test'); + expect(config.publicGateways).toEqual(['https://a.example.test', 'https://b.example.test']); + }); + + it('leaves the content gateway unset rather than defaulting it', () => { + // No endpoint nobody chose: an unconfigured build reads nothing, which the + // engine surfaces as unavailable rather than trusting a stand-in. + const config = engineHostConfig({}, artifact); + expect(config.acceleratorBaseUrl).toBeUndefined(); + expect(config.publicGateways).toBeUndefined(); + expect( + engineHostConfig({ VITE_PUBLIC_GATEWAYS: ' , ' }, artifact).publicGateways + ).toBeUndefined(); + }); +}); + +describe('missingDeployEnv', () => { + it('names the login variables a deployed build is missing', () => { + expect(missingDeployEnv({ VITE_ENVIRONMENT: 'staging' })).toEqual([ + 'VITE_WEB3AUTH_CLIENT_ID', + 'VITE_WEB3AUTH_VERIFIER', + ]); + // A variable substituted as blank is as unusable as an absent one. + expect( + missingDeployEnv({ + VITE_ENVIRONMENT: 'production', + VITE_WEB3AUTH_CLIENT_ID: 'client', + VITE_WEB3AUTH_VERIFIER: '', + }) + ).toEqual(['VITE_WEB3AUTH_VERIFIER']); + }); + + it('passes a fully configured deployment', () => { + expect( + missingDeployEnv({ + VITE_ENVIRONMENT: 'staging', + VITE_WEB3AUTH_CLIENT_ID: 'client', + VITE_WEB3AUTH_VERIFIER: 'verifier', + }) + ).toEqual([]); + }); + + it('exempts builds that name no deployment', () => { + expect(missingDeployEnv({})).toEqual([]); + expect(missingDeployEnv({ VITE_ENVIRONMENT: 'ci' })).toEqual([]); + }); }); describe('environment', () => { diff --git a/apps/web/src/engine/config.ts b/apps/web/src/engine/config.ts index 50588aca4..8e552630a 100644 --- a/apps/web/src/engine/config.ts +++ b/apps/web/src/engine/config.ts @@ -14,6 +14,17 @@ export function apiBaseUrl(env: Partial): string { const ENVIRONMENTS: readonly Environment[] = ['local', 'ci', 'staging', 'production']; +/** Deployments whose bundle is shipped to users, and so must be able to log in. */ +const DEPLOYED: readonly Environment[] = ['staging', 'production']; + +/** Reads a comma-separated variable as a trimmed, blank-free list. */ +function list(value: string | undefined): string[] { + return (value ?? '') + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +} + /** * Which deployment this build is; absent means a working-copy `vite dev`. A * typo is rejected rather than defaulted: it would silently pick the wrong @@ -36,18 +47,36 @@ export function engineHostConfig( env: Partial, artifact: Pick ): EngineHostConfig { - const recordEndpoints = (env.VITE_ROUTING_ENDPOINTS ?? DEFAULT_ROUTING_ENDPOINTS) - .split(',') - .map((endpoint) => endpoint.trim()) - .filter((endpoint) => endpoint.length > 0); + const recordEndpoints = list(env.VITE_ROUTING_ENDPOINTS ?? DEFAULT_ROUTING_ENDPOINTS); // Config-edge mirror of the `FetchRecordTransport` empty-endpoint-set rejection. if (recordEndpoints.length === 0) { throw new Error('VITE_ROUTING_ENDPOINTS must list at least one routing endpoint'); } + // The content gateway has no default: an unconfigured build reads nothing + // rather than reaching for an endpoint nobody chose. The network is canonical + // and every block is CID-verified, so these are accelerator hints, not trust + // anchors (CONTEXT.md "Read accelerator"). + const publicGateways = list(env.VITE_PUBLIC_GATEWAYS); + return { apiBaseUrl: apiBaseUrl(env), recordEndpoints, + acceleratorBaseUrl: env.VITE_READ_ACCELERATOR_URL || undefined, + publicGateways: publicGateways.length > 0 ? publicGateways : undefined, ...artifact, }; } + +/** + * The build-time variables a deployed bundle cannot log in without, of those + * `env` does not supply. Checked by the bundler so a missing one is a red build + * rather than a throw in the browser at first login; a working-copy or CI build + * names no deployment and is exempt. + */ +export function missingDeployEnv(env: Partial): string[] { + if (!DEPLOYED.includes(environment(env))) return []; + return (['VITE_WEB3AUTH_CLIENT_ID', 'VITE_WEB3AUTH_VERIFIER'] as const).filter( + (name) => !env[name] + ); +} diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 7fa17b00d..27a4e29e9 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -5,6 +5,10 @@ interface ImportMetaEnv { readonly VITE_API_URL?: string; /** Comma-separated `/routing/v1` origins: someguy plus a public endpoint. */ readonly VITE_ROUTING_ENDPOINTS?: string; + /** Base URL of the token-authed read accelerator; unset leaves reads dormant. */ + readonly VITE_READ_ACCELERATOR_URL?: string; + /** Comma-separated public trustless-gateway origins, tried after the accelerator. */ + readonly VITE_PUBLIC_GATEWAYS?: string; /** `local` | `ci` | `staging` | `production` — picks the Web3Auth network. */ readonly VITE_ENVIRONMENT?: string; readonly VITE_WEB3AUTH_CLIENT_ID?: string; diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 28ed98469..46c4a37aa 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -3,9 +3,11 @@ import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import react from '@vitejs/plugin-react'; -import { build, type Plugin } from 'vite'; +import { build, loadEnv, type Plugin } from 'vite'; import { defineConfig } from 'vitest/config'; +import { missingDeployEnv } from './src/engine/config'; + const OUT_DIR = fileURLToPath(new URL('dist', import.meta.url)); const SW_ENTRY = fileURLToPath(new URL('src/sw.ts', import.meta.url)); const SW_FILE = 'sw.js'; @@ -79,8 +81,28 @@ function appShell(): Plugin[] { ]; } +/** + * Fails a deployment build whose login-critical environment is unset, so the + * gap surfaces here instead of as a throw in the browser at first login. + */ +function deployEnvGate(): Plugin { + return { + name: 'cipherbox:deploy-env-gate', + apply: 'build', + config(_config, { mode }) { + const env = loadEnv(mode, import.meta.dirname, 'VITE_'); + const missing = missingDeployEnv(env); + if (missing.length > 0) { + throw new Error( + `a ${env.VITE_ENVIRONMENT} build cannot log in without ${missing.join(', ')}` + ); + } + }, + }; +} + export default defineConfig({ - plugins: [react(), ...appShell()], + plugins: [react(), deployEnvGate(), ...appShell()], // `@cipherbox/client`'s engine worker dynamically imports the wasm-bindgen ES // module, which a classic worker cannot do (blueprint/web-client.md). worker: { format: 'es' }, diff --git a/crates/engine/src/facade.rs b/crates/engine/src/facade.rs index c36c183c0..abdfb4250 100644 --- a/crates/engine/src/facade.rs +++ b/crates/engine/src/facade.rs @@ -1229,8 +1229,9 @@ pub struct Engine { /// The upload-cancel interlock, shared with the drain the tick loop runs. cancels: Rc>, /// The API base URL the liveness loop's [`ApiClient`] registers renewals - /// against. Empty until the auth/config slice supplies it; the register-first - /// renewal is a no-op against an empty base until then. + /// against. Hosts reject an empty base at construction, so only harnesses + /// that never reach the API run against one; the register-first renewal and + /// the cold-start login are both inert there. api_base_url: String, /// The resolved content read-source set, built once from the injected /// [`GatewayConfig`] at construction. Empty (dormant) until the host supplies @@ -1396,8 +1397,8 @@ impl Engine { // The one shared client for login, publish, and renewal. Login is // fail-closed: a rejected login returns before the session is committed // or any loop spawns, so the loop never runs unauthenticated (rules 3/6). - // An empty base URL is the pre-integration dormant state (field doc) — no - // API to authenticate against, so login is skipped. + // An empty base is a harness with no API to authenticate against (field + // doc); hosts refuse one, so production never takes that branch. let api = Rc::new(ApiClient::new( self.seams.http.clone(), self.seams.credential_store.clone(), diff --git a/crates/wasm/src/host.rs b/crates/wasm/src/host.rs index 7bae7c432..c65e793a8 100644 --- a/crates/wasm/src/host.rs +++ b/crates/wasm/src/host.rs @@ -90,10 +90,12 @@ impl EngineHandle { /// `mailbox`, `refreshHints`, `scheduler`, `stagingStore`, `snapshotCache`, /// `credentialStore`); a missing seam fails closed. `profile` selects the /// sync timing policy (`"ci"` for the compressed e2e cadences, production - /// otherwise). The content gateway is configured from `acceleratorBaseUrl` - /// (+ optional `acceleratorBearer`) and `publicGateways`; all absent leaves - /// it dormant (reads fail closed as `Unavailable`) until E4 wires real - /// endpoints. + /// otherwise). `apiBaseUrl` is required and non-empty: an absent one would + /// leave the engine unauthenticated rather than erroring, so it is rejected + /// here rather than silently skipping login. The content gateway is + /// configured from `acceleratorBaseUrl` (+ optional `acceleratorBearer`) + /// and `publicGateways`; all absent leaves it dormant, and reads then fail + /// closed as `Unavailable`. #[wasm_bindgen(constructor)] pub fn new( seams: JsValue, @@ -106,6 +108,10 @@ impl EngineHandle { ) -> Result { console_error_panic_hook::set_once(); + let api_base_url = api_base_url.filter(|url| !url.is_empty()).ok_or_else(|| { + JsError::new("apiBaseUrl is required: the engine must authenticate to the API") + })?; + let seam_set = SeamSet:: { floor_store: FloorStoreAdapter { js: take_seam::(&seams, "floorStore")?, @@ -157,9 +163,10 @@ impl EngineHandle { }, }; - // Dormant until the config slice (E4) supplies real endpoints: with no - // accelerator base URL and no fallbacks the gateway is empty, and reads - // fail closed as `Unavailable` (availability, never a trust violation). + // With no accelerator base URL and no fallbacks the gateway is empty and + // reads fail closed as `Unavailable` (availability, never a trust + // violation) — an unconfigured host reads nothing rather than reaching + // for an untrusted default. // Zeroize the bearer before branching on the base URL: if no accelerator // base URL is supplied the source closure never runs, so wrapping inside // it would drop the Rust-owned bearer String unzeroized (security rule 7). @@ -179,8 +186,6 @@ impl EngineHandle { .collect(), }; - // Empty until the auth/config slice supplies the real API base URL; the - // register-first renewal is inert against an empty base until then. let (engine, events) = Engine::new( seam_set, Box::new(GetrandomEntropy), @@ -189,7 +194,7 @@ impl EngineHandle { // always writes the shipped profile — never the CI one. ContentProfile::PRODUCTION, storage_policy, - api_base_url.unwrap_or_default(), + api_base_url, gateway, ); Ok(EngineHandle { @@ -528,4 +533,30 @@ mod tests { fn a_command_that_queues_nothing_crosses_as_undefined() { assert!(op_id_value(None).is_undefined()); } + + /// An absent or blank API base is refused at construction: building the + /// engine over one would leave `start` with nothing to authenticate against. + #[wasm_bindgen_test] + fn an_engine_without_an_api_base_url_is_refused() { + for api_base_url in [None, Some(String::new())] { + let error = EngineHandle::new( + js_sys::Object::new().into(), + None, + api_base_url, + None, + None, + None, + None, + ) + .err() + .expect("no API base is a construction failure"); + + let message = String::from( + JsValue::from(error) + .unchecked_into::() + .message(), + ); + assert!(message.contains("apiBaseUrl"), "{message}"); + } + } } diff --git a/packages/client/src/spawnEngineWorker.test.ts b/packages/client/src/spawnEngineWorker.test.ts index d595db1d6..4f5aa1987 100644 --- a/packages/client/src/spawnEngineWorker.test.ts +++ b/packages/client/src/spawnEngineWorker.test.ts @@ -4,6 +4,8 @@ import { spawnEngineWorker, type EngineHostConfig } from './spawnEngineWorker.js const config: EngineHostConfig = { apiBaseUrl: 'https://api.example.test/', recordEndpoints: ['https://routing.example.test'], + acceleratorBaseUrl: 'https://accelerator.example.test', + publicGateways: ['https://gateway.example.test'], wasmModuleUrl: '/wasm/cipherbox_wasm.js', wasmBinaryUrl: '/wasm/cipherbox_wasm_bg.wasm', }; @@ -23,6 +25,8 @@ describe('spawnEngineWorker', () => { type: 'bootstrap', recordEndpoints: ['https://routing.example.test'], apiBaseUrl: 'https://api.example.test/', + acceleratorBaseUrl: 'https://accelerator.example.test', + publicGateways: ['https://gateway.example.test'], dbPrefix: undefined, wasmModuleUrl: '/wasm/cipherbox_wasm.js', wasmBinaryUrl: '/wasm/cipherbox_wasm_bg.wasm', diff --git a/packages/client/src/spawnEngineWorker.ts b/packages/client/src/spawnEngineWorker.ts index 57c68b15b..30783756a 100644 --- a/packages/client/src/spawnEngineWorker.ts +++ b/packages/client/src/spawnEngineWorker.ts @@ -14,6 +14,14 @@ export interface EngineHostConfig { apiBaseUrl: string; /** `/routing/v1` origins: someguy plus at least one public endpoint. */ recordEndpoints: string[]; + /** + * Base URL of the read accelerator (CONTEXT.md "Read accelerator"). Absent + * leaves the content gateway dormant: reads fail closed as unavailable rather + * than falling back to an endpoint nobody configured. + */ + acceleratorBaseUrl?: string; + /** Public trustless-gateway fallbacks, tried in order after the accelerator. */ + publicGateways?: string[]; /** URL of the wasm-bindgen ES glue module the worker imports. */ wasmModuleUrl: string; /** URL of the wasm binary handed to the glue's `init`. */ @@ -36,6 +44,8 @@ export function spawnEngineWorker( type: 'bootstrap', recordEndpoints: config.recordEndpoints, apiBaseUrl: config.apiBaseUrl, + acceleratorBaseUrl: config.acceleratorBaseUrl, + publicGateways: config.publicGateways, dbPrefix: config.dbPrefix, wasmModuleUrl: config.wasmModuleUrl, wasmBinaryUrl: config.wasmBinaryUrl, diff --git a/packages/client/src/worker/engineHost.test.ts b/packages/client/src/worker/engineHost.test.ts new file mode 100644 index 000000000..645369e10 --- /dev/null +++ b/packages/client/src/worker/engineHost.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { EngineHost } from './engineHost.js'; +import type { EngineWasm } from './engineWasm.js'; + +/** Records the arguments the host constructs the wasm `EngineHandle` with. */ +function recordingWasm(constructed: unknown[][]): EngineWasm { + return { + EngineHandle: class { + constructor(...args: unknown[]) { + constructed.push(args); + } + }, + } as unknown as EngineWasm; +} + +describe('EngineHost', () => { + it('hands the engine the API base URL so cold start can log in', () => { + const constructed: unknown[][] = []; + + new EngineHost( + recordingWasm(constructed), + { seam: true }, + { + apiBaseUrl: 'https://api.example.test', + profile: 'ci', + storageHeadroomBytes: 1024, + } + ); + + const [seams, profile, apiBaseUrl, , , , storageHeadroomBytes] = constructed[0]; + expect(seams).toEqual({ seam: true }); + expect(profile).toBe('ci'); + expect(apiBaseUrl).toBe('https://api.example.test'); + expect(storageHeadroomBytes).toBe(1024); + }); + + it('forwards the content gateway configuration, bearerless', () => { + const constructed: unknown[][] = []; + + new EngineHost( + recordingWasm(constructed), + {}, + { + apiBaseUrl: 'https://api.example.test', + acceleratorBaseUrl: 'https://accelerator.example.test', + publicGateways: ['https://gateway.example.test'], + } + ); + + const [, , , acceleratorBaseUrl, acceleratorBearer, publicGateways] = constructed[0]; + expect(acceleratorBaseUrl).toBe('https://accelerator.example.test'); + expect(publicGateways).toEqual(['https://gateway.example.test']); + // A bearer is a session credential; no build-time surface supplies one. + expect(acceleratorBearer).toBeUndefined(); + }); + + it('leaves the gateway dormant when no endpoint is configured', () => { + const constructed: unknown[][] = []; + + new EngineHost(recordingWasm(constructed), {}, { apiBaseUrl: 'https://api.example.test' }); + + const [, , , acceleratorBaseUrl, , publicGateways] = constructed[0]; + expect(acceleratorBaseUrl).toBeUndefined(); + expect(publicGateways).toBeUndefined(); + }); +}); diff --git a/packages/client/src/worker/engineHost.ts b/packages/client/src/worker/engineHost.ts index aec78e9e5..34abebfaa 100644 --- a/packages/client/src/worker/engineHost.ts +++ b/packages/client/src/worker/engineHost.ts @@ -49,23 +49,38 @@ function ownedBuffer(bytes: Uint8Array): ArrayBuffer { : (bytes.slice().buffer as ArrayBuffer); } +/** What the engine instance itself is configured with, beyond its seams. */ +export interface EngineHostOptions { + /** Absolute base URL of the API the engine authenticates and publishes against. */ + apiBaseUrl: string; + /** Read accelerator base URL; absent leaves the content gateway dormant. */ + acceleratorBaseUrl?: string; + /** Public trustless-gateway fallbacks, tried in order after the accelerator. */ + publicGateways?: string[]; + /** Sync timing profile. */ + profile?: string; + /** Origin headroom the engine splits into its staging budget. */ + storageHeadroomBytes?: number; +} + export class EngineHost implements EngineHostLike { private readonly handle; constructor( private readonly wasm: EngineWasm, seams: unknown, - profile?: string, - storageHeadroomBytes?: number + options: EngineHostOptions ) { this.handle = new wasm.EngineHandle( seams, - profile, - undefined, - undefined, - undefined, + options.profile, + options.apiBaseUrl, + options.acceleratorBaseUrl, + // The accelerator bearer is a session credential, never a build-time + // constant, so no browser config surface supplies one yet. undefined, - storageHeadroomBytes + options.publicGateways, + options.storageHeadroomBytes ); } diff --git a/packages/client/src/worker/engineWorker.ts b/packages/client/src/worker/engineWorker.ts index 07fb06ebb..4a40baef7 100644 --- a/packages/client/src/worker/engineWorker.ts +++ b/packages/client/src/worker/engineWorker.ts @@ -28,6 +28,10 @@ export interface EngineWorkerBootstrap extends BrowserSeamsConfig { wasmBinaryUrl: string; /** Sync timing profile. */ profile?: 'ci' | 'production'; + /** Read accelerator base URL; absent leaves the content gateway dormant. */ + acceleratorBaseUrl?: string; + /** Public trustless-gateway fallbacks, tried in order after the accelerator. */ + publicGateways?: string[]; } interface WasmGlue extends EngineWasm { @@ -54,7 +58,13 @@ async function bootstrap(config: EngineWorkerBootstrap): Promise { const wasm = (await import(/* @vite-ignore */ config.wasmModuleUrl)) as WasmGlue; await wasm.default({ module_or_path: config.wasmBinaryUrl }); const seams = makeBrowserSeams(config); - const host = new EngineHost(wasm, seams, config.profile, await measureStorageHeadroomBytes()); + const host = new EngineHost(wasm, seams, { + apiBaseUrl: config.apiBaseUrl, + acceleratorBaseUrl: config.acceleratorBaseUrl, + publicGateways: config.publicGateways, + profile: config.profile, + storageHeadroomBytes: await measureStorageHeadroomBytes(), + }); serveEngine(workerScope as unknown as WorkerScopeLike, host); } catch (error) { workerScope.postMessage({ diff --git a/packages/client/src/worker/serve.test.ts b/packages/client/src/worker/serve.test.ts index 3be847791..9c57603fe 100644 --- a/packages/client/src/worker/serve.test.ts +++ b/packages/client/src/worker/serve.test.ts @@ -395,7 +395,7 @@ describe('serveEngine event pump over the real EngineHost', () => { } as unknown as EngineWasm; const { scope, worker, toUi } = loopback(); - serveEngine(scope, new EngineHost(wasm, {})); + serveEngine(scope, new EngineHost(wasm, {}, { apiBaseUrl: 'https://api.example.test' })); const transport = new LocalTransport(worker); const received: EventDescriptor[] = []; transport.subscribe((event) => received.push(event)); diff --git a/packages/client/test/browser/engine.worker.ts b/packages/client/test/browser/engine.worker.ts index eff7514a6..61dac5d34 100644 --- a/packages/client/test/browser/engine.worker.ts +++ b/packages/client/test/browser/engine.worker.ts @@ -20,12 +20,13 @@ async function boot(): Promise { await init({ module_or_path: wasmUrl }); const wasm = glue as unknown as EngineWasm; const { origin } = scope.location; + const apiBaseUrl = `${origin}/mock-api/engine`; const seams = makeBrowserSeams({ recordEndpoints: [`${origin}/routing`], - apiBaseUrl: `${origin}/mock-api/engine`, + apiBaseUrl, dbPrefix: `engine-${Date.now()}`, }); - const host = new EngineHost(wasm, seams, 'ci'); + const host = new EngineHost(wasm, seams, { apiBaseUrl, profile: 'ci' }); serveEngine(scope, host); } diff --git a/packages/client/test/browser/mockAuth.ts b/packages/client/test/browser/mockAuth.ts new file mode 100644 index 000000000..f7f8d09a2 --- /dev/null +++ b/packages/client/test/browser/mockAuth.ts @@ -0,0 +1,86 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; + +import { readBody } from './mockMailbox.js'; + +/** + * In-memory mock of the API's identity-login exchange (blueprint/api.md + * "Auth"), for the browser suite's engine cold start. + * + * It mints tokens for whatever signature it is handed — the signature itself is + * checked where the crypto lives, in `crates/engine` and against the real API in + * the contract suite. What it does enforce is the exchange: `/auth/login` is + * refused unless it echoes a challenge this mock issued, so an engine that came + * up without logging in fails its cold start here. + */ + +/** Challenges issued and not yet spent, by the publicKey that asked for one. */ +const issued = new Map(); + +export function mockAuthRequest(req: IncomingMessage, res: ServerResponse): boolean { + const url = (req.url ?? '').split('?')[0]; + if (req.method !== 'POST' || !url.startsWith('/mock-api/')) return false; + + if (url.endsWith('/auth/challenge')) { + void readBody(req).then( + (body) => challenge(res, body), + () => send(res, 400, { error: 'request aborted' }) + ); + return true; + } + if (url.endsWith('/auth/login')) { + void readBody(req).then( + (body) => login(res, body), + () => send(res, 400, { error: 'request aborted' }) + ); + return true; + } + return false; +} + +function challenge(res: ServerResponse, body: Buffer): void { + const publicKey = field(body, 'publicKey'); + if (publicKey === null) { + send(res, 400, { error: 'publicKey must be a string' }); + return; + } + const value = `cipherbox-login:v2:${publicKey.slice(0, 16)}`; + issued.set(publicKey, value); + send(res, 200, { challenge: value, expiresAt: '2099-01-01T00:00:00Z' }); +} + +function login(res: ServerResponse, body: Buffer): void { + const publicKey = field(body, 'publicKey'); + const echoed = field(body, 'challenge'); + const signature = field(body, 'signature'); + if (publicKey === null || echoed === null || signature === null) { + send(res, 400, { error: 'publicKey, challenge and signature are required' }); + return; + } + if (issued.get(publicKey) !== echoed) { + send(res, 401, { message: 'no such challenge' }); + return; + } + issued.delete(publicKey); + send(res, 200, { + accessToken: 'browser-suite-access', + refreshToken: 'r'.repeat(64), + isNewUser: true, + }); +} + +function field(body: Buffer, name: string): string | null { + let dto: Record; + try { + dto = JSON.parse(body.toString('utf8')) as Record; + } catch { + return null; + } + const value = dto[name]; + return typeof value === 'string' && value.length > 0 ? value : null; +} + +function send(res: ServerResponse, status: number, body: unknown): void { + res.statusCode = status; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify(body)); +} diff --git a/packages/client/test/browser/vite.config.ts b/packages/client/test/browser/vite.config.ts index 9710c4767..cb0c8fa3c 100644 --- a/packages/client/test/browser/vite.config.ts +++ b/packages/client/test/browser/vite.config.ts @@ -1,14 +1,15 @@ import { defineConfig, type Plugin } from 'vite'; +import { mockAuthRequest } from './mockAuth.js'; import { mockMailboxRequest, readBody } from './mockMailbox.js'; /** * Serves the browser-suite harness and stands up an in-memory mock of the * network surfaces the seams touch: the `/routing/v1` delegated-routing - * endpoint set (for `RecordTransport`), the API mailbox routes (for - * `Mailbox`), and a couple of plain HTTP endpoints (for the `Http` seam - * behavioral check). No crypto, no real network — the mock stores and returns - * opaque bytes, exactly the shape the seam contracts exercise. + * endpoint set (for `RecordTransport`), the API identity-login and mailbox + * routes, and a couple of plain HTTP endpoints (for the `Http` seam behavioral + * check). No crypto, no real network — the mock stores and returns opaque + * bytes, exactly the shape the seam contracts exercise. */ function mockNetwork(): Plugin { const records = new Map(); @@ -19,6 +20,7 @@ function mockNetwork(): Plugin { server.middlewares.use((req, res, next) => { const url = req.url ?? ''; + if (mockAuthRequest(req, res)) return; if (mockMailboxRequest(req, res)) return; const routing = url.match(/\/routing\/v1\/ipns\/([^/?]+)/); From 3c473d927e1b53b2a2e2ac4c39b743341df5e66c Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Tue, 4 Aug 2026 14:25:05 +0200 Subject: [PATCH 2/3] refactor: fold the review-gate findings into the browser engine config Requires VITE_API_URL of a deployed build too: an unset one falls back to localhost, which the engine would then authenticate against. Single-sources the login variables so the build gate and the Core Kit session cannot drift apart. Derives the worker bootstrap and the host options from one config type and posts the handshake as a checked spread, so a field added upstream reaches the engine without a copy nothing typechecks. Wraps the accelerator bearer before the constructor's first fallible step so an early return cannot drop it unzeroized, and trims a whitespace-only API base. The browser suite now tallies the identity-login exchange, so a cold start that skipped login fails the gate instead of passing silently. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Db7mRD4qUCPxkdc8JHqjfo --- .github/workflows/deploy-staging.yml | 4 + apps/web/src/auth/coreKit.ts | 8 +- apps/web/src/engine/config.test.ts | 56 ++++++++----- apps/web/src/engine/config.ts | 51 ++++++++---- apps/web/vite.config.ts | 5 +- crates/engine/src/facade.rs | 8 +- crates/wasm/src/host.rs | 33 ++++---- packages/client/src/spawnEngineWorker.test.ts | 15 +--- packages/client/src/spawnEngineWorker.ts | 15 +--- packages/client/src/worker/engineHost.test.ts | 77 ++++++++++++------ packages/client/src/worker/engineHost.ts | 16 ++-- packages/client/src/worker/engineWorker.ts | 22 ++---- packages/client/test/browser/engine.spec.ts | 9 ++- packages/client/test/browser/mockAuth.ts | 78 +++++++++---------- packages/client/test/browser/mockMailbox.ts | 2 +- 15 files changed, 215 insertions(+), 184 deletions(-) diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index 9e11b8a46..dfcb46527 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -102,6 +102,10 @@ jobs: VITE_WEB3AUTH_CLIENT_ID: ${{ vars.VITE_WEB3AUTH_CLIENT_ID }} VITE_WEB3AUTH_VERIFIER: ${{ vars.VITE_WEB3AUTH_VERIFIER }} VITE_API_URL: ${{ vars.STAGING_API_URL }} + # Unset leaves the content gateway dormant: reads fail closed as + # unavailable rather than reaching for an unconfigured endpoint. + VITE_READ_ACCELERATOR_URL: ${{ vars.VITE_READ_ACCELERATOR_URL }} + VITE_PUBLIC_GATEWAYS: ${{ vars.VITE_PUBLIC_GATEWAYS }} VITE_ENVIRONMENT: staging VITE_GOOGLE_CLIENT_ID: ${{ vars.GOOGLE_CLIENT_ID }} VITE_FARO_URL: ${{ vars.VITE_FARO_URL }} diff --git a/apps/web/src/auth/coreKit.ts b/apps/web/src/auth/coreKit.ts index 4cfe29146..a18567622 100644 --- a/apps/web/src/auth/coreKit.ts +++ b/apps/web/src/auth/coreKit.ts @@ -7,7 +7,7 @@ import { COREKIT_STATUS, WEB3AUTH_NETWORK, Web3AuthMPCCoreKit } from '@web3auth/mpc-core-kit'; import { tssLib } from '@toruslabs/tss-dkls-lib'; -import { environment } from '../engine/config'; +import { environment, loginEnv } from '../engine/config'; import type { LoginSecretExporter } from '../engine/loginHandoff'; /** How a session was established; also the `authStore` login method. */ @@ -84,11 +84,7 @@ class Web3AuthSession implements CoreKitSession { /** Builds this tab's Core Kit session from the build-time environment. */ export function createCoreKitSession(env: Partial): CoreKitSession { - const clientId = env.VITE_WEB3AUTH_CLIENT_ID; - const verifier = env.VITE_WEB3AUTH_VERIFIER; - if (!clientId || !verifier) { - throw new Error('VITE_WEB3AUTH_CLIENT_ID and VITE_WEB3AUTH_VERIFIER must both be configured'); - } + const { clientId, verifier } = loginEnv(env); const coreKit = new Web3AuthMPCCoreKit({ web3AuthClientId: clientId, diff --git a/apps/web/src/engine/config.test.ts b/apps/web/src/engine/config.test.ts index 3d0d50617..d48ecda77 100644 --- a/apps/web/src/engine/config.test.ts +++ b/apps/web/src/engine/config.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { engineHostConfig, environment, missingDeployEnv } from './config'; +import { engineHostConfig, environment, loginEnv, missingDeployEnv } from './config'; const artifact = { wasmModuleUrl: '/assets/cipherbox_wasm-deadbeef.js', @@ -59,41 +59,42 @@ describe('engineHostConfig', () => { }); it('leaves the content gateway unset rather than defaulting it', () => { - // No endpoint nobody chose: an unconfigured build reads nothing, which the - // engine surfaces as unavailable rather than trusting a stand-in. const config = engineHostConfig({}, artifact); expect(config.acceleratorBaseUrl).toBeUndefined(); - expect(config.publicGateways).toBeUndefined(); + expect(config.publicGateways).toEqual([]); + expect(engineHostConfig({ VITE_PUBLIC_GATEWAYS: ' , ' }, artifact).publicGateways).toEqual([]); expect( - engineHostConfig({ VITE_PUBLIC_GATEWAYS: ' , ' }, artifact).publicGateways + engineHostConfig({ VITE_READ_ACCELERATOR_URL: '' }, artifact).acceleratorBaseUrl ).toBeUndefined(); }); }); describe('missingDeployEnv', () => { - it('names the login variables a deployed build is missing', () => { + const deployed = { + VITE_ENVIRONMENT: 'staging', + VITE_WEB3AUTH_CLIENT_ID: 'client', + VITE_WEB3AUTH_VERIFIER: 'verifier', + VITE_API_URL: 'https://api.example.test', + }; + + it('names the variables a deployed build is missing', () => { expect(missingDeployEnv({ VITE_ENVIRONMENT: 'staging' })).toEqual([ 'VITE_WEB3AUTH_CLIENT_ID', 'VITE_WEB3AUTH_VERIFIER', + 'VITE_API_URL', ]); // A variable substituted as blank is as unusable as an absent one. - expect( - missingDeployEnv({ - VITE_ENVIRONMENT: 'production', - VITE_WEB3AUTH_CLIENT_ID: 'client', - VITE_WEB3AUTH_VERIFIER: '', - }) - ).toEqual(['VITE_WEB3AUTH_VERIFIER']); + expect(missingDeployEnv({ ...deployed, VITE_WEB3AUTH_VERIFIER: '' })).toEqual([ + 'VITE_WEB3AUTH_VERIFIER', + ]); + }); + + it('refuses a deployed build with no API origin, which would default to localhost', () => { + expect(missingDeployEnv({ ...deployed, VITE_API_URL: '' })).toEqual(['VITE_API_URL']); }); it('passes a fully configured deployment', () => { - expect( - missingDeployEnv({ - VITE_ENVIRONMENT: 'staging', - VITE_WEB3AUTH_CLIENT_ID: 'client', - VITE_WEB3AUTH_VERIFIER: 'verifier', - }) - ).toEqual([]); + expect(missingDeployEnv(deployed)).toEqual([]); }); it('exempts builds that name no deployment', () => { @@ -102,6 +103,21 @@ describe('missingDeployEnv', () => { }); }); +describe('loginEnv', () => { + it('reads the Web3Auth identifiers a session is built from', () => { + expect(loginEnv({ VITE_WEB3AUTH_CLIENT_ID: 'client', VITE_WEB3AUTH_VERIFIER: 'v' })).toEqual({ + clientId: 'client', + verifier: 'v', + }); + }); + + it('refuses a build missing one, naming it', () => { + expect(() => loginEnv({ VITE_WEB3AUTH_CLIENT_ID: 'client' })).toThrow( + /^VITE_WEB3AUTH_VERIFIER must be configured$/ + ); + }); +}); + describe('environment', () => { it('names the deployment, defaulting an absent value to local', () => { expect(environment({ VITE_ENVIRONMENT: 'staging' })).toBe('staging'); diff --git a/apps/web/src/engine/config.ts b/apps/web/src/engine/config.ts index 8e552630a..36ffd30bb 100644 --- a/apps/web/src/engine/config.ts +++ b/apps/web/src/engine/config.ts @@ -1,3 +1,6 @@ +// `vite.config.ts` imports the deploy gate from here while resolving config, so +// this module must keep to type-only imports — a value import of the client +// package would pull the WASM engine into the bundler. import type { EngineHostConfig } from '@cipherbox/client'; const DEFAULT_API_URL = 'http://localhost:3000'; @@ -17,6 +20,16 @@ const ENVIRONMENTS: readonly Environment[] = ['local', 'ci', 'staging', 'product /** Deployments whose bundle is shipped to users, and so must be able to log in. */ const DEPLOYED: readonly Environment[] = ['staging', 'production']; +/** The one list of what a Core Kit session needs, shared with the build gate. */ +const LOGIN_ENV = ['VITE_WEB3AUTH_CLIENT_ID', 'VITE_WEB3AUTH_VERIFIER'] as const; + +/** + * What a deployed bundle cannot work without. `VITE_API_URL` is here because an + * unset one falls back to `localhost`, which the engine would then authenticate + * against — a working build pointed at whatever answers on the user's machine. + */ +const DEPLOY_ENV = [...LOGIN_ENV, 'VITE_API_URL'] as const; + /** Reads a comma-separated variable as a trimmed, blank-free list. */ function list(value: string | undefined): string[] { return (value ?? '') @@ -53,30 +66,38 @@ export function engineHostConfig( throw new Error('VITE_ROUTING_ENDPOINTS must list at least one routing endpoint'); } - // The content gateway has no default: an unconfigured build reads nothing - // rather than reaching for an endpoint nobody chose. The network is canonical - // and every block is CID-verified, so these are accelerator hints, not trust - // anchors (CONTEXT.md "Read accelerator"). - const publicGateways = list(env.VITE_PUBLIC_GATEWAYS); - return { apiBaseUrl: apiBaseUrl(env), recordEndpoints, + // The content gateway has no default: unset reads nothing rather than + // reaching for an endpoint nobody chose. A blank accelerator would build a + // gateway source with no base URL, so it reads as unset. acceleratorBaseUrl: env.VITE_READ_ACCELERATOR_URL || undefined, - publicGateways: publicGateways.length > 0 ? publicGateways : undefined, + publicGateways: list(env.VITE_PUBLIC_GATEWAYS), ...artifact, }; } +/** Of the variables Core Kit login needs, those `env` does not supply. */ +export function missingLoginEnv(env: Partial): string[] { + return LOGIN_ENV.filter((name) => !env[name]); +} + +/** The Web3Auth identifiers a Core Kit session is built from; refuses a build missing any. */ +export function loginEnv(env: Partial): { clientId: string; verifier: string } { + const { VITE_WEB3AUTH_CLIENT_ID: clientId, VITE_WEB3AUTH_VERIFIER: verifier } = env; + if (!clientId || !verifier) { + throw new Error(`${missingLoginEnv(env).join(' and ')} must be configured`); + } + return { clientId, verifier }; +} + /** - * The build-time variables a deployed bundle cannot log in without, of those - * `env` does not supply. Checked by the bundler so a missing one is a red build - * rather than a throw in the browser at first login; a working-copy or CI build - * names no deployment and is exempt. + * The variables a deployed bundle is missing. The bundler checks this so an + * unset one is a red build rather than a broken deploy nobody notices until a + * user tries to log in; a working-copy or CI build names no deployment and is + * exempt. */ export function missingDeployEnv(env: Partial): string[] { - if (!DEPLOYED.includes(environment(env))) return []; - return (['VITE_WEB3AUTH_CLIENT_ID', 'VITE_WEB3AUTH_VERIFIER'] as const).filter( - (name) => !env[name] - ); + return DEPLOYED.includes(environment(env)) ? DEPLOY_ENV.filter((name) => !env[name]) : []; } diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 46c4a37aa..b1fd27255 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -81,10 +81,7 @@ function appShell(): Plugin[] { ]; } -/** - * Fails a deployment build whose login-critical environment is unset, so the - * gap surfaces here instead of as a throw in the browser at first login. - */ +/** Fails a deployment build whose login-critical environment is unset. */ function deployEnvGate(): Plugin { return { name: 'cipherbox:deploy-env-gate', diff --git a/crates/engine/src/facade.rs b/crates/engine/src/facade.rs index abdfb4250..b747de919 100644 --- a/crates/engine/src/facade.rs +++ b/crates/engine/src/facade.rs @@ -1228,10 +1228,8 @@ pub struct Engine { live_blocks: Rc>, /// The upload-cancel interlock, shared with the drain the tick loop runs. cancels: Rc>, - /// The API base URL the liveness loop's [`ApiClient`] registers renewals - /// against. Hosts reject an empty base at construction, so only harnesses - /// that never reach the API run against one; the register-first renewal and - /// the cold-start login are both inert there. + /// The API base URL cold start logs in against and the liveness loop + /// registers renewals against. Empty is the harness's no-API mode. api_base_url: String, /// The resolved content read-source set, built once from the injected /// [`GatewayConfig`] at construction. Empty (dormant) until the host supplies @@ -1397,8 +1395,6 @@ impl Engine { // The one shared client for login, publish, and renewal. Login is // fail-closed: a rejected login returns before the session is committed // or any loop spawns, so the loop never runs unauthenticated (rules 3/6). - // An empty base is a harness with no API to authenticate against (field - // doc); hosts refuse one, so production never takes that branch. let api = Rc::new(ApiClient::new( self.seams.http.clone(), self.seams.credential_store.clone(), diff --git a/crates/wasm/src/host.rs b/crates/wasm/src/host.rs index c65e793a8..372614eb5 100644 --- a/crates/wasm/src/host.rs +++ b/crates/wasm/src/host.rs @@ -90,12 +90,9 @@ impl EngineHandle { /// `mailbox`, `refreshHints`, `scheduler`, `stagingStore`, `snapshotCache`, /// `credentialStore`); a missing seam fails closed. `profile` selects the /// sync timing policy (`"ci"` for the compressed e2e cadences, production - /// otherwise). `apiBaseUrl` is required and non-empty: an absent one would - /// leave the engine unauthenticated rather than erroring, so it is rejected - /// here rather than silently skipping login. The content gateway is - /// configured from `acceleratorBaseUrl` (+ optional `acceleratorBearer`) - /// and `publicGateways`; all absent leaves it dormant, and reads then fail - /// closed as `Unavailable`. + /// otherwise). `apiBaseUrl` is required and non-blank. The content gateway + /// is configured from `acceleratorBaseUrl` (+ optional `acceleratorBearer`) + /// and `publicGateways`. #[wasm_bindgen(constructor)] pub fn new( seams: JsValue, @@ -108,9 +105,16 @@ impl EngineHandle { ) -> Result { console_error_panic_hook::set_once(); - let api_base_url = api_base_url.filter(|url| !url.is_empty()).ok_or_else(|| { - JsError::new("apiBaseUrl is required: the engine must authenticate to the API") - })?; + // Wrapped before the first `?`: an early return would otherwise drop the + // Rust-owned bearer String unzeroized (security rule 7). + let accelerator_bearer = accelerator_bearer.map(Zeroizing::new); + + let api_base_url = api_base_url + .map(|url| url.trim().to_owned()) + .filter(|url| !url.is_empty()) + .ok_or_else(|| { + JsError::new("apiBaseUrl is required: the engine must authenticate to the API") + })?; let seam_set = SeamSet:: { floor_store: FloorStoreAdapter { @@ -165,12 +169,7 @@ impl EngineHandle { // With no accelerator base URL and no fallbacks the gateway is empty and // reads fail closed as `Unavailable` (availability, never a trust - // violation) — an unconfigured host reads nothing rather than reaching - // for an untrusted default. - // Zeroize the bearer before branching on the base URL: if no accelerator - // base URL is supplied the source closure never runs, so wrapping inside - // it would drop the Rust-owned bearer String unzeroized (security rule 7). - let accelerator_bearer = accelerator_bearer.map(Zeroizing::new); + // violation). let gateway = GatewayConfig { accelerator: accelerator_base_url.map(|base_url| GatewaySource { base_url, @@ -534,11 +533,9 @@ mod tests { assert!(op_id_value(None).is_undefined()); } - /// An absent or blank API base is refused at construction: building the - /// engine over one would leave `start` with nothing to authenticate against. #[wasm_bindgen_test] fn an_engine_without_an_api_base_url_is_refused() { - for api_base_url in [None, Some(String::new())] { + for api_base_url in [None, Some(String::new()), Some(" ".to_owned())] { let error = EngineHandle::new( js_sys::Object::new().into(), None, diff --git a/packages/client/src/spawnEngineWorker.test.ts b/packages/client/src/spawnEngineWorker.test.ts index 4f5aa1987..8e1019fdd 100644 --- a/packages/client/src/spawnEngineWorker.test.ts +++ b/packages/client/src/spawnEngineWorker.test.ts @@ -20,18 +20,7 @@ describe('spawnEngineWorker', () => { const worker = recordingWorker(posted); expect(spawnEngineWorker(config, () => worker)).toBe(worker); - expect(posted).toEqual([ - { - type: 'bootstrap', - recordEndpoints: ['https://routing.example.test'], - apiBaseUrl: 'https://api.example.test/', - acceleratorBaseUrl: 'https://accelerator.example.test', - publicGateways: ['https://gateway.example.test'], - dbPrefix: undefined, - wasmModuleUrl: '/wasm/cipherbox_wasm.js', - wasmBinaryUrl: '/wasm/cipherbox_wasm_bg.wasm', - profile: undefined, - }, - ]); + // Every config field reaches the worker, not just the ones spelled out here. + expect(posted).toEqual([{ type: 'bootstrap', ...config }]); }); }); diff --git a/packages/client/src/spawnEngineWorker.ts b/packages/client/src/spawnEngineWorker.ts index 30783756a..78b9cd60e 100644 --- a/packages/client/src/spawnEngineWorker.ts +++ b/packages/client/src/spawnEngineWorker.ts @@ -40,17 +40,10 @@ export function spawnEngineWorker( createWorker: () => Worker = spawnModuleWorker ): Worker { const worker = createWorker(); - const bootstrap: EngineWorkerBootstrap = { - type: 'bootstrap', - recordEndpoints: config.recordEndpoints, - apiBaseUrl: config.apiBaseUrl, - acceleratorBaseUrl: config.acceleratorBaseUrl, - publicGateways: config.publicGateways, - dbPrefix: config.dbPrefix, - wasmModuleUrl: config.wasmModuleUrl, - wasmBinaryUrl: config.wasmBinaryUrl, - profile: config.profile, - }; + // Spread, not a field-by-field copy: a config field added upstream reaches the + // worker without a second edit that nothing would typecheck. The annotation + // keeps the handshake checked against the contract at both ends. + const bootstrap: EngineWorkerBootstrap = { type: 'bootstrap', ...config }; worker.postMessage(bootstrap); return worker; } diff --git a/packages/client/src/worker/engineHost.test.ts b/packages/client/src/worker/engineHost.test.ts index 645369e10..bfa3f101d 100644 --- a/packages/client/src/worker/engineHost.test.ts +++ b/packages/client/src/worker/engineHost.test.ts @@ -2,23 +2,52 @@ import { describe, expect, it } from 'vitest'; import { EngineHost } from './engineHost.js'; import type { EngineWasm } from './engineWasm.js'; -/** Records the arguments the host constructs the wasm `EngineHandle` with. */ -function recordingWasm(constructed: unknown[][]): EngineWasm { - return { +/** The arguments one `EngineHandle` construction crossed the WASM boundary with. */ +interface Constructed { + seams: unknown; + profile: unknown; + apiBaseUrl: unknown; + acceleratorBaseUrl: unknown; + acceleratorBearer: unknown; + publicGateways: unknown; + storageHeadroomBytes: unknown; +} + +/** A wasm module whose `EngineHandle` records what it was constructed with. */ +function recordingWasm(): { wasm: EngineWasm; constructed: Constructed[] } { + const constructed: Constructed[] = []; + const wasm = { EngineHandle: class { - constructor(...args: unknown[]) { - constructed.push(args); + constructor( + seams: unknown, + profile: unknown, + apiBaseUrl: unknown, + acceleratorBaseUrl: unknown, + acceleratorBearer: unknown, + publicGateways: unknown, + storageHeadroomBytes: unknown + ) { + constructed.push({ + seams, + profile, + apiBaseUrl, + acceleratorBaseUrl, + acceleratorBearer, + publicGateways, + storageHeadroomBytes, + }); } }, } as unknown as EngineWasm; + return { wasm, constructed }; } describe('EngineHost', () => { it('hands the engine the API base URL so cold start can log in', () => { - const constructed: unknown[][] = []; + const { wasm, constructed } = recordingWasm(); new EngineHost( - recordingWasm(constructed), + wasm, { seam: true }, { apiBaseUrl: 'https://api.example.test', @@ -27,18 +56,19 @@ describe('EngineHost', () => { } ); - const [seams, profile, apiBaseUrl, , , , storageHeadroomBytes] = constructed[0]; - expect(seams).toEqual({ seam: true }); - expect(profile).toBe('ci'); - expect(apiBaseUrl).toBe('https://api.example.test'); - expect(storageHeadroomBytes).toBe(1024); + expect(constructed[0]).toMatchObject({ + seams: { seam: true }, + profile: 'ci', + apiBaseUrl: 'https://api.example.test', + storageHeadroomBytes: 1024, + }); }); it('forwards the content gateway configuration, bearerless', () => { - const constructed: unknown[][] = []; + const { wasm, constructed } = recordingWasm(); new EngineHost( - recordingWasm(constructed), + wasm, {}, { apiBaseUrl: 'https://api.example.test', @@ -47,20 +77,19 @@ describe('EngineHost', () => { } ); - const [, , , acceleratorBaseUrl, acceleratorBearer, publicGateways] = constructed[0]; - expect(acceleratorBaseUrl).toBe('https://accelerator.example.test'); - expect(publicGateways).toEqual(['https://gateway.example.test']); - // A bearer is a session credential; no build-time surface supplies one. - expect(acceleratorBearer).toBeUndefined(); + expect(constructed[0]).toMatchObject({ + acceleratorBaseUrl: 'https://accelerator.example.test', + publicGateways: ['https://gateway.example.test'], + }); + expect(constructed[0].acceleratorBearer).toBeUndefined(); }); it('leaves the gateway dormant when no endpoint is configured', () => { - const constructed: unknown[][] = []; + const { wasm, constructed } = recordingWasm(); - new EngineHost(recordingWasm(constructed), {}, { apiBaseUrl: 'https://api.example.test' }); + new EngineHost(wasm, {}, { apiBaseUrl: 'https://api.example.test' }); - const [, , , acceleratorBaseUrl, , publicGateways] = constructed[0]; - expect(acceleratorBaseUrl).toBeUndefined(); - expect(publicGateways).toBeUndefined(); + expect(constructed[0].acceleratorBaseUrl).toBeUndefined(); + expect(constructed[0].publicGateways).toBeUndefined(); }); }); diff --git a/packages/client/src/worker/engineHost.ts b/packages/client/src/worker/engineHost.ts index 34abebfaa..ac5c2d02d 100644 --- a/packages/client/src/worker/engineHost.ts +++ b/packages/client/src/worker/engineHost.ts @@ -13,6 +13,7 @@ import type { WriteTarget, } from './protocol.js'; import type { EngineWasm } from './engineWasm.js'; +import type { EngineHostConfig } from '../spawnEngineWorker.js'; import { buildCommand, readEvent, readSnapshot } from './commandCodec.js'; /** @@ -50,18 +51,13 @@ function ownedBuffer(bytes: Uint8Array): ArrayBuffer { } /** What the engine instance itself is configured with, beyond its seams. */ -export interface EngineHostOptions { - /** Absolute base URL of the API the engine authenticates and publishes against. */ - apiBaseUrl: string; - /** Read accelerator base URL; absent leaves the content gateway dormant. */ - acceleratorBaseUrl?: string; - /** Public trustless-gateway fallbacks, tried in order after the accelerator. */ - publicGateways?: string[]; - /** Sync timing profile. */ - profile?: string; +export type EngineHostOptions = Pick< + EngineHostConfig, + 'apiBaseUrl' | 'acceleratorBaseUrl' | 'publicGateways' | 'profile' +> & { /** Origin headroom the engine splits into its staging budget. */ storageHeadroomBytes?: number; -} +}; export class EngineHost implements EngineHostLike { private readonly handle; diff --git a/packages/client/src/worker/engineWorker.ts b/packages/client/src/worker/engineWorker.ts index 4a40baef7..74527d216 100644 --- a/packages/client/src/worker/engineWorker.ts +++ b/packages/client/src/worker/engineWorker.ts @@ -12,27 +12,16 @@ * never import it into the UI realm. */ -import { makeBrowserSeams, type BrowserSeamsConfig } from './browserSeams.js'; +import { makeBrowserSeams } from './browserSeams.js'; import { EngineHost } from './engineHost.js'; import type { EngineWasm } from './engineWasm.js'; import { serveEngine, type WorkerScopeLike } from './serve.js'; import type { WorkerMessage } from './protocol.js'; +import type { EngineHostConfig } from '../spawnEngineWorker.js'; import { measureStorageHeadroomBytes } from './storageHeadroom.js'; /** The one-shot handshake the leader sends after spawning the worker. */ -export interface EngineWorkerBootstrap extends BrowserSeamsConfig { - type: 'bootstrap'; - /** URL of the wasm-bindgen ES glue module (dynamically imported). */ - wasmModuleUrl: string; - /** URL of the wasm binary handed to the glue's `init`. */ - wasmBinaryUrl: string; - /** Sync timing profile. */ - profile?: 'ci' | 'production'; - /** Read accelerator base URL; absent leaves the content gateway dormant. */ - acceleratorBaseUrl?: string; - /** Public trustless-gateway fallbacks, tried in order after the accelerator. */ - publicGateways?: string[]; -} +export type EngineWorkerBootstrap = EngineHostConfig & { type: 'bootstrap' }; interface WasmGlue extends EngineWasm { default: (options: { module_or_path: string }) => Promise; @@ -55,6 +44,9 @@ function onBootstrap(event: MessageEvent): void { async function bootstrap(config: EngineWorkerBootstrap): Promise { try { + // The quota estimate is independent of the WASM fetch and compile, so it + // rides alongside them rather than extending cold start. + const headroom = measureStorageHeadroomBytes(); const wasm = (await import(/* @vite-ignore */ config.wasmModuleUrl)) as WasmGlue; await wasm.default({ module_or_path: config.wasmBinaryUrl }); const seams = makeBrowserSeams(config); @@ -63,7 +55,7 @@ async function bootstrap(config: EngineWorkerBootstrap): Promise { acceleratorBaseUrl: config.acceleratorBaseUrl, publicGateways: config.publicGateways, profile: config.profile, - storageHeadroomBytes: await measureStorageHeadroomBytes(), + storageHeadroomBytes: await headroom, }); serveEngine(workerScope as unknown as WorkerScopeLike, host); } catch (error) { diff --git a/packages/client/test/browser/engine.spec.ts b/packages/client/test/browser/engine.spec.ts index a6c69db0c..785a07e5d 100644 --- a/packages/client/test/browser/engine.spec.ts +++ b/packages/client/test/browser/engine.spec.ts @@ -31,11 +31,18 @@ test.describe('engine worker host', () => { ); }); - test('cold start, RPC round-trip, and logout teardown end to end', async ({ page }) => { + test('cold start, RPC round-trip, and logout teardown end to end', async ({ page, request }) => { + const before = await (await request.get('/mock-api/engine/auth/seen')).json(); const result: RealEngineResult = await page.evaluate(() => (window as unknown as EngineHarness).runRealEngine() ); + // Cold start exchanged a challenge for tokens: a start that skipped the + // login would resolve just the same, so the mock's own tally is the proof. + const after = await (await request.get('/mock-api/engine/auth/seen')).json(); + expect(after.logins).toBe(before.logins + 1); + expect(after.challenges).toBe(before.challenges + 1); + // A command before start is rejected as "not started" (start lifecycle gate). expect(result.beforeStart).toContain('not started'); // start(secret) resolves: the secret transferred in and the engine came up. diff --git a/packages/client/test/browser/mockAuth.ts b/packages/client/test/browser/mockAuth.ts index f7f8d09a2..a8a917169 100644 --- a/packages/client/test/browser/mockAuth.ts +++ b/packages/client/test/browser/mockAuth.ts @@ -1,58 +1,57 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; -import { readBody } from './mockMailbox.js'; +import { readBody, send } from './mockMailbox.js'; /** * In-memory mock of the API's identity-login exchange (blueprint/api.md - * "Auth"), for the browser suite's engine cold start. - * - * It mints tokens for whatever signature it is handed — the signature itself is - * checked where the crypto lives, in `crates/engine` and against the real API in - * the contract suite. What it does enforce is the exchange: `/auth/login` is - * refused unless it echoes a challenge this mock issued, so an engine that came - * up without logging in fails its cold start here. + * "Auth"), for the browser suite's engine cold start. `/auth/login` is refused + * unless it echoes a challenge this mock issued, so an engine that came up + * without logging in fails its cold start here. The signature itself is checked + * where the crypto lives, in `crates/engine` and the contract suite. */ /** Challenges issued and not yet spent, by the publicKey that asked for one. */ const issued = new Map(); +/** Exchanges completed, so a suite can assert the engine logged in at all. */ +const completed = { challenges: 0, logins: 0 }; export function mockAuthRequest(req: IncomingMessage, res: ServerResponse): boolean { const url = (req.url ?? '').split('?')[0]; - if (req.method !== 'POST' || !url.startsWith('/mock-api/')) return false; - - if (url.endsWith('/auth/challenge')) { - void readBody(req).then( - (body) => challenge(res, body), - () => send(res, 400, { error: 'request aborted' }) - ); - return true; - } - if (url.endsWith('/auth/login')) { - void readBody(req).then( - (body) => login(res, body), - () => send(res, 400, { error: 'request aborted' }) - ); + if (!url.startsWith('/mock-api/')) return false; + if (req.method === 'GET' && url.endsWith('/auth/seen')) { + send(res, 200, completed); return true; } - return false; + if (req.method !== 'POST') return false; + + let respond; + if (url.endsWith('/auth/challenge')) respond = challenge; + else if (url.endsWith('/auth/login')) respond = login; + else return false; + + void readBody(req).then( + (body) => respond(res, parse(body)), + () => send(res, 400, { error: 'request aborted' }) + ); + return true; } -function challenge(res: ServerResponse, body: Buffer): void { - const publicKey = field(body, 'publicKey'); +function challenge(res: ServerResponse, dto: Fields): void { + const publicKey = field(dto, 'publicKey'); if (publicKey === null) { send(res, 400, { error: 'publicKey must be a string' }); return; } const value = `cipherbox-login:v2:${publicKey.slice(0, 16)}`; issued.set(publicKey, value); + completed.challenges += 1; send(res, 200, { challenge: value, expiresAt: '2099-01-01T00:00:00Z' }); } -function login(res: ServerResponse, body: Buffer): void { - const publicKey = field(body, 'publicKey'); - const echoed = field(body, 'challenge'); - const signature = field(body, 'signature'); - if (publicKey === null || echoed === null || signature === null) { +function login(res: ServerResponse, dto: Fields): void { + const publicKey = field(dto, 'publicKey'); + const echoed = field(dto, 'challenge'); + if (publicKey === null || echoed === null || field(dto, 'signature') === null) { send(res, 400, { error: 'publicKey, challenge and signature are required' }); return; } @@ -61,6 +60,7 @@ function login(res: ServerResponse, body: Buffer): void { return; } issued.delete(publicKey); + completed.logins += 1; send(res, 200, { accessToken: 'browser-suite-access', refreshToken: 'r'.repeat(64), @@ -68,19 +68,17 @@ function login(res: ServerResponse, body: Buffer): void { }); } -function field(body: Buffer, name: string): string | null { - let dto: Record; +type Fields = Record; + +function parse(body: Buffer): Fields { try { - dto = JSON.parse(body.toString('utf8')) as Record; + return JSON.parse(body.toString('utf8')) as Fields; } catch { - return null; + return {}; } - const value = dto[name]; - return typeof value === 'string' && value.length > 0 ? value : null; } -function send(res: ServerResponse, status: number, body: unknown): void { - res.statusCode = status; - res.setHeader('content-type', 'application/json'); - res.end(JSON.stringify(body)); +function field(dto: Fields, name: string): string | null { + const value = dto[name]; + return typeof value === 'string' && value.length > 0 ? value : null; } diff --git a/packages/client/test/browser/mockMailbox.ts b/packages/client/test/browser/mockMailbox.ts index adb075653..b51afb25b 100644 --- a/packages/client/test/browser/mockMailbox.ts +++ b/packages/client/test/browser/mockMailbox.ts @@ -122,7 +122,7 @@ export function readBody(req: IncomingMessage): Promise { }); } -function send(res: ServerResponse, status: number, body: unknown): void { +export function send(res: ServerResponse, status: number, body: unknown): void { res.statusCode = status; res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(body)); From 13001b20916e40faa5d6067461d9f09916f8d5d0 Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Tue, 4 Aug 2026 22:32:22 +0200 Subject: [PATCH 3/3] fix: treat a whitespace-only build variable as unset, not configured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deploy gate tested truthiness, so `VITE_API_URL=" "` passed it and shipped a bundle that authenticates against whatever answers on the user's machine — reopening past the gate the very hole the gate was added to close. A whitespace accelerator was worse than useless: it configured a gateway source with an unusable base URL, so reads failed per request instead of staying dormant, which is the fail-closed state chosen deliberately. One `configured()` helper now backs the gate, the Core Kit accessor and the accelerator. The API origin is trimmed because it is concatenated into request URLs, but a blank one never defaults — defaulting would turn the engine's fail-closed refusal into a silent localhost fallback. The browser suite's auth mock answered `JSON.parse('null')` by throwing inside its response callback, leaving the request unanswered so a failure surfaced as a timeout with no signal. It now answers 400. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Db7mRD4qUCPxkdc8JHqjfo --- apps/web/src/engine/config.test.ts | 49 ++++++++++++++++++--- apps/web/src/engine/config.ts | 35 +++++++++++---- packages/client/test/browser/engine.spec.ts | 13 ++++++ packages/client/test/browser/mockAuth.ts | 6 ++- 4 files changed, 87 insertions(+), 16 deletions(-) diff --git a/apps/web/src/engine/config.test.ts b/apps/web/src/engine/config.test.ts index d48ecda77..fee2b0d25 100644 --- a/apps/web/src/engine/config.test.ts +++ b/apps/web/src/engine/config.test.ts @@ -63,9 +63,25 @@ describe('engineHostConfig', () => { expect(config.acceleratorBaseUrl).toBeUndefined(); expect(config.publicGateways).toEqual([]); expect(engineHostConfig({ VITE_PUBLIC_GATEWAYS: ' , ' }, artifact).publicGateways).toEqual([]); + for (const unset of ['', ' ']) { + // Whitespace would configure a gateway source with an unusable base URL — + // reads then fail per-request instead of staying dormant. + expect( + engineHostConfig({ VITE_READ_ACCELERATOR_URL: unset }, artifact).acceleratorBaseUrl + ).toBeUndefined(); + } + }); + + it('trims a configured API origin, which is concatenated into request URLs', () => { expect( - engineHostConfig({ VITE_READ_ACCELERATOR_URL: '' }, artifact).acceleratorBaseUrl - ).toBeUndefined(); + engineHostConfig({ VITE_API_URL: ' https://api.example.test\n' }, artifact).apiBaseUrl + ).toBe('https://api.example.test'); + }); + + it('never defaults a whitespace API origin to localhost', () => { + // Defaulting here would point a misconfigured deployment at whatever answers + // on the user's machine; the engine's own edge check refuses a blank base. + expect(engineHostConfig({ VITE_API_URL: ' ' }, artifact).apiBaseUrl).toBe(''); }); }); @@ -83,14 +99,23 @@ describe('missingDeployEnv', () => { 'VITE_WEB3AUTH_VERIFIER', 'VITE_API_URL', ]); - // A variable substituted as blank is as unusable as an absent one. - expect(missingDeployEnv({ ...deployed, VITE_WEB3AUTH_VERIFIER: '' })).toEqual([ - 'VITE_WEB3AUTH_VERIFIER', - ]); + // A variable substituted as blank is as unusable as an absent one, and a + // whitespace-only one is blank — a repo variable set to a stray space or + // newline must not sail through the gate this build exists to fail on. + for (const blank of ['', ' ', '\n']) { + expect(missingDeployEnv({ ...deployed, VITE_WEB3AUTH_VERIFIER: blank })).toEqual([ + 'VITE_WEB3AUTH_VERIFIER', + ]); + expect(missingDeployEnv({ ...deployed, VITE_WEB3AUTH_CLIENT_ID: blank })).toEqual([ + 'VITE_WEB3AUTH_CLIENT_ID', + ]); + } }); it('refuses a deployed build with no API origin, which would default to localhost', () => { - expect(missingDeployEnv({ ...deployed, VITE_API_URL: '' })).toEqual(['VITE_API_URL']); + for (const blank of ['', ' ']) { + expect(missingDeployEnv({ ...deployed, VITE_API_URL: blank })).toEqual(['VITE_API_URL']); + } }); it('passes a fully configured deployment', () => { @@ -111,10 +136,20 @@ describe('loginEnv', () => { }); }); + it('trims the identifiers, which are sent to Web3Auth verbatim', () => { + expect( + loginEnv({ VITE_WEB3AUTH_CLIENT_ID: ' client\n', VITE_WEB3AUTH_VERIFIER: 'v ' }) + ).toEqual({ clientId: 'client', verifier: 'v' }); + }); + it('refuses a build missing one, naming it', () => { expect(() => loginEnv({ VITE_WEB3AUTH_CLIENT_ID: 'client' })).toThrow( /^VITE_WEB3AUTH_VERIFIER must be configured$/ ); + // Whitespace is missing, not configured. + expect(() => + loginEnv({ VITE_WEB3AUTH_CLIENT_ID: 'client', VITE_WEB3AUTH_VERIFIER: ' ' }) + ).toThrow(/^VITE_WEB3AUTH_VERIFIER must be configured$/); }); }); diff --git a/apps/web/src/engine/config.ts b/apps/web/src/engine/config.ts index 36ffd30bb..478dec2f5 100644 --- a/apps/web/src/engine/config.ts +++ b/apps/web/src/engine/config.ts @@ -9,10 +9,17 @@ const DEFAULT_ROUTING_ENDPOINTS = 'https://delegated-ipfs.dev'; /** The deployments the build-time environment names. */ export type Environment = 'local' | 'ci' | 'staging' | 'production'; -/** The API origin the engine authenticates and publishes against. */ +/** + * The API origin the engine authenticates and publishes against. Trimmed, since + * it is concatenated into request URLs; a whitespace-only value trims to blank + * rather than defaulting, so the engine's own edge check refuses it instead of + * a misconfigured deployment quietly talking to the user's own machine. + */ export function apiBaseUrl(env: Partial): string { // `VITE_API_URL=` reads as `''`, which `new URL` rejects rather than defaults. - return env.VITE_API_URL || DEFAULT_API_URL; + return env.VITE_API_URL === undefined || env.VITE_API_URL === '' + ? DEFAULT_API_URL + : env.VITE_API_URL.trim(); } const ENVIRONMENTS: readonly Environment[] = ['local', 'ci', 'staging', 'production']; @@ -30,6 +37,15 @@ const LOGIN_ENV = ['VITE_WEB3AUTH_CLIENT_ID', 'VITE_WEB3AUTH_VERIFIER'] as const */ const DEPLOY_ENV = [...LOGIN_ENV, 'VITE_API_URL'] as const; +/** + * A variable's configured value, or `undefined` when it carries none. Absent, + * empty and whitespace-only are one state: a repo variable set to a stray space + * or newline is unset, not configured. + */ +function configured(value: string | undefined): string | undefined { + return value?.trim() || undefined; +} + /** Reads a comma-separated variable as a trimmed, blank-free list. */ function list(value: string | undefined): string[] { return (value ?? '') @@ -70,9 +86,10 @@ export function engineHostConfig( apiBaseUrl: apiBaseUrl(env), recordEndpoints, // The content gateway has no default: unset reads nothing rather than - // reaching for an endpoint nobody chose. A blank accelerator would build a - // gateway source with no base URL, so it reads as unset. - acceleratorBaseUrl: env.VITE_READ_ACCELERATOR_URL || undefined, + // reaching for an endpoint nobody chose. Dormant is the fail-closed state, + // so a blank value must land there rather than configuring a gateway source + // whose every request fails. + acceleratorBaseUrl: configured(env.VITE_READ_ACCELERATOR_URL), publicGateways: list(env.VITE_PUBLIC_GATEWAYS), ...artifact, }; @@ -80,12 +97,13 @@ export function engineHostConfig( /** Of the variables Core Kit login needs, those `env` does not supply. */ export function missingLoginEnv(env: Partial): string[] { - return LOGIN_ENV.filter((name) => !env[name]); + return LOGIN_ENV.filter((name) => configured(env[name]) === undefined); } /** The Web3Auth identifiers a Core Kit session is built from; refuses a build missing any. */ export function loginEnv(env: Partial): { clientId: string; verifier: string } { - const { VITE_WEB3AUTH_CLIENT_ID: clientId, VITE_WEB3AUTH_VERIFIER: verifier } = env; + const clientId = configured(env.VITE_WEB3AUTH_CLIENT_ID); + const verifier = configured(env.VITE_WEB3AUTH_VERIFIER); if (!clientId || !verifier) { throw new Error(`${missingLoginEnv(env).join(' and ')} must be configured`); } @@ -99,5 +117,6 @@ export function loginEnv(env: Partial): { clientId: string; verif * exempt. */ export function missingDeployEnv(env: Partial): string[] { - return DEPLOYED.includes(environment(env)) ? DEPLOY_ENV.filter((name) => !env[name]) : []; + if (!DEPLOYED.includes(environment(env))) return []; + return DEPLOY_ENV.filter((name) => configured(env[name]) === undefined); } diff --git a/packages/client/test/browser/engine.spec.ts b/packages/client/test/browser/engine.spec.ts index 785a07e5d..6b06f1620 100644 --- a/packages/client/test/browser/engine.spec.ts +++ b/packages/client/test/browser/engine.spec.ts @@ -31,6 +31,19 @@ test.describe('engine worker host', () => { ); }); + // The auth mock backs the cold-start assertion below, so it has to answer + // every request: one that throws mid-handler leaves the engine's own fetch + // hanging, and this suite reports a timeout instead of the real failure. + test('the auth mock answers a malformed body rather than hanging', async ({ request }) => { + for (const route of ['challenge', 'login']) { + const response = await request.post(`/mock-api/engine/auth/${route}`, { + headers: { 'content-type': 'application/json' }, + data: 'null', + }); + expect(response.status()).toBe(400); + } + }); + test('cold start, RPC round-trip, and logout teardown end to end', async ({ page, request }) => { const before = await (await request.get('/mock-api/engine/auth/seen')).json(); const result: RealEngineResult = await page.evaluate(() => diff --git a/packages/client/test/browser/mockAuth.ts b/packages/client/test/browser/mockAuth.ts index a8a917169..33ccea991 100644 --- a/packages/client/test/browser/mockAuth.ts +++ b/packages/client/test/browser/mockAuth.ts @@ -71,11 +71,15 @@ function login(res: ServerResponse, dto: Fields): void { type Fields = Record; function parse(body: Buffer): Fields { + let parsed: unknown; try { - return JSON.parse(body.toString('utf8')) as Fields; + parsed = JSON.parse(body.toString('utf8')); } catch { return {}; } + // `JSON.parse('null')` is a valid parse of a non-object: reading a field off + // it throws inside the response callback, and the request never answers. + return typeof parsed === 'object' && parsed !== null ? (parsed as Fields) : {}; } function field(dto: Fields, name: string): string | null {