- TypeScript 66.4%
- Svelte 27.7%
- CSS 2.9%
- Shell 1.6%
- JavaScript 0.7%
- Other 0.7%
Two small UX fixes on the Profile page: 1. FriendsPanel: incoming-friends list previously had only a "Blokker" button. Add a "Legg til som venn"-knapp alongside it when the entry isn't already mutual. Shows "(gjensidig)" inline for entries where I've already added them back, and hides the add button when the incoming user doesn't have a username (rare — addFriend goes by username). 2. Invite list: claimed entries now show "✓ Mottatt og tatt i bruk av <bruker> · <dato>" instead of the "Brukt" badge + separate "av X · Y" line. Clearer wording, same data. |
||
|---|---|---|
| frontend | ||
| server | ||
| shared | ||
| tests | ||
| .dockerignore | ||
| .gitignore | ||
| bun.lock | ||
| CLAUDE.md | ||
| Containerfile | ||
| package.json | ||
| README.md | ||
| SECURITY.md | ||
| tsconfig.json | ||
| winter-list-claude-code-prompt.md | ||
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 for the cryptographic model. It's load-bearing
— read it before changing anything in shared/crypto.ts or the auth flow.
Stack
- Runtime: Bun 1.3+. TypeScript everywhere.
- HTTP: Hono 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 standardlibsodium-wrappersdoesn't shipcrypto_pwhash). Argon2id viacrypto_pwhash; AEAD via XChaCha20-Poly1305-IETF. - Frontend: Svelte 5 + Vite. Private tag index in IndexedDB.
- Container: single
oven/bunimage, 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.
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
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
bun run typecheck
Production build
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
Container (podman)
The provided Containerfile builds a single image that serves API + frontend
and persists the SQLite database in /app/data (one volume).
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. Use podman run --replace ... for redeploys — it's atomic and avoids the "container exists"
race.
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:
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:
# 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
- Build a new image with current
BUILD_DATEandGIT_REVISIONargs. podman run --replace— schema migrations are idempotent (CREATE TABLE IF NOT EXISTS …andensureColumn(...)add new columns without touching existing data).- Verify
/api/healthreturns the newrevision. - The
activitiestable's CHECK constraint includes all visibility values; thefriendsvisibility added later is migrated in viaensureActivitiesCheckIncludesFriends()(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:
# 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 /<origin>/invite/<token> and the
recipient is dropped straight into the signup form with the token attached.
Successful invite-signups record users.invited_by = <inviter_id> 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:
viewportset towidth=device-width, initial-scale=1, viewport-fit=cover- safe-area insets in
paddingso content doesn't slip under iOS notches min-height: 44pxon buttons (WCAG 2.5.5 enhanced touch target)font-size: 16pxon 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 /<username>/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:
# 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:
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
tagstable + 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)