Skip to content

Unify local subdomain with builds subdomain#33

Merged
calvinstudebaker merged 3 commits into
mainfrom
local-subdomain-matches-prod
May 15, 2026
Merged

Unify local subdomain with builds subdomain#33
calvinstudebaker merged 3 commits into
mainfrom
local-subdomain-matches-prod

Conversation

@calvinstudebaker
Copy link
Copy Markdown
Member

No need to special case local domain
Both builds domain and local domain use gcid and user hash as the subdomain

@claude
Copy link
Copy Markdown

claude Bot commented May 15, 2026

Claude finished @calvinstudebaker's task in 2m 56s —— View job


Code Review

Generalizes the dev-app's host handling from a single <gameId>.local.<playsite> subdomain to a wildcard suffix (*.local.<playsite>). The mechanical refactor (rename game_subdomainlocal_host_suffix, wildcard MAP, wildcard cert SAN, endsWith cert-verify proc, Host-header-derived proxy origin) is consistent and reads cleanly. Below are the issues worth addressing before merging.


🔴 dev-app/README.md IPC contract is now wrong (will break anyone copying it)

dev-app/README.md:84-93 still documents the old field name and the wrong value shape:

{
  "uploadDir": "/abs/path",
  "gameSubdomain": "<gameCloudId>-<userHash>.local.wavedashcdn.com",
  ...
}

But after this PR, RawConfig (dev-app/src/main.ts:54) and DevAppConfig (src/dev/launcher.rs:40) both expect localHostSuffix — and the CLI sends just the suffix (local.wavedashcdn.com), not the full subdomain. readConfig will throw "config missing required fields" for anyone who follows this example. Fix:

{
  "uploadDir": "/abs/path",
  "localHostSuffix": "local.wavedashcdn.com",
  ...
}

While you're there, dev-app/README.md:13 ("Handles requests for the game subdomain") and :17-18 ("whitelisted … for the game subdomain only") and :27 ("Routing the game subdomain through …") are all stale in the same way — single-subdomain wording for what is now a wildcard. Fix this →


🟡 setIframeOriginHeaders lost the rationale that justified it

dev-app/src/server.ts:161-163 collapses what used to be a load-bearing comment into a one-liner. The deleted block was:

  • 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.

That explanation is precisely why setIframeOriginHeaders has to run on every response path including 404 (sendNotFound) and 500 (handle's catch). Future-you (or a refactor that adds a new response path) will be tempted to drop one without it. Nothing in the PR's scope made that rationale stale — please keep it.


🟡 build.rs drops a still-true cross-repo invariant

build.rs:7-11 removed:

Host that serves game iframes (e.g. wavedashcdn.com). Mirrors the mainsite's PUBLIC_PLAYSITE_HOST — must match, since the iframe origin is <gameId>.local.<PLAYSITE_HOST> and wavedash dev maps that subdomain to the local server via chromium --host-rules.

The <gameId>.local.<PLAYSITE_HOST> example is stale, but the invariant ("PLAYSITE_HOST here must match the mainsite's PUBLIC_PLAYSITE_HOST") is still load-bearing — if those drift, wavedash dev silently fails because the host-rules MAP won't catch the iframe load. Keep a trimmed version pointing to the new pattern (*.local.<PLAYSITE_HOST>).


🟢 Minor: cert-verify proc is broader than the cert SAN

dev-app/src/main.ts:192 accepts any host where hostname.endsWith('.local.wavedashcdn.com') — including multi-level like a.b.local.wavedashcdn.com. The wildcard cert (*.local.wavedashcdn.com) is only valid for one level. In practice callback(0) bypasses cert validation entirely, so this is consistent at runtime, and host-rules MAP *.<suffix> also only matches one level on the routing side, so multi-level hosts can't reach the server anyway. Not a bug — but if you ever change the cert override to fall through to default verification, the asymmetry will surprise you.


🟢 Minor: dev-app/package-lock.json added with no explanation

5336 lines of npm lockfile added in the same commit as the subdomain change. dev-app/README.md:67-72 documents bun install as the workflow, so an npm lockfile alongside that is at minimum surprising and possibly a mistake. Worth either splitting into its own commit with a "why" or removing if accidental.


🟢 Minor: PR description could explain the model change

The body says only "Both builds domain and local domain use gcid and user hash as the subdomain." The substantive change — that the dev-app no longer needs to know the specific gameId because the mainsite can navigate the iframe to any {gcid}-{userhash}.local.<playsite> dynamically — would help a reviewer (and future bisect) understand why the wildcard is necessary rather than just convenient.


Todo

  • Gather context from PR diff and changed files
  • Review Rust changes (build.rs, src/dev/launcher.rs, src/dev/mod.rs)
  • Review TypeScript changes (dev-app/src/cert.ts, main.ts, server.ts)
  • Review README and package-lock changes
  • Post review feedback
    • Branch: local-subdomain-matches-prod

@claude
Copy link
Copy Markdown

claude Bot commented May 15, 2026

Claude finished @calvinstudebaker's task in 3m 20s —— View job


Code Review

  • Gather context from PR diff and changed files
  • Review Rust changes (build.rs, src/dev/launcher.rs, src/dev/mod.rs)
  • Review TypeScript changes (dev-app/src/cert.ts, main.ts, server.ts)
  • Review README change
  • Post review feedback

Overall this is a clean, well-scoped refactor. Swapping a per-game MAP <gameId>.local.<host> rule for a single wildcard MAP *.local.<host> rule plus a wildcard self-signed cert is the right move — it lets the mainsite hand the dev-app any {gcid}-{userHash}.local.<playsite> host without re-launching, and the trust boundary stays narrow (only hosts under local.<playsite> are accepted). The Rust → JSON IPC rename (gameSubdomainlocalHostSuffix) is consistent across launcher.rs, mod.rs, and main.ts, and the proxy now correctly forwards to whatever host chromium asked for via the Host header.

Findings

Should fix

  • dev-app/README.md:89 is out of sync with the renamed IPC field. The example still says "gameSubdomain" (the field name was renamed to localHostSuffix in this PR) and the example value is also wrong — it's a suffix now, not a full subdomain. Inline comment with a suggestion block. While in there, the surrounding "over stdin" description (lines 84/103) is stale relative to the temp-file + --parent-pid IPC; worth a follow-up.

  • dev-app/src/server.ts:161-163: regression in comment quality on setIframeOriginHeaders. The deletion drops the explanation for why Origin-Agent-Cluster: ?1 and Cross-Origin-Resource-Policy: cross-origin are required. That rationale isn't local-vs-prod-specific — it's still load-bearing, and the OAC stickiness in particular is the kind of thing future maintainers need to know before removing or reordering a header. Inline comment with a suggested restored block.

Worth considering (optional)

  • dev-app/src/server.ts:112-122: no validation that Host is under localHostSuffix. The value flows directly into the upstream host: of https.request (line 429). Today the only path to this server is chromium's single-label MAP rule, so this is safe in practice — but a one-line endsWith check would harden against any future path that lets a non-mapped request reach the server, and would also make the assumption explicit in code rather than only in the comment.

  • dev-app/src/main.ts:189-198: endsWith(dotSuffix) in cert verification accepts multi-level subdomains (a.b.local.<host>) that neither the host-rules MAP nor the wildcard SAN actually cover. Benign today since those hosts can't reach the proc, but tightening to a single-label match keeps the trust surface aligned with what host-rules can deliver.

Nits

  • server.ts:112 comment still says "the game subdomain" (singular); with the wildcard, it's now "any host under localHostSuffix".
  • build.rs: removed comment is fine to drop — the iframe-origin specifics moved into mod.rs and main.ts where they're more discoverable.

Nothing blocking. The two should-fix items are doc/comment regressions, not behavior bugs.
• Branch: local-subdomain-matches-prod

Comment thread dev-app/README.md Outdated
{
"uploadDir": "/abs/path",
"gameSubdomain": "<gameId>.local.wavedashcdn.com",
"gameSubdomain": "<gameCloudId>-<userHash>.local.wavedashcdn.com",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The field name and example value here are now inconsistent with the code. The Rust side (src/dev/launcher.rs:40) serializes local_host_suffixlocalHostSuffix (camelCase), and main.ts:57 reads localHostSuffix. Also, the value isn't the full game subdomain anymore — it's just the suffix (e.g. local.wavedashcdn.com). The wildcard expansion happens in applyChromeSwitches and trustLocalCertFor, not in this field.

Suggested change
"gameSubdomain": "<gameCloudId>-<userHash>.local.wavedashcdn.com",
"localHostSuffix": "local.wavedashcdn.com",

Also worth noting (separate from this PR but adjacent): the surrounding text on line 84 still says "CLI → dev-app over stdin" and line 103 says "Closing stdin from the CLI side signals the dev-app to quit", but the IPC switched to a temp config file + --parent-pid= watchdog (see main.ts:8-13 and launcher.rs:5-13). Worth a follow-up to fix the rest of the section while you're here.

Comment thread dev-app/src/server.ts
Comment on lines 161 to 163
/**
* 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).
* Headers every response on the game subdomain must carry.
*/
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The deletion here drops useful context that isn't actually local-vs-prod-specific — the explanation for why each header is set still applies. In particular the Origin-Agent-Cluster: ?1 note is load-bearing: it's a per-origin, per-BCG sticky flag, and one response missing it locks the origin into site-keyed mode for the rest of the chromium session. That's the kind of thing future-you (or someone else touching this) needs to know before removing or reordering a header.

Suggest restoring the body of the comment and just adjusting the lead sentence to drop the "game subdomain" framing:

Suggested change
/**
* 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).
* Headers every response on the game subdomain must carry.
*/
/**
* Headers every response on the local server 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).
*/

Comment thread dev-app/src/server.ts Outdated
Comment on lines +112 to +122
// 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;
// browser-facing host (the game subdomain). chromium's host-rules MAP
// always sets it; we reject anything missing rather than guessing.
const hostHeader = req.headers.host;
if (!hostHeader) {
res.statusCode = 400;
res.end("Missing Host header");
return;
}
// Strip the optional `:port` so the proxy origin and logs use the bare host.
const originHost = hostHeader.split(":")[0];
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two thoughts here:

  1. Stale framing: the comment still says "the game subdomain" (singular). With the wildcard MAP, it's now "any host under localHostSuffix". Worth re-wording so the next reader doesn't get confused.

  2. No validation that hostHeader is actually under localHostSuffix: the value flows into proxyToOrigin as the upstream host (line 429). In practice the only path here is via chromium's host-rules MAP (single-label wildcard), so the host is constrained — but if anything ever reaches the server outside that path (a stray fetch from another local process, a misconfigured rule, etc.), this becomes a small SSRF: arbitrary Host: evil.com → request goes to evil.com:443 with the inbound headers. Cookie scoping should protect against credential leakage from chromium, but a cheap defense-in-depth would be:

    if (!originHost.endsWith(`.${config.localHostSuffix}`)) {
      res.statusCode = 400;
      res.end("Unexpected Host");
      return;
    }

    Up to you whether that's worth the line — fine to leave as-is given the local-only blast radius, but the assumption is worth making explicit either in code or in the comment.

Comment thread dev-app/src/main.ts
const dotSuffix = `.${localHostSuffix}`;
s.setCertificateVerifyProc((request, callback) => {
if (request.hostname === gameSubdomain) {
if (request.hostname.endsWith(dotSuffix)) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: endsWith(dotSuffix) accepts multi-level subdomains (a.b.local.wavedashcdn.com), but both the chromium MAP *.<suffix>:443 rule and the wildcard SAN cert only cover single-label (a.local.wavedashcdn.com). The asymmetry is benign today because the host-rules rule is what actually routes traffic here, so multi-level hosts never reach this proc — but pinning the check to single-label keeps the trust surface aligned with what host-rules can deliver, and reads as more intentional:

s.setCertificateVerifyProc((request, callback) => {
  if (request.hostname.endsWith(dotSuffix)) {
    const label = request.hostname.slice(0, -dotSuffix.length);
    if (label.length > 0 && !label.includes(".")) {
      callback(0);
      return;
    }
  }
  callback(-3);
});

Optional — happy to leave as-is if you'd rather not add the extra check.

@claude
Copy link
Copy Markdown

claude Bot commented May 15, 2026

Claude finished @calvinstudebaker's task in 2m 0s —— View job


Code Review (re-review of 681234b)

The latest commit cleanly addresses every should-fix item from the earlier reviews. Nice tight pass — what landed is better than what either prior review asked for in a few places (notably the actual defense-in-depth check at server.ts:121, which the earlier review only floated as optional). Below is what I verified and the one residual nit.


What's resolved

  • dev-app/README.md (was the main blocker)
    • gameSubdomainlocalHostSuffix in the IPC example (README.md:94), value shape matches what launcher.rs:40 serializes and main.ts:57 parses.
    • "game subdomain" framing replaced throughout (lines 13–17, 30–32) with the wildcard model.
    • Bonus: stdin → temp-file IPC description (README.md:87–89, :114–116) updated to match the --config-path / --parent-pid reality. The README.md is now coherent end-to-end with the code, which is more than the prior review asked for.
  • build.rs:8–9 — invariant ("must match mainsite's PUBLIC_PLAYSITE_HOST") preserved in a trimmer form, updated to the new *.local.<PLAYSITE_HOST> pattern. Good.
  • dev-app/src/server.ts:164–167 (setIframeOriginHeaders) — restored, condensed. Both load-bearing facts kept: OAC ?1 is per-BCG sticky (one missing response = site-keyed lock-in), CORP cross-origin is required for the mainsite/playsite split. Compression is fine; the rationale survives.
  • dev-app/src/server.ts:120–125endsWith('.${localHostSuffix}') validation on the inbound Host. Closes the small SSRF-shaped surface that the earlier review flagged as optional. The 400 path is reachable only from non-chromium probes (chromium's *.<suffix> MAP guarantees a matching Host), so skipping setIframeOriginHeaders on the error response is fine — no real iframe load lands there.
  • npm lockfile noise — resolved by 2713a64 ("don't commit package lock dev app uses bun").

Residual nit (still optional)

dev-app/src/main.ts:192 and dev-app/src/server.ts:121 use the same endsWith(dotSuffix) shape, which accepts multi-level subdomains (a.b.local.<host>) that chromium's MAP *.<suffix>:443 rule won't actually route here. Two consequences:

  1. Asymmetry between the cert SAN, the cert-verify proc, and the new server suffix check — all three nominally cover "hosts under <suffix>", but the wildcard SAN and the host-rules MAP are single-label, while the two endsWith checks are not. Benign today (multi-level hosts can't reach the proc), but the trust surface drifts from "what host-rules can deliver" to "anything under the suffix" — a future routing change could turn the asymmetry from cosmetic into real.

  2. Both checks would read more intentional with a single-label match. Sketch:

    function isSingleLabelUnder(host: string, suffix: string): boolean {
      const dotSuffix = `.${suffix}`;
      if (!host.endsWith(dotSuffix)) return false;
      const label = host.slice(0, -dotSuffix.length);
      return label.length > 0 && !label.includes(".");
    }

    Use the same helper in trustLocalCertFor (main.ts:192) and the handle Host check (server.ts:121). Fix this →

Still optional — fine to leave as-is, the practical blast radius is "a stray local process couldn't reach the proc anyway." Just flagging it because the asymmetry is now codified in two places rather than one.


Verdict

Nothing blocking. The prior should-fix items are all resolved and the doc/comment quality is materially better than before this PR — README.md in particular is now in sync with both the IPC change and the earlier stdin→temp-file switch. LGTM modulo the single-label-match nit, which is the reviewer's call.

Todo

  • Inspect latest state of the PR (commit 681234b "clean up comments and docs")
  • Re-review Rust changes (build.rs, src/dev/launcher.rs, src/dev/mod.rs)
  • Re-review TypeScript changes (dev-app/src/cert.ts, main.ts, server.ts)
  • Re-review docs (dev-app/README.md)
  • Post review
    • Branch: local-subdomain-matches-prod

@calvinstudebaker calvinstudebaker merged commit 0ba0a9e into main May 15, 2026
8 checks passed
@calvinstudebaker calvinstudebaker deleted the local-subdomain-matches-prod branch May 15, 2026 20:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant