Lossless Changelog
product

Calmstorm-decks auth gate fixed — prerendered Play routes were silently bypassing the middleware

Mid-afternoon detour from the dididecks TOC redesign: the calmstorm-decks fundraise deck was stuck in an auth loop — submit `staycalm`, land on a page once, then every subsequent navigation bounced back to `/access`. Server logs said `[middleware] /play/variant/v2: no cookie → /access` even though DevTools showed `cs_session` sitting in the browser cookie jar with all the right attributes. The HAR export looked like the browser wasn't sending cookies at all. The real culprit, after three rounds of red herrings: `/play/variant/[variant].astro` and `/play/section/[slot].astro` both had `export const prerender = true`. In Astro 6 with `output: "server"`, prerendered routes serve through a code path where the middleware's request context doesn't surface the inbound `Cookie` header — so the cookie was being set correctly and sent correctly, but the middleware was structurally blind to it. Worse, in production those routes would be static HTML files served directly by the Vercel CDN, **bypassing the auth gate entirely**. Flipping both to `prerender = false` makes every request to a gated route flow through the middleware properly, in dev and prod. The auth hardening done before discovering the prerender issue (explicit `Set-Cookie` headers on manually-constructed redirect Responses in verify / logout / link redemption) stays — it removes ambiguity from a part of the stack that has bitten this codebase before.

Michael StatonMusic Editoraugmented with Claude Code on Claude Opus 4.7 (1M context)

Calmstorm-decks auth gate fixed — prerendered Play routes were silently bypassing the middleware

Why care?

The calmstorm-decks fundraise deck is a live LP-facing surface gated by a passcode flow. Today's failure mode — submit the access code, land on one page, get bounced on every subsequent click — wasn't just an annoyance, it was a sign that the auth model wasn't enforcing what the spec said. The actual production risk was worse than the dev symptom: in output: "server" mode with prerender = true on the /play/variant/* and /play/section/* routes, the static HTML for those pages would be served by the Vercel CDN with zero middleware invocation. Anyone with the URL could play the deck. The bounce in dev was the same bug surfacing differently (the middleware context for prerendered routes in dev doesn't surface the inbound Cookie header, so even though the browser was correctly sending cs_session, the middleware reported "no cookie" and 302'd to /access).

Calmstorm-decks lives at dididecks-ai/client-sites/calmstorm-decks/ (git submodule). Today's work intersected with the dididecks-ai work because the founder paused the TOC redesign to demonstrate calmstorm's thin-chrome pattern for shell-extraction reference, hit the auth loop, and asked for the fix.

What's new

prerender = false on the two gated Play routes (the load-bearing fix)

  • src/pages/play/variant/[variant].astro — full-deck player for one variant, walks every adapted slide for v1 / v2 / v3 in section order. Was prerender = true with a getStaticPaths enumerating the three variants. Now prerender = false.

  • src/pages/play/section/[slot].astro — per-section player, walks one slot's variants back-to-back (01-v1 → 01-v2 → 01-v3). Same change.

  • Why this is correct: middleware-based auth requires the middleware to actually run on every request. Prerendered routes in output: "server" mode are baked into static .html files at build time and served directly by the CDN — middleware never sees them. The /changelog/[slug].astro route (which is explicitly public in PUBLIC_PREFIXES in middleware) can safely remain prerendered; the play routes can't.

  • In-body comments explain the constraint so a future agent or human doesn't naively flip it back for build-time perf wins without understanding the auth implication.

Done before the prerender root-cause was identified. Doesn't impact today's symptom but removes a known Astro 6 flakiness (cookies sometimes not flushing on redirect responses from API routes) and tightens the auth surface.

  • src/lib/auth/session.ts — two new exports:

    • buildSessionCookieHeader(sessionId) returns the literal Set-Cookie header value. Used by routes that construct their own Response objects.

    • buildSessionCookieDeleteHeader() returns the deletion header (Max-Age=0; Expires=Thu, 01 Jan 1970 ...). Same purpose for logout.

  • src/pages/api/access/verify.ts — now returns new Response(null, { status: 302, headers: { Location, "Set-Cookie": buildSessionCookieHeader(...) } }) instead of setSessionCookie(cookies, ...) + return redirect(...). The duplicate cookies.set() call was intentionally dropped so Astro's response transformer can't overwrite the manually-attached header with its own (potentially malformed) copy. Also: the Session insert is now wrapped in try/catch and logs the failure reason + writes an unknown_error AuthEvent row, so a future infra hiccup surfaces in audit logs instead of silently producing a 302 with no session.

  • src/pages/api/access/logout.ts — same explicit-Set-Cookie pattern for the deletion. Old clearSessionCookie + redirect could leave a stale cookie behind in the browser.

  • src/pages/access/link/[token].astro — magic-link redemption path got the same fix.

Diagnostic surfaces

  • src/pages/api/access/debug-cookie.ts — a tiny endpoint that sets a harmless cs_debug cookie via the same new Response() pattern verify uses. Visit /api/access/debug-cookie and check DevTools → Application → Cookies to confirm manual Set-Cookie is reaching the browser. Useful for future "is it my code or is it Astro" debugging. (Originally name-prefixed _debug-cookie.ts — Astro routing excludes underscore-prefixed files by design, so it 404'd until renamed.)

  • src/middleware.ts — when the "no cookie" path fires, the log now dumps the raw Cookie header value, what cookies.get("cs_session") returns, and Astro's outgoing Set-Cookie count. This is what definitively localized the bug to the prerendered routes — every other request had raw Cookie header: "cs_session=...", but /play/variant/v2 (prerendered) logged raw Cookie header: <absent>.

The story

The symptom

Submit staycalm → land on TOC. Click any Play link → bounce to /access. Re-enter staycalm → bounce again. Cookie cs_session clearly visible in DevTools → Application → Cookies, with Domain=localhost, Path=/, SameSite=Lax, HttpOnly, expiry 90 days out. Browser SHOULD send it on every same-origin navigation. Server log says [middleware] /play/variant/v2: no cookie → /access. Contradiction.

The detours

Detour 1 — Astro's flaky cookies-on-redirect. Known issue across recent Astro versions: context.cookies.set() followed by return context.redirect() from an API route doesn't always attach the Set-Cookie header to the redirect response. Fixed by manually constructing the Response with the Set-Cookie header explicitly. Verified working via diagnostic — cs_session showed up correctly in DevTools after the next login. Symptom unchanged: still bouncing on the next navigation.

Detour 2 — Chrome's HAR export hides cookies. The HAR file the founder downloaded showed zero Cookie headers on any /play/variant/v2 request, even though cs_session was in the cookie jar. This was misleading: Chrome strips both Cookie request headers and Set-Cookie response headers from HAR exports by default for security/privacy reasons. The cookies might have been on the wire while the HAR claimed otherwise. Spent more time than warranted chasing "the browser isn't sending the cookie" before realizing the only authoritative check is the server-side log of request.headers.get("cookie").

Detour 3 — "Maybe Firebase Studio is proxying localhost." The founder's browser had an fsvc_session JWT cookie issued by fullstack-vc (Firebase Studio / Cloud Workstation context). Plausible theory: if the dev preview was being served through a cloud-workstation proxy at some https://4321-xxx.cloudworkstations.dev URL, then cookies set with Domain=localhost wouldn't be sent on requests to the proxy URL — and DevTools would still show the localhost cookies because they're stored under that domain. Asked the founder to confirm what their browser's address bar actually said. They were on real localhost:4321. Ruled out.

The actual root cause

The middleware diagnostic added in detour 2 finally lit up the answer:

[middleware] /play/variant/v2: no cookie → /access
  raw Cookie header: <absent>
  cookies.get("cs_session"): <undefined>
  cookies.headers() Set-Cookie count: 0

The <absent> was the smoking gun — but only after the founder restarted the dev server (Astro doesn't HMR prerender flag changes). The same warning was firing every time the page rendered:

[WARN] `Astro.request.headers` was used when rendering the route
  `src/pages/play/variant/[variant].astro'`. `Astro.request.headers`
  is not available on prerendered pages.

The route had export const prerender = true. In output: "server" mode with a prerendered dynamic route, Astro intercepts the request at a different point in the pipeline. The middleware still runs in dev (we saw the log line) but the request context passed to it doesn't carry the parsed Cookie header through to context.cookies reliably. Worse, in production, prerendered routes become static .html files served by the CDN — middleware never runs for them, so the auth gate is structurally absent.

Flip both gated Play routes to prerender = false. Restart the dev server. Cookie now visible to middleware on every navigation. Auth gate now runs in production. Symptom gone.

What the calmstorm thin-chrome pattern looks like, while we're here

The founder's original reason for spinning up calmstorm-decks was to demonstrate how it manages a thin, brand-quiet chrome strip on top of scroll and play decks — material for the dididecks shell's continued extraction of calmstorm primitives (the Restore-Calmstorm-Nav-Elegance plan, partially shipped). The auth fix unblocked that demonstration. The thin-chrome pattern itself is captured in calmstorm's own DESIGN.md and the existing dididecks <DeckChrome> component (apps/deck-shell/src/components/DeckChrome.astro) is the first port of it.

How it works (or "Under the hood")

Why prerender = true on a gated route is a silent production bug

TS
// astro.config.mjs
output: "server",
adapter: vercel(),

In output: "server" mode, every route is SSR by default. A route opts OUT of server-rendering via export const prerender = true; — Astro then bakes the route's output into static HTML at build time. On Vercel that HTML is served by the CDN at the edge.

TS
// src/middleware.ts
export const onRequest = defineMiddleware(async (context, next) => {
  // ... auth checks ...
});

Astro middleware runs only on requests served by the SSR runtime. Static files served by the CDN go around the runtime entirely. So:

USER → CDN → static /play/variant/v2.html       ← middleware NEVER runs
                                                  ← auth gate is invisible
                                                  ← anyone with the URL gets in

vs. the corrected version:

USER → CDN → SSR function → middleware → /play/variant/[variant].astro render
                            ← auth gate ALWAYS runs

The dev symptom (cookie not visible to middleware on a prerendered route) is a related-but-different manifestation of the same architectural mismatch.

TS
// session.ts
export function buildSessionCookieHeader(sessionId: string): string {
  const maxAge = SESSION_MAX_AGE_DAYS * 24 * 60 * 60;
  const parts = [
    `${SESSION_COOKIE}=${sessionId}`,
    `Path=/`,
    `Max-Age=${maxAge}`,
    `SameSite=Lax`,
    `HttpOnly`,
  ];
  if (process.env.NODE_ENV === "production") parts.push("Secure");
  return parts.join("; ");
}

// verify.ts
return new Response(null, {
  status: 302,
  headers: {
    Location: redirectTo,
    "Set-Cookie": buildSessionCookieHeader(sessionId),
  },
});

The deliberate choice is not to also call cookies.set(...). Doing both means Astro's response transformer would try to overwrite the manually-attached Set-Cookie with its own version from the cookies API store — and in our experience that overwrite has been a source of malformed-cookie bugs. One source of truth, attached on the Response we construct ourselves.

What's next

  • Audit every prerendered route in calmstorm-decks against the public/private map in middleware.ts. /changelog/[slug].astro is public-by-design (in PUBLIC_PREFIXES), so prerender is fine there. Any other prerendered route under a gated path is a potential silent-bypass — quick grep, quick flip. (Done for the two play routes today; the rest needs a sweep.)

  • Promote the lesson to the astro-knots skill. "If you use output: \"server\" + middleware auth, every gated route MUST be prerender = false. Period. Prerendered routes bypass middleware in production." Worth a single-paragraph reference in the skill.

  • Resume the dididecks TOC redesign. Phase 5.5 (DeckMatrix component extraction) and Phase 6.5 (chroma index consumes it) shipped this morning before this detour. Next up: Phase 6 (deprecate per-variant /toc/[deck]/[variant]/ route → 302 redirect to /toc/[deck]/?variant=X) and Phase 7 (cross-cutting verify + version bump).

  • [[../client-sites/calmstorm-decks/context-v/specs/Confidential-Access-v2-Persistent-Sessions-and-Telemetry]] — the spec this auth model implements; the prerender constraint deserves an in-body note there

  • [[2026-05-17_01]] — today's earlier entry covering A++.2 / audits schema migration / TOC redesign plan / Phase 1; this detour interrupted Phase 6 work

  • [[../context-v/plans/Restore-Calmstorm-Nav-Elegance-as-Themable-Shell-Primitives]] — the partial-shipped plan whose continuation prompted today's calmstorm spin-up

  • astro-knots skill — candidate destination for the "prerender + middleware-auth = silent bypass" rule