diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index 37d406412..dfcb46527 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -100,7 +100,12 @@ 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 }} + # 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 }} @@ -177,6 +182,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 +276,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 +352,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/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 619ca99d2..fee2b0d25 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, loginEnv, missingDeployEnv } from './config'; const artifact = { wasmModuleUrl: '/assets/cipherbox_wasm-deadbeef.js', @@ -45,6 +45,112 @@ 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', () => { + const config = engineHostConfig({}, artifact); + 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_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(''); + }); +}); + +describe('missingDeployEnv', () => { + 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, 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', () => { + for (const blank of ['', ' ']) { + expect(missingDeployEnv({ ...deployed, VITE_API_URL: blank })).toEqual(['VITE_API_URL']); + } + }); + + it('passes a fully configured deployment', () => { + expect(missingDeployEnv(deployed)).toEqual([]); + }); + + it('exempts builds that name no deployment', () => { + expect(missingDeployEnv({})).toEqual([]); + expect(missingDeployEnv({ VITE_ENVIRONMENT: 'ci' })).toEqual([]); + }); +}); + +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('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$/); + }); }); describe('environment', () => { diff --git a/apps/web/src/engine/config.ts b/apps/web/src/engine/config.ts index 50588aca4..478dec2f5 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'; @@ -6,14 +9,51 @@ 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']; +/** 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; + +/** + * 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 ?? '') + .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,10 +76,7 @@ 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'); @@ -48,6 +85,38 @@ export function engineHostConfig( return { apiBaseUrl: apiBaseUrl(env), recordEndpoints, + // The content gateway has no default: unset reads nothing rather than + // 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, }; } + +/** Of the variables Core Kit login needs, those `env` does not supply. */ +export function missingLoginEnv(env: Partial): string[] { + 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 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`); + } + return { clientId, verifier }; +} + +/** + * 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 DEPLOY_ENV.filter((name) => configured(env[name]) === undefined); +} 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..b1fd27255 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,25 @@ function appShell(): Plugin[] { ]; } +/** Fails a deployment build whose login-critical environment is unset. */ +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..b747de919 100644 --- a/crates/engine/src/facade.rs +++ b/crates/engine/src/facade.rs @@ -1228,9 +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. Empty until the auth/config slice supplies it; the register-first - /// renewal is a no-op against an empty base until then. + /// 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 @@ -1396,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 URL is the pre-integration dormant state (field doc) — no - // API to authenticate against, so login is skipped. 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..372614eb5 100644 --- a/crates/wasm/src/host.rs +++ b/crates/wasm/src/host.rs @@ -90,10 +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). 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-blank. The content gateway + /// is configured from `acceleratorBaseUrl` (+ optional `acceleratorBearer`) + /// and `publicGateways`. #[wasm_bindgen(constructor)] pub fn new( seams: JsValue, @@ -106,6 +105,17 @@ impl EngineHandle { ) -> Result { console_error_panic_hook::set_once(); + // 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 { js: take_seam::(&seams, "floorStore")?, @@ -157,13 +167,9 @@ 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). - // 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); + // With no accelerator base URL and no fallbacks the gateway is empty and + // reads fail closed as `Unavailable` (availability, never a trust + // violation). let gateway = GatewayConfig { accelerator: accelerator_base_url.map(|base_url| GatewaySource { base_url, @@ -179,8 +185,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 +193,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 +532,28 @@ mod tests { fn a_command_that_queues_nothing_crosses_as_undefined() { assert!(op_id_value(None).is_undefined()); } + + #[wasm_bindgen_test] + fn an_engine_without_an_api_base_url_is_refused() { + for api_base_url in [None, Some(String::new()), Some(" ".to_owned())] { + 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..8e1019fdd 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', }; @@ -18,16 +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/', - 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 57c68b15b..78b9cd60e 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`. */ @@ -32,15 +40,10 @@ export function spawnEngineWorker( createWorker: () => Worker = spawnModuleWorker ): Worker { const worker = createWorker(); - const bootstrap: EngineWorkerBootstrap = { - type: 'bootstrap', - recordEndpoints: config.recordEndpoints, - apiBaseUrl: config.apiBaseUrl, - 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 new file mode 100644 index 000000000..bfa3f101d --- /dev/null +++ b/packages/client/src/worker/engineHost.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; +import { EngineHost } from './engineHost.js'; +import type { EngineWasm } from './engineWasm.js'; + +/** 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( + 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 { wasm, constructed } = recordingWasm(); + + new EngineHost( + wasm, + { seam: true }, + { + apiBaseUrl: 'https://api.example.test', + profile: 'ci', + storageHeadroomBytes: 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 { wasm, constructed } = recordingWasm(); + + new EngineHost( + wasm, + {}, + { + apiBaseUrl: 'https://api.example.test', + acceleratorBaseUrl: 'https://accelerator.example.test', + publicGateways: ['https://gateway.example.test'], + } + ); + + 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 { wasm, constructed } = recordingWasm(); + + new EngineHost(wasm, {}, { apiBaseUrl: 'https://api.example.test' }); + + 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 aec78e9e5..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'; /** @@ -49,23 +50,33 @@ function ownedBuffer(bytes: Uint8Array): ArrayBuffer { : (bytes.slice().buffer as ArrayBuffer); } +/** What the engine instance itself is configured with, beyond its seams. */ +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; 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..74527d216 100644 --- a/packages/client/src/worker/engineWorker.ts +++ b/packages/client/src/worker/engineWorker.ts @@ -12,23 +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'; -} +export type EngineWorkerBootstrap = EngineHostConfig & { type: 'bootstrap' }; interface WasmGlue extends EngineWasm { default: (options: { module_or_path: string }) => Promise; @@ -51,10 +44,19 @@ 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); - 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 headroom, + }); 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.spec.ts b/packages/client/test/browser/engine.spec.ts index a6c69db0c..6b06f1620 100644 --- a/packages/client/test/browser/engine.spec.ts +++ b/packages/client/test/browser/engine.spec.ts @@ -31,11 +31,31 @@ test.describe('engine worker host', () => { ); }); - test('cold start, RPC round-trip, and logout teardown end to end', async ({ page }) => { + // 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(() => (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/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..33ccea991 --- /dev/null +++ b/packages/client/test/browser/mockAuth.ts @@ -0,0 +1,88 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; + +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. `/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 (!url.startsWith('/mock-api/')) return false; + if (req.method === 'GET' && url.endsWith('/auth/seen')) { + send(res, 200, completed); + return true; + } + 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, 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, 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; + } + if (issued.get(publicKey) !== echoed) { + send(res, 401, { message: 'no such challenge' }); + return; + } + issued.delete(publicKey); + completed.logins += 1; + send(res, 200, { + accessToken: 'browser-suite-access', + refreshToken: 'r'.repeat(64), + isNewUser: true, + }); +} + +type Fields = Record; + +function parse(body: Buffer): Fields { + let parsed: unknown; + try { + 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 { + 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)); 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\/([^/?]+)/);