vinterliste/CLAUDE.md
Ole-Morten Duesund bd82f71a01 Admin role, root/home URL split, activity permalinks
Three related changes.

1. **Admin role.** New `is_admin INTEGER NOT NULL DEFAULT 0` column on
   users; added to MeResponse. Admin strictly implies moderator —
   shared/roles.ts has a single isModerator()/isAdmin() pair so the
   implication can't drift between callers. The duplicated isModerator()
   helpers in server/activities.ts and server/feedback.ts now import
   from there.

   /api/admin endpoints (admin-only):
     GET   /admin/users           — list users with their roles
     PATCH /admin/users/:id/role  — set is_moderator and/or is_admin

   Last-admin guard: the role-update endpoint refuses to demote the only
   remaining admin (409 cannot_demote_last_admin). Bootstrap is via
   `sqlite3 ... UPDATE users SET is_admin=1` — documented in README.

   Frontend Admin.svelte: table of users with toggles for moderator and
   admin. Visible from the nav only when the current user is admin.
   Toggling our own role refreshes session.user so the nav adapts
   immediately.

2. **Root/home split.** The URL `/` always shows the public landing
   (public + semi activities), even when the user is logged in. `/home`
   is the authenticated dashboard. After login or signup the SPA pushes
   `/home`; after logout it pushes `/`. popstate is wired so the
   back/forward buttons work. Unknown paths fall through to the public
   landing, not a 404.

3. **Activity permalinks at /a/:id.** New SPA route renders a single
   activity via the existing GET /api/activities/:id endpoint (private
   rows still require the owner's session to decrypt). A "Del" button
   on each ActivityRow copies the absolute permalink to the clipboard.
   Clipboard API has a prompt() fallback for environments where it's
   blocked.

Server changes minimal: server/admin.ts is the new file; server/roles.ts
is the lifted helper; server/index.ts wires the admin routes; server/db.ts
gets one more ensureColumn() line.

26 tests still pass; typecheck clean; Vite build succeeds. Bundle grew
from 28.6 KB gzipped to 30.2 KB reflecting the Admin + permalink views.
2026-05-25 13:23:13 +02:00

7 KiB

CLAUDE.md — Vinterliste

Project-specific guidance. The global rules in ~/.claude/CLAUDE.md still apply; this file adds what's specific to this codebase.

What this app is

Vinterliste is an end-to-end-encrypted "things to do this winter" list. Activities are private, semi, or public. The original spec is in winter-list-claude-code-prompt.md. The cryptographic model is in SECURITY.mdread it before touching shared/crypto.ts, the auth flow, or anything that handles passwords, recovery codes, or DEKs.

Non-negotiable invariants

Before changing code that touches auth, encryption, or the activity row shape, re-check that all of these still hold:

  1. The server never sees the user's raw password, the recovery code, or the unwrapped DEK. Adding a new endpoint that takes one of those is a bug, not a feature.
  2. Private activities have ciphertext + nonce populated and title, tags, loc_*, scheduled_at all NULL. No row in activity_tags for a private activity.
  3. Semi activities never serialize owner_id (or any creator-identifying field). The column is still set so the owner can edit/delete, but serialize() in server/activities.ts strips it.
  4. Public activities do serialize owner_id.
  5. auth_salt ≠ kek_salt. Generated independently on signup. If you ever need to "reuse" a salt to save a round trip, don't — re-read SECURITY.md.
  6. Argon2id parameters in shared/crypto.ts are stable. If you raise them, you have to store per-user parameters next to salts so old accounts unlock.
  7. Bun.password is for the auth verifier only. Never use it to derive a KEK — the KEK needs raw key bytes from crypto_pwhash.

Crypto module is the trust root

shared/crypto.ts is pure (no I/O, no globals beyond a memoised ready() promise). It runs in both Bun (tests) and the browser (Vite). If you find yourself adding a network call or platform-specific code in there, push it out to the caller instead.

Tests in tests/crypto.test.ts are the regression net. If you change any primitive, the round-trip and tamper tests must still pass.

Stack choices that are locked

These are pinned by the spec, not just by preference. Don't substitute:

  • Bun runtime + bun:sqlite + Bun.password.
  • Hono for HTTP. Bun.serve is OK if it's simpler, but prefer Hono.
  • libsodium-wrappers-sumo for client crypto (Argon2id + XChaCha20-Poly1305-IETF). The SUMO build is required — the standard libsodium-wrappers package doesn't ship crypto_pwhash. The import goes through createRequire because both packages have a broken ESM entry that references a .mjs file they don't actually publish; see the comment at the top of shared/crypto.ts.
  • Svelte 5 + Vite for the frontend. (Svelte 5 runes — $state, $derived, $effect, $props, $bindable — are the reactivity model. Don't import writable stores from svelte/store unless there's a real reason.)
  • IndexedDB for the private tag index. Not localStorage.

Things that aren't pinned and can change: CSS approach, exact error-display patterns, the choice of how the TagInput merges suggestion sources (currently labelled merge — see frontend/src/components/TagInput.svelte).

Visibility transitions

private → semi/public and back is an update PATCH, not a server toggle. The client decrypts locally (or re-encrypts), then sends the appropriate shape. server/activities.ts:patchActivity wipes the columns from the old visibility and populates the new ones — keep this logic centralised there.

Sessions

Sessions are opaque tokens stored in the sessions table; the cookie is vl_session, httpOnly, SameSite=Lax, secure-when-https. Don't switch to JWT — revocation matters more than statelessness for this app.

recovery-complete deletes all sessions for the affected user. That's the right behaviour: it kicks out any logged-in session that may have been hijacked, and the user has to re-login with the new password.

Roles

Three levels: user / moderator / admin. Admin implies moderator — isModerator() in server/roles.ts returns true for admins. Keep that implication invariant: an admin who can't moderate is meaningless and breaks the UI's assumptions. Add new privileges by checking isAdmin(), not by relaxing isModerator().

The admin endpoints (/api/admin/*) are gated by the isAdmin() check in server/admin.ts. A last-admin safety net prevents the only remaining admin from demoting themselves via the API — explicit sqlite3 is required for that, so the operator can't accidentally lock themselves out.

Tag input merging — design decision

Server tags and IndexedDB tags are merged in one dropdown, each row labelled with its source ("offentlig", "privat", "kun din"). For a public/semi activity, suggestions from the private index are shown but clearly marked "kun din" so the user understands accepting them will publish that tag.

If we ever want to keep them strictly separate, the change goes in TagInput.svelte — the rest of the app passes suggestions through unchanged.

What's deferred (documented in SECURITY.md)

  • Server-side rate limiting on auth/recovery endpoints. The recovery verifier closes the lockout-DoS; rate limiting reduces the online brute-force surface on top of that.
  • CSP / SRI for the SPA.

Recovery verifier — deviation from the spec

The original spec stored only kek_salt, wrapped_dek_pw+nonce, rec_salt, and wrapped_dek_rec+nonce. We additionally store rec_auth_salt and rec_auth_verifier_hash so the server can verify the caller knows the recovery code before /auth/recovery-complete writes anything. This is the only deviation from the spec's stated storage model — documented in SECURITY.md.

If you find yourself "simplifying away" the rec_auth_* columns or the verifier check, stop: that re-opens the lockout DoS. See the test in tests/auth.test.ts for the regression case.

Run / test / typecheck

  • bun install
  • bun run dev:server (API on :3000)
  • bun run dev:frontend (SPA on :5173, proxies /api)
  • bun test — must pass. Crypto tests are the regression net.
  • bun run typecheck — server and frontend TS.

Build / deploy

  • bun run build:frontend produces frontend/dist.
  • NODE_ENV=production bun run start serves API + static frontend.
  • Containerfile builds a single podman-ready image; one volume mounts /app/data for the SQLite file. README has the podman build / podman run snippet. Build args BUILD_DATE and GIT_REVISION propagate to OCI labels and /etc/build-info.

Language

UI is in Norwegian Bokmål (lang="nb" on <html>). Default text for any new UI strings should be Bokmål too. Don't add Nynorsk unless the user explicitly asks for it.

When stuck, re-read

  1. winter-list-claude-code-prompt.md — the original spec.
  2. SECURITY.md — the cryptographic model.
  3. Then look at the code.

If the spec, SECURITY.md, and the code disagree, the spec wins and the code needs fixing. If the spec and SECURITY.md disagree, flag it before changing anything.