diff --git a/build.rs b/build.rs index 92c6417..52a568c 100644 --- a/build.rs +++ b/build.rs @@ -5,15 +5,11 @@ fn main() { // Required config vars - must be set either by Doppler or .env let required = [ "SITE_HOST", - // Host that serves game iframes (e.g. wavedashcdn.com). Mirrors the - // mainsite's PUBLIC_PLAYSITE_HOST — must match, since the iframe - // origin is `.local.` and `wavedash dev` maps - // that subdomain to the local server via chromium --host-rules. + // MUST match mainsite's PUBLIC_PLAYSITE_HOST — `wavedash dev` MAPs + // `*.local.` via chromium --host-rules; drift = silent. "PLAYSITE_HOST", "CONVEX_HTTP_URL", - // Public host of the wavedash-dev-app R2 bucket (e.g. r2.dev URL or - // a custom domain). Baked in here so `wavedash dev` always knows - // where to fetch the matching Wavedash Dev build. + // R2 host for the wavedash-dev-app zip downloaded by `wavedash dev`. "CF_R2_WAVEDASH_DEV_APP_HOST", ]; diff --git a/dev-app/README.md b/dev-app/README.md index 0108ac3..2aeb496 100644 --- a/dev-app/README.md +++ b/dev-app/README.md @@ -6,16 +6,19 @@ and execs the binary directly. ## Layout -- `src/main.ts` — process entry. Reads JSON config from stdin, starts the - local HTTPS server, points Chromium at it via `--host-rules`, and - navigates to the playtest URL. -- `src/server.ts` — local HTTPS server. Handles requests for the game - subdomain: synthesizes the `/dev-app-embed` shell, serves files from the upload - dir with COEP/COOP/CORP, and reverse-proxies a fixed set of paths - (`/embed.js`, `/auth/`, `/gameplay/`, …) to the real network. -- `src/cert.ts` — generates a self-signed cert at startup. The cert is - whitelisted via `session.setCertificateVerifyProc` for the game - subdomain only, so DevTools network is unaffected. +- `src/main.ts` — process entry. Reads JSON config from the temp file at + `--config-path=` (CLI also passes `--user-data-dir` and + `--parent-pid`), starts the local HTTPS server, points Chromium at it + via `--host-rules`, and navigates to the playtest URL. +- `src/server.ts` — local HTTPS server. Handles requests for any + `{gcid}-{userhash}.` host: synthesizes the + `/dev-app-embed` shell, serves files from the upload dir with + COEP/COOP/CORP, and reverse-proxies a fixed set of paths (`/embed.js`, + `/sw-bootstrap`, `/auth/refresh`, …) to the real network. +- `src/cert.ts` — generates a self-signed cert at startup with a wildcard + SAN `*.`. The cert is whitelisted via + `session.setCertificateVerifyProc` for any host under that suffix only, + so DevTools network is unaffected. - `electron-builder.json5` — produces `---.zip` per platform. The release workflow renames each to `.zip`. @@ -24,9 +27,9 @@ and execs the binary directly. Electron's `webContents.debugger` and bundled DevTools share a single CDP slot. While the debugger is attached with `Fetch.enable`, the bundled DevTools' Network tab is broken (and opening DevTools detaches our -debugger). Routing the game subdomain through a real local HTTPS server -keeps Chromium's network stack untouched, so right-click → Inspect Element -opens DevTools with full Network/Performance panels. +debugger). Routing the wildcard `*.` through a real local +HTTPS server keeps Chromium's network stack untouched, so right-click → +Inspect Element opens DevTools with full Network/Performance panels. ## No code signing @@ -81,17 +84,25 @@ bundler doesn't typecheck, it just transpiles. ## IPC contract -CLI → dev-app over **stdin** — one JSON line: +CLI → dev-app via the temp file at `--config-path=` — one JSON +object. Stdin is intentionally unused (Windows GUI-subsystem .exes detach +from inherited stdin pipes): ```json { "uploadDir": "/abs/path", - "gameSubdomain": ".local.wavedashcdn.com", + "localHostSuffix": "local.wavedashcdn.com", "playtestUrl": "https://wavedash.com/playtest//", "verbose": false } ``` +The dev-app reads it synchronously at startup and unlinks the file. +`localHostSuffix` is the **shared suffix** for every iframe origin the +mainsite may navigate to (`{gcid}-{userhash}.`); the +dev-app uses a wildcard for cert SAN, `--host-rules` MAP, and the +cert-verify check. + Dev-app → CLI over **stdout** — one JSON object per line: - `{"type":"ready"}` after first `did-finish-load`. @@ -100,5 +111,6 @@ Dev-app → CLI over **stdout** — one JSON object per line: stderr: server access log (one line per intercepted request) plus errors. Always forwarded by the CLI; `--verbose` only adds CLI-side noise. -Closing stdin from the CLI side signals the dev-app to quit (used for -clean Ctrl+C shutdown). +Shutdown: on Ctrl+C the CLI kills the child directly. The dev-app also +polls `--parent-pid=` every second and quits if the CLI process is +gone, so a CLI crash doesn't leave an orphan. diff --git a/dev-app/src/cert.ts b/dev-app/src/cert.ts index 1b56168..d14b0fb 100644 --- a/dev-app/src/cert.ts +++ b/dev-app/src/cert.ts @@ -2,7 +2,8 @@ * Generate a self-signed cert at startup. * * The cert is presented by the local HTTPS server and whitelisted via - * `session.setCertificateVerifyProc` for the game subdomain only — so we + * `session.setCertificateVerifyProc` for any host under the local suffix + * (every `{gcid}-{userhash}.{suffix}` the mainsite may navigate to) — so we * don't need a real CA, and we don't weaken trust for any real origin. * * One key per process; rotates every dev-app launch. @@ -16,9 +17,10 @@ export interface CertPair { keyPem: string; } -export function generateCert(commonName: string): CertPair { +export function generateCert(localHostSuffix: string): CertPair { + const wildcard = `*.${localHostSuffix}`; const pems = selfsigned.generate( - [{ name: 'commonName', value: commonName }], + [{ name: 'commonName', value: wildcard }], { keySize: 2048, days: 30, @@ -34,7 +36,7 @@ export function generateCert(commonName: string): CertPair { { name: 'subjectAltName', altNames: [ - { type: 2, value: commonName }, // DNS + { type: 2, value: wildcard }, // DNS — wildcard SAN { type: 7, ip: '127.0.0.1' }, // IPv4 — for direct localhost probes { type: 7, ip: '::1' }, // IPv6 ], diff --git a/dev-app/src/main.ts b/dev-app/src/main.ts index 4ca39b9..2c0412b 100644 --- a/dev-app/src/main.ts +++ b/dev-app/src/main.ts @@ -17,12 +17,12 @@ * at the top of this module so every per-app path is pinned before any * `app.whenReady` work runs. * 3. Start a local HTTPS server (`server.ts`) on a free port and apply - * `--host-rules=MAP :443 127.0.0.1:` so chromium - * routes the game iframe to us. No CDP `Fetch.enable`, so the bundled - * DevTools' Network tab works normally. + * `--host-rules=MAP *.:443 127.0.0.1:` so chromium + * routes every {gcid}-{userhash}.local. iframe to us. No CDP + * `Fetch.enable`, so the bundled DevTools' Network tab works normally. * 4. Open a single BrowserWindow, install a cert-verify proc that accepts - * our self-signed cert for the game subdomain, and navigate to the - * playtest URL. + * our self-signed cert for any host under , and + * navigate to the playtest URL. * 5. Emit `{"type":"ready"}` on first did-finish-load. * 6. On window close → emit `{"type":"closed"}` and exit 0. * 7. Parent watchdog: poll `process.kill(parentPid, 0)` once a second and @@ -54,7 +54,7 @@ import { startServer, type StartedServer } from "./server"; interface RawConfig { uploadDir: string; - gameSubdomain: string; + localHostSuffix: string; playtestUrl: string; verbose?: boolean; } @@ -134,7 +134,7 @@ function readConfig(): RawConfig { const parsed = JSON.parse(raw) as RawConfig; if ( typeof parsed.uploadDir !== "string" || - typeof parsed.gameSubdomain !== "string" || + typeof parsed.localHostSuffix !== "string" || typeof parsed.playtestUrl !== "string" ) { throw new Error("config missing required fields"); @@ -160,10 +160,13 @@ function watchParentProcess(): void { interval.unref(); } -function applyChromeSwitches(gameSubdomain: string, serverPort: number): void { +function applyChromeSwitches(localHostSuffix: string, serverPort: number): void { + // Wildcard MAP covers every {gcid}-{userhash}.{suffix} the mainsite may + // navigate to in the same dev session (account switches, incognito with + // an anonymous id, etc.). app.commandLine.appendSwitch( "host-rules", - `MAP ${gameSubdomain}:443 127.0.0.1:${serverPort}`, + `MAP *.${localHostSuffix}:443 127.0.0.1:${serverPort}`, ); // GPU: match chrome://flags/#{enable-unsafe-webgpu, enable-vulkan, // force-high-performance-gpu}. WebGPU games often need these for @@ -174,17 +177,19 @@ function applyChromeSwitches(gameSubdomain: string, serverPort: number): void { } /** - * Trust our self-signed cert for the game subdomain only. Every other - * hostname falls through to chromium's default verification — so HTTPS - * to wavedash.com / third-party CDNs is verified normally. + * Trust our self-signed cert for any host under the local suffix (every + * `{gcid}-{userhash}.{suffix}` the mainsite may navigate to). Every other + * hostname falls through to chromium's default verification — so HTTPS to + * wavedash.com / third-party CDNs is verified normally. * - * Safe because `--host-rules` guarantees that `:443` resolves - * to our 127.0.0.1 server inside this chromium instance. There is no path - * by which a remote origin could impersonate it. + * Safe because `--host-rules` guarantees those hosts resolve to our + * 127.0.0.1 server inside this chromium instance. There is no path by + * which a remote origin could impersonate them. */ -function trustLocalCertFor(s: Session, gameSubdomain: string): void { +function trustLocalCertFor(s: Session, localHostSuffix: string): void { + const dotSuffix = `.${localHostSuffix}`; s.setCertificateVerifyProc((request, callback) => { - if (request.hostname === gameSubdomain) { + if (request.hostname.endsWith(dotSuffix)) { callback(0); // 0 = accept return; } @@ -268,7 +273,7 @@ async function bootstrap(): Promise { try { server = await startServer({ uploadDir: config.uploadDir, - gameSubdomain: config.gameSubdomain, + localHostSuffix: config.localHostSuffix, verbose: !!config.verbose, }); } catch (err) { @@ -278,15 +283,15 @@ async function bootstrap(): Promise { } if (config.verbose) { process.stderr.write( - `local server listening on https://127.0.0.1:${server.port} (proxy for ${config.gameSubdomain})\n`, + `local server listening on https://127.0.0.1:${server.port} (proxy for *.${config.localHostSuffix})\n`, ); } - applyChromeSwitches(config.gameSubdomain, server.port); + applyChromeSwitches(config.localHostSuffix, server.port); await app.whenReady(); - trustLocalCertFor(session.defaultSession, config.gameSubdomain); + trustLocalCertFor(session.defaultSession, config.localHostSuffix); // Packaged builds get their icon from build/icon.png via electron-builder, // which runs Apple's icon template at .icns generation time so the bundled diff --git a/dev-app/src/server.ts b/dev-app/src/server.ts index b08d0de..000b23d 100644 --- a/dev-app/src/server.ts +++ b/dev-app/src/server.ts @@ -3,7 +3,7 @@ * * Replaces the CDP `Fetch.enable` interceptor that previously hijacked the * game subdomain inside chromium itself. Chromium now reaches us through - * `--host-rules=MAP :443 127.0.0.1:`, so the network + * `--host-rules=MAP *.:443 127.0.0.1:`, so the network * stack stays untouched and the bundled DevTools' Network tab works. * * Routing per request (matches the previous interceptor 1:1): @@ -11,7 +11,7 @@ * CUSTOM-HTML with the SDK bootstrap * injected). * 2. Path in PASSTHROUGH_PREFIXES → reverse-proxy to the real - * `https://` (Node DNS, + * `https://` (Node DNS, * no host-rules → real network). * 3. Otherwise → serve a file from `uploadDir` with * COEP/COOP/CORP and transparent @@ -30,7 +30,7 @@ import { generateCert, type CertPair } from "./cert"; export interface ServerConfig { uploadDir: string; - gameSubdomain: string; + localHostSuffix: string; verbose: boolean; } @@ -71,7 +71,7 @@ function logServed(res: http.ServerResponse, url: string): void { export async function startServer( config: ServerConfig, ): Promise { - const cert = generateCert(config.gameSubdomain); + const cert = generateCert(config.localHostSuffix); const server = https.createServer( { cert: cert.certPem, key: cert.keyPem }, @@ -109,10 +109,20 @@ async function handle( res: http.ServerResponse, config: ServerConfig, ): Promise { - // SNI is honored by https.createServer, so req.headers.host carries the - // browser-facing host (the game subdomain). We use that to build the - // proxy URL for passthrough paths. - const hostHeader = req.headers.host ?? config.gameSubdomain; + // Host header is the browser-facing `{gcid}-{userhash}.`. + const hostHeader = req.headers.host; + if (!hostHeader) { + res.statusCode = 400; + res.end("Missing Host header"); + return; + } + const originHost = hostHeader.split(":")[0]; + // Defense-in-depth: pin the upstream `proxyToOrigin` sees to our suffix. + if (!originHost.endsWith(`.${config.localHostSuffix}`)) { + res.statusCode = 400; + res.end("Unexpected Host"); + return; + } const url = new URL(req.url ?? "/", `https://${hostHeader}`); const urlPath = url.pathname; @@ -122,15 +132,15 @@ async function handle( log( "passthrough", req.method ?? "GET", - config.gameSubdomain + urlPath + url.search, + originHost + urlPath + url.search, ); } - await proxyToOrigin(req, res, config.gameSubdomain); + await proxyToOrigin(req, res, originHost); return; } serveLocalFile(res, config.uploadDir, urlPath); - logServed(res, config.gameSubdomain + urlPath); + logServed(res, originHost + urlPath); } catch (err) { process.stderr.write( `handler error for ${req.url ?? ""}: ${String(err)}\n`, @@ -152,15 +162,8 @@ function isPassthroughPath(p: string): boolean { /** - * Headers every response on the game subdomain must carry. Mirrors the - * production play worker (`play/src/server/index.ts`). - * - * - `Origin-Agent-Cluster: ?1` is sticky per origin per BrowsingContextGroup, - * so a single response without it locks the origin into site-keyed mode for - * the rest of the chromium session and breaks `crossOriginIsolated`. - * - `Cross-Origin-Resource-Policy: cross-origin` is required because the - * mainsite (which sets COEP=require-corp) is on a different site than the - * playsite (wavedash.com vs wavedashcdn.com, and likewise in dev). + * Mirrors the play worker: OAC=?1 (sticky per BrowsingContextGroup, lock-in + * if ever absent), CORP=cross-origin for the mainsite/playsite split. */ function setIframeOriginHeaders(res: http.ServerResponse): void { res.setHeader("Origin-Agent-Cluster", "?1"); diff --git a/src/dev/launcher.rs b/src/dev/launcher.rs index 6dd1f09..e95160e 100644 --- a/src/dev/launcher.rs +++ b/src/dev/launcher.rs @@ -37,7 +37,7 @@ use super::dev_app::DevAppLaunch; #[serde(rename_all = "camelCase")] pub struct DevAppConfig { pub upload_dir: String, - pub game_subdomain: String, + pub local_host_suffix: String, pub playtest_url: String, pub verbose: bool, } diff --git a/src/dev/mod.rs b/src/dev/mod.rs index b35f1fa..2283b0c 100644 --- a/src/dev/mod.rs +++ b/src/dev/mod.rs @@ -141,12 +141,7 @@ pub async fn handle_dev(config_path: Option, verbose: bool) -> Result<( let site_host = config::get("open_browser_website_host")?; let playsite_host = config::get("playsite_host")?; - // Iframe origin is `.local.` — must match - // GameRunnerComponent's `gameplayOrigin`, which builds the same string - // from PUBLIC_PLAYSITE_HOST and the Convex game `_id` (i.e. wavedash.toml's - // `game_id`, not the slug). The mainsite and playsite TLDs are split - // (wavedash.com vs wavedashcdn.com), so this MUST be the playsite. - let game_subdomain = format!("{}.local.{}", wavedash_config.game_id, playsite_host); + let local_host_suffix = format!("local.{}", playsite_host); let playtest_url = format!( "{}/playtest/{}/{}", @@ -159,7 +154,7 @@ pub async fn handle_dev(config_path: Option, verbose: bool) -> Result<( let dev_app_config = DevAppConfig { upload_dir: upload_dir.to_string_lossy().to_string(), - game_subdomain, + local_host_suffix, playtest_url, verbose, };