Et verktøy for å lage lister over ting å gjøre om vinteren for å holde vinterdepresjonen unna. Og selvfølgelig kan du også lage lister for andre formål — noen liker jo ikke sommeren heller.
  • TypeScript 66.4%
  • Svelte 27.7%
  • CSS 2.9%
  • Shell 1.6%
  • JavaScript 0.7%
  • Other 0.7%
Find a file
Ole-Morten Duesund 5e5bf92afb feat(auth): UnlockBanner so post-reload DEK loss is recoverable
After a page reload the SPA rehydrates session.user from /me but the
DEK lives only in memory and is intentionally gone. Previously this
manifested as:

  - "Logg inn på nytt med passordet ditt" line under each private
    row (vague — full re-login replaces the cookie too)
  - A raw "not_logged_in" Error.message on saving a private activity
  - Export silently dropping every private row from the file

New UnlockBanner.svelte mounts unconditionally in App.svelte and
renders only when session.user is set but session.dek is null. It
takes the password inline and runs the existing login() flow — same
challenge/derive/unwrap path — so the existing wrapped DEK is
recovered and all the user's private ciphertexts stay readable.
Replacing the cookie as a side effect is fine.

Polished a few other DEK-missing paths:

  - ActivityRow's private branch now says "Lås opp øverst på siden"
    instead of "Logg inn på nytt"
  - ActivityForm has a pre-flight check before submit + a friendly
    catch for the internal dek_missing sentinel
  - Profile's "Last ned eksport" refuses early with a "lås opp"
    pointer instead of producing a quietly truncated export
2026-05-25 22:14:45 +02:00
frontend feat(auth): UnlockBanner so post-reload DEK loss is recoverable 2026-05-25 22:14:45 +02:00
server feat(invites): drop literal token after claim; cleaner UI 2026-05-25 20:47:33 +02:00
shared feat(invites): drop literal token after claim; cleaner UI 2026-05-25 20:47:33 +02:00
tests feat(activity): Markdown rendering for descriptions 2026-05-25 21:15:31 +02:00
.dockerignore Scaffold Vinterliste — end-to-end encrypted winter activity list 2026-05-25 12:27:14 +02:00
.gitignore feat(ops): deploy.sh — build, tag, replace, prune 2026-05-25 21:39:44 +02:00
bun.lock feat(activity): Markdown rendering for descriptions 2026-05-25 21:15:31 +02:00
CLAUDE.md Admin role, root/home URL split, activity permalinks 2026-05-25 13:23:13 +02:00
Containerfile Scaffold Vinterliste — end-to-end encrypted winter activity list 2026-05-25 12:27:14 +02:00
deploy.env.example feat(ops): deploy.sh — build, tag, replace, prune 2026-05-25 21:39:44 +02:00
deploy.sh feat(ops): deploy.sh — build, tag, replace, prune 2026-05-25 21:39:44 +02:00
package.json feat(activity): Markdown rendering for descriptions 2026-05-25 21:15:31 +02:00
README.md feat(ops): deploy.sh — build, tag, replace, prune 2026-05-25 21:39:44 +02:00
SECURITY.md Close the recovery lockout-DoS hole on /auth/recovery-complete 2026-05-25 12:28:26 +02:00
tsconfig.json Scaffold Vinterliste — end-to-end encrypted winter activity list 2026-05-25 12:27:14 +02:00
winter-list-claude-code-prompt.md Scaffold Vinterliste — end-to-end encrypted winter activity list 2026-05-25 12:27:14 +02:00

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 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.

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

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:

./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:

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 stoprmrun race).

Manual podman build / podman run

If you don't want the script:

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:

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

  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:

# 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:

  • 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 /<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 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)