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>
11 KiB
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 2–7 (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:
-
Proactive (primary):
hooks.server.tsdecodes the JWT'siatclaim (payload only, no signature verification) and callsrefreshSingleFlightwhen ≤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. -
Reactive (safety net): the 401 interceptor in
src/lib/server/api.tsstill 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 insrc/lib/schemas/. Server actions validate withsuperValidate(request, zod(schema)); client usessuperForm(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'sdrawer lg:drawer-openwithis-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/...forsrc/lib/,$lib/apifor the generated SDK,$lib/server/...for server-only modules. - Commits:
feat:/fix:/chore:/docs:/refactor:/test:prefix. Include trailerCo-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>when Claude produced the change. - Type safety: no
anywithout a cast-comment explaining why. Preferunknown+ narrowing. - Never commit without running
pnpm checkfirst. - Run
pnpm lint:fixonce 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:
-
Import the customized
testfromtests/e2e/helpers/fixtures— never@playwright/testdirectly. The custom test wrapsmock.clear()inbeforeEach, so tests start with empty overrides + empty request log. -
Auth ceremony lives in the
loggedInUserfixture. Tests that need to start authenticated request the fixture:test('foo', async ({ page, loggedInUser }) => { ... }). This injects session cookies and registers aGET /user/infooverride. The full magic-link UI flow is verified exactly once, inauth.spec.ts. -
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. -
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. -
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. -
401-triggered refresh. Any test that overrides an SDK call with
status: 401will triggercreateApiClient's 401 interceptor and callPOST /auth/refresh— register a/auth/refreshoverride 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/tokenexchange.