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
|
|
|
# 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`](./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](https://bun.sh) 1.3+. TypeScript everywhere.
|
|
|
|
|
- **HTTP:** [Hono](https://hono.dev) 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.
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
bun run typecheck
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
## Production build
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
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).
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
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`.
|
|
|
|
|
|
User profile, activity editing, search, OSM links, moderator role,
opt-in /<username>/list, and a feedback channel
Six related features that touch the user model and activity UX:
1. **User profile** (display_name, username, public_list_enabled).
New `display_name`, `username` (UNIQUE, slug-shaped), and
`public_list_enabled` columns. PATCH /api/auth/profile is a partial
update — pass only the fields you want to change, null to clear.
MeResponse exposes all three. Display name is shown on public
activities and in the nav; falls back to the email prefix when
unset.
2. **Change password from the profile editor.** Existing
/api/auth/password endpoint surfaced in the new Profile.svelte;
the local-decrypt failure path on a wrong current password is
mapped to a clean error.
3. **Edit existing activities.** ActivityForm becomes dual-purpose
(create or edit). Title, tags, date/time, location, and
visibility are all editable. Visibility transitions decrypt or
re-encrypt client-side as needed before PATCH, and the IndexedDB
private-tag index is kept in sync diff-style.
4. **Search.** A search input on Home filters across visible
activities. Private rows are searched against their decrypted
cleartext (decrypted once and memoised via $derived, so the work
is amortised across keystrokes). Matches across title, tags,
location label, and (for public) author display name.
5. **OpenStreetMap links.** Each row with a location renders the
label as an OSM link. Smart: coords if present
(?mlat=&mlon=&map=15/lat/lng → pinned view), else
/search?query=. Built with the WHATWG URL constructor so
Norwegian characters and commas survive.
6. **Moderator role + semi-delete by owner.** New is_moderator
column on users. Owners always delete their own rows; moderators
can additionally delete any semi or public activity (private is
excluded — it's invisible to others, so there's no moderation
case). README documents the manual promotion via sqlite3.
7. **Opt-in /<username>/list.** New server route
/api/users/:username/list returns the user's public activities
when both `username` is set AND `public_list_enabled = 1`. 404
when either condition fails — same response in both cases so the
route doesn't leak username existence for users who haven't opted
in. SPA-side, App.svelte parses window.location.pathname on
mount; falls back to "/" via history.replaceState after
authenticating from a deep link.
8. **Feedback channel.** New `feedback` table. POST /api/feedback
for any authenticated user; GET /api/feedback gated to
moderators. The Feedback.svelte component is dual-mode — the
form is universal; the list view auto-loads only for
moderators. Submitter identity (email + display name) is shown
to moderators so they can follow up; not exposed to the
submitter themselves.
Schema migrations land via the existing ensureColumn() helper so
scaffold DBs upgrade cleanly. The username UNIQUE constraint is
applied as a partial unique index (WHERE username IS NOT NULL) so
multiple users with NULL usernames don't collide.
All 26 existing tests still pass; typecheck clean for both
tsconfigs; Vite build succeeds.
2026-05-25 12:44:33 +02:00
|
|
|
## Promoting a moderator
|
|
|
|
|
|
|
|
|
|
Moderators can delete any `semi` or `public` activity (not `private` — those
|
|
|
|
|
aren't visible to anyone else anyway). There's no admin UI; promotion is a
|
|
|
|
|
one-liner against the SQLite file:
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
sqlite3 data/vinterliste.db \
|
|
|
|
|
"UPDATE users SET is_moderator = 1 WHERE email = 'olemd@example.org';"
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
The user has to log out and back in for `MeResponse.is_moderator` to refresh.
|
|
|
|
|
|
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
|
|
|
## 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:
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
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
|
2026-05-25 12:28:26 +02:00
|
|
|
- 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)
|