Skip to content

WVDSH-1588: re-mint gameplay JWT on forced refresh (?fresh=1)#39

Merged
franzwarning merged 1 commit into
mainfrom
franzwarning/wvdsh-1588-disable-parent-required-iframe-sdk-calls-in-sdk-js
Jun 26, 2026
Merged

WVDSH-1588: re-mint gameplay JWT on forced refresh (?fresh=1)#39
franzwarning merged 1 commit into
mainfrom
franzwarning/wvdsh-1588-disable-parent-required-iframe-sdk-calls-in-sdk-js

Conversation

@franzwarning

@franzwarning franzwarning commented Jun 25, 2026

Copy link
Copy Markdown
Member

What

Dev-server support for the standalone simulated paywall (WVDSH-1588).

Context

The standalone paywall now grants entitlements directly via a gameplay-JWT Convex mutation (api.sdk.paidContent.mockPurchase), so the dev server no longer mediates the purchase — the earlier /__wavedash/purchase route is gone.

How

/auth/refresh serves the per-browser gameplay JWT from a cookie while it's still fresh (sparing a backend round-trip). After a simulated purchase that cached token would hide the new entitlement, so the handler now honors ?fresh=1 (sent by the SDK on forced refreshes): skip the cache, re-mint from the backend, and update the cookie — which also keeps a reload entitled.

Testing

Live through the real wavedash dev: after a simulated purchase the forced refresh returns a token carrying the entitlement, and a fresh reload stays entitled.

Paired with the sdk-js and wvdsh PRs. Linear: WVDSH-1588

🤖 Generated with Claude Code

@claude

claude Bot commented Jun 25, 2026

Copy link
Copy Markdown

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


Code Review: WVDSH-1588 dev-server purchase route

  • Read changed file src/dev/server.rs
  • Analyze the new /__wavedash/purchase handler
  • Review for bugs, security, and best practices
  • Post review feedback

Overall this is a clean, well-scoped addition that follows the conventions already established by handle_callback and handle_auth_refresh. The cookie-expiry trick to force a JWT re-mint is the right mechanism, and the doc comment explains why clearly. A few notes below — all minor; nothing blocking.

👍 What's good

  • CSRF is naturally covered. The session cookie is HttpOnly; SameSite=Lax, so a cross-site fetch(..., {credentials:'include'}) from another origin won't attach it → the handler returns 401 before ever touching the backend. No extra CSRF token needed for this local-dev route.
  • Auth-gating is consistent. Same cookie_value(...) guard + "Auth not ready" 401 as handle_auth_refresh (server.rs:344).
  • The JWT-cookie expiry is the correct fix for the "still-fresh cached JWT hides the new entitlement" problem — handle_auth_refresh serves the cached JWT while jwt_fresh (server.rs:454), so expiring it forces the backend round-trip that re-mints with the entitlement. Attributes on expire_cookie (Path=/; HttpOnly; SameSite=Lax) match set_cookie, so the browser will actually drop it.

🔸 Minor: error path hard-codes application/json

Ok(res) => {
    let status = StatusCode::from_u16(res.status().as_u16())
        .unwrap_or(StatusCode::BAD_GATEWAY);
    let text = res.text().await.unwrap_or_else(|_| "Purchase failed".to_string());
    respond(status, "application/json; charset=utf-8", None, text)
}

The body is forwarded verbatim but the Content-Type is always labeled JSON. If the backend's error path ever returns text/plain or an HTML 502 (e.g. a gateway in front of it), the SDK doing res.json() will throw on a non-JSON body and mask the real message — the opposite of the intent ("so the SDK surfaces it"). Consider forwarding the upstream Content-Type instead, falling back to text/plain:

let ct = res
    .headers()
    .get(reqwest::header::CONTENT_TYPE)
    .and_then(|v| v.to_str().ok())
    .unwrap_or(TEXT)
    .to_string();
let status = StatusCode::from_u16(res.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
let text = res.text().await.unwrap_or_else(|_| "Purchase failed".to_string());
respond(status, &ct, None, text)

Fix this →

🔹 Nit: StatusCode::from_u16(res.status().as_u16())

This round-trip is the correct defensive move if reqwest's and axum's http crate versions ever diverge, so I'd keep it — just flagging that if both are on http 1.x (they appear to be) it's a no-op and res.status() would pass through directly. Not worth changing.

🔹 Optional: DRY the upstream POST

This is now the third handler (handle_callback:189, handle_auth_refresh:354, here) repeating the cfg.client.post(url).header("Authorization", format!("Bearer {}", cfg.api_key)).json(...) shape. A small cfg.dev_api_post(path, body) helper would centralize the bearer-auth + host formatting. Purely a cleanup; fine to defer.

Note

I did not compile-check (doppler run -- cargo check) — the change is small and type-consistent with the surrounding handlers, but a local build/clippy run before merge is worth it since I couldn't run it here.

The standalone paywall now grants entitlements directly via a gameplay-JWT
mutation, so the dev server no longer mediates the purchase. It does need to
stop hiding the new entitlement: /auth/refresh serves a cached JWT while still
fresh, so a post-purchase forced refresh would return a stale token. Honor
`?fresh=1` (sent by the SDK on forced refreshes) to skip the cache and re-mint
from the backend, which also updates the cookie so a reload stays entitled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@franzwarning franzwarning force-pushed the franzwarning/wvdsh-1588-disable-parent-required-iframe-sdk-calls-in-sdk-js branch from f4d1c12 to eaf3ef3 Compare June 26, 2026 01:05
@claude

claude Bot commented Jun 26, 2026

Copy link
Copy Markdown

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


Code Review: WVDSH-1588 (current diff)

  • Read changed file src/dev/server.rs
  • Review for bugs, security, and best practices
  • Post review feedback

⚠️ Heads-up: the code no longer matches the PR description. The latest commit (eaf3ef3"re-mint gameplay JWT on forced refresh (?fresh=1)") replaced the POST /__wavedash/purchase route described in the PR body. The current origin/main...HEAD diff (+17/-4, only src/dev/server.rs) does not add a purchase route at all — there's no /__wavedash/purchase in the router (server.rs:94-97). Instead it adds a ?fresh query param to the existing /auth/refresh handler. Worth updating the PR title/description so reviewers and the eventual squash-merge message reflect what actually ships.

What the change actually does

#[derive(Deserialize)]
struct RefreshParams {
    fresh: Option<String>,
}
// ...
if p.fresh.is_none() {
    if let Some(jwt) = cookie_value(&headers, &cfg.jwt_cookie_name()) {
        if jwt_fresh(&cfg, &jwt).await {
            return respond(StatusCode::OK, TEXT, None, jwt);
        }
    }
}

When the SDK hits /auth/refresh?fresh=1 (after a claims-changing event like a simulated purchase), the handler skips the cached-cookie fast path and forces a backend refresh-gameplay round-trip, which re-mints a JWT carrying the new entitlement and re-sets the cookie (server.rs:376-386). This is a cleaner version of the previously-reviewed "expire the cookie" trick — same goal, fewer moving parts, and the grant itself now lives in the sdk-js / wvdsh side.

👍 What's good

  • Correct mechanism. jwt_fresh (server.rs:414) is exactly what would otherwise mask a fresh entitlement, and gating the fast path behind p.fresh.is_none() bypasses it only when asked. The success branch re-sets the JWT cookie so subsequent (non-fresh) refreshes serve the entitlement-bearing token.
  • Auth gating unchanged. Still 401s on a missing session cookie before any backend call (server.rs:352), so ?fresh adds no new abuse surface — worst case is an extra backend round-trip, and it's a localhost dev-only server.
  • Query/Deserialize already imported (server.rs:16), consistent with handle_callback's Query<CallbackParams> (server.rs:182). No new deps.
  • The inline comment explains why (?fresh follows a claims-changing event), matching the existing doc-comment style.

🔹 Nit: fresh is presence-detected, not value-parsed

fresh: Option<String> checked via is_none() means any presence forces a re-mint — ?fresh, ?fresh=1, but also ?fresh=0 and ?fresh=false. The commit names it ?fresh=1, so a future caller passing ?fresh=0 expecting "don't force" would be surprised. Since the SDK only ever sends ?fresh=1 today this is harmless, but a one-word type swap makes intent self-documenting and rejects garbage values:

#[derive(Deserialize)]
struct RefreshParams {
    #[serde(default)]
    fresh: bool, // ?fresh=true forces a re-mint
}
// ...
if !p.fresh { /* cached fast path */ }

Trade-off: bool requires ?fresh=true/?fresh=1 (serde_urlencoded won't accept bare ?fresh), so keep Option<String> if you specifically want bare-flag ?fresh to work. Either is fine — flagging only so the choice is deliberate. Fix this →

Note on verification

I could not run doppler run -- cargo check here (the sandbox blocks the Doppler-wrapped command — needs an approved/allowlisted tool). The change is small and type-consistent with the surrounding handlers (Query<RefreshParams> mirrors the existing Query<CallbackParams>), so I'd expect it to compile, but please run a local doppler run -- cargo check && doppler run -- cargo clippy before merge.

Verdict: clean, well-scoped, no blocking issues. Just (1) update the PR description to match the ?fresh approach, and (2) optionally tighten the fresh param type.

@franzwarning franzwarning changed the title WVDSH-1588: dev-server purchase route for simulated paywall WVDSH-1588: re-mint gameplay JWT on forced refresh (?fresh=1) Jun 26, 2026
@franzwarning franzwarning merged commit 5a21579 into main Jun 26, 2026
7 checks passed
@franzwarning franzwarning deleted the franzwarning/wvdsh-1588-disable-parent-required-iframe-sdk-calls-in-sdk-js branch June 26, 2026 03:21
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