# 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. ## Deployment ### One-shot deploy: `./deploy.sh` The repository ships a small bash deploy script that wraps the usual `podman build` + `podman run --replace` dance, tags images with both `:latest` and a UTC timestamp, and prunes older timestamped images so local disk doesn't grow forever: ```bash ./deploy.sh # build → replace container → prune (default) ./deploy.sh build # build + tag only ./deploy.sh run # restart from existing :latest, no rebuild ./deploy.sh prune # drop old timestamped images ./deploy.sh config # print resolved configuration ``` Settings come from `deploy.env` (gitignored). Copy the example and edit: ```bash cp deploy.env.example deploy.env ``` Available overrides: | Variable | Default | Notes | |---------------------------|-----------------------|------------------------------------------------------------------| | `IMAGE_NAME` | `vinterliste` | Local image-tag prefix. | | `CONTAINER_NAME` | `vinterliste` | `podman` container name. | | `VOLUME_NAME` | `vinterliste-data` | Named volume holding the SQLite file. | | `HOST_PORT` | `3000` | Host port mapped to the container's `:3000`. | | `BIND_ADDR` | `0.0.0.0` | Use `127.0.0.1` when fronting with a reverse proxy on the host. | | `KEEP_IMAGES` | `3` | Number of timestamped images to keep on prune. | | `PUBLIC_BASE_URL` | (auto) | Canonical URL injected into OpenGraph tags. | | `EXTRA_PODMAN_RUN_ARGS` | (empty) | Appended verbatim to `podman run`. Word-split, quote properly. | The `.env` parser is allowlist-based — unknown keys log a warning and are ignored; lines that aren't `KEY=value` are skipped; quoted values are stripped of one layer of `'` or `"`. Command-substitution forms like `KEY=$(…)` are NOT evaluated, so a malicious `deploy.env` can't run code. The container exposes `/api/health` for healthchecks and bakes the build date / git revision into both OCI labels and `/etc/build-info`. The script uses `podman run --replace` for atomicity (no `stop` → `rm` → `run` race). ### Manual `podman build` / `podman run` If you don't want the script: ```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 . podman volume create --ignore vinterliste-data podman run --replace --name vinterliste -d \ -p 3000:3000 \ -v vinterliste-data:/app/data:Z \ vinterliste:latest # Visit http://localhost:3000 ``` ### Environment variables | Variable | Default | Notes | |--------------------|------------------------|---------------------------------------------------------------| | `PORT` | `3000` | TCP port the server listens on. | | `NODE_ENV` | (unset) | Set to `production` to serve `frontend/dist` from the API. | | `VINTERLISTE_DB` | `data/vinterliste.db` | Path to the SQLite file. Override for an external volume. | | `PUBLIC_BASE_URL` | (derived from request) | Override the absolute URL used in OpenGraph `og:url` tags. | There are no secrets to set. Auth verifiers and DEK wraps live in the SQLite file; session tokens are generated per process and stored server-side, not signed. ### TLS termination The app speaks plain HTTP — terminate TLS at a reverse proxy (Caddy, nginx, Traefik). The session cookie is marked `Secure` when the request was HTTPS (`X-Forwarded-Proto: https`), so make sure the proxy sets that header. Sample Caddyfile: ```caddyfile vinterliste.example.org { encode zstd gzip reverse_proxy localhost:3000 } ``` Caddy auto-provisions a Let's Encrypt cert. Other proxies need the cert configured manually. ### Backup and restore The SQLite database is the entire app state — user accounts, DEK wraps, activity ciphertexts, sessions, the lot. Backing it up while the server is running is safe because of WAL mode: ```bash # Atomic backup using SQLite's built-in copy sqlite3 data/vinterliste.db ".backup '/path/to/backup/vinterliste-$(date +%F).db'" # Or via the container's volume podman exec vinterliste sqlite3 /app/data/vinterliste.db \ ".backup '/app/data/backup-$(date +%F).db'" ``` Plain file copy of the `.db` works too if the server is stopped first. With WAL files (`.db-wal`, `.db-shm`) present, copy all three or use `.backup`. To restore: replace the file on disk and restart the server. There are no out-of-band caches. ### Healthcheck `GET /api/health` returns `{ ok: true, build: { revision, built_at } }` with HTTP 200. Hook your monitoring or `HEALTHCHECK` directive at this endpoint. ### Upgrading 1. Build a new image with current `BUILD_DATE` and `GIT_REVISION` args. 2. `podman run --replace` — schema migrations are idempotent (`CREATE TABLE IF NOT EXISTS …` and `ensureColumn(...)` add new columns without touching existing data). 3. Verify `/api/health` returns the new `revision`. 4. The `activities` table's CHECK constraint includes all visibility values; the `friends` visibility added later is migrated in via `ensureActivitiesCheckIncludesFriends()` (table copy-drop-rename) on first boot if needed. Take a backup beforehand the first time you upgrade past a CHECK-constraint change. ### Emergency password reset (CLI) If an admin has lost access (forgotten password, lost recovery code, etc.) and can't recover via the UI, the server box has a CLI tool: ```bash # Inside the container: podman exec -it vinterliste bun run reset-password admin@example.org # Or on the host if you're running the server directly: bun run reset-password admin@example.org ``` It asks one question first: **do you still have this user's recovery code?** - **Yes → recovery mode.** Behaves exactly like the in-app recovery flow: unwraps the existing DEK with the recovery code, re-wraps it with the new password. No data is lost. The recovery code stays valid afterwards. - **No → nuke mode.** Generates a brand-new DEK + new recovery code and prints the new code to stdout (write it down — it's shown once). The user's **private activities are deleted** because their ciphertext was encrypted with the now-unrecoverable old DEK. Public, semi, friends-only activities, plus hearts / bookmarks / "gjort" marks, are kept. Both modes invalidate every existing session for the user, matching the hygiene of the in-app `/auth/recovery-complete` endpoint. The CLI requires direct DB access — there is no network exposure of this code path. ## 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)