# Vinterliste A small end-to-end-encrypted app for collecting *winter activities* — things to do when winter feels long. Activities can be: - **private** — encrypted client-side; the server only ever sees ciphertext; - **semi-public** — visible to everyone, but the creator is **not** shown; - **public** — visible to everyone, attributed to the creator. See [`SECURITY.md`](./SECURITY.md) for the cryptographic model. It's load-bearing — read it before changing anything in `shared/crypto.ts` or the auth flow. ## Stack - **Runtime:** [Bun](https://bun.sh) 1.3+. TypeScript everywhere. - **HTTP:** [Hono](https://hono.dev) on Bun. - **DB:** `bun:sqlite` (built-in), WAL mode. - **Server password hashing:** `Bun.password` (argon2id) — auth verifier only. - **Client crypto:** `libsodium-wrappers-sumo` (WASM — the SUMO build is needed because the standard `libsodium-wrappers` doesn't ship `crypto_pwhash`). Argon2id via `crypto_pwhash`; AEAD via XChaCha20-Poly1305-IETF. - **Frontend:** Svelte 5 + Vite. Private tag index in IndexedDB. - **Container:** single `oven/bun` image, one volume for the SQLite file. ## Layout ``` shared/ pure modules used by both server and frontend crypto.ts libsodium-backed key derivation, AEAD, wrap/unwrap, helpers types.ts wire-level types shared across the network boundary server/ Bun + Hono backend db.ts bun:sqlite, WAL, idempotent schema migration session.ts opaque, server-stored sessions (httpOnly cookie) auth.ts signup, challenge, login, logout, password change, recovery activities.ts CRUD with visibility rules tags.ts server-side (public/semi) tag store + autocomplete index.ts Hono app + static frontend in production frontend/ Vite + Svelte 5 SPA src/lib/crypto.ts re-exports shared/crypto for bundling src/lib/api.ts fetch wrapper for the JSON API src/lib/auth.ts signup/login/recovery orchestration src/lib/tagIndex.ts IndexedDB store for private tags src/lib/session.svelte.ts in-memory DEK + current user src/components/ Svelte 5 components (Login, Signup, Recovery, Home, …) tests/ Bun tests crypto.test.ts round-trip wrap/unwrap, AEAD, password change, recovery Containerfile single-image build for podman ``` ## Running locally You need Bun 1.3+ installed. ```bash bun install # 1. In one terminal — start the API on http://localhost:3000 bun run dev:server # 2. In another terminal — start the Vite dev server on http://localhost:5173 # (it proxies /api to :3000) bun run dev:frontend ``` The dev server writes the SQLite file to `data/vinterliste.db`. Set `VINTERLISTE_DB=/some/other/path` to override. ## Tests ```bash bun test ``` The crypto tests cover: - DEK wrap/unwrap via both the password and recovery paths; - AEAD encrypt/decrypt round-trip, plus tamper and wrong-key rejection; - password change preserves activity ciphertexts (DEK is the same); - recovery unlocks even after multiple password changes; - recovery-code normalisation handles dashes and casing; - the safe alphabet excludes visually ambiguous characters. ## Typecheck ```bash bun run typecheck ``` ## Production build ```bash bun run build:frontend # produces frontend/dist NODE_ENV=production bun run start ``` The server serves the SPA from `frontend/dist` in production. All non-`/api/*`, non-`/assets/*` requests fall through to `index.html` so client-side routing still works. ## Container (podman) The provided `Containerfile` builds a single image that serves API + frontend and persists the SQLite database in `/app/data` (one volume). ```bash BUILDAH_FORMAT=docker podman build \ --build-arg BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ --build-arg GIT_REVISION="$(git describe --always --dirty 2>/dev/null || echo dev)" \ -t vinterliste:latest . # Create a named volume for the SQLite file podman volume create vinterliste-data podman run --replace --name vinterliste \ -p 3000:3000 \ -v vinterliste-data:/app/data:Z \ vinterliste:latest # Visit http://localhost:3000 ``` The container exposes `/api/health` for healthchecks and bakes the build date / git revision into both OCI labels and `/etc/build-info`. ## Registration: open, invite-only, or both Admins can toggle self-registration from the Admin UI. Two modes: - **Open** (default): anyone can hit `/` → "Logg inn" → "Opprett konto". - **Closed**: the "Opprett konto" button is hidden; new accounts can only be created through invite links. Any logged-in user can generate invite links from their Profile page. Each link is single-use; the link URL is `//invite/` and the recipient is dropped straight into the signup form with the token attached. Successful invite-signups record `users.invited_by = ` so the account has a traceable origin. Invites that have been claimed are kept in the DB (they can no longer be cancelled) so the audit trail survives. ## Installable (PWA) + mobile The SPA ships with a web app manifest (`/manifest.webmanifest`), an SVG icon (`/icon.svg`), and a small service worker (`/sw.js`) that caches the bundled shell for offline reads. The API itself is **never** cached — sessions and ciphertexts must come fresh from the server. On supported browsers (Chrome/Edge on Android and desktop, Firefox with the flag) you'll see an "Install" prompt; on iOS you can Add to Home Screen but iOS doesn't render SVG icons, so the home-screen icon will fall back to the page screenshot. Layout adapts to small screens via: - `viewport` set to `width=device-width, initial-scale=1, viewport-fit=cover` - safe-area insets in `padding` so content doesn't slip under iOS notches - `min-height: 44px` on buttons (WCAG 2.5.5 enhanced touch target) - `font-size: 16px` on inputs below 480px so iOS doesn't auto-zoom ## Roles: moderator and admin There are three privilege levels: | Role | What it grants | |-----------------|--------------------------------------------------------------------------------| | **Anonymous** | Browse public + semi activities, view opt-in `//list` pages | | **User** | + manage own activities, edit own profile, submit feedback | | **Moderator** | + delete any `semi`/`public` activity, read the feedback list | | **Admin** | + grant/revoke moderator and admin on other users (via `/api/admin/users`) | Admin implies moderator — admins automatically pass any `is_moderator` check. The **first user** to sign up is auto-promoted to admin so a fresh deployment is never stranded without one. After that, admins can grant moderator/admin to others through the Admin UI. If for any reason that didn't happen (e.g., you imported a DB), you can still bootstrap manually: ```bash # Manually promote (e.g. for imported DBs): sqlite3 data/vinterliste.db \ "UPDATE users SET is_admin = 1 WHERE email = 'you@example.org';" # Promote a plain moderator (admins can also do this from the UI): sqlite3 data/vinterliste.db \ "UPDATE users SET is_moderator = 1 WHERE email = 'them@example.org';" ``` The user has to log out and back in for the in-memory `session.user` to refresh — server-side authz updates on the next request regardless. A last-admin safety net is wired into the role-update endpoint: an admin trying to demote themselves while they're the only remaining admin gets a `409 cannot_demote_last_admin`. If you really want to strand the deployment with no admin, you have to use `sqlite3` directly. ## Manual verification After signing up an account, the spec asks you to inspect a `private` row directly in the DB and confirm only ciphertext is stored: ```bash sqlite3 data/vinterliste.db \ "SELECT id, visibility, title, loc_label, scheduled_at, length(ciphertext) AS ct_len, length(nonce) AS nc_len FROM activities WHERE visibility = 'private';" ``` You should see `title`, `loc_label`, and `scheduled_at` all `NULL`, and the `ciphertext` / `nonce` columns populated. ## Status / scope This is the scaffold from `winter-list-claude-code-prompt.md`. In scope: - repo structure, schema, single-image container - crypto module + tests - signup / login / password change / recovery - activity CRUD with strict visibility handling - tag autocomplete (server `tags` table + client IndexedDB) Explicitly **out of scope** for now: - sharing/permissions beyond the three visibility levels - comments, notifications, other social features - native/mobile apps - server-side full-text search over private data - rate limiting on auth/recovery endpoints (defense-in-depth — the recovery verifier already closes the lockout-DoS hole; rate limiting reduces online brute-force surface)