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`.
|
|
|
|
|
|
Public landing, owner-list links, owner-conditional semi, PWA + mobile
Four related UX/privacy/install changes.
1. **Logged-out lands on the public list.** The root route now shows the
same Home view as a logged-in user, minus their own private rows and
the "Ny aktivitet" button. The nav exposes a "Logg inn" button when
no session is present. Login becomes one click away, not the forced
landing — anyone can browse the public + semi list anonymously.
2. **Public activities link to /<owner_username>/list.** When a public
activity's owner has opted into a public list, the "Lagt til av X"
line renders X as a link to /<username>/list. Server populates
`owner_username` on every public-row serialisation (null when the
owner hasn't opted in, so the client just renders plain text).
3. **Conditional owner_id on semi rows.** The server now serialises
`owner_id` on a semi row ONLY when the viewer IS the owner. The
wire type's `ActivitySemi.owner_id` is therefore optional. This
solves the semi-delete UX without leaking attribution: owners see
Edit/Delete buttons on their own semi rows; non-owners get the same
bare row they got before. The privacy property is enforced at the
API boundary, not in client-side render logic.
4. **Mobile-friendly + installable PWA.**
- `manifest.webmanifest` with name, theme color, standalone display,
and a maskable SVG icon (icon.svg).
- Service worker (sw.js): cache-first for the bundled shell;
network-only for /api/* (we never cache session-dependent or
ciphertext data — see the comment in sw.js for the rationale).
Falls back to the SPA shell for navigation requests when offline.
- SW registered in main.ts only in production builds (import.meta.env.PROD).
- viewport-fit=cover + env(safe-area-inset-*) padding so content
doesn't slip under iOS notches when installed.
- WCAG 2.5.5 touch-target sizing: min-height: 44px on buttons,
with an explicit opt-out for tag-close buttons (24×24 still meets
the 2.5.8 minimum).
- 16px font on form inputs below 480px so iOS doesn't auto-zoom.
Server-side: server/index.ts now serves manifest, icon, and sw.js
from frontend/dist alongside /assets/*. The catch-all still serves
index.html so the SPA's /<username>/list path routing keeps working.
Smoke-tested with a production-mode server: manifest returns the
correct application/manifest+json MIME, SVG renders, sw.js is
loadable, and unknown paths fall through to index.html as expected.
26 tests still pass; both tsconfigs typecheck (frontend now pulls
vite/client types for import.meta.env.PROD); Vite build succeeds.
2026-05-25 12:57:59 +02:00
|
|
|
## 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
|
|
|
|
|
|
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)
|