- TypeScript 66.4%
- Svelte 27.7%
- CSS 2.9%
- Shell 1.6%
- JavaScript 0.7%
- Other 0.7%
Once an invite is claimed, the token has no functional role — claims
are one-way and the link is dead. Stop returning the literal token in
the GET /api/invites response for claimed entries (server/invites.ts
toEntry). The audit trail — claimed_at, claimed_by_display — stays.
Helps a little with data minimization: a compromised inviter account
can no longer see used-up invitation URLs.
Type: InviteEntry.token is now string | null. Callers that still need
to use the token (signup-via-invite tests, the cancel button, the
copy button) are guarded so they only run on entries where the token
is present (i.e. unclaimed). The each-key falls back to a synthetic
composite when token is null so Svelte's keyed-each stays stable.
UI: claimed entries collapse to a single muted line, no card frame,
no URL placeholder:
✓ Laget DD.MM.YYYY · godtatt av <bruker> DD.MM.YYYY
Unclaimed entries keep the existing card with copy / cancel buttons.
Heading on the invite section also renamed from "Invitasjonslenker"
to "Invitasjoner" — claimed entries don't have a link anymore so the
older label was misleading.
Tests updated to match by created_at instead of token for the
claimed-invite lookup, and to assert that token is null post-claim.
|
||
|---|---|---|
| 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)