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 47963c9225 Scaffold Vinterliste — end-to-end encrypted winter activity list
Foundation for an E2E-encrypted activity list per
winter-list-claude-code-prompt.md.

Server (Bun + Hono):
- bun:sqlite with WAL and the spec's schema (idempotent migration)
- opaque server-stored sessions, httpOnly cookie
- signup / challenge / login / logout / me / password / recovery-challenge /
  recovery-complete
- activity CRUD with strict visibility rules: private uses ciphertext+nonce,
  semi never serializes owner_id, public attributes the owner
- tag store with normalisation + autocomplete (semi/public only)

Frontend (Svelte 5 + Vite):
- libsodium-wrappers-sumo for client crypto (Argon2id + XChaCha20-Poly1305).
  SUMO is required because the standard build omits crypto_pwhash.
- IndexedDB-backed private tag index (never leaves the browser)
- in-memory DEK (no localStorage); page reload re-prompts for password
- signup shows the recovery code once; tag input merges server + private
  sources with clear labelling
- Bokmål UI

Crypto module (shared/crypto.ts):
- pure, runs in both Bun and the browser via a runtime-conditional loader
  that papers over libsodium-wrappers-sumo's broken ESM entry (createRequire
  on server, Vite alias in the browser)
- DEK wrap/unwrap, AEAD payload encryption, recovery code generation with
  a visually-unambiguous alphabet

Verification:
- 22 crypto round-trip tests (wrap/unwrap, AEAD tamper rejection, password
  change preserves ciphertexts, recovery still works after rotation)
- typecheck passes for server and frontend
- Vite production build succeeds; libsodium SUMO chunk is ~315 KB gzipped

Single-image Containerfile for podman: builds frontend in a builder stage,
runs Bun in a slim runtime; one volume for the SQLite file; BUILD_DATE /
GIT_REVISION baked into OCI labels and /etc/build-info.

Known limitation deferred for this commit: the recovery endpoint has no
server-side proof of the recovery code (anyone who knows an email can lock
out the legitimate user, though they can't read any data). Closed in the
next commit.
2026-05-25 12:27:14 +02:00
frontend Scaffold Vinterliste — end-to-end encrypted winter activity list 2026-05-25 12:27:14 +02:00
server Scaffold Vinterliste — end-to-end encrypted winter activity list 2026-05-25 12:27:14 +02:00
shared Scaffold Vinterliste — end-to-end encrypted winter activity list 2026-05-25 12:27:14 +02:00
tests Scaffold Vinterliste — end-to-end encrypted winter activity list 2026-05-25 12:27:14 +02:00
.dockerignore Scaffold Vinterliste — end-to-end encrypted winter activity list 2026-05-25 12:27:14 +02:00
.gitignore Scaffold Vinterliste — end-to-end encrypted winter activity list 2026-05-25 12:27:14 +02:00
bun.lock Scaffold Vinterliste — end-to-end encrypted winter activity list 2026-05-25 12:27:14 +02:00
CLAUDE.md Scaffold Vinterliste — end-to-end encrypted winter activity list 2026-05-25 12:27:14 +02:00
Containerfile Scaffold Vinterliste — end-to-end encrypted winter activity list 2026-05-25 12:27:14 +02:00
package.json Scaffold Vinterliste — end-to-end encrypted winter activity list 2026-05-25 12:27:14 +02:00
README.md Scaffold Vinterliste — end-to-end encrypted winter activity list 2026-05-25 12:27:14 +02:00
SECURITY.md Scaffold Vinterliste — end-to-end encrypted winter activity list 2026-05-25 12:27:14 +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.

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.

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
  • email-confirmation step for recovery (lockout-DoS mitigation noted in SECURITY.md)