Skip to content

Rewrite wavedash dev as a plain local static server#35

Merged
franzwarning merged 8 commits into
mainfrom
wavedash-dev
Jun 12, 2026
Merged

Rewrite wavedash dev as a plain local static server#35
franzwarning merged 8 commits into
mainfrom
wavedash-dev

Conversation

@franzwarning

@franzwarning franzwarning commented Jun 11, 2026

Copy link
Copy Markdown
Member

What

wavedash dev no longer downloads and launches the Electron dev-app — the game now runs top-level at http://localhost:PORT from a plain axum static server. dev-app/, its release workflow, the self-signed certs, and the --host-rules subdomain MITM are all deleted (−3,200 lines net).

Auth (per-browser sessions)

Credential-less requests 303 to the mainsite /auth/dev handoff, which mints a 1-day gameplay session for that browser (signed-in user, or anonymous — incognito gets a play-anonymously choice page) and bounces back to /__wavedash/callback; the token lives in an HttpOnly wd_session cookie. /auth/refresh proxies the cookie to /api/dev/refresh-gameplay for fresh 1h gameplay JWTs, so play sessions renew silently. A 401 refresh clears the dead cookies; HTML navigations without this browser's cookies are sent back through the handoff — so a reload self-heals cleared cookies and expired sessions.

SDK injection (prod-identical mechanism)

At serve time the server injects a parser-blocking classic script tag for @wvdsh/sdk-js dist/inject.global.js (jsdelivr @latest) right after <head> — same approach as prod's embed.js injection, so window.Wavedash exists before any game script parses. A second tag loads /__wavedash/dev.js: boot-error surfacing plus an init gate that stays up until the game calls Wavedash.init() (stricter than prod, deliberately — with a hint after 10s).

Engine builds boot through play's real default entrypoints

engine_shell.html sets window.entrypointParams (computed by /cli/entrypoint-params for Unity/Godot from the exported HTML — same parsers prod runs at upload — or from the toml for js-dos/Ruffle) and loads {PLAYSITE_HOST}/default-entrypoint.js?engine&engineVersion. The CLI has zero version-resolution logic; stale CLIs gain new engine versions on play deploys. .js entrypoints get a synthetic shell; HTML entrypoints are served with tags injected.

Misc

  • Engine entrypoints load from the existing PLAYSITE_HOST build env (bare play domain, same value as mainsite's PUBLIC_PLAYSITE_HOST; https-prefixed like SITE_HOST) — already present in every cli Doppler config, so no release prep needed. CF_R2_WAVEDASH_DEV_APP_HOST removed.
  • --verbose: timestamped request log (status, method, path) with decoded query params printed as indented aligned key/value lines.
  • Deps: −zip −rcgen −axum-server −tower −tower-http, +mime_guess +chrono(clock).
  • release.yml regenerated via dist generate after dropping the dev-app jobs.

Verification

Full E2E against the live local stack across multiple sessions: JS leaderboard game (read/write, achievements, stats), Godot 4.x fixture through play's real entrypoint, custom-path regression, session-expiry/cleared-cookie recovery, incognito anonymous flow — all green. cargo check/clippy clean.

Companion PRs: wvdsh/wavedash and wvdsh/play (wavedash-dev branches).


Note

Medium Risk
Large behavioral change to the primary local dev workflow (auth cookies, engine boot path) and removal of the packaged dev-app release pipeline; core CLI upload/auth paths are mostly unchanged.

Overview
Replaces the Electron dev-app with an in-process local dev serverwavedash dev now serves the upload directory at http://localhost:<port> via axum instead of downloading a platform zip, spawning Electron, and MITMing *.local.<playsite> with self-signed HTTPS.

The dev-app/ tree, dev-app-release.yml, and R2 dev-app publishing are removed from CI (release.yml no longer waits on custom-dev-app-release). Build env drops CF_R2_WAVEDASH_DEV_APP_HOST; PLAYSITE_HOST is used for engine default-entrypoint URLs.

src/dev is rewritten around server::run: per-browser auth (redirect to mainsite /auth/dev, /__wavedash/callback, HttpOnly wd_session cookies, proxied /auth/refresh), HTML injection of the SDK (inject.global.js) plus /__wavedash/dev.js (boot gate / errors), and engine boots through {playsite}/default-entrypoint.js with params from /cli/entrypoint-params or toml. Old modules (dev_app, launcher, zip/CDN install) are deleted; first run sweeps stale ~/.wavedash/dev-app* caches.

Dependencies: drops zip, axum-server, rcgen, tower/tower-http; adds jsonwebtoken, mime_guess, chrono. CLI version 0.1.84.

Reviewed by Cursor Bugbot for commit 6253341. Bugbot is set up for automated code reviews on this repo. Configure here.

Drops the Electron dev-app entirely (dev-app/, its release workflow,
self-signed certs, --host-rules subdomain MITM) in favor of a plain
axum static server: the game runs top-level at http://localhost:PORT.

- Per-browser auth: requests without credentials 303 to the mainsite
  /auth/dev handoff, which mints a 1-day gameplay session per browser
  (signed-in user or anonymous) and bounces back to /__wavedash/callback;
  the session token lives in an HttpOnly wd_session cookie. /auth/refresh
  proxies the cookie to /api/dev/refresh-gameplay for fresh 1h gameplay
  JWTs, so sessions renew silently. A 401 clears the dead cookie and the
  handoff re-runs on reload; navigations carrying ?sdkconfig without the
  cookie are redirected back through the handoff (cookie is the
  credential, sdkconfig is not).
- SDK injection at serve time, prod-identical mechanism: a parser-
  blocking classic script tag for @wvdsh/sdk-js dist/inject.global.js
  (jsdelivr @latest) right after <head>, followed by /__wavedash/dev.js
  (boot-error surfacing + an init gate that stays up until the game
  calls Wavedash.init()).
- Engine builds (unity/godot/jsdos/ruffle) boot through play's real
  default entrypoints: engine_shell.html sets window.entrypointParams
  (computed by the un-deprecated /cli/entrypoint-params for unity/godot,
  or the toml for jsdos/ruffle) and loads
  {PLAY_STATICS_HOST}/default-entrypoint.js?engine&engineVersion. The
  CLI has zero version-resolution logic. .js entrypoints get a synthetic
  shell; HTML entrypoints are served with the tags injected.
- New required build env PLAY_STATICS_HOST (bare play domain). Removed
  PLAYSITE_HOST. Dropped deps: zip, rcgen, axum-server, tower,
  tower-http; added mime_guess, chrono (clock).
- --verbose: timestamped request log with status, method, path, and
  decoded query params as indented aligned key/value lines.
- release.yml regenerated via dist generate after removing the dev-app
  local-artifacts jobs from dist-workspace.toml.
@claude

claude Bot commented Jun 11, 2026

Copy link
Copy Markdown

Claude finished @franzwarning's task in 3m 24s —— View job


Code Review — Rewrite wavedash dev as a plain local static server

  • Gather PR diff and context
  • Review new server (src/dev/server.rs)
  • Review mod.rs, config, build.rs changes
  • Review injected assets (dev.js, engine_shell.html, synth_shell.html)
  • Post review feedback

This is a strong, well-documented rewrite — the prod-parity framing (embed.tsx entry rule, embed.js injection mechanism, play's real default entrypoints) is the right call, and dropping the Electron/cert/MITM machinery removes a lot of surface area. The path-traversal guard, COOP/COEP parity, <-escaping in the engine shell, and the cleared-cookie self-heal are all handled carefully. A few things worth addressing before merge:


🟠 1. dev.js init gate crashes (silently) when the SDK bundle fails to load

src/dev/dev.js:35initGate() opens with if (window.Wavedash.initialized) return;. If the parser-blocking inject.global.js tag fails (jsdelivr unreachable, offline, @latest resolves to a broken publish), window.Wavedash is never defined, so this line throws TypeError: Cannot read properties of undefined and the overlay never mounts. The result is a blank page with no error surfaced — which is exactly the failure mode this file exists to catch. The unhandledrejection handler doesn't help here since no SDK promise is ever created.

Suggest guarding for the missing global and surfacing it, e.g.:

function initGate() {
  var wd = window.Wavedash;
  if (wd && wd.initialized) return;
  // ...build overlay...
  if (!window.Wavedash && Date.now() - started > 3000) {
    msg.textContent = 'Wavedash SDK failed to load (CDN unreachable?). Check your connection and reload.';
  }
  // tick: also re-check window.Wavedash existence before reading .initialized
}

Fix this →

🟡 2. Confirm PLAY_STATICS_HOST serves a CORP header for the entrypoint script

Every response sets cross-origin-embedder-policy: require-corp. engine_shell.html:35 loads {{ENTRYPOINT_URL}} from PLAY_STATICS_HOST as a plain <script> with no crossorigin attribute (a no-cors cross-origin load). Under COEP require-corp, that load is blocked unless the response carries Cross-Origin-Resource-Policy: cross-origin/cross-site. INJECT_URL is explicitly noted as CORP-safe (jsdelivr); the entrypoint host is not. If play serves CORP on these statics (it should, since prod's embed runs the same path) this is fine — just worth confirming, since a missing header would break all engine builds while custom HTML keeps working.

🟡 3. unhandledrejection matches on a hard-coded SDK message string

src/dev/dev.js:25 keys the "session expired — reload" message off String(event.reason).indexOf('Failed to refresh gameplay token'). This silently couples the CLI to an exact SDK error string; if @wvdsh/sdk-js@latest rewords it, the friendly recovery hint disappears (the user just gets a stuck gate). Low severity, but since the SDK is pulled at @latest and versions independently, it's a real drift risk — worth a comment noting the coupling, or a more tolerant match.

🔵 4. Minor notes

  • generate_state() (src/auth.rs:121) is a nanosecond timestamp, not a CSRF-grade random token. It's now also the handoff state for wavedash dev. Fine for a localhost-only dev flow, but it's predictable — a random token would be stronger if this ever widens.
  • CallbackParams rejectionQuery<CallbackParams> (server.rs:152) 400s with axum's default rejection text if any of session_token/sdkconfig/state is absent. Since the mainsite controls these param names, a rename there yields a cryptic error rather than the explicit "Unexpected state" path. Optional: make fields Option and return a clearer message.
  • resolve_path (server.rs:395) re-canonicalize()s upload_dir on every request even though mod.rs:90 already canonicalized it before constructing ServeConfig. Harmless, just a redundant syscall per request.
  • Nice touch: head_insert_pos uses to_ascii_lowercase() (byte-length-preserving) so the index stays valid when slicing the original html — this is correct precisely because it's ASCII-only lowercasing. Worth keeping the comment near it so a future to_lowercase() "cleanup" doesn't introduce a panic.

Note: I could not run cargo check/clippy here — doppler isn't available in this environment and build.rs hard-requires SITE_HOST/CONVEX_HTTP_URL/PLAY_STATICS_HOST. Review above is static. ⚠️ Per the PR description, remember to set PLAY_STATICS_HOST in the prd/stg Doppler configs before the next release or the build will fail loudly.
wavedash-dev

Comment thread src/dev/dev.js
document.body.append(overlay);
var started = Date.now();
var tick = setInterval(function () {
if (window.Wavedash.initialized) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Gate assumes Wavedash exists

Medium Severity

The initGate function accesses window.Wavedash.initialized without checking if window.Wavedash is defined. If the SDK script fails to load, this throws an error, which prevents the loading gate and any boot error messages from displaying.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4b6a9ea. Configure here.

Comment thread src/dev/server.rs
Comment thread src/dev/mod.rs
synth_shell.html and engine_shell.html merge into shell.html:
window.entrypointParams is always set (null for plain .js entries),
{{SCRIPT_SRC}} is either play's default entrypoint or the game's own
script, and both get the onerror -> gate error surfacing that was
engine-only before.

The engine shell's #wavedash-target is fixed inset-0, which would
swallow every click in a .js game that doesn't mount into it — so it's
visibility:hidden while :empty: no paint, no hit-testing, but its
viewport dimensions stay measurable for entrypoints that size the
canvas before mounting, and it shows itself as soon as an entrypoint
(or the game) appends content.

dev.js falls back to a generic message when the failed entry's error
body is empty, since the local static 404s are plain text.
@claude

claude Bot commented Jun 11, 2026

Copy link
Copy Markdown

Claude finished @franzwarning's task in 3m 8s —— View job


Code Review — Rewrite wavedash dev as a plain local static server

  • Gather PR diff & CLAUDE.md context
  • Review server (src/dev/server.rs)
  • Review mod.rs, config.rs, build.rs
  • Review injected assets (dev.js, shell.html)
  • Validate high-signal issues
  • Post inline review comments

I re-ran a focused, high-signal pass over the latest commit (def510f). Solid rewrite — the prod-parity framing, the path-traversal guard in resolve_path, the byte-length-preserving to_ascii_lowercase() head-insert, COOP/COEP parity, and the cleared-cookie self-heal all hold up. I posted 2 inline comments for confirmed issues:

  • src/dev/dev.js:36-37initGate() derefs window.Wavedash.initialized unguarded (and again in the tick at L55). If the parser-blocking SDK bundle fails to load, window.Wavedash is undefined → TypeError, the overlay never mounts, and the user gets a blank page with no error — the exact failure mode this file exists to catch.
  • src/dev/server.rs:212-223handle_auth_refresh clears wd_session and returns 401 on any non-success upstream response, so a transient backend 5xx silently drops a still-valid 1-day session. Only 401/403 should clear the cookie; transient errors should 502 (as the Err(_) arm already does).

Checked but not flagged

  • "Browser opens before server" (Cursor Bugbot) — false positive. The listener is bound at mod.rs:146 before open::that at mod.rs:169; axum::serve only accept()s afterward. TCP completes the handshake and queues the connection in the listen backlog, so the first navigation waits rather than getting connection-refused.
  • generate_state() predictability, CallbackParams rejection text, redundant canonicalize() per request, has_sdkconfig substring match — minor / localhost-dev-only; not high-signal enough to flag inline.

Note: I couldn't run cargo check/clippy here — doppler is unavailable and build.rs hard-requires SITE_HOST/CONVEX_HTTP_URL/PLAY_STATICS_HOST, so the review is static. ⚠️ Per the PR description, set PLAY_STATICS_HOST in the prd/stg Doppler configs before the next release or the build fails loudly.
wavedash-dev

Comment thread src/dev/dev.js
Comment on lines +36 to +37
function initGate() {
if (window.Wavedash.initialized) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: the init gate crashes (silently) when the SDK bundle fails to load — exactly the failure this file exists to surface.

initGate() opens with if (window.Wavedash.initialized) return;. The injected inject.global.js tag (jsdelivr @latest) is parser-blocking, so if it fails to load — CDN unreachable, offline, or @latest resolving to a broken publish — window.Wavedash is never defined. This line then throws TypeError: Cannot read properties of undefined (reading 'initialized'), the overlay never mounts, and the user gets a blank page with no error shown. The unhandledrejection handler doesn't help, since no SDK promise is ever created.

The same unguarded deref repeats inside the tick at dev.js:55, so even guarding line 37 alone would leave the interval throwing before it ever reaches the boot-error display at lines 62–68.

Suggested fix (both spots): read var wd = window.Wavedash; and guard wd && wd.initialized, and when wd is still undefined after a short delay, surface a "Wavedash SDK failed to load (CDN unreachable?) — check your connection and reload." message in msg instead of letting the gate throw.

Comment thread src/dev/server.rs Outdated
Comment on lines +212 to +223
// Expired/revoked session. Clear the dead cookie so the next page
// load re-runs the handoff instead of serving a game that 401s again.
Ok(_) => Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header(header::CONTENT_TYPE, TEXT)
.header(header::CACHE_CONTROL, "no-store")
.header(
header::SET_COOKIE,
format!("{SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"),
)
.body(Body::from("Session expired"))
.unwrap(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A transient upstream error drops a still-valid dev session.

This arm treats any non-success response from /api/dev/refresh-gameplay as "expired/revoked" and clears wd_session. But Ok(_) also catches transient upstream failures — a 502/503 from the backend, a brief blip — and those will silently delete a perfectly valid 1-day session, forcing the browser back through the full /auth/dev handoff mid-play.

Only a genuine auth rejection (401/403) should clear the cookie. Suggest matching the status explicitly:

// Truly dead session — clear the cookie so the next load re-runs the handoff.
Ok(res) if matches!(res.status().as_u16(), 401 | 403) => Response::builder()
    .status(StatusCode::UNAUTHORIZED)
    // ... existing Set-Cookie Max-Age=0 + "Session expired" body ...
    .unwrap(),
// Transient upstream error — surface as 502 WITHOUT clearing a valid cookie.
Ok(_) => respond(StatusCode::BAD_GATEWAY, TEXT, None, "Refresh failed"),

(Matches the Err(_) arm's 502 behavior, which already correctly leaves the cookie intact — see server.rs:224-227.)

Comment thread src/dev/server.rs Outdated
The mainsite handoff now redirects back with a short-lived playKey
(prod's play-iframe credential) rather than the 1-day session token.
The callback exchanges it server-side via the api-key-gated
/api/dev/exchange-playkey and stores the returned session token in the
wd_session cookie — the long-lived credential never rides in a URL.
An expired playKey (redirect restored past its 2-minute TTL) gets a
clear 401 telling the dev to reopen the game URL.
@claude

claude Bot commented Jun 11, 2026

Copy link
Copy Markdown

Claude finished @franzwarning's task in 2m 18s —— View job


Code Review — Rewrite wavedash dev as a plain local static server

  • Gather PR diff & current branch state
  • Review src/dev/server.rs
  • Review src/dev/mod.rs, config.rs, build.rs, auth.rs
  • Review injected assets (dev.js, shell.html)
  • Post review feedback

I re-reviewed at the current HEAD (b6e18f4), which adds the playKey exchange at the callback (the browser now receives a short-lived single-purpose playKey in the URL and the server exchanges it server-side for the 1-day session — a genuine improvement: no session token ever rides in a URL). The prod-parity framing, the path-traversal guard, COOP/COEP parity, the byte-length-preserving to_ascii_lowercase() head-insert, and the cleared-cookie self-heal all continue to hold up.

Two confirmed issues from the prior reviews are still present at this commit — I posted both inline:

🟠 src/dev/dev.js:37 (and :55) — init gate crashes silently if the SDK bundle fails to load

initGate() derefs window.Wavedash.initialized unguarded. If the parser-blocking inject.global.js (jsdelivr @latest) fails — offline, CDN down, broken @latest publish — window.Wavedash is undefined → TypeError, the overlay never mounts, blank page with no error. That's the exact failure mode this file exists to catch. Guard wd && wd.initialized in both spots and surface a "SDK failed to load" message when it stays undefined.

🟠 src/dev/server.rs:256 — refresh drops a still-valid session on a transient upstream error

The Ok(_) arm of handle_auth_refresh clears wd_session on any non-success from /api/dev/refresh-gameplay, including a 502/503 blip — silently killing a valid 1-day session and forcing a full handoff mid-play. Only 401/403 should clear the cookie; transient errors should 502 and leave it intact (matching the Err(_) arm right below). Suggestion posted inline.

🟡 Worth confirming / minor

  • CORP on the entrypoint scriptshell.html:44 loads {{SCRIPT_SRC}} (the PLAY_STATICS_HOST default-entrypoint.js) as a plain <script> with no crossorigin. Under the cross-origin-embedder-policy: require-corp every response sets, that cross-origin no-cors load is blocked unless play serves Cross-Origin-Resource-Policy: cross-origin. INJECT_URL is noted CORP-safe; the entrypoint host isn't — a missing header breaks all engine builds while custom HTML keeps working. Should be fine if prod's embed loads the same path, just worth a one-line confirm.
  • Callback exchange arm (server.rs:198) has the same any-non-success → 401 pattern, but it's far less harmful (one-shot handoff, reopening the URL recovers), so leaving it is reasonable.
  • unhandledrejection string match (dev.js:27) couples the recovery hint to the exact SDK error string 'Failed to refresh gameplay token'; since the SDK is pulled at @latest and versions independently, a reword silently drops the hint. Low severity — a comment noting the coupling would help.
  • Engine shell only on / (Cursor) — handle_index returns the engine shell for /, but other HTML paths fall through to handle_static (SDK-injected, no engine boot). In practice play's default entrypoint boots into #wavedash-target and doesn't navigate, so this is low-risk, but worth keeping in mind if an engine ever does a sub-navigation.
  • generate_state() (auth.rs:121) is a nanosecond timestamp, now pub(crate) and reused as the dev handoff CSRF state. Predictable but localhost-dev-only; a random token would be stronger if this ever widens.
  • resolve_path re-canonicalize()s upload_dir every request (server.rs:429) even though mod.rs:90 already canonicalized it — harmless redundant syscall.

⚠️ Per the PR description: set PLAY_STATICS_HOST in the prd/stg Doppler configs before the next release — build.rs now hard-requires it and will fail loudly otherwise.

Note: I couldn't run cargo check/clippy here — doppler is unavailable and build.rs requires SITE_HOST/CONVEX_HTTP_URL/PLAY_STATICS_HOST, so this review is static.
wavedash-dev

Comment thread src/dev/dev.js
// during parse never see a frame of it. Translucent on purpose, so engine
// loading UI stays visible underneath.
function initGate() {
if (window.Wavedash.initialized) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Still unaddressed in b6e18f4: the init gate crashes silently when the SDK bundle fails to load — the exact failure this file exists to surface.

initGate() opens with if (window.Wavedash.initialized) return;. The injected inject.global.js tag (jsdelivr @latest) is parser-blocking, so if it fails to load — CDN unreachable, offline, or @latest resolving to a broken publish — window.Wavedash is never defined and this line throws TypeError: Cannot read properties of undefined (reading 'initialized'). The overlay never mounts and the user gets a blank page with no error. The unhandledrejection handler doesn't help, since no SDK promise was ever created.

The same unguarded deref repeats in the tick at dev.js:55, so guarding line 37 alone still leaves the interval throwing before it can reach the boot-error display (lines 62–68).

Suggested fix for both spots: read var wd = window.Wavedash; and guard wd && wd.initialized, and when wd is still undefined after a short delay, surface a "Wavedash SDK failed to load (CDN unreachable?) — check your connection and reload." message in msg instead of letting the gate throw.

Fix this →

Comment thread src/dev/server.rs
Comment on lines +256 to +265
Ok(_) => Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header(header::CONTENT_TYPE, TEXT)
.header(header::CACHE_CONTROL, "no-store")
.header(
header::SET_COOKIE,
format!("{SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"),
)
.body(Body::from("Session expired"))
.unwrap(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Still unaddressed in b6e18f4: a transient upstream error drops a still-valid 1-day dev session.

This Ok(_) arm treats any non-success response from /api/dev/refresh-gameplay as "expired/revoked" and clears wd_session. But it also catches transient upstream failures — a 502/503, a brief backend blip — and silently deletes a perfectly valid session, forcing the browser back through the full /auth/dev handoff mid-play.

Only a genuine auth rejection (401/403) should clear the cookie; transient errors should surface as 502 and leave the cookie intact — matching the Err(_) arm just below (server.rs:266), which already gets this right.

        // Truly dead session — clear the cookie so the next load re-runs the handoff.
        Ok(res) if matches!(res.status().as_u16(), 401 | 403) => Response::builder()
            .status(StatusCode::UNAUTHORIZED)
            .header(header::CONTENT_TYPE, TEXT)
            .header(header::CACHE_CONTROL, "no-store")
            .header(
                header::SET_COOKIE,
                format!("{SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"),
            )
            .body(Body::from("Session expired"))
            .unwrap(),
        // Transient upstream error — don't clear a still-valid cookie.
        Ok(_) => respond(StatusCode::BAD_GATEWAY, TEXT, None, "Refresh failed"),

(The same any-non-success pattern is in the new handle_callback exchange arm at server.rs:198, but it's far less harmful there — the playKey handoff is one-shot and reopening the URL recovers cleanly.)

Comment thread src/dev/server.rs
@claude

claude Bot commented Jun 11, 2026

Copy link
Copy Markdown

Claude finished @franzwarning's task in 1m 50s —— View job


Code Review — Rewrite wavedash dev as a plain local static server

  • Gather diff & current branch state
  • Review src/dev/server.rs
  • Review src/dev/mod.rs, config.rs, build.rs, auth.rs
  • Review injected assets (dev.js, shell.html)
  • Check whether prior-review issues were addressed
  • Post review feedback

I re-reviewed at the current HEAD (460dc0c). That commit, "Trim comments to the non-obvious why," is comments-only across dev.js/mod.rs/server.rs/shell.htmlgit show confirms no logic changed. So the prod-parity framing, path-traversal guard, COOP/COEP parity, the byte-length-preserving to_ascii_lowercase() head-insert, the playKey exchange, and the cleared-cookie self-heal all still hold up — and the two confirmed issues from the prior reviews remain unaddressed at this commit. I'm not re-posting duplicate inline comments (they're already on the lines below); this is the consolidated status.


🟠 1. dev.js init gate still crashes silently when the SDK bundle fails to load — src/dev/dev.js:30 (and the tick at :48)

initGate() opens with if (window.Wavedash.initialized) return;. The injected inject.global.js tag (jsdelivr @latest) is parser-blocking, so if it fails — CDN unreachable, offline, or @latest resolving to a broken publish — window.Wavedash is never defined and this line throws TypeError: Cannot read properties of undefined (reading 'initialized'). The overlay never mounts, the user gets a blank page with no error surfaced — the exact failure mode this file exists to catch. The unhandledrejection handler doesn't help, since no SDK promise was ever created. The same unguarded deref repeats in the tick at dev.js:48, so guarding line 30 alone still leaves the interval throwing before it reaches the boot-error display (lines 55–61).

Suggested fix (both spots): read var wd = window.Wavedash;, guard wd && wd.initialized, and after a short delay with wd still undefined, surface "Wavedash SDK failed to load (CDN unreachable?) — check your connection and reload." in msg rather than letting the gate throw.

Fix this →

🟠 2. Refresh still drops a still-valid 1-day session on a transient upstream error — src/dev/server.rs:230

The Ok(_) arm of handle_auth_refresh clears wd_session and returns 401 on any non-success from /api/dev/refresh-gameplay, including a 502/503 blip — silently killing a valid session and forcing a full /auth/dev handoff mid-play. Only a genuine auth rejection (401/403) should clear the cookie; transient errors should 502 and leave it intact — matching the Err(_) arm just below (server.rs:240-243), which already gets this right.

        // Truly dead session — clear the cookie so the next load re-runs the handoff.
        Ok(res) if matches!(res.status().as_u16(), 401 | 403) => Response::builder()
            .status(StatusCode::UNAUTHORIZED)
            .header(header::CONTENT_TYPE, TEXT)
            .header(header::CACHE_CONTROL, "no-store")
            .header(
                header::SET_COOKIE,
                format!("{SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"),
            )
            .body(Body::from("Session expired"))
            .unwrap(),
        // Transient upstream error — don't clear a still-valid cookie.
        Ok(_) => respond(StatusCode::BAD_GATEWAY, TEXT, None, "Refresh failed"),

Fix this →

(The callback exchange arm at server.rs:175 has the same any-non-success → 401 shape, but it's far less harmful — the playKey handoff is one-shot and reopening the URL recovers, so leaving it is reasonable.)


🟡 Worth confirming / minor (unchanged from prior passes)

  • CORP on the entrypoint scriptshell.html loads {{SCRIPT_SRC}} (the PLAY_STATICS_HOST default-entrypoint.js) as a plain <script> with no crossorigin. Under the cross-origin-embedder-policy: require-corp every response sets (server.rs:333), that cross-origin no-cors load is blocked unless play serves Cross-Origin-Resource-Policy: cross-origin. INJECT_URL is noted CORP-safe; the entrypoint host isn't — a missing header breaks all engine builds while custom HTML keeps working. Should be fine if prod's embed loads the same path; worth a one-line confirm.
  • Case-sensitive .js entry (Cursor) — handle_index (server.rs:271) gates the synthetic shell on cfg.entry.ends_with(".js"), lowercase only. A configured entry like game.JS would fall through to a static redirect with no shell/SDK injection. Low-risk (uncommon casing), but ends_with(".js") on entry.to_ascii_lowercase() would close it.
  • Engine shell only on / (Cursor) — other HTML paths fall through to handle_static (SDK-injected, no engine boot). In practice play's default entrypoint boots into #wavedash-target without sub-navigating, so low-risk; worth keeping in mind.
  • unhandledrejection string match (dev.js:22) couples the recovery hint to the exact SDK string 'Failed to refresh gameplay token'; since the SDK is pulled @latest and versions independently, a reword silently drops the hint. A comment noting the coupling would help.
  • generate_state() (auth.rs) is a nanosecond timestamp reused as the dev handoff CSRF state — predictable but localhost-dev-only; a random token would be stronger if this ever widens.
  • resolve_path re-canonicalize()s upload_dir every request (server.rs:394) though mod.rs:87 already canonicalized it — harmless redundant syscall.

⚠️ Per the PR description: set PLAY_STATICS_HOST in the prd/stg Doppler configs before the next release — build.rs now hard-requires it and fails loudly otherwise.

Note: I couldn't run cargo check/clippy here — doppler is unavailable and build.rs requires SITE_HOST/CONVEX_HTTP_URL/PLAY_STATICS_HOST, so this review is static.
wavedash-dev

Comment thread src/dev/mod.rs
Comment thread src/dev/mod.rs
The callback now sets three cookies and redirects to a bare '/':
- wd_session_{build} (HttpOnly): the gameplay session, as before.
- wd_jwt_{build} (HttpOnly): the last issued gameplay JWT. /auth/refresh
  verifies it locally (RS256 against the backend's public
  /.well-known/jwks.json, cached; 5-min freshness margin) and serves it
  back without a backend round-trip; tampered/expired falls back to a
  real refresh that re-cookies.
- wd_sdkconfig_{port} (JS-readable): setupWavedashSDK >=1.3.23 falls
  back to it when the URL has no ?sdkconfig=, so the game URL is just
  http://localhost:PORT/.

Session/JWT cookies are build-scoped (a relaunched server can reuse a
port but never a build uuid, so stale cookies can't resurrect an old
build's session); the sdkconfig cookie is port-scoped because page JS
can only derive the port from location — a stale port collision fails
the session check and re-runs the handoff.

Requires @wvdsh/sdk-js 1.3.23 (cookie fallback) on jsdelivr @latest.
@claude

claude Bot commented Jun 11, 2026

Copy link
Copy Markdown

Claude finished @franzwarning's task in 2m 53s —— View job


Code Review — Rewrite wavedash dev as a plain local static server

  • Gather diff & current branch state
  • Review the new cookie/JWT delivery (server.rs)
  • Check whether the two prior confirmed issues were addressed
  • Review injected assets (dev.js, shell.html)
  • Post review feedback

Re-reviewed at the current HEAD (f45f884"Deliver sdkconfig and the gameplay JWT in cookies; clean game URLs"). The new cookie delivery is a nice improvement: build-scoped HttpOnly wd_session/wd_jwt plus a port-scoped JS-readable wd_sdkconfig lets the game URL collapse to a bare http://localhost:PORT/, and the local RS256 verification against the cached JWKS (with the 5-min margin, falling back to a real refresh on any failure) is sound — jwt_fresh fails closed on every error path, which is the right default. The prod-parity framing, the path-traversal guard, COOP/COEP parity, the to_ascii_lowercase() head-insert, and the playKey exchange all continue to hold up.

Both confirmed issues from the prior passes are still present at this commit — I posted fresh inline comments on the current lines:


🟠 1. dev.js:30 (and the tick at :48) — init gate crashes silently if the SDK bundle fails to load

initGate() derefs window.Wavedash.initialized unguarded. If the parser-blocking inject.global.js fails (offline / CDN down / broken @latest publish), window.Wavedash is undefined → TypeError, the overlay never mounts, and the user gets a blank page with no error — the exact failure this file exists to catch. Guard wd && wd.initialized in both spots and surface a "SDK failed to load" message once wd stays undefined. Inline comment + suggestion →

🟠 2. server.rs:298 — refresh drops a still-valid session on a transient upstream error

f45f884 added wd_jwt to this arm's clear list, but the shape is unchanged: the Ok(_) arm clears the cookies and returns 401 on any non-success from /api/dev/refresh-gameplay, including a 502/503 blip — silently killing a valid 1-day session mid-play. Only 401/403 should clear; transient errors should 502 and leave the cookies intact, matching the Err(_) arm right below. Inline comment + suggestion →


🟡 New in f45f884 — worth confirming

  • wd_sdkconfig cookie size → potential redirect loop. has_credentials requires both wd_session and wd_sdkconfig (server.rs:382). The sdkconfig value is stored urlencoding::encode'd (server.rs:242) — and per the truncate comment it's "an entire JSON blob," so the percent-encoding inflation pushes it further toward the browser's ~4 KB per-cookie limit. If it's ever dropped for size, //auth/dev → callback → re-set (dropped again) → / loops with no error surfaced. Almost certainly fine for today's small config, but worth a sanity check on the largest realistic sdkconfig, and consider keying has_credentials on the session cookie alone (the sdkconfig cookie is a page-JS convenience, not an auth gate).
  • sdkconfig encoding couples to SDK ≥1.3.23. The cookie holds the urlencoding::encode'd JSON, so setupWavedashSDK's fallback must decodeURIComponent it. This contract isn't visible from the CLI side and the SDK is pulled @latest; a one-line comment noting the encode/decode pairing (and the 1.3.23 floor) would help future debugging. (Same drift class as the unhandledrejection string match at dev.js:22.)

🔵 Minor / unchanged from prior passes

  • CORP on the entrypoint scriptshell.html:39 loads {{SCRIPT_SRC}} (PLAY_STATICS_HOST default-entrypoint.js) as a plain <script> with no crossorigin; under cross-origin-embedder-policy: require-corp this is blocked unless play serves Cross-Origin-Resource-Policy: cross-origin. Should be fine since prod's embed loads the same path — just a one-line confirm.
  • Uppercase .js entry (Cursor) — handle_index (server.rs:403) gates the synthetic shell on cfg.entry.ends_with(".js"), lowercase only; game.JS would fall through to a static redirect with no shell/SDK. Low-risk; entry.to_ascii_lowercase().ends_with(".js") closes it.
  • Engine shell only on / (Cursor) — other HTML paths fall through to handle_static (SDK-injected, no engine boot). Low-risk since play's default entrypoint boots into #wavedash-target without sub-navigating.
  • strip_prefix fallback (Cursor, mod.rs:124) — .unwrap_or(&html) falls back to the absolute filesystem path, which would then form an invalid /{entry} redirect. In practice locate_html_entrypoint walks upload_dir so the prefix always matches; harmless today but a misleading fallback.
  • api_host concatenation (Cursor, mod.rs:247 and the server.rs exchange/refresh/jwks URLs) — none trim a trailing slash, so a CONVEX_HTTP_URL ending in / yields //.... Low severity since the value is build-time controlled.
  • generate_state() (auth.rs) is a nanosecond timestamp reused as the handoff CSRF state — predictable but localhost-dev-only.
  • resolve_path re-canonicalize()s upload_dir every request (server.rs:528) though mod.rs already canonicalized it — harmless redundant syscall.

⚠️ Per the PR description: set PLAY_STATICS_HOST in the prd/stg Doppler configs before the next release — build.rs hard-requires it and fails loudly otherwise. Also note the new floor: this flow now requires @wvdsh/sdk-js ≥ 1.3.23 on jsdelivr @latest for the sdkconfig-cookie fallback.

Note: I couldn't run cargo check/clippy here — doppler is unavailable and build.rs requires SITE_HOST/CONVEX_HTTP_URL/PLAY_STATICS_HOST, so this review is static.
wavedash-dev

Comment thread src/dev/dev.js
// Covers the page until the game calls Wavedash.init() — stricter than
// prod's gate, deliberately. Translucent so engine loading UI shows through.
function initGate() {
if (window.Wavedash.initialized) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Still unaddressed at f45f884: the init gate crashes silently when the SDK bundle fails to load — the exact failure this file exists to surface.

initGate() opens with if (window.Wavedash.initialized) return; (and the tick derefs it again at dev.js:48). The injected inject.global.js (jsdelivr @latest) is parser-blocking, so if it fails — CDN unreachable, offline, or @latest resolving to a broken publish — window.Wavedash is never defined and both lines throw TypeError: Cannot read properties of undefined (reading 'initialized'). The overlay never mounts → blank page with no error. The unhandledrejection handler can't help: no SDK promise was ever created.

Guard both spots and surface the failure once wd stays undefined:

function initGate() {
  var wd = window.Wavedash;
  if (wd && wd.initialized) return;
  // ...build overlay...
  var tick = setInterval(function () {
    wd = window.Wavedash;
    if (wd && wd.initialized) { overlay.remove(); clearInterval(tick); return; }
    if (!overlay.isConnected) document.body.append(overlay);
    if (window.__wavedashBootError) {
      msg.textContent = window.__wavedashBootError;
    } else if (!wd && Date.now() - started > 3000) {
      msg.textContent = 'Wavedash SDK failed to load (CDN unreachable?) — check your connection and reload.';
    } else if (Date.now() - started > 10000) {
      msg.textContent = "Your game hasn't called Wavedash.init() — on wavedash.com the loading screen hangs exactly like this.";
    }
  }, 150);
}

Comment thread src/dev/server.rs
Comment on lines +298 to +318
// Clear the dead cookies so the next reload re-runs the handoff.
Ok(_) => Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header(header::CONTENT_TYPE, TEXT)
.header(header::CACHE_CONTROL, "no-store")
.header(
header::SET_COOKIE,
format!(
"{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
cfg.session_cookie_name()
),
)
.header(
header::SET_COOKIE,
format!(
"{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
cfg.jwt_cookie_name()
),
)
.body(Body::from("Session expired"))
.unwrap(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Still unaddressed at f45f884: a transient upstream error drops a still-valid 1-day session. This commit added the JWT cookie to the clear list, but the underlying arm is unchanged.

This Ok(_) arm treats any non-success from /api/dev/refresh-gameplay as expired/revoked and clears both wd_session and wd_jwt, returning 401. A transient 502/503 blip therefore silently kills a valid session and forces the browser back through the full /auth/dev handoff mid-play. Only a genuine auth rejection (401/403) should clear the cookies; transient errors should 502 and leave them intact — matching the Err(_) arm just below (server.rs:319-322), which already gets this right.

Suggested change
// Clear the dead cookies so the next reload re-runs the handoff.
Ok(_) => Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header(header::CONTENT_TYPE, TEXT)
.header(header::CACHE_CONTROL, "no-store")
.header(
header::SET_COOKIE,
format!(
"{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
cfg.session_cookie_name()
),
)
.header(
header::SET_COOKIE,
format!(
"{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
cfg.jwt_cookie_name()
),
)
.body(Body::from("Session expired"))
.unwrap(),
// A genuine auth rejection — clear the dead cookies so the next reload
// re-runs the handoff.
Ok(res) if matches!(res.status().as_u16(), 401 | 403) => Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header(header::CONTENT_TYPE, TEXT)
.header(header::CACHE_CONTROL, "no-store")
.header(
header::SET_COOKIE,
format!(
"{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
cfg.session_cookie_name()
),
)
.header(
header::SET_COOKIE,
format!(
"{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
cfg.jwt_cookie_name()
),
)
.body(Body::from("Session expired"))
.unwrap(),
// Transient upstream error — don't clear a still-valid session.
Ok(_) => respond(StatusCode::BAD_GATEWAY, TEXT, None, "Refresh failed"),

Comment thread src/dev/server.rs
Comment thread src/dev/server.rs
Comment thread src/dev/server.rs
cfg.sdkconfig_cookie_name(),
urlencoding::encode(&p.sdkconfig)
),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Session cookies omit Max-Age

Low Severity

After a successful auth callback, wd_session and gameplay JWT cookies are set without Max-Age or Expires, so they behave as browser session cookies. That ends the advertised one-day dev gameplay session when the browser closes, even if the backend session is still valid.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f45f884. Configure here.

The sdkconfig cookie becomes HttpOnly and build-scoped like the others
(no port scoping anywhere); every served page gets a parser-ordered
inline 'window.__wavedashSdkConfig = ...' before the SDK tag, which
setupWavedashSDK >=1.3.23 falls back to. Classic scripts execute in
document order, so the global always exists before the SDK runs — the
same guarantee entrypointParams already relies on. The value is
JSON-string-escaped with < as < so config can't close the tag.
@claude

claude Bot commented Jun 11, 2026

Copy link
Copy Markdown

Claude finished @franzwarning's task in 2m 3s —— View job


Code Review — Rewrite wavedash dev as a plain local static server

  • Gather diff & current branch state
  • Review src/dev/server.rs, mod.rs, config.rs
  • Review injected assets (dev.js, shell.html)
  • Check whether prior confirmed issues were addressed
  • Post review feedback

Re-reviewed at the current HEAD (9680bd6"Inline the SDK config into served pages instead of a JS-readable cookie").

The new commit is a clean security/UX improvement: wd_sdkconfig becomes HttpOnly and build-scoped like the other cookies (no more port scoping anywhere), and the config is now injected as a parser-ordered inline window.__wavedashSdkConfig = "…" before the SDK tag in both the shell and inject_sdk. Classic scripts run in document order, so the global reliably exists before the SDK parses — the same guarantee entrypointParams already relied on. The js_string_literal helper JSON-escapes and rewrites <<, so a config value can't close the surrounding <script> tag. browser_config cleanly replaces has_credentials (session cookie gates, sdkconfig rides along). All sound.

The prod-parity framing, the path-traversal guard, COOP/COEP parity, the to_ascii_lowercase() head-insert, and the playKey exchange all continue to hold up.

The two confirmed issues from the prior passes are still present at this commit — fresh inline comments posted on the current lines:


🟠 1. dev.js:30 (and the tick at :48) — init gate crashes silently if the SDK bundle fails to load

initGate() derefs window.Wavedash.initialized unguarded. If the parser-blocking inject.global.js fails (offline / CDN down / broken @latest publish), window.Wavedash is undefined → TypeError, the overlay never mounts, blank page with no error — the exact failure this file exists to catch. Guard wd && wd.initialized in both spots and surface a "SDK failed to load" message once wd stays undefined. Inline comment + suggestion →

🟠 2. server.rs:295 — refresh drops a still-valid session on a transient upstream error

The Ok(_) arm clears wd_session + wd_jwt and returns 401 on any non-success from /api/dev/refresh-gameplay, including a 502/503 blip — silently killing a valid 1-day session mid-play. Only 401/403 should clear; transient errors should 502 and leave the cookies intact, matching the Err(_) arm right below. Inline comment + suggestion →


🟡 Minor / worth confirming (unchanged from prior passes)

  • wd_sdkconfig cookie size → potential redirect loop. browser_config still requires both wd_session and wd_sdkconfig (server.rs:380-383); the value is stored urlencoding::encode'd (server.rs:238) and is "an entire JSON blob," so percent-encoding inflation pushes it toward the ~4 KB per-cookie limit. If it's ever dropped for size, //auth/dev → callback → re-set (dropped again) → / loops with no error surfaced. Almost certainly fine today; consider gating browser_config on the session cookie alone and treating a missing sdkconfig as "fall through with no inline config" rather than an auth failure.
  • CORP on the entrypoint scriptshell.html:41 loads {{SCRIPT_SRC}} (PLAY_STATICS_HOST default-entrypoint.js) as a plain <script> with no crossorigin; under cross-origin-embedder-policy: require-corp (server.rs:472) this is blocked unless play serves Cross-Origin-Resource-Policy: cross-origin. Fine if prod's embed loads the same path — one-line confirm.
  • unhandledrejection string match (dev.js:22) couples the recovery hint to the exact SDK string 'Failed to refresh gameplay token'; SDK pulled @latest, so a reword silently drops the hint. Same drift class as the new setupWavedashSDK ≥ 1.3.23 dependency on the window.__wavedashSdkConfig fallback — a comment noting these contracts would help.
  • Verbose logs the handoff secrets (Cursor, server.rs:122) — format_query prints decoded play_key + sdkconfig to stderr on the callback. Short-lived, localhost, opt-in --verbose — low severity, but redacting those two keys would avoid them landing in shared terminals / CI logs.
  • Deep-link loss on handoff (Cursor) — callback always redirects to /, and the handoff doesn't record the requested path; a developer who opens a nested HTML page and signs in lands on the main entry. Low-risk for a dev server.
  • Uppercase .js entry (Cursor, server.rs:406) — cfg.entry.ends_with(".js") is lowercase-only; game.JS skips the synthetic shell. entry.to_ascii_lowercase().ends_with(".js") closes it.
  • strip_prefix fallback (Cursor, mod.rs) — falls back to the absolute fs path, forming an invalid /{entry} redirect; harmless today since locate_html_entrypoint walks upload_dir.
  • api_host trailing-slash (Cursor) — concatenations don't trim a trailing /; build-time controlled, low severity.
  • Session cookies omit Max-Age (Cursor, server.rs:218-239) — wd_session/wd_jwt/wd_sdkconfig are browser-session cookies, so closing the browser ends the advertised 1-day session even if the backend session is still valid. Minor; a reload re-runs the handoff.
  • generate_state() (auth.rs) is a nanosecond timestamp reused as the handoff CSRF state — predictable but localhost-dev-only.
  • resolve_path re-canonicalize()s upload_dir every request (server.rs:544) though mod.rs already canonicalized it — harmless redundant syscall.

⚠️ Per the PR description: set PLAY_STATICS_HOST in the prd/stg Doppler configs before the next release — build.rs hard-requires it and fails loudly otherwise. This flow also now requires @wvdsh/sdk-js ≥ 1.3.23 on jsdelivr @latest for the window.__wavedashSdkConfig fallback.

Note: I couldn't run cargo check/clippy here — doppler is unavailable and build.rs requires SITE_HOST/CONVEX_HTTP_URL/PLAY_STATICS_HOST, so this review is static.
wavedash-dev

Comment thread src/dev/dev.js
// Covers the page until the game calls Wavedash.init() — stricter than
// prod's gate, deliberately. Translucent so engine loading UI shows through.
function initGate() {
if (window.Wavedash.initialized) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Still unaddressed at 9680bd6: the init gate crashes silently when the SDK bundle fails to load — the exact failure this file exists to surface.

initGate() opens with if (window.Wavedash.initialized) return; (and the tick derefs it again at dev.js:48). The injected inject.global.js (jsdelivr @latest) is parser-blocking, so if it fails — CDN unreachable, offline, or @latest resolving to a broken publish — window.Wavedash is never defined and both lines throw TypeError: Cannot read properties of undefined (reading 'initialized'). The overlay never mounts → blank page with no error. The unhandledrejection handler can't help: no SDK promise was ever created.

Guard both reads and surface the failure once wd stays undefined:

		function initGate() {
			var wd = window.Wavedash;
			if (wd && wd.initialized) return;
			// ...build overlay...
			var started = Date.now();
			var tick = setInterval(function () {
				wd = window.Wavedash;
				if (wd && wd.initialized) {
					overlay.remove();
					clearInterval(tick);
					return;
				}
				if (!overlay.isConnected) document.body.append(overlay);
				if (window.__wavedashBootError) {
					msg.textContent = window.__wavedashBootError;
				} else if (!wd && Date.now() - started > 3000) {
					msg.textContent =
						'Wavedash SDK failed to load (CDN unreachable?) — check your connection and reload.';
				} else if (Date.now() - started > 10000) {
					msg.textContent =
						"Your game hasn't called Wavedash.init() — on wavedash.com " +
						'the loading screen hangs exactly like this.';
				}
			}, 150);
		}

Comment thread src/dev/server.rs
Comment on lines +294 to +314
// Clear the dead cookies so the next reload re-runs the handoff.
Ok(_) => Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header(header::CONTENT_TYPE, TEXT)
.header(header::CACHE_CONTROL, "no-store")
.header(
header::SET_COOKIE,
format!(
"{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
cfg.session_cookie_name()
),
)
.header(
header::SET_COOKIE,
format!(
"{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
cfg.jwt_cookie_name()
),
)
.body(Body::from("Session expired"))
.unwrap(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Still unaddressed at 9680bd6: a transient upstream error drops a still-valid 1-day session.

This Ok(_) arm treats any non-success from /api/dev/refresh-gameplay as expired/revoked and clears both wd_session and wd_jwt, returning 401. A transient 502/503 blip therefore silently kills a valid session and forces the browser back through the full /auth/dev handoff mid-play. Only a genuine auth rejection (401/403) should clear the cookies; transient errors should 502 and leave them intact — matching the Err(_) arm just below (server.rs:315-318), which already gets this right.

Suggested change
// Clear the dead cookies so the next reload re-runs the handoff.
Ok(_) => Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header(header::CONTENT_TYPE, TEXT)
.header(header::CACHE_CONTROL, "no-store")
.header(
header::SET_COOKIE,
format!(
"{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
cfg.session_cookie_name()
),
)
.header(
header::SET_COOKIE,
format!(
"{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
cfg.jwt_cookie_name()
),
)
.body(Body::from("Session expired"))
.unwrap(),
// A genuine auth rejection — clear the dead cookies so the next reload
// re-runs the handoff.
Ok(res) if matches!(res.status().as_u16(), 401 | 403) => Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header(header::CONTENT_TYPE, TEXT)
.header(header::CACHE_CONTROL, "no-store")
.header(
header::SET_COOKIE,
format!(
"{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
cfg.session_cookie_name()
),
)
.header(
header::SET_COOKIE,
format!(
"{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
cfg.jwt_cookie_name()
),
)
.body(Body::from("Session expired"))
.unwrap(),
// Transient upstream error — don't clear a still-valid session.
Ok(_) => respond(StatusCode::BAD_GATEWAY, TEXT, None, "Refresh failed"),

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

There are 6 total unresolved issues (including 4 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 9680bd6. Configure here.

Comment thread src/dev/mod.rs
Comment thread src/dev/server.rs
@claude

claude Bot commented Jun 12, 2026

Copy link
Copy Markdown

Claude finished @franzwarning's task in 2m 26s —— View job


Code Review — Rewrite wavedash dev as a plain local static server

  • Gather diff & current branch state
  • Review src/dev/server.rs, mod.rs, config.rs, build.rs
  • Review injected assets (dev.js, shell.html)
  • Check whether prior confirmed issues were addressed
  • Post review feedback

Re-reviewed at the current HEAD (ccef8e2"Reuse the existing PLAYSITE_HOST build env instead of a new PLAY_STATICS_HOST"). That commit is a clean rename: build.rs now requires PLAYSITE_HOST instead of PLAY_STATICS_HOST, config.rs renames the field to playsite_host (still https-prefixed like site_host), and mod.rs reads the new key. No logic changed in the server, the gate, or the auth flow — so the prod-parity framing, the path-traversal guard, COOP/COEP parity, the to_ascii_lowercase() head-insert, the playKey exchange, and the inline-config injection all continue to hold up.

Because nothing in dev.js or the refresh handler moved, both confirmed issues from every prior pass are still present — fresh inline comments posted on the current lines:


🟠 1. dev.js:30 (and the tick at :48) — init gate crashes silently if the SDK bundle fails to load

initGate() derefs window.Wavedash.initialized unguarded. If the parser-blocking inject.global.js fails (offline / CDN down / broken @latest publish), window.Wavedash is undefined → TypeError, the overlay never mounts, blank page with no error — the exact failure this file exists to catch. Guard wd && wd.initialized in both spots and surface a "SDK failed to load" message once wd stays undefined. Inline comment + suggestion →

🟠 2. server.rs:295 — refresh drops a still-valid session on a transient upstream error

The Ok(_) arm clears wd_session + wd_jwt and returns 401 on any non-success from /api/dev/refresh-gameplay, including a 502/503 blip — silently killing a valid 1-day session mid-play. Only 401/403 should clear; transient errors should 502 and leave the cookies intact, matching the Err(_) arm right below. Inline comment + suggestion →


🟡 Worth confirming / minor (unchanged from prior passes)

  • CORP on the entrypoint scriptshell.html:41 loads {{SCRIPT_SRC}} (the PLAYSITE_HOST default-entrypoint.js) as a plain <script> with no crossorigin; under the cross-origin-embedder-policy: require-corp every response sets (server.rs:472), that cross-origin no-cors load is blocked unless play serves Cross-Origin-Resource-Policy: cross-origin. INJECT_URL is noted CORP-safe; the entrypoint host isn't — a missing header breaks all engine builds while custom HTML keeps working. Should be fine if prod's embed loads the same path — one-line confirm.
  • RenPy create-local metadata (Cursor) — create_local_build (mod.rs:21) never sends entrypointParams, and RenPy falls through to the custom-HTML branch (mod.rs:119). If /auth/dev derives sdkconfig from build-row params for executable/web engines, RenPy could get a wrong config. This is play-side behavior I can't verify from here — worth a quick check against a RenPy fixture.
  • wd_sdkconfig cookie size → potential redirect loopbrowser_config requires both wd_session and wd_sdkconfig (server.rs:380-383); the value is stored urlencoding::encode'd (server.rs:238) and is "an entire JSON blob," so percent-encoding inflation pushes it toward the ~4 KB per-cookie limit. If dropped for size, //auth/dev → callback → re-set (dropped) → / loops with no error. Fine today; consider gating browser_config on the session cookie alone and treating a missing sdkconfig as "no inline config" rather than an auth failure.
  • unhandledrejection string match (dev.js:22) couples the recovery hint to the exact SDK string 'Failed to refresh gameplay token'; SDK pulled @latest, so a reword silently drops the hint. Same drift class as the window.__wavedashSdkConfig ≥ 1.3.23 contract — a comment noting these would help.
  • Verbose logs the handoff secrets (Cursor, server.rs:122) — format_query prints decoded play_key + sdkconfig to stderr on the callback. Short-lived, localhost, opt-in — low severity, but redacting those two keys avoids them landing in shared terminals / CI logs.
  • Uppercase .js entry (Cursor, server.rs:406) — cfg.entry.ends_with(".js") is lowercase-only; game.JS skips the synthetic shell. entry.to_ascii_lowercase().ends_with(".js") closes it.
  • strip_prefix fallback (Cursor, mod.rs:125) — .unwrap_or(&html) falls back to the absolute fs path, forming an invalid /{entry} redirect; harmless today since locate_html_entrypoint walks the canonicalized upload_dir so the prefix always matches.
  • api_host trailing slash (Cursor, mod.rs:246 + the server.rs exchange/refresh/jwks URLs) — concatenations don't trim a trailing /; build-time controlled, low severity.
  • Session cookies omit Max-Age (Cursor, server.rs:214-240) — wd_session/wd_jwt/wd_sdkconfig are browser-session cookies, so closing the browser ends the advertised 1-day session even if the backend session is still valid. Minor; a reload re-runs the handoff.
  • Deep-link loss on handoff (Cursor) — the callback always redirects to / and the handoff doesn't record the requested path; low-risk for a dev server.
  • generate_state() (auth.rs) is a nanosecond timestamp reused as the handoff CSRF state — predictable but localhost-dev-only.
  • resolve_path re-canonicalize()s upload_dir every request (server.rs:544) though mod.rs:87 already canonicalized it — harmless redundant syscall.

⚠️ Per the PR description: the build now hard-requires PLAYSITE_HOST (build.rs) — already present in every cli Doppler config per the PR notes, so no release prep, but worth confirming prd/stg actually carry it before the next release. This flow also requires @wvdsh/sdk-js ≥ 1.3.23 on jsdelivr @latest for the window.__wavedashSdkConfig fallback.

Note: I couldn't run cargo check/clippy here — doppler is unavailable and build.rs requires SITE_HOST/CONVEX_HTTP_URL/PLAYSITE_HOST, so this review is static.
wavedash-dev

Comment thread src/dev/dev.js
// Covers the page until the game calls Wavedash.init() — stricter than
// prod's gate, deliberately. Translucent so engine loading UI shows through.
function initGate() {
if (window.Wavedash.initialized) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Still unaddressed at ccef8e2: the init gate crashes silently when the SDK bundle fails to load — the exact failure this file exists to surface.

initGate() opens with if (window.Wavedash.initialized) return;, and the tick derefs it again at dev.js:48. The injected inject.global.js (jsdelivr @latest) is parser-blocking, so if it fails — CDN unreachable, offline, or @latest resolving to a broken publish — window.Wavedash is never defined and both lines throw TypeError: Cannot read properties of undefined (reading 'initialized'). The overlay never mounts → blank page with no error shown. The unhandledrejection handler can't help: no SDK promise was ever created.

Guard both reads (var wd = window.Wavedash; if (wd && wd.initialized)) and surface the failure once wd stays undefined past a short delay:

Suggested change
if (window.Wavedash.initialized) return;
function initGate() {
var wd = window.Wavedash;
if (wd && wd.initialized) return;
var style = document.createElement('style');
style.textContent = '@keyframes wd-spin{to{transform:rotate(360deg)}}';
var spinner = document.createElement('div');
spinner.style.cssText =
'width:28px;height:28px;border-radius:50%;border:3px solid rgba(226,232,240,0.25);' +
'border-top-color:#e2e8f0;animation:wd-spin 0.8s linear infinite';
var msg = document.createElement('p');
msg.style.cssText = 'margin:0;max-width:42ch;line-height:1.5';
var overlay = document.createElement('div');
overlay.style.cssText =
'position:fixed;inset:0;z-index:2147483647;display:flex;flex-direction:column;' +
'align-items:center;justify-content:center;gap:16px;background:rgba(8,10,18,0.65);' +
'color:#e2e8f0;font:14px ui-sans-serif,system-ui,sans-serif;text-align:center';
overlay.append(style, spinner, msg);
document.body.append(overlay);
var started = Date.now();
var tick = setInterval(function () {
wd = window.Wavedash;
if (wd && wd.initialized) {
overlay.remove();
clearInterval(tick);
return;
}
// Re-attach if the game replaced the body's contents.
if (!overlay.isConnected) document.body.append(overlay);
if (window.__wavedashBootError) {
msg.textContent = window.__wavedashBootError;
} else if (!wd && Date.now() - started > 3000) {
msg.textContent =
'Wavedash SDK failed to load (CDN unreachable?) — check your connection and reload.';
} else if (Date.now() - started > 10000) {
msg.textContent =
"Your game hasn't called Wavedash.init() — on wavedash.com " +
'the loading screen hangs exactly like this.';
}
}, 150);
}

Comment thread src/dev/server.rs
Comment on lines +294 to +314
// Clear the dead cookies so the next reload re-runs the handoff.
Ok(_) => Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header(header::CONTENT_TYPE, TEXT)
.header(header::CACHE_CONTROL, "no-store")
.header(
header::SET_COOKIE,
format!(
"{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
cfg.session_cookie_name()
),
)
.header(
header::SET_COOKIE,
format!(
"{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
cfg.jwt_cookie_name()
),
)
.body(Body::from("Session expired"))
.unwrap(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Still unaddressed at ccef8e2: a transient upstream error drops a still-valid 1-day session.

This Ok(_) arm treats any non-success from /api/dev/refresh-gameplay as expired/revoked and clears both wd_session and wd_jwt, returning 401. A transient 502/503 blip therefore silently kills a valid session and forces the browser back through the full /auth/dev handoff mid-play. Only a genuine auth rejection (401/403) should clear the cookies; transient errors should 502 and leave them intact — matching the Err(_) arm right below (server.rs:315-318), which already gets this right.

Suggested change
// Clear the dead cookies so the next reload re-runs the handoff.
Ok(_) => Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header(header::CONTENT_TYPE, TEXT)
.header(header::CACHE_CONTROL, "no-store")
.header(
header::SET_COOKIE,
format!(
"{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
cfg.session_cookie_name()
),
)
.header(
header::SET_COOKIE,
format!(
"{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
cfg.jwt_cookie_name()
),
)
.body(Body::from("Session expired"))
.unwrap(),
// A genuine auth rejection — clear the dead cookies so the next reload
// re-runs the handoff.
Ok(res) if matches!(res.status().as_u16(), 401 | 403) => Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header(header::CONTENT_TYPE, TEXT)
.header(header::CACHE_CONTROL, "no-store")
.header(
header::SET_COOKIE,
format!(
"{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
cfg.session_cookie_name()
),
)
.header(
header::SET_COOKIE,
format!(
"{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
cfg.jwt_cookie_name()
),
)
.body(Body::from("Session expired"))
.unwrap(),
// Transient upstream error — don't clear a still-valid session.
Ok(_) => respond(StatusCode::BAD_GATEWAY, TEXT, None, "Refresh failed"),

@claude

claude Bot commented Jun 12, 2026

Copy link
Copy Markdown

Claude finished @franzwarning's task in 1m 36s —— View job


Code Review — Rewrite wavedash dev as a plain local static server

  • Gather diff & current branch state
  • Review src/dev/server.rs, mod.rs, config.rs, build.rs
  • Review injected assets (dev.js, shell.html)
  • Check whether prior confirmed issues were addressed
  • Post review feedback

Re-reviewed at the current HEAD (6253341"Bump version"). That commit is a version bump only; nothing in dev.js, the refresh handler, or the auth flow moved. The prod-parity framing, the path-traversal guard in resolve_path, COOP/COEP parity, the byte-length-preserving to_ascii_lowercase() head-insert, the playKey exchange, and the inline-config injection (window.__wavedashSdkConfig before the SDK tag, JSON-escaped + <-rewritten so it can't close the <script>) all continue to hold up.

Both confirmed issues from every prior pass are still present — fresh inline comments posted on the current lines:


🟠 1. dev.js:30 (and the tick at :48) — init gate crashes silently if the SDK bundle fails to load

initGate() derefs window.Wavedash.initialized unguarded in both spots. If the parser-blocking inject.global.js fails (offline / CDN down / broken @latest publish), window.Wavedash is undefined → TypeError, the overlay never mounts, blank page with no error — the exact failure this file exists to catch. The unhandledrejection handler can't help since no SDK promise was ever created. Guard wd && wd.initialized in both spots and surface a "SDK failed to load" message once wd stays undefined. Inline comment + suggestion →

🟠 2. server.rs:295 — refresh drops a still-valid session on a transient upstream error

The Ok(_) arm clears wd_session + wd_jwt and returns 401 on any non-success from /api/dev/refresh-gameplay, including a 502/503 blip — silently killing a valid 1-day session mid-play. Only 401/403 should clear; transient errors should 502 and leave the cookies intact, matching the Err(_) arm right below (server.rs:315-318). Inline comment + suggestion →


🟡 Worth confirming / minor (unchanged from prior passes)

  • CORP on the entrypoint scriptshell.html:41 loads {{SCRIPT_SRC}} (the PLAYSITE_HOST default-entrypoint.js) as a plain <script> with no crossorigin; under the cross-origin-embedder-policy: require-corp every response sets (server.rs:472), that cross-origin no-cors load is blocked unless play serves Cross-Origin-Resource-Policy: cross-origin. INJECT_URL is noted CORP-safe; the entrypoint host isn't — a missing header breaks all engine builds while custom HTML keeps working. Should be fine if prod's embed loads the same path — one-line confirm.
  • wd_sdkconfig cookie size → potential redirect loopbrowser_config requires both wd_session and wd_sdkconfig (server.rs:380-384); the value is stored urlencoding::encode'd (server.rs:238) and is "an entire JSON blob," so percent-encoding inflation pushes it toward the ~4 KB per-cookie limit. If dropped for size, //auth/dev → callback → re-set (dropped) → / loops with no error. Fine today; consider gating browser_config on the session cookie alone and treating a missing sdkconfig as "no inline config" rather than an auth failure.
  • RenPy create-local metadata (Cursor) — create_local_build (mod.rs) never sends entrypointParams, and RenPy falls through to the custom-HTML branch. If /auth/dev derives sdkconfig from build-row params for executable/web engines, RenPy could get a wrong config. Play-side behavior I can't verify from here — worth a quick check against a RenPy fixture.
  • unhandledrejection string match (dev.js:22) couples the recovery hint to the exact SDK string 'Failed to refresh gameplay token'; SDK pulled @latest, so a reword silently drops the hint. Same drift class as the window.__wavedashSdkConfig ≥ 1.3.23 contract — a comment noting these would help.
  • Verbose logs the handoff secrets (Cursor, server.rs:122) — format_query prints decoded play_key + sdkconfig to stderr on the callback. Short-lived, localhost, opt-in --verbose — low severity, but redacting those two keys avoids them landing in shared terminals / CI logs.
  • Uppercase .js entry (Cursor, server.rs:406) — cfg.entry.ends_with(".js") is lowercase-only; game.JS skips the synthetic shell. entry.to_ascii_lowercase().ends_with(".js") closes it.
  • Deep-link loss on handoff (Cursor) — the callback always redirects to / and the handoff doesn't record the requested path; low-risk for a dev server.
  • Session cookies omit Max-Age (Cursor, server.rs:214-240) — wd_session/wd_jwt/wd_sdkconfig are browser-session cookies, so closing the browser ends the advertised 1-day session even if the backend session is still valid. Minor; a reload re-runs the handoff.
  • api_host trailing slash (Cursor) — the server.rs exchange/refresh/jwks URLs and mod.rs concatenations don't trim a trailing /; build-time controlled, low severity.
  • generate_state() (auth.rs) is a nanosecond timestamp reused as the handoff CSRF state — predictable but localhost-dev-only.
  • resolve_path re-canonicalize()s upload_dir every request (server.rs:544) though mod.rs already canonicalized it — harmless redundant syscall.

⚠️ Per the PR description: the build now hard-requires PLAYSITE_HOST (build.rs) — already present in every cli Doppler config per the PR notes, so no release prep, but worth confirming prd/stg actually carry it before the next release. This flow also requires @wvdsh/sdk-js ≥ 1.3.23 on jsdelivr @latest for the window.__wavedashSdkConfig fallback.

Note: I couldn't run cargo check/clippy here — doppler is unavailable and build.rs requires SITE_HOST/CONVEX_HTTP_URL/PLAYSITE_HOST, so this review is static.
wavedash-dev

Comment thread src/dev/dev.js
// Covers the page until the game calls Wavedash.init() — stricter than
// prod's gate, deliberately. Translucent so engine loading UI shows through.
function initGate() {
if (window.Wavedash.initialized) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Still unaddressed at 6253341: the init gate crashes silently when the SDK bundle fails to load — the exact failure this file exists to surface.

initGate() opens with if (window.Wavedash.initialized) return;, and the tick derefs it again at dev.js:48. The injected inject.global.js (jsdelivr @latest) is parser-blocking, so if it fails — CDN unreachable, offline, or @latest resolving to a broken publish — window.Wavedash is never defined and both lines throw TypeError: Cannot read properties of undefined (reading 'initialized'). The overlay never mounts → blank page with no error. The unhandledrejection handler can't help: no SDK promise was ever created.

Guard both reads and surface the failure once wd stays undefined past a short delay:

Suggested change
if (window.Wavedash.initialized) return;
function initGate() {
var wd = window.Wavedash;
if (wd && wd.initialized) return;
var style = document.createElement('style');
style.textContent = '@keyframes wd-spin{to{transform:rotate(360deg)}}';
var spinner = document.createElement('div');
spinner.style.cssText =
'width:28px;height:28px;border-radius:50%;border:3px solid rgba(226,232,240,0.25);' +
'border-top-color:#e2e8f0;animation:wd-spin 0.8s linear infinite';
var msg = document.createElement('p');
msg.style.cssText = 'margin:0;max-width:42ch;line-height:1.5';
var overlay = document.createElement('div');
overlay.style.cssText =
'position:fixed;inset:0;z-index:2147483647;display:flex;flex-direction:column;' +
'align-items:center;justify-content:center;gap:16px;background:rgba(8,10,18,0.65);' +
'color:#e2e8f0;font:14px ui-sans-serif,system-ui,sans-serif;text-align:center';
overlay.append(style, spinner, msg);
document.body.append(overlay);
var started = Date.now();
var tick = setInterval(function () {
wd = window.Wavedash;
if (wd && wd.initialized) {
overlay.remove();
clearInterval(tick);
return;
}
// Re-attach if the game replaced the body's contents.
if (!overlay.isConnected) document.body.append(overlay);
if (window.__wavedashBootError) {
msg.textContent = window.__wavedashBootError;
} else if (!wd && Date.now() - started > 3000) {
msg.textContent =
'Wavedash SDK failed to load (CDN unreachable?) — check your connection and reload.';
} else if (Date.now() - started > 10000) {
msg.textContent =
"Your game hasn't called Wavedash.init() — on wavedash.com " +
'the loading screen hangs exactly like this.';
}
}, 150);
}

Comment thread src/dev/server.rs
Comment on lines +295 to +314
Ok(_) => Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header(header::CONTENT_TYPE, TEXT)
.header(header::CACHE_CONTROL, "no-store")
.header(
header::SET_COOKIE,
format!(
"{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
cfg.session_cookie_name()
),
)
.header(
header::SET_COOKIE,
format!(
"{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
cfg.jwt_cookie_name()
),
)
.body(Body::from("Session expired"))
.unwrap(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Still unaddressed at 6253341: a transient upstream error drops a still-valid 1-day session.

This Ok(_) arm treats any non-success from /api/dev/refresh-gameplay as expired/revoked and clears both wd_session and wd_jwt, returning 401. A transient 502/503 blip therefore silently kills a valid session and forces the browser back through the full /auth/dev handoff mid-play. Only a genuine auth rejection (401/403) should clear the cookies; transient errors should 502 and leave them intact — matching the Err(_) arm right below (server.rs:315-318), which already gets this right.

Suggested change
Ok(_) => Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header(header::CONTENT_TYPE, TEXT)
.header(header::CACHE_CONTROL, "no-store")
.header(
header::SET_COOKIE,
format!(
"{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
cfg.session_cookie_name()
),
)
.header(
header::SET_COOKIE,
format!(
"{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
cfg.jwt_cookie_name()
),
)
.body(Body::from("Session expired"))
.unwrap(),
// A genuine auth rejection — clear the dead cookies so the next reload
// re-runs the handoff.
Ok(res) if matches!(res.status().as_u16(), 401 | 403) => Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header(header::CONTENT_TYPE, TEXT)
.header(header::CACHE_CONTROL, "no-store")
.header(
header::SET_COOKIE,
format!(
"{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
cfg.session_cookie_name()
),
)
.header(
header::SET_COOKIE,
format!(
"{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
cfg.jwt_cookie_name()
),
)
.body(Body::from("Session expired"))
.unwrap(),
// Transient upstream error — don't clear a still-valid session.
Ok(_) => respond(StatusCode::BAD_GATEWAY, TEXT, None, "Refresh failed"),

@franzwarning franzwarning merged commit 6253341 into main Jun 12, 2026
12 checks passed
@franzwarning franzwarning deleted the wavedash-dev branch June 12, 2026 05:09
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