# 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 ```bash 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`: ```ts const api = createApiClient(event); const result = await callSdk(() => someEndpoint({ client: api, body: {...} })); ``` The factory: injects `Authorization: Bearer `, 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`](https://github.com/option-t/option-t) Result types so callers never hand-roll `try/catch` + `if (result.error)`. ```ts import { isErr, unwrapErr, unwrapOk } from 'option-t/plain_result'; import { callSdk, loadSdk } from '$lib/server/errors'; ``` **In form actions** (keep user input, inline error): ```ts 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`): ```ts 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 ``). - **Bits UI** for behavior (dropdowns, dialogs, selects) styled with DaisyUI classes. - **Lucide icons** via `@lucide/svelte`. Render dynamically with ``. - 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 ``. - 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) ` 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--design.md` and a plan under `docs/superpowers/plans/YYYY-MM-DD-.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.