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 dc349a9373 chore(sessions): schedule periodic expired-session GC
gcSessions() existed but only ran on signup. On low-volume instances the
sessions table would grow forever as users logged in/out without ever
triggering a sweep. Schedule it hourly from server boot via
setInterval(...).unref() so it doesn't keep the process alive. Run once
on boot too, to clean up whatever accumulated while the previous instance
was down. Tests import individual route modules instead of server/index.ts,
so this only fires in a real server boot.

Surfaced by /audit simplify.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 17:50:06 +02:00
frontend refactor(frontend): extract decryptPrivateCleartext helper 2026-05-25 17:41:13 +02:00
server chore(sessions): schedule periodic expired-session GC 2026-05-25 17:50:06 +02:00
shared Drag-and-drop unified activity list with per-user sort order 2026-05-25 16:47:55 +02:00
tests Drag-and-drop unified activity list with per-user sort order 2026-05-25 16:47:55 +02:00
.dockerignore Scaffold Vinterliste — end-to-end encrypted winter activity list 2026-05-25 12:27:14 +02:00
.gitignore fix(design): drop landing hr + duplicate link + snowflake background 2026-05-25 15:57:56 +02:00
bun.lock Drag-reorder works on touch via svelte-dnd-action 2026-05-25 16:59:43 +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
package.json Drag-reorder works on touch via svelte-dnd-action 2026-05-25 16:59:43 +02:00
README.md Self-registry toggle, invite links with attribution, first-user-admin 2026-05-25 13:45:32 +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.

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.

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)