vinterliste/README.md

231 lines
8.7 KiB
Markdown
Raw Normal View History

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`.
Self-registry toggle, invite links with attribution, first-user-admin Three pieces of a single registration story. 1. **Self-registry toggle.** New generic `settings` key/value table. Initial key: `self_registry_enabled` (default `1`). Admin-only PATCH /api/settings flips it. GET /api/settings is public so the login screen can hide the "Opprett konto" CTA when registration is closed. 2. **Invite links.** New `invites(token, inviter_user_id, created_at, claimed_at, claimed_by_user_id)` table; tokens are 22-char base64url (~128 bits of entropy). Endpoints: POST /api/invites — create (any logged-in user) GET /api/invites — list mine DELETE /api/invites/:token — cancel an unclaimed invite Claimed invites are kept in the DB (the audit trail of who-invited- whom survives) — only unclaimed ones can be cancelled. The signup endpoint accepts an optional `invite_token`. The signup handler does the claim + user-insert in a single SQLite transaction so we can't end up with a claimed invite pointing at a missing user. A concurrent claim race is closed by `UPDATE … WHERE claimed_at IS NULL` — only one transaction's UPDATE actually flips the column. New `users.invited_by` column records the inviter id so accounts have a traceable origin. Profile page shows the user's invites with "Kopier lenke" / "Avbryt" buttons; the SPA serves /invite/<token> into the Signup view with the token prefilled. 3. **First-user auto-admin.** The signup handler counts users *before* the insert; if it's the first one, `is_admin` is set on the row. This solves the bootstrap chicken-and-egg without an env var or sqlite3 step. Documented in README. When self-registry is **off**: - The login screen hides "Opprett konto" and shows a "stengt" notice - /api/auth/signup with no invite returns 403 signup_closed - /api/auth/signup with a valid invite still works (and attributes) - /api/auth/signup with an *invalid* invite returns 403 invalid_invite When self-registry is **on**: - Anyone can sign up (no invite required) - An invite that comes along is still consumed for attribution - An invalid invite is ignored — signup proceeds without attribution 26 tests still pass; typecheck clean; bundle 31.2 KB → 32.7 KB gzipped.
2026-05-25 13:45:32 +02:00
## 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.
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
Admin role, root/home URL split, activity permalinks Three related changes. 1. **Admin role.** New `is_admin INTEGER NOT NULL DEFAULT 0` column on users; added to MeResponse. Admin strictly implies moderator — shared/roles.ts has a single isModerator()/isAdmin() pair so the implication can't drift between callers. The duplicated isModerator() helpers in server/activities.ts and server/feedback.ts now import from there. /api/admin endpoints (admin-only): GET /admin/users — list users with their roles PATCH /admin/users/:id/role — set is_moderator and/or is_admin Last-admin guard: the role-update endpoint refuses to demote the only remaining admin (409 cannot_demote_last_admin). Bootstrap is via `sqlite3 ... UPDATE users SET is_admin=1` — documented in README. Frontend Admin.svelte: table of users with toggles for moderator and admin. Visible from the nav only when the current user is admin. Toggling our own role refreshes session.user so the nav adapts immediately. 2. **Root/home split.** The URL `/` always shows the public landing (public + semi activities), even when the user is logged in. `/home` is the authenticated dashboard. After login or signup the SPA pushes `/home`; after logout it pushes `/`. popstate is wired so the back/forward buttons work. Unknown paths fall through to the public landing, not a 404. 3. **Activity permalinks at /a/:id.** New SPA route renders a single activity via the existing GET /api/activities/:id endpoint (private rows still require the owner's session to decrypt). A "Del" button on each ActivityRow copies the absolute permalink to the clipboard. Clipboard API has a prompt() fallback for environments where it's blocked. Server changes minimal: server/admin.ts is the new file; server/roles.ts is the lifted helper; server/index.ts wires the admin routes; server/db.ts gets one more ensureColumn() line. 26 tests still pass; typecheck clean; Vite build succeeds. Bundle grew from 28.6 KB gzipped to 30.2 KB reflecting the Admin + permalink views.
2026-05-25 13:23:13 +02:00
## Roles: moderator and admin
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
Admin role, root/home URL split, activity permalinks Three related changes. 1. **Admin role.** New `is_admin INTEGER NOT NULL DEFAULT 0` column on users; added to MeResponse. Admin strictly implies moderator — shared/roles.ts has a single isModerator()/isAdmin() pair so the implication can't drift between callers. The duplicated isModerator() helpers in server/activities.ts and server/feedback.ts now import from there. /api/admin endpoints (admin-only): GET /admin/users — list users with their roles PATCH /admin/users/:id/role — set is_moderator and/or is_admin Last-admin guard: the role-update endpoint refuses to demote the only remaining admin (409 cannot_demote_last_admin). Bootstrap is via `sqlite3 ... UPDATE users SET is_admin=1` — documented in README. Frontend Admin.svelte: table of users with toggles for moderator and admin. Visible from the nav only when the current user is admin. Toggling our own role refreshes session.user so the nav adapts immediately. 2. **Root/home split.** The URL `/` always shows the public landing (public + semi activities), even when the user is logged in. `/home` is the authenticated dashboard. After login or signup the SPA pushes `/home`; after logout it pushes `/`. popstate is wired so the back/forward buttons work. Unknown paths fall through to the public landing, not a 404. 3. **Activity permalinks at /a/:id.** New SPA route renders a single activity via the existing GET /api/activities/:id endpoint (private rows still require the owner's session to decrypt). A "Del" button on each ActivityRow copies the absolute permalink to the clipboard. Clipboard API has a prompt() fallback for environments where it's blocked. Server changes minimal: server/admin.ts is the new file; server/roles.ts is the lifted helper; server/index.ts wires the admin routes; server/db.ts gets one more ensureColumn() line. 26 tests still pass; typecheck clean; Vite build succeeds. Bundle grew from 28.6 KB gzipped to 30.2 KB reflecting the Admin + permalink views.
2026-05-25 13:23:13 +02:00
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.
Self-registry toggle, invite links with attribution, first-user-admin Three pieces of a single registration story. 1. **Self-registry toggle.** New generic `settings` key/value table. Initial key: `self_registry_enabled` (default `1`). Admin-only PATCH /api/settings flips it. GET /api/settings is public so the login screen can hide the "Opprett konto" CTA when registration is closed. 2. **Invite links.** New `invites(token, inviter_user_id, created_at, claimed_at, claimed_by_user_id)` table; tokens are 22-char base64url (~128 bits of entropy). Endpoints: POST /api/invites — create (any logged-in user) GET /api/invites — list mine DELETE /api/invites/:token — cancel an unclaimed invite Claimed invites are kept in the DB (the audit trail of who-invited- whom survives) — only unclaimed ones can be cancelled. The signup endpoint accepts an optional `invite_token`. The signup handler does the claim + user-insert in a single SQLite transaction so we can't end up with a claimed invite pointing at a missing user. A concurrent claim race is closed by `UPDATE … WHERE claimed_at IS NULL` — only one transaction's UPDATE actually flips the column. New `users.invited_by` column records the inviter id so accounts have a traceable origin. Profile page shows the user's invites with "Kopier lenke" / "Avbryt" buttons; the SPA serves /invite/<token> into the Signup view with the token prefilled. 3. **First-user auto-admin.** The signup handler counts users *before* the insert; if it's the first one, `is_admin` is set on the row. This solves the bootstrap chicken-and-egg without an env var or sqlite3 step. Documented in README. When self-registry is **off**: - The login screen hides "Opprett konto" and shows a "stengt" notice - /api/auth/signup with no invite returns 403 signup_closed - /api/auth/signup with a valid invite still works (and attributes) - /api/auth/signup with an *invalid* invite returns 403 invalid_invite When self-registry is **on**: - Anyone can sign up (no invite required) - An invite that comes along is still consumed for attribution - An invalid invite is ignored — signup proceeds without attribution 26 tests still pass; typecheck clean; bundle 31.2 KB → 32.7 KB gzipped.
2026-05-25 13:45:32 +02:00
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:
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
```bash
Self-registry toggle, invite links with attribution, first-user-admin Three pieces of a single registration story. 1. **Self-registry toggle.** New generic `settings` key/value table. Initial key: `self_registry_enabled` (default `1`). Admin-only PATCH /api/settings flips it. GET /api/settings is public so the login screen can hide the "Opprett konto" CTA when registration is closed. 2. **Invite links.** New `invites(token, inviter_user_id, created_at, claimed_at, claimed_by_user_id)` table; tokens are 22-char base64url (~128 bits of entropy). Endpoints: POST /api/invites — create (any logged-in user) GET /api/invites — list mine DELETE /api/invites/:token — cancel an unclaimed invite Claimed invites are kept in the DB (the audit trail of who-invited- whom survives) — only unclaimed ones can be cancelled. The signup endpoint accepts an optional `invite_token`. The signup handler does the claim + user-insert in a single SQLite transaction so we can't end up with a claimed invite pointing at a missing user. A concurrent claim race is closed by `UPDATE … WHERE claimed_at IS NULL` — only one transaction's UPDATE actually flips the column. New `users.invited_by` column records the inviter id so accounts have a traceable origin. Profile page shows the user's invites with "Kopier lenke" / "Avbryt" buttons; the SPA serves /invite/<token> into the Signup view with the token prefilled. 3. **First-user auto-admin.** The signup handler counts users *before* the insert; if it's the first one, `is_admin` is set on the row. This solves the bootstrap chicken-and-egg without an env var or sqlite3 step. Documented in README. When self-registry is **off**: - The login screen hides "Opprett konto" and shows a "stengt" notice - /api/auth/signup with no invite returns 403 signup_closed - /api/auth/signup with a valid invite still works (and attributes) - /api/auth/signup with an *invalid* invite returns 403 invalid_invite When self-registry is **on**: - Anyone can sign up (no invite required) - An invite that comes along is still consumed for attribution - An invalid invite is ignored — signup proceeds without attribution 26 tests still pass; typecheck clean; bundle 31.2 KB → 32.7 KB gzipped.
2026-05-25 13:45:32 +02:00
# Manually promote (e.g. for imported DBs):
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
sqlite3 data/vinterliste.db \
Admin role, root/home URL split, activity permalinks Three related changes. 1. **Admin role.** New `is_admin INTEGER NOT NULL DEFAULT 0` column on users; added to MeResponse. Admin strictly implies moderator — shared/roles.ts has a single isModerator()/isAdmin() pair so the implication can't drift between callers. The duplicated isModerator() helpers in server/activities.ts and server/feedback.ts now import from there. /api/admin endpoints (admin-only): GET /admin/users — list users with their roles PATCH /admin/users/:id/role — set is_moderator and/or is_admin Last-admin guard: the role-update endpoint refuses to demote the only remaining admin (409 cannot_demote_last_admin). Bootstrap is via `sqlite3 ... UPDATE users SET is_admin=1` — documented in README. Frontend Admin.svelte: table of users with toggles for moderator and admin. Visible from the nav only when the current user is admin. Toggling our own role refreshes session.user so the nav adapts immediately. 2. **Root/home split.** The URL `/` always shows the public landing (public + semi activities), even when the user is logged in. `/home` is the authenticated dashboard. After login or signup the SPA pushes `/home`; after logout it pushes `/`. popstate is wired so the back/forward buttons work. Unknown paths fall through to the public landing, not a 404. 3. **Activity permalinks at /a/:id.** New SPA route renders a single activity via the existing GET /api/activities/:id endpoint (private rows still require the owner's session to decrypt). A "Del" button on each ActivityRow copies the absolute permalink to the clipboard. Clipboard API has a prompt() fallback for environments where it's blocked. Server changes minimal: server/admin.ts is the new file; server/roles.ts is the lifted helper; server/index.ts wires the admin routes; server/db.ts gets one more ensureColumn() line. 26 tests still pass; typecheck clean; Vite build succeeds. Bundle grew from 28.6 KB gzipped to 30.2 KB reflecting the Admin + permalink views.
2026-05-25 13:23:13 +02:00
"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';"
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
```
Admin role, root/home URL split, activity permalinks Three related changes. 1. **Admin role.** New `is_admin INTEGER NOT NULL DEFAULT 0` column on users; added to MeResponse. Admin strictly implies moderator — shared/roles.ts has a single isModerator()/isAdmin() pair so the implication can't drift between callers. The duplicated isModerator() helpers in server/activities.ts and server/feedback.ts now import from there. /api/admin endpoints (admin-only): GET /admin/users — list users with their roles PATCH /admin/users/:id/role — set is_moderator and/or is_admin Last-admin guard: the role-update endpoint refuses to demote the only remaining admin (409 cannot_demote_last_admin). Bootstrap is via `sqlite3 ... UPDATE users SET is_admin=1` — documented in README. Frontend Admin.svelte: table of users with toggles for moderator and admin. Visible from the nav only when the current user is admin. Toggling our own role refreshes session.user so the nav adapts immediately. 2. **Root/home split.** The URL `/` always shows the public landing (public + semi activities), even when the user is logged in. `/home` is the authenticated dashboard. After login or signup the SPA pushes `/home`; after logout it pushes `/`. popstate is wired so the back/forward buttons work. Unknown paths fall through to the public landing, not a 404. 3. **Activity permalinks at /a/:id.** New SPA route renders a single activity via the existing GET /api/activities/:id endpoint (private rows still require the owner's session to decrypt). A "Del" button on each ActivityRow copies the absolute permalink to the clipboard. Clipboard API has a prompt() fallback for environments where it's blocked. Server changes minimal: server/admin.ts is the new file; server/roles.ts is the lifted helper; server/index.ts wires the admin routes; server/db.ts gets one more ensureColumn() line. 26 tests still pass; typecheck clean; Vite build succeeds. Bundle grew from 28.6 KB gzipped to 30.2 KB reflecting the Admin + permalink views.
2026-05-25 13:23:13 +02:00
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.
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
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
Close the recovery lockout-DoS hole on /auth/recovery-complete The original spec stored only `kek_salt`, `wrapped_dek_pw`+nonce, `rec_salt`, and `wrapped_dek_rec`+nonce. Under that model, anyone who knew a user's email could POST to /auth/recovery-complete with junk material and overwrite the password-side wrap, locking the legitimate user out. The data stayed safe (the attacker couldn't decrypt anything) but the account was effectively DoS'd until the user dug up their recovery code. Fix: add a recovery-side verifier mirroring the password-side one. Storage: two new columns on `users`: - rec_auth_salt BLOB NOT NULL — independent of rec_salt - rec_auth_verifier_hash TEXT NOT NULL — Bun.password.hash output The migration adds them via ensureColumn() for forward-compat with scaffold DBs that pre-date this commit; new tables get them via the CREATE TABLE statement. Wire protocol: - SignupRequest gains rec_auth_salt + rec_auth_verifier - RecoveryChallengeResponse gains rec_auth_salt - RecoveryCompleteRequest gains rec_auth_verifier Server (server/auth.ts): - signup hashes the recovery verifier alongside the auth verifier and stores both - recovery-challenge returns rec_auth_salt so the client can derive the verifier; refuses with 409 for pre-fix accounts that have a NULL rec_auth_salt - recovery-complete calls Bun.password.verify against the stored hash BEFORE touching any state. Always runs verify even for unknown emails (against a dummy hash) so timing doesn't leak existence — same pattern we already used for /auth/login. Client (frontend/src/lib/auth.ts): - signup() generates a fourth salt and derives the recovery verifier from the recovery code - recover() fetches the new rec_auth_salt and submits the derived verifier as part of recovery-complete Recovery.svelte distinguishes the new 401 ("Feil gjenopprettingskode") and 409 ("Denne kontoen mangler gjenopprettingsverifikator") cases. Regression test (tests/auth.test.ts) asserts the gate is real: - junk recovery verifier → 401, no state changes - unknown email → 401 (constant-time) - challenge response includes rec_auth_salt - correctly-derived verifier passes the gate SECURITY.md is updated to describe four salts instead of three, the new key-model storage, and the closed lockout DoS. CLAUDE.md flags the rec_auth_* columns as load-bearing — removing them re-opens the hole. This is the only deviation from the spec's stated storage model; documented as such in both SECURITY.md and CLAUDE.md.
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)