Files
cms-client/CLAUDE.md
Noa Virellia ccb7680d38 fix(session): proactive token refresh to eliminate 401 rotation races
The backend does not implement a refresh-token grace window. Without it,
two browser requests sent within the same ~15s window but in separate
HTTP round-trips both carry the same expired access token. The first
request successfully rotates the token; the backend immediately invalidates
the old token; the second request arrives after the first response's
Set-Cookie has been sent but before the browser has applied it, so it
still presents the old token — and the backend rejects the refresh.

The recentlyRotated cache (previous commit) covers same-process sequential
races. This commit adds the primary defence:

Proactive refresh in hooks.server.ts: decode the access token's iat claim
(payload only, no signature verification) and rotate when ≤5s remain on
the 15s lifetime, before any API call is made. All parallel load functions
in the same request then see a freshly-issued token. The single-flight in
refreshSingleFlight collapses simultaneous proactive refreshes from
concurrent browser requests on the same pod. The 401 interceptor in
api.ts remains as a safety net for unexpected cases (clock skew, etc.).

Adds getJwtIat and isTokenAboutToExpire helpers with full unit tests.
Updates CLAUDE.md to document the two-path refresh design.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 18:12:12 +08:00

11 KiB
Raw Permalink Blame History

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

App name

The correct spelling is NixCN CMS (no space between Nix and CN). Never write "Nix CN CMS".

Project context

SvelteKit rewrite of ~/Projects/cms-client (a React 19 SPA). This is not a 1:1 port — we use SSR + server actions instead of client fetching, and DaisyUI 5 instead of shadcn/Radix.

The rewrite is decomposed into sequential milestones, each with its own design spec + implementation plan under docs/superpowers/. Milestone 1 (Foundation) is complete on branch foundation. Milestones 27 (Profile, Events, Admin events, Admin users+stats, Check-in, Polish) still need to be brainstormed, planned, and built.

Read docs/superpowers/overview.md first — it maps all seven milestones, status, and cross-cutting invariants. Then docs/superpowers/specs/2026-04-14-foundation-design.md + docs/superpowers/plans/2026-04-14-foundation.md for the canonical Foundation design — those docs are more authoritative than this file for how the pieces fit.

Commands

pnpm dev          # Vite dev server on :5173; mounts app at /app/
pnpm build        # vite build → build/ (adapter-node), runs via `node build`
pnpm preview      # preview the built app on :4173
pnpm check        # svelte-kit sync + svelte-check — run before every commit
pnpm gen          # regenerate src/lib/api/ from backend OpenAPI (requires VPN to 10.0.0.10:8000)
pnpm lint         # prettier --check + eslint
pnpm format       # prettier --write
pnpm test:unit    # vitest run (src/**/*.{test,spec}.ts)
pnpm test:e2e     # playwright test (tests/e2e/**) — uses `pnpm dev` as webServer
pnpm test         # unit then e2e

Run a single vitest test: pnpm test:unit src/lib/server/session.test.ts -t 'single-flight'. Run a single Playwright test: pnpm test:e2e -g 'redirected'.

Base path

The app is mounted at /app/ (svelte.config.js sets paths.base = '/app'). Always use base from $app/paths when constructing links/redirects; hardcoded /app/... is fine in server-side redirect() calls but use base in templates.

Architecture

Request lifecycle (every request)

Browser → SvelteKit node server (adapter-node)
  hooks.server.ts              # read access_token cookie → event.locals.accessToken;
                               # call getUserInfo through the API client (→ interceptor);
                               # set event.locals.user
  root +layout.server.ts       # return { user }
  (app)/+layout.server.ts      # guard: if !locals.user → redirect to /app/authorize
  +page.server.ts              # createApiClient(event) → SDK calls → return data
  +page.svelte                 # renders server data (no client fetching)

Form submissions use actions in +page.server.ts. Everything data-fetching is server-side.

Session + auth

Short-lived JWT access token (~15s) + long-lived refresh token, both in httpOnly cookies set by /token after the magic-link OAuth callback.

Refresh has two paths, both funnelling through refreshSingleFlight in src/lib/server/session.ts:

  1. Proactive (primary): hooks.server.ts decodes the JWT's iat claim (payload only, no signature verification) and calls refreshSingleFlight when ≤5s remain on the 15s access token — before any API call is made. This ensures all parallel load functions in the same request already see a freshly-issued token. Single-flight collapses concurrent proactive refreshes from multiple in-flight browser requests on the same process.

  2. Reactive (safety net): the 401 interceptor in src/lib/server/api.ts still fires for any token that slipped through (e.g., clock skew), retrying the original request once.

After a successful rotation, the new token pair is cached in recentlyRotated (10s TTL) keyed by the old refresh token. A second browser request that arrives after the first has already refreshed (within 10s) gets the cached pair without a backend round-trip — covering the race where the browser sends a request before receiving the Set-Cookie from a previous response.

If refresh fails, cookies are cleared; the next request's (app) guard redirects to /authorize.

API client

Generated by @hey-api/openapi-ts into src/lib/api/never edit manually (it's prettier + eslint ignored). Regenerate with pnpm gen.

Always call the SDK through createApiClient(event) from $lib/server/api:

const api = createApiClient(event);
const result = await callSdk(() => someEndpoint({ client: api, body: {...} }));

The factory: injects Authorization: Bearer <accessToken>, installs the 401 interceptor, binds the client to event.fetch. baseUrl is derived from event.url.origin + /app/api/v1 — the backend is always same-origin (Vite proxy in dev, Caddy in prod), so no API_BASE_URL env var is needed.

Unified SDK error handling

src/lib/server/errors.ts is the single normalization point for every hey-api SDK call. It uses option-t Result types so callers never hand-roll try/catch + if (result.error).

import { isErr, unwrapErr, unwrapOk } from 'option-t/plain_result';
import { callSdk, loadSdk } from '$lib/server/errors';

In form actions (keep user input, inline error):

const result = await callSdk(() => postAuthMagic({ client: api, body }));
if (isErr(result)) return setError(form, '', unwrapErr(result).message);
const data = unwrapOk(result);

In load functions (error page via +error.svelte):

const data = await loadSdk(() => getEvents({ client: api }));
// throws SvelteKit error(status, message) on failure

normalizeSdkError handles three cases uniformly: transport errors (fetch threw) get prefixed 网络错误: …, backend envelopes { status, msg?, message? } are unwrapped, unknown shapes fall back to status=500.

Never ad-hoc try/catch around SDK calls or read .error / .data directly — use callSdk / loadSdk.

UI conventions

  • DaisyUI 5 with a custom dark-only theme in src/routes/layout.css (OKLCH palette; data-theme="dark" on <html>).
  • Bits UI for behavior (dropdowns, dialogs, selects) styled with DaisyUI classes.
  • Lucide icons via @lucide/svelte. Render dynamically with <item.icon class="..." />.
  • Forms: sveltekit-superforms + Zod. Schemas live in src/lib/schemas/. Server actions validate with superValidate(request, zod(schema)); client uses superForm(data.form, { dataType: 'form' }).
  • Cloudflare Turnstile via svelte-turnstile. In dev (import { dev } from '$app/environment'), short-circuit with a hidden <input name="cf-turnstile-response" value="turnstile_token">.
  • App shell ((app)/+layout.svelte) uses DaisyUI's drawer lg:drawer-open with is-drawer-close: / is-drawer-open: variants for icon-only collapse.

Svelte 5

We're on Svelte 5 runes mode (enforced project-wide in svelte.config.js). Use $state, $derived, $props, $effect — no Svelte 4 export let or $:.

Conventions

  • Imports: $lib/... for src/lib/, $lib/api for the generated SDK, $lib/server/... for server-only modules.
  • Commits: feat: / fix: / chore: / docs: / refactor: / test: prefix. Include trailer Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> when Claude produced the change.
  • Type safety: no any without a cast-comment explaining why. Prefer unknown + narrowing.
  • Never commit without running pnpm check first.
  • Run pnpm lint:fix once when a task is complete, then make the single commit for that task. Formatting and the change belong in the same commit — do not follow up with a separate "format" commit.

Writing E2E tests

E2E tests run against a programmable mock backend at http://localhost:4010 (Hono in scripts/mock-server.ts). Vite's proxy auto-targets the mock when NODE_ENV=test (set by the test:e2e script), and strips the /app/api/v1 prefix so test overrides target operation paths (e.g. /auth/magic) directly. No real backend is involved.

Conventions:

  1. Import the customized test from tests/e2e/helpers/fixtures — never @playwright/test directly. The custom test wraps mock.clear() in beforeEach, so tests start with empty overrides + empty request log.

  2. Auth ceremony lives in the loggedInUser fixture. Tests that need to start authenticated request the fixture: test('foo', async ({ page, loggedInUser }) => { ... }). This injects session cookies and registers a GET /user/info override. The full magic-link UI flow is verified exactly once, in auth.spec.ts.

  3. Every other endpoint the test hits, override inline. Use mock.override(METHOD, PATH, { status, body }) in the test body. Don't hide overrides in helpers — readers should see what the backend returns for that test without chasing dependencies.

  4. Mutation tests chain overrides. Set the pre-mutation GET override → trigger the UI action that PATCHes → set the post-mutation GET override → assert on UI re-render → assert on mock.requests({method, path}) to verify the PATCH carried the right body.

  5. Unmatched requests are a signal, not a bug to suppress. If a test fails with mock: no override for METHOD /path, the test is incomplete — add the missing override. Never add safety defaults to silence the failure.

  6. 401-triggered refresh. Any test that overrides an SDK call with status: 401 will trigger createApiClient's 401 interceptor and call POST /auth/refresh — register a /auth/refresh override too, otherwise the refresh hits the unmatched-500 and the test fails for the wrong reason.

Local gotcha: playwright.config.ts uses reuseExistingServer: !process.env.CI for both the mock and the dev server. If you have a non-test pnpm dev already running on port 5173, pnpm test:e2e will reuse it and your tests will silently hit the real backend (or 500 with no VPN). Kill any standalone dev server before running E2E tests locally.

See docs/superpowers/specs/2026-04-14-test-infrastructure-design.md for the full design.

Milestone workflow

This project follows the milestone discipline from ~/.claude/CLAUDE.md: substantive changes get a spec under docs/superpowers/specs/YYYY-MM-DD-<topic>-design.md and a plan under docs/superpowers/plans/YYYY-MM-DD-<topic>.md before code. Trivial typo/doc fixes skip the overhead. Use the /next-milestone flow for new features; run the superpowers brainstorming → writing-plans → subagent-driven-development chain for bigger chunks.

Integration assumptions

  • Backend at http://10.0.0.10:8000 (VPN required). Vite dev proxy mounts it under /app/api.
  • Access token is a JWT issued by /auth/token; backend tolerates one grace-use of a rotated refresh token for multi-tab races.
  • Magic-link flow: POST /auth/magic → email with link → /app/token?code=…POST /auth/token exchange.