commit 47963c9225e0f8ad70ee82ca845fa4f655bfa5db Author: Ole-Morten Duesund Date: Mon May 25 12:27:14 2026 +0200 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. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bbfad5b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +node_modules +frontend/node_modules +frontend/dist +data +.git +.github +*.db +*.db-wal +*.db-shm +.env +.env.local +.DS_Store diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c7cbad9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +frontend/dist/ +data/ +*.db +*.db-wal +*.db-shm +.env +.env.local +.DS_Store diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..85c9af5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,133 @@ +# CLAUDE.md — Vinterliste + +Project-specific guidance. The global rules in `~/.claude/CLAUDE.md` still +apply; this file adds what's specific to this codebase. + +## What this app is + +Vinterliste is an end-to-end-encrypted "things to do this winter" list. +Activities are `private`, `semi`, or `public`. The original spec is in +`winter-list-claude-code-prompt.md`. The cryptographic model is in +`SECURITY.md` — **read it before touching `shared/crypto.ts`, the auth flow, +or anything that handles passwords, recovery codes, or DEKs.** + +## Non-negotiable invariants + +Before changing code that touches auth, encryption, or the activity row +shape, re-check that all of these still hold: + +1. **The server never sees** the user's raw password, the recovery code, or + the unwrapped DEK. Adding a new endpoint that takes one of those is a + bug, not a feature. +2. **Private activities** have `ciphertext` + `nonce` populated and `title`, + `tags`, `loc_*`, `scheduled_at` all `NULL`. No row in `activity_tags` + for a private activity. +3. **Semi activities** never serialize `owner_id` (or any creator-identifying + field). The column is still set so the owner can edit/delete, but + `serialize()` in `server/activities.ts` strips it. +4. **Public activities** do serialize `owner_id`. +5. **`auth_salt ≠ kek_salt`**. Generated independently on signup. If you ever + need to "reuse" a salt to save a round trip, don't — re-read SECURITY.md. +6. **Argon2id parameters** in `shared/crypto.ts` are stable. If you raise them, + you have to store per-user parameters next to salts so old accounts unlock. +7. **`Bun.password`** is for the auth verifier *only*. Never use it to derive + a KEK — the KEK needs raw key bytes from `crypto_pwhash`. + +## Crypto module is the trust root + +`shared/crypto.ts` is pure (no I/O, no globals beyond a memoised `ready()` +promise). It runs in both Bun (tests) and the browser (Vite). If you find +yourself adding a network call or platform-specific code in there, push it +out to the caller instead. + +Tests in `tests/crypto.test.ts` are the regression net. If you change any +primitive, the round-trip and tamper tests must still pass. + +## Stack choices that are locked + +These are pinned by the spec, not just by preference. Don't substitute: + +- **Bun** runtime + `bun:sqlite` + `Bun.password`. +- **Hono** for HTTP. `Bun.serve` is OK if it's simpler, but prefer Hono. +- **libsodium-wrappers-sumo** for client crypto (Argon2id + XChaCha20-Poly1305-IETF). + The SUMO build is required — the standard `libsodium-wrappers` package doesn't + ship `crypto_pwhash`. The import goes through `createRequire` because both + packages have a broken ESM entry that references a `.mjs` file they don't + actually publish; see the comment at the top of `shared/crypto.ts`. +- **Svelte 5 + Vite** for the frontend. (Svelte 5 runes — `$state`, `$derived`, + `$effect`, `$props`, `$bindable` — are the reactivity model. Don't import + `writable` stores from `svelte/store` unless there's a real reason.) +- **IndexedDB** for the private tag index. Not localStorage. + +Things that aren't pinned and can change: CSS approach, exact error-display +patterns, the choice of how the TagInput merges suggestion sources (currently +labelled merge — see `frontend/src/components/TagInput.svelte`). + +## Visibility transitions + +`private → semi/public` and back is an *update* PATCH, not a server toggle. +The client decrypts locally (or re-encrypts), then sends the appropriate +shape. `server/activities.ts:patchActivity` wipes the columns from the old +visibility and populates the new ones — keep this logic centralised there. + +## Sessions + +Sessions are opaque tokens stored in the `sessions` table; the cookie is +`vl_session`, httpOnly, SameSite=Lax, secure-when-https. Don't switch to JWT +— revocation matters more than statelessness for this app. + +`recovery-complete` deletes all sessions for the affected user. That's the +right behaviour: it kicks out any logged-in session that may have been +hijacked, and the user has to re-login with the new password. + +## Tag input merging — design decision + +Server tags and IndexedDB tags are merged in one dropdown, each row labelled +with its source ("offentlig", "privat", "kun din"). For a public/semi +activity, suggestions from the private index are shown but clearly marked +"kun din" so the user understands accepting them will publish that tag. + +If we ever want to keep them strictly separate, the change goes in +`TagInput.svelte` — the rest of the app passes suggestions through unchanged. + +## What's deferred (documented in SECURITY.md) + +- Recovery-code lockout-DoS. The recovery endpoint has no server-side proof + of the recovery code; an attacker who knows the email can lock out the + user (but **not** read their data). Mitigations: rate limiting and email + confirmation. Out of scope for the scaffold. +- Server-side rate limiting in general. +- CSP / SRI for the SPA. + +## Run / test / typecheck + +- `bun install` +- `bun run dev:server` (API on :3000) +- `bun run dev:frontend` (SPA on :5173, proxies `/api`) +- `bun test` — must pass. Crypto tests are the regression net. +- `bun run typecheck` — server and frontend TS. + +## Build / deploy + +- `bun run build:frontend` produces `frontend/dist`. +- `NODE_ENV=production bun run start` serves API + static frontend. +- `Containerfile` builds a single podman-ready image; one volume mounts + `/app/data` for the SQLite file. README has the `podman build` / `podman run` + snippet. Build args `BUILD_DATE` and `GIT_REVISION` propagate to OCI labels + and `/etc/build-info`. + +## Language + +UI is in Norwegian Bokmål (`lang="nb"` on ``). Default text for any +new UI strings should be Bokmål too. Don't add Nynorsk unless the user +explicitly asks for it. + +## When stuck, re-read + +1. `winter-list-claude-code-prompt.md` — the original spec. +2. `SECURITY.md` — the cryptographic model. +3. Then look at the code. + +If the spec, SECURITY.md, and the code disagree, the spec wins and the code +needs fixing. If the spec and SECURITY.md disagree, flag it before changing +anything. diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..1af7683 --- /dev/null +++ b/Containerfile @@ -0,0 +1,67 @@ +# syntax=docker/dockerfile:1 +# +# Vinterliste — single-image container. +# Build stage compiles the frontend with Vite; runtime image runs Bun. + +ARG BUN_VERSION=1.3 +ARG BUILD_DATE +ARG GIT_REVISION + +# ---- Builder --------------------------------------------------------------- +FROM docker.io/oven/bun:${BUN_VERSION} AS builder +WORKDIR /app + +# Install dependencies first so they cache independently of source. +COPY package.json bun.lockb* ./ +RUN bun install --frozen-lockfile || bun install + +# Copy the rest of the source and build the frontend bundle. +COPY tsconfig.json ./ +COPY shared ./shared +COPY server ./server +COPY frontend ./frontend +RUN bun run build:frontend + +# ---- Runtime --------------------------------------------------------------- +FROM docker.io/oven/bun:${BUN_VERSION}-slim +WORKDIR /app + +ARG BUILD_DATE +ARG GIT_REVISION + +# OCI labels keep the image traceable even when tagged :latest. +LABEL org.opencontainers.image.title="vinterliste" \ + org.opencontainers.image.description="End-to-end encrypted winter activity list" \ + org.opencontainers.image.source="https://example.invalid/vinterliste" \ + org.opencontainers.image.created="${BUILD_DATE}" \ + org.opencontainers.image.revision="${GIT_REVISION}" + +# Bake the same info into /etc/build-info so a running container can report it. +RUN printf 'build_date=%s\ngit_revision=%s\n' "${BUILD_DATE:-unknown}" "${GIT_REVISION:-unknown}" > /etc/build-info + +# Copy production artefacts only. node_modules is reproducible from package.json, +# but we install fresh to keep the runtime image small and unambiguous. +COPY package.json bun.lockb* ./ +RUN bun install --frozen-lockfile --production || bun install --production + +COPY --from=builder /app/shared ./shared +COPY --from=builder /app/server ./server +COPY --from=builder /app/frontend/dist ./frontend/dist + +# SQLite WAL files live in /app/data, which is the documented mount point. +RUN mkdir -p /app/data && chown -R bun:bun /app/data +VOLUME /app/data + +ENV NODE_ENV=production \ + PORT=3000 \ + VINTERLISTE_DB=/app/data/vinterliste.db \ + BUILD_DATE=${BUILD_DATE} \ + GIT_REVISION=${GIT_REVISION} + +EXPOSE 3000 +USER bun + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD bun -e "fetch('http://localhost:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + +CMD ["bun", "run", "server/index.ts"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..7353f23 --- /dev/null +++ b/README.md @@ -0,0 +1,161 @@ +# 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`. + +## 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 +- email-confirmation step for recovery (lockout-DoS mitigation noted in + `SECURITY.md`) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..f03fffa --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,215 @@ +# SECURITY.md — Vinterliste key & trust model + +This document is the authoritative description of what the server can and cannot see, +and how keys are derived, wrapped, and rotated. The crypto code in `shared/crypto.ts` +is written against this document; if behaviour diverges from what's described here, +the document is the source of truth and the code must be fixed. + +## Threat model + +We assume: + +- The server operator is honest-but-curious. They may inspect the database file, + the request logs, and memory of the server process at any point. +- The TLS terminator is trusted not to MITM. (For local podman deployment behind a + reverse proxy, this is operator-controlled.) +- The user's browser session is trusted while the user is logged in (the DEK lives + in memory there). +- An attacker may know the user's email address. + +We protect against: + +- Server-side disclosure of **private** activity contents (title, tags, location, + scheduled time). +- Server-side disclosure of the user's password. +- Account takeover by an attacker who has the database but not the password or the + recovery code. + +We do **not** protect against: + +- Compromise of the user's browser (XSS, malicious extensions). A logged-in client + holds the DEK in JS memory — anything in the same origin can read it. Mitigated + but not eliminated by a strict CSP. +- Targeted denial-of-service via password resets. See "Recovery flow" below. +- Side-channel attacks against `libsodium`'s WASM build (we use the SUMO + build of `libsodium-wrappers-sumo`; the standard build omits `crypto_pwhash`). +- Traffic analysis (number/timing/size of private activities is observable). + +## Primitives (locked — do not substitute) + +| Purpose | Primitive | libsodium API | +|----------------------------------------|-----------------------------------------------|--------------------------------------------| +| Password / recovery-code key derivation | Argon2id, 32-byte raw output | `crypto_pwhash` | +| Authenticated encryption (AEAD) | XChaCha20-Poly1305-IETF, 24-byte nonce | `crypto_aead_xchacha20poly1305_ietf_*` | +| Random bytes (DEK, salts, nonces, code) | CSPRNG | `randombytes_buf` | +| Server-side verifier hash | Argon2id (Bun's default tuning) | `Bun.password.hash` / `.verify` | + +Argon2id parameters for `crypto_pwhash` use the libsodium `MODERATE` profile +(`crypto_pwhash_OPSLIMIT_MODERATE`, `crypto_pwhash_MEMLIMIT_MODERATE`). They are +recorded as constants in `shared/crypto.ts` and must be kept consistent between +signup and any future unlock — if parameters are tuned upward in the future, the +old parameters must be stored per-user so unlock still works. + +## Per-user state (server-stored) + +For each user the server stores **exactly**: + +``` +auth_salt (16 bytes, public) -- distinct from kek_salt +auth_verifier_hash (text) -- Bun.password.hash of the auth verifier +kek_salt (16 bytes, public) -- for password-derived KEK +wrapped_dek_pw (48 bytes) -- DEK encrypted under KEK_pw +dek_pw_nonce (24 bytes) +rec_salt (16 bytes, public) -- for recovery-code-derived KEK +wrapped_dek_rec (48 bytes) -- DEK encrypted under KEK_rec +dek_rec_nonce (24 bytes) +``` + +The server never sees, derives, or stores: + +- The user's raw password. +- The recovery code. +- The DEK itself. +- Plaintext title / tags / location / scheduled time for any **private** activity. + +## Why three salts? + +Three independently random salts ensure that knowing one derivation tells you +nothing about another: + +- `auth_salt` — input to the **auth verifier** the server holds. +- `kek_salt` — input to **KEK_pw**, which unwraps the DEK. +- `rec_salt` — input to **KEK_rec**, the recovery-code-derived unwrap key. + +In particular, `auth_salt ≠ kek_salt` guarantees that even if a server-side +breach leaks the verifier hash *and* an attacker brute-forces it, they still +need to redo Argon2id against `kek_salt` to derive the KEK. The verifier hash +is never sufficient on its own. + +## Signup flow (client-driven) + +1. Client generates `dek` (32 bytes), `kek_salt`, `rec_salt`, `auth_salt` (16 bytes each). +2. Client generates a high-entropy `recovery_code` (≥120 bits), shows it to the + user, and never sends it. +3. Client derives: + - `kek_pw = pwhash(password, kek_salt)` + - `kek_rec = pwhash(recovery_code, rec_salt)` + - `auth_verifier = pwhash(password, auth_salt)` (≠ kek_pw because salts differ) +4. Client wraps: + - `wrapped_dek_pw = AEAD(kek_pw, dek, dek_pw_nonce)` + - `wrapped_dek_rec = AEAD(kek_rec, dek, dek_rec_nonce)` +5. Client posts the salts, wraps, nonces, and `auth_verifier` to the server. +6. Server hashes `auth_verifier` with `Bun.password.hash` and stores the row. + +## Unlock / login flow + +1. Client posts `{ email }` to `/api/auth/challenge`; server returns the public + parameters: `{ auth_salt, kek_salt, wrapped_dek_pw, dek_pw_nonce }`. +2. Client derives `kek_pw` and `auth_verifier` locally (two `pwhash` calls). +3. Client posts `{ email, auth_verifier }` to `/api/auth/login`; server verifies + via `Bun.password.verify` and, on success, issues an httpOnly session cookie. +4. Client unwraps the DEK locally. DEK lives in JS memory for the session. + +## Password change + +1. Client unwraps DEK with the old `kek_pw`. +2. Client generates `kek_salt_new`, `dek_pw_nonce_new`, `auth_salt_new`, derives + `kek_pw_new` and `auth_verifier_new`, and produces `wrapped_dek_pw_new`. +3. Client posts the new material to `/api/auth/password`. The server updates the + password wrap and verifier in a single transaction. +4. The recovery wrap (`wrapped_dek_rec`, `rec_salt`) is **not touched** — + the recovery code still works. +5. Activity ciphertexts are **not re-encrypted** — they're still under the + same DEK. + +## Recovery flow + +The recovery code path is intentionally symmetric to the password path. The +server cannot tell whether the submitted new wrap is "of the same DEK" — it +just stores what the client sends. Trust is anchored entirely in the +recovery-code holder. + +1. Client posts `{ email }` to `/api/auth/recovery-challenge`; server returns + `{ rec_salt, wrapped_dek_rec, dek_rec_nonce }`. +2. Client derives `kek_rec = pwhash(recovery_code, rec_salt)` and unwraps the DEK. +3. Client chooses a new password, derives new salts/verifier/wrap as in signup, + and posts to `/api/auth/recovery-complete`. +4. Server replaces the password wrap, auth salt, and verifier in a single + transaction. The recovery wrap is unchanged (the same recovery code keeps + working). + +### Known limitation: lockout DoS + +Anyone who knows a user's email can trigger `/api/auth/recovery-complete` and, +without the recovery code, submit a "junk" new password wrap. The data is **not +disclosed** (the attacker can't decrypt anything), but the legitimate user is +locked out unless they still hold a logged-in session or the recovery code. + +Mitigations (out of scope for the scaffold but intended): + +- Per-IP and per-email rate limiting on recovery endpoints. +- Email confirmation before activating the new password wrap. +- A "recovery verifier" stored server-side so the server can reject submissions + that don't prove knowledge of the recovery code. This is a deviation from the + spec's stated storage model and is therefore deferred. + +## Private activity encryption + +A `private` activity has a JSON payload: + +```ts +{ title: string; tags: string[]; loc_label?: string; loc_lat?: number; loc_lng?: number; scheduled_at?: number } +``` + +The payload is JSON-serialized, encoded as UTF-8, and encrypted with +`crypto_aead_xchacha20poly1305_ietf_encrypt(payload, /* additional data */ null, +nonce, dek)`. A fresh nonce is generated for every write (including updates). + +The row stores only `ciphertext` and `nonce`. The `title`, `loc_*`, and +`scheduled_at` columns are `NULL` and not used. No row in `tags` or +`activity_tags` is created for a private activity — those tables hold only +public/semi tag data. + +## Visibility transitions + +Visibility transitions are explicit, client-driven operations rather than +server flags: + +- `private → semi/public`: client decrypts locally, then issues a normal update + that sets plaintext columns and clears `ciphertext`/`nonce`. +- `semi/public → private`: client reads the plaintext, encrypts locally, then + issues an update that sets `ciphertext`/`nonce` and clears plaintext columns. + Server also deletes any rows in `activity_tags` for that activity. +- `semi ↔ public`: server-side toggle. `owner_id` is unchanged; the API simply + starts or stops including the owner in serialized responses. + +## Serialization rules + +- For `semi` activities, API responses must **not** include `owner_id` or any + field that identifies the creator. The server still has `owner_id` for + authorization (only the owner can edit/delete), but it is stripped from + responses. +- For `public` activities, `owner_id` (or a derived public handle) **is** + serialized. +- For `private` activities, responses are only returned to the owner and contain + `ciphertext` + `nonce`; the client decrypts. + +## Tags + +- Server-side `tags` and `activity_tags` tables hold only tags for `semi` and + `public` activities. They are normalized to lowercase trimmed strings, joined + to activities via `activity_tags`. +- Private tags are stored only inside the encrypted activity payload, and + indexed client-side in IndexedDB for autocomplete. They never reach the + server. +- The autocomplete endpoint returns matches from the server-side `tags` table + only. The frontend may merge those results with the IndexedDB index, clearly + labelled. + +## Things to flag, not silently change + +The spec invites flagging anything cryptographically unsound. The above design +follows the spec exactly. The one place where I would push back if asked to go +to production (not deferred for this scaffold) is the recovery lockout DoS — +without a server-side proof of the recovery code, the recovery endpoint is a +soft-DoS vector. Documented above, deferred for the scaffold. diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..d0fb04e --- /dev/null +++ b/bun.lock @@ -0,0 +1,236 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "vinterliste", + "dependencies": { + "hono": "^4.6.0", + "libsodium-wrappers-sumo": "^0.7.15", + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@tsconfig/svelte": "^5.0.4", + "@types/bun": "^1.1.0", + "@types/libsodium-wrappers-sumo": "^0.7.8", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "typescript": "^5.6.0", + "vite": "^6.0.0", + }, + }, + }, + "packages": { + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.4", "", { "os": "android", "cpu": "arm" }, "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.4", "", { "os": "android", "cpu": "arm64" }, "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.4", "", { "os": "linux", "cpu": "arm" }, "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.4", "", { "os": "linux", "cpu": "arm" }, "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.4", "", { "os": "linux", "cpu": "x64" }, "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.4", "", { "os": "none", "cpu": "arm64" }, "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw=="], + + "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.10", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA=="], + + "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@5.1.1", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "debug": "^4.4.1", "deepmerge": "^4.3.1", "kleur": "^4.1.5", "magic-string": "^0.30.17", "vitefu": "^1.0.6" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ=="], + + "@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@4.0.1", "", { "dependencies": { "debug": "^4.3.7" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^5.0.0", "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw=="], + + "@tsconfig/svelte": ["@tsconfig/svelte@5.0.8", "", {}, "sha512-UkNnw1/oFEfecR8ypyHIQuWYdkPvHiwcQ78sh+ymIiYoF+uc5H1UBetbjyqT+vgGJ3qQN6nhucJviX6HesWtKQ=="], + + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/libsodium-wrappers": ["@types/libsodium-wrappers@0.7.14", "", {}, "sha512-5Kv68fXuXK0iDuUir1WPGw2R9fOZUlYlSAa0ztMcL0s0BfIDTqg9GXz8K30VJpPP3sxWhbolnQma2x+/TfkzDQ=="], + + "@types/libsodium-wrappers-sumo": ["@types/libsodium-wrappers-sumo@0.7.8", "", { "dependencies": { "@types/libsodium-wrappers": "*" } }, "sha512-N2+df4MB/A+W0RAcTw7A5oxKgzD+Vh6Ye7lfjWIi5SdTzVLfHPzxUjhwPqHLO5Ev9fv/+VHl+sUaUuTg4fUPqw=="], + + "@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], + + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + + "aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="], + + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + + "devalue": ["devalue@5.8.1", "", {}, "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="], + + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + + "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], + + "esrap": ["esrap@2.2.9", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-4KijP+NxCWthMCUC3qHbE6n4vCjqgJS1uAYKhuT/GWfFTf1Qyive2TgOjep+gzbSzRfnNyaN/UU9YmdOt8Eg0A=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "hono": ["hono@4.12.23", "", {}, "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA=="], + + "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], + + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + + "libsodium-sumo": ["libsodium-sumo@0.7.16", "", {}, "sha512-x6atrz2AdXCJg6G709x9W9TTJRI6/0NcL5dD0l5GGVqNE48UJmDsjO4RUWYTeyXXUpg+NXZ2SHECaZnFRYzwGA=="], + + "libsodium-wrappers-sumo": ["libsodium-wrappers-sumo@0.7.16", "", { "dependencies": { "libsodium-sumo": "^0.7.16" } }, "sha512-gR0JEFPeN3831lB9+ogooQk0KH4K5LSMIO5Prd5Q5XYR2wHFtZfPg0eP7t1oJIWq+UIzlU4WVeBxZ97mt28tXw=="], + + "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + + "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + + "rollup": ["rollup@4.60.4", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.4", "@rollup/rollup-android-arm64": "4.60.4", "@rollup/rollup-darwin-arm64": "4.60.4", "@rollup/rollup-darwin-x64": "4.60.4", "@rollup/rollup-freebsd-arm64": "4.60.4", "@rollup/rollup-freebsd-x64": "4.60.4", "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", "@rollup/rollup-linux-arm-musleabihf": "4.60.4", "@rollup/rollup-linux-arm64-gnu": "4.60.4", "@rollup/rollup-linux-arm64-musl": "4.60.4", "@rollup/rollup-linux-loong64-gnu": "4.60.4", "@rollup/rollup-linux-loong64-musl": "4.60.4", "@rollup/rollup-linux-ppc64-gnu": "4.60.4", "@rollup/rollup-linux-ppc64-musl": "4.60.4", "@rollup/rollup-linux-riscv64-gnu": "4.60.4", "@rollup/rollup-linux-riscv64-musl": "4.60.4", "@rollup/rollup-linux-s390x-gnu": "4.60.4", "@rollup/rollup-linux-x64-gnu": "4.60.4", "@rollup/rollup-linux-x64-musl": "4.60.4", "@rollup/rollup-openbsd-x64": "4.60.4", "@rollup/rollup-openharmony-arm64": "4.60.4", "@rollup/rollup-win32-arm64-msvc": "4.60.4", "@rollup/rollup-win32-ia32-msvc": "4.60.4", "@rollup/rollup-win32-x64-gnu": "4.60.4", "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g=="], + + "sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "svelte": ["svelte@5.55.9", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.9", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-fTjjT8cHLDwigcu2j3pv7Jq04LklXevPB8uBgyHNiTXv+RMNvVnrjS4UEYrLMkhuq1vpCodHjiW+z/95SDs/fg=="], + + "svelte-check": ["svelte-check@4.4.8", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-67adfgBox5eNSNIvIIwgFizKGdcRrGpiMoNO2obHcYuLz7iTa8Xgm/NGU3ntMFnNm8K1grFOIG6HhMLX/vcN8w=="], + + "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + + "vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="], + + "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], + + "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="], + + "rollup/@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + } +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..5abf4f8 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Vinterliste + + +
+ + + diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte new file mode 100644 index 0000000..7bfb03b --- /dev/null +++ b/frontend/src/App.svelte @@ -0,0 +1,73 @@ + + +
+ + + {#if view === 'loading'} +

Laster …

+ {:else if view === 'login'} + (view = 'signup')} + onWantRecovery={() => (view = 'recovery')} + /> + {:else if view === 'signup'} + (view = 'login')} /> + {:else if view === 'recovery'} + (view = 'login')} + /> + {:else} + + {/if} +
diff --git a/frontend/src/components/ActivityForm.svelte b/frontend/src/components/ActivityForm.svelte new file mode 100644 index 0000000..0846ae8 --- /dev/null +++ b/frontend/src/components/ActivityForm.svelte @@ -0,0 +1,119 @@ + + +
+

Ny vinteraktivitet

+ + + + + + + +
Etiketter
+
+ (tags = t)} /> +
+ + + + + + + + {#if error}{/if} + +
+ + {#if onCancel} + + {/if} +
+
diff --git a/frontend/src/components/ActivityRow.svelte b/frontend/src/components/ActivityRow.svelte new file mode 100644 index 0000000..d2dc36b --- /dev/null +++ b/frontend/src/components/ActivityRow.svelte @@ -0,0 +1,113 @@ + + +
+ {#if activity.visibility === 'private'} + {#if decrypted} +

+ {decrypted.title} + Privat +

+ {#if decrypted.tags.length} +
+ {#each decrypted.tags as t}{t}{/each} +
+ {/if} + {#if decrypted.loc_label}

📍 {decrypted.loc_label}

{/if} + {#if decrypted.scheduled_at}

🕒 {formatDate(decrypted.scheduled_at)}

{/if} + {:else if decryptError} +

{decryptError}

+ {:else} +

Dekrypterer …

+ {/if} + {:else} +

+ {activity.title} + + {activity.visibility === 'semi' ? 'Anonym' : 'Offentlig'} + +

+ {#if activity.tags.length} +
+ {#each activity.tags as t}{t}{/each} +
+ {/if} + {#if activity.loc_label}

📍 {activity.loc_label}

{/if} + {#if activity.scheduled_at}

🕒 {formatDate(activity.scheduled_at)}

{/if} + {#if activity.visibility === 'public'} +

Lagt til av bruker {activity.owner_id.slice(0, 8)}

+ {/if} + {/if} + + {#if isOwner || activity.visibility === 'private'} +
+ +
+ {/if} +
diff --git a/frontend/src/components/Home.svelte b/frontend/src/components/Home.svelte new file mode 100644 index 0000000..3197d11 --- /dev/null +++ b/frontend/src/components/Home.svelte @@ -0,0 +1,87 @@ + + +
+
+

+ Velkommen, {session.user?.email}. Her er aktivitetene dine for vinteren. +

+ {#if !showForm} + + {/if} +
+ + {#if showForm} + (showForm = false)} /> + {/if} + + {#if loading} +

Laster …

+ {:else if error} +

{error}

+ {:else} + {#if myPrivate.length} +

Dine private

+ {#each myPrivate as a (a.id)} + + {/each} + {/if} + + {#if semi.length} +

Anonyme

+ {#each semi as a (a.id)} + + {/each} + {/if} + + {#if pub.length} +

Offentlige

+ {#each pub as a (a.id)} + + {/each} + {/if} + + {#if !myPrivate.length && !semi.length && !pub.length} +

Ingen aktiviteter ennå. Trykk «Ny aktivitet» for å starte.

+ {/if} + {/if} +
diff --git a/frontend/src/components/Login.svelte b/frontend/src/components/Login.svelte new file mode 100644 index 0000000..d6d8791 --- /dev/null +++ b/frontend/src/components/Login.svelte @@ -0,0 +1,71 @@ + + +
+

Logg inn

+ + + + + + + + {#if error}{/if} + +
+ + + +
+
diff --git a/frontend/src/components/Recovery.svelte b/frontend/src/components/Recovery.svelte new file mode 100644 index 0000000..dea066b --- /dev/null +++ b/frontend/src/components/Recovery.svelte @@ -0,0 +1,75 @@ + + +
+

Gjenopprett konto

+

Skriv inn gjenopprettingskoden du fikk da du opprettet kontoen, og velg et nytt passord.

+ + + + + + + + + + + + + + {#if error}{/if} + +
+ + +
+
diff --git a/frontend/src/components/Signup.svelte b/frontend/src/components/Signup.svelte new file mode 100644 index 0000000..46325af --- /dev/null +++ b/frontend/src/components/Signup.svelte @@ -0,0 +1,83 @@ + + +{#if recoveryCode} +
+

Skriv ned gjenopprettingskoden din

+

+ Denne koden lar deg låse opp dataene dine om du glemmer passordet. Vi + lagrer den ikke på serveren — skriver du ikke ned + koden nå, vil dataene være borte for godt om passordet forsvinner. +

+ {recoveryCode} + +
+{:else} +
+

Opprett konto

+

+ Vi krypterer alt du markerer som privat i nettleseren din. Serveren + ser aldri passordet ditt eller innholdet i private oppføringer. +

+ + + + + + + + + + + {#if error}{/if} + +
+ + +
+
+{/if} diff --git a/frontend/src/components/TagInput.svelte b/frontend/src/components/TagInput.svelte new file mode 100644 index 0000000..6037eae --- /dev/null +++ b/frontend/src/components/TagInput.svelte @@ -0,0 +1,122 @@ + + +
+ {#each tags as t (t)} + + {t} + + + {/each} +
+ + + +{#if serverHits.length || privateHits.length} +
+ {#each serverHits as s (s.name)} + + {/each} + {#each privateHits as p (p.name)} + {#if !serverHits.find((s) => s.name === p.name)} + + {/if} + {/each} +
+{/if} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..1f7bea4 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,72 @@ +import type { + SignupRequest, ChallengeResponse, LoginRequest, + RecoveryChallengeResponse, RecoveryCompleteRequest, PasswordChangeRequest, + MeResponse, Activity, CreateActivityRequest, UpdateActivityRequest, + TagSuggestion, +} from '../../../shared/types'; + +const BASE = '/api'; + +async function http(path: string, init: RequestInit = {}): Promise { + const res = await fetch(`${BASE}${path}`, { + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + ...(init.headers ?? {}), + }, + ...init, + }); + if (!res.ok) { + let detail: unknown = null; + try { detail = await res.json(); } catch { /* ignore */ } + const err = new ApiError(res.status, detail); + throw err; + } + if (res.status === 204) return undefined as T; + return (await res.json()) as T; +} + +export class ApiError extends Error { + constructor(public readonly status: number, public readonly detail: unknown) { + super(`API ${status}: ${JSON.stringify(detail)}`); + this.name = 'ApiError'; + } +} + +// --- auth ------------------------------------------------------------------- +export const api = { + signup: (body: SignupRequest) => + http('/auth/signup', { method: 'POST', body: JSON.stringify(body) }), + challenge: (email: string) => + http('/auth/challenge', { method: 'POST', body: JSON.stringify({ email }) }), + login: (body: LoginRequest) => + http('/auth/login', { method: 'POST', body: JSON.stringify(body) }), + logout: () => http<{ ok: true }>('/auth/logout', { method: 'POST' }), + me: () => http('/auth/me'), + passwordChange: (body: PasswordChangeRequest) => + http<{ ok: true }>('/auth/password', { method: 'POST', body: JSON.stringify(body) }), + recoveryChallenge: (email: string) => + http('/auth/recovery-challenge', { + method: 'POST', body: JSON.stringify({ email }), + }), + recoveryComplete: (body: RecoveryCompleteRequest) => + http<{ ok: true }>('/auth/recovery-complete', { + method: 'POST', body: JSON.stringify(body), + }), + + // --- activities ----------------------------------------------------------- + listActivities: () => http('/activities'), + createActivity: (body: CreateActivityRequest) => + http('/activities', { method: 'POST', body: JSON.stringify(body) }), + updateActivity: (id: string, body: UpdateActivityRequest) => + http(`/activities/${encodeURIComponent(id)}`, { + method: 'PATCH', body: JSON.stringify(body), + }), + deleteActivity: (id: string) => + http<{ ok: true }>(`/activities/${encodeURIComponent(id)}`, { method: 'DELETE' }), + + // --- tags ----------------------------------------------------------------- + tagSuggestions: (q: string, limit = 20) => + http(`/tags?q=${encodeURIComponent(q)}&limit=${limit}`), +}; diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts new file mode 100644 index 0000000..cffbe57 --- /dev/null +++ b/frontend/src/lib/auth.ts @@ -0,0 +1,186 @@ +/** + * High-level auth flows — these orchestrate libsodium key derivation, the API + * client, and the in-memory session state. The crypto module is pure; this + * module is where the *workflow* (signup, login, password change, recovery) + * lives. + */ +import { api } from './api'; +import { setSession, clearSession as clearLocal } from './session.svelte'; +import { privateTagIndex } from './tagIndex'; +import { + ready, + generateDek, + generateSalt, + generateRecoveryCode, + normalizeRecoveryCode, + deriveKey, + deriveAuthVerifier, + wrapDek, + unwrapDek, + bytesToBase64, + base64ToBytes, + zero, +} from './crypto'; +import type { MeResponse } from '../../../shared/types'; + +export interface SignupResult { + user: MeResponse; + recoveryCode: string; // shown once, never sent +} + +/** + * Signup: generate DEK and both wraps client-side, send wraps + auth verifier + * to the server. + */ +export async function signup(email: string, password: string): Promise { + await ready(); + + const dek = generateDek(); + const recoveryCode = generateRecoveryCode(); + + const kekSalt = generateSalt(); + const recSalt = generateSalt(); + const authSalt = generateSalt(); + + const kekPw = deriveKey(password, kekSalt); + const kekRec = deriveKey(normalizeRecoveryCode(recoveryCode), recSalt); + const authVerifier = deriveAuthVerifier(password, authSalt); + + const wrappedPw = wrapDek(dek, kekPw); + const wrappedRec = wrapDek(dek, kekRec); + + // Zero out KEKs we no longer need before going async. + zero(kekPw); + zero(kekRec); + + const user = await api.signup({ + email, + auth_salt: bytesToBase64(authSalt), + auth_verifier: authVerifier, + kek_salt: bytesToBase64(kekSalt), + wrapped_dek_pw: bytesToBase64(wrappedPw.ciphertext), + dek_pw_nonce: bytesToBase64(wrappedPw.nonce), + rec_salt: bytesToBase64(recSalt), + wrapped_dek_rec: bytesToBase64(wrappedRec.ciphertext), + dek_rec_nonce: bytesToBase64(wrappedRec.nonce), + }); + + setSession(user, dek); + return { user, recoveryCode }; +} + +/** + * Login: fetch challenge, derive both KEK and verifier, send verifier to login, + * unwrap DEK locally on success. + */ +export async function login(email: string, password: string): Promise { + await ready(); + + const challenge = await api.challenge(email); + const authSalt = base64ToBytes(challenge.auth_salt); + const kekSalt = base64ToBytes(challenge.kek_salt); + + const authVerifier = deriveAuthVerifier(password, authSalt); + const kekPw = deriveKey(password, kekSalt); + + const user = await api.login({ email, auth_verifier: authVerifier }); + + const dek = unwrapDek( + { + ciphertext: base64ToBytes(challenge.wrapped_dek_pw), + nonce: base64ToBytes(challenge.dek_pw_nonce), + }, + kekPw, + ); + zero(kekPw); + + setSession(user, dek); + return user; +} + +export async function logout(): Promise { + await api.logout().catch(() => null); + await privateTagIndex.clear().catch(() => null); + clearLocal(); +} + +/** + * Password change: unwrap DEK with old password, re-wrap with new password, + * push the new material. Recovery wrap stays put. + */ +export async function changePassword(oldPassword: string, newPassword: string, email: string): Promise { + await ready(); + + // Re-derive old keys from the server's current material. + const challenge = await api.challenge(email); + const oldKek = deriveKey(oldPassword, base64ToBytes(challenge.kek_salt)); + const dek = unwrapDek( + { + ciphertext: base64ToBytes(challenge.wrapped_dek_pw), + nonce: base64ToBytes(challenge.dek_pw_nonce), + }, + oldKek, + ); + zero(oldKek); + + const kekSalt = generateSalt(); + const authSalt = generateSalt(); + const newKek = deriveKey(newPassword, kekSalt); + const verifier = deriveAuthVerifier(newPassword, authSalt); + const wrapped = wrapDek(dek, newKek); + zero(newKek); + + await api.passwordChange({ + auth_salt: bytesToBase64(authSalt), + auth_verifier: verifier, + kek_salt: bytesToBase64(kekSalt), + wrapped_dek_pw: bytesToBase64(wrapped.ciphertext), + dek_pw_nonce: bytesToBase64(wrapped.nonce), + }); +} + +/** + * Recovery: user provides their recovery code; we unwrap the DEK via the + * recovery wrap, then build new password-side material. + */ +export async function recover( + email: string, + recoveryCode: string, + newPassword: string, +): Promise { + await ready(); + + const challenge = await api.recoveryChallenge(email); + const kekRec = deriveKey( + normalizeRecoveryCode(recoveryCode), + base64ToBytes(challenge.rec_salt), + ); + const dek = unwrapDek( + { + ciphertext: base64ToBytes(challenge.wrapped_dek_rec), + nonce: base64ToBytes(challenge.dek_rec_nonce), + }, + kekRec, + ); + zero(kekRec); + + const kekSalt = generateSalt(); + const authSalt = generateSalt(); + const newKek = deriveKey(newPassword, kekSalt); + const verifier = deriveAuthVerifier(newPassword, authSalt); + const wrapped = wrapDek(dek, newKek); + zero(newKek); + // dek not yet zeroed — we still need it to log the user back in. + + await api.recoveryComplete({ + email, + auth_salt: bytesToBase64(authSalt), + auth_verifier: verifier, + kek_salt: bytesToBase64(kekSalt), + wrapped_dek_pw: bytesToBase64(wrapped.ciphertext), + dek_pw_nonce: bytesToBase64(wrapped.nonce), + }); + + // Log in with the new password so the user lands in an authenticated state. + await login(email, newPassword); +} diff --git a/frontend/src/lib/crypto.ts b/frontend/src/lib/crypto.ts new file mode 100644 index 0000000..91f6a3e --- /dev/null +++ b/frontend/src/lib/crypto.ts @@ -0,0 +1,4 @@ +// Re-export the shared crypto module so frontend imports don't have to know +// where it physically lives. Bundlers (Vite) inline this; tests (Bun) import +// the shared module directly. +export * from '../../../shared/crypto'; diff --git a/frontend/src/lib/session.svelte.ts b/frontend/src/lib/session.svelte.ts new file mode 100644 index 0000000..f2814a6 --- /dev/null +++ b/frontend/src/lib/session.svelte.ts @@ -0,0 +1,30 @@ +/** + * In-memory session state — DEK and current user. The DEK NEVER leaves this + * module's closure (no localStorage, no sessionStorage) so a page reload + * intentionally drops it and forces a re-unlock with the password. + * + * Svelte 5 runes give us cheap reactivity without a separate store. + */ +import type { MeResponse } from '../../../shared/types'; + +interface State { + user: MeResponse | null; + dek: Uint8Array | null; +} + +export const session = $state({ user: null, dek: null }); + +export function setSession(user: MeResponse, dek: Uint8Array): void { + session.user = user; + session.dek = dek; +} + +export function clearSession(): void { + if (session.dek) { + // Best-effort zeroisation. The buffer may have been copied internally by + // libsodium before we get here, but we still wipe what we hold. + session.dek.fill(0); + } + session.user = null; + session.dek = null; +} diff --git a/frontend/src/lib/tagIndex.ts b/frontend/src/lib/tagIndex.ts new file mode 100644 index 0000000..c2ad46b --- /dev/null +++ b/frontend/src/lib/tagIndex.ts @@ -0,0 +1,121 @@ +/** + * Client-side index of the user's private tags, kept in IndexedDB. + * + * Why IndexedDB and not localStorage: + * - we want this scoped per-origin and not synced across tabs through the + * JS event loop; + * - we want async, non-blocking reads on the autocomplete hot path; + * - private tags must never round-trip through the server, so the storage + * has to be local. + * + * The schema is intentionally tiny: a single object store keyed by the + * lowercase tag name, with a `count` field for ranking suggestions. + */ + +const DB_NAME = 'vinterliste'; +const STORE = 'private_tags'; +const VERSION = 1; + +interface TagRow { + name: string; + count: number; +} + +function openDb(): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, VERSION); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(STORE)) { + db.createObjectStore(STORE, { keyPath: 'name' }); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +async function withStore( + mode: IDBTransactionMode, + fn: (store: IDBObjectStore) => Promise | T, +): Promise { + const db = await openDb(); + return await new Promise((resolve, reject) => { + const tx = db.transaction(STORE, mode); + const store = tx.objectStore(STORE); + let result: T; + Promise.resolve(fn(store)).then((r) => { result = r; }).catch(reject); + tx.oncomplete = () => resolve(result); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + }); +} + +function normalise(name: string): string { + return name.trim().toLowerCase(); +} + +export const privateTagIndex = { + /** Bump or insert every tag in `tags`. Call after creating/updating a private activity. */ + async record(tags: string[]): Promise { + const names = [...new Set(tags.map(normalise).filter(Boolean))]; + if (names.length === 0) return; + await withStore('readwrite', (store) => { + for (const name of names) { + const req = store.get(name); + req.onsuccess = () => { + const existing = req.result as TagRow | undefined; + const next: TagRow = existing + ? { name, count: existing.count + 1 } + : { name, count: 1 }; + store.put(next); + }; + } + }); + }, + + /** Decrement counts for the tags of an activity that's being removed/edited. */ + async release(tags: string[]): Promise { + const names = [...new Set(tags.map(normalise).filter(Boolean))]; + if (names.length === 0) return; + await withStore('readwrite', (store) => { + for (const name of names) { + const req = store.get(name); + req.onsuccess = () => { + const existing = req.result as TagRow | undefined; + if (!existing) return; + if (existing.count <= 1) store.delete(name); + else store.put({ name, count: existing.count - 1 }); + }; + } + }); + }, + + /** Return suggestions matching `q` (prefix match), sorted by count desc. */ + async suggest(q: string, limit = 20): Promise { + const prefix = normalise(q); + return withStore('readonly', (store) => new Promise((resolve, reject) => { + const out: TagRow[] = []; + const req = store.openCursor(); + req.onsuccess = () => { + const cursor = req.result; + if (!cursor) { + out.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)); + resolve(out.slice(0, limit)); + return; + } + const row = cursor.value as TagRow; + if (!prefix || row.name.startsWith(prefix)) out.push(row); + cursor.continue(); + }; + req.onerror = () => reject(req.error); + })); + }, + + /** Drop the index — used on logout to leave no per-user residue. */ + async clear(): Promise { + await withStore('readwrite', (store) => { + store.clear(); + }); + }, +}; diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..b4a8bac --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,8 @@ +import { mount } from 'svelte'; +import App from './App.svelte'; +import './styles.css'; + +const target = document.getElementById('app'); +if (!target) throw new Error('No #app element in DOM'); + +mount(App, { target }); diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..66c2410 --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,158 @@ +:root { + color-scheme: light dark; + --bg: #fafafa; + --fg: #1c1c1c; + --muted: #6c6c6c; + --border: #d8d8d8; + --accent: #1f6feb; + --accent-fg: white; + --danger: #b3261e; + --card: #ffffff; + --radius: 10px; + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #161616; + --fg: #e8e8e8; + --muted: #9a9a9a; + --border: #2d2d2d; + --accent: #4a8cff; + --card: #1f1f1f; + --danger: #f28b82; + } +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--fg); + min-height: 100vh; +} + +main { + max-width: 720px; + margin: 0 auto; + padding: 1.5rem 1rem 4rem; +} + +h1, h2, h3 { line-height: 1.2; margin-top: 0; } +h1 { font-size: 1.75rem; } +h2 { font-size: 1.25rem; } +p { line-height: 1.5; } +a { color: var(--accent); } + +input, button, select, textarea { + font: inherit; + color: inherit; +} + +input[type="text"], input[type="email"], input[type="password"], +input[type="datetime-local"], textarea, select { + width: 100%; + padding: 0.5rem 0.6rem; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--card); + color: var(--fg); +} + +input:focus-visible, button:focus-visible, select:focus-visible, textarea:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +button { + cursor: pointer; + padding: 0.5rem 0.9rem; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--card); + color: var(--fg); +} +button.primary { + background: var(--accent); + color: var(--accent-fg); + border-color: transparent; +} +button.danger { + background: transparent; + color: var(--danger); + border-color: var(--danger); +} +button:disabled { opacity: 0.5; cursor: progress; } + +label { display: block; margin: 0.75rem 0 0.25rem; font-weight: 500; } +.row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; } +.muted { color: var(--muted); font-size: 0.9rem; } +.card { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1rem 1rem; + margin-bottom: 0.75rem; +} + +.banner { + background: var(--card); + border: 1px solid var(--border); + border-left: 4px solid var(--accent); + padding: 0.75rem 1rem; + border-radius: var(--radius); + margin-bottom: 1rem; +} +.banner.danger { border-left-color: var(--danger); } + +.tag { + display: inline-flex; + align-items: center; + background: rgba(127,127,127,0.15); + border-radius: 999px; + padding: 0.1rem 0.55rem; + font-size: 0.85rem; + margin: 0.15rem 0.2rem 0.15rem 0; +} +.tag.private { background: rgba(31,111,235,0.15); } + +.recovery-code { + display: block; + font-family: ui-monospace, "SF Mono", Menlo, monospace; + background: var(--card); + border: 1px dashed var(--border); + padding: 0.75rem 1rem; + border-radius: var(--radius); + font-size: 1.1rem; + letter-spacing: 0.05em; + text-align: center; + margin: 0.5rem 0 1rem; + user-select: all; +} + +nav.top { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1.5rem; +} + +.vis-badge { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 0.1rem 0.5rem; + border-radius: 999px; + margin-left: 0.5rem; +} +.vis-badge.private { background: rgba(31,111,235,0.15); color: var(--accent); } +.vis-badge.semi { background: rgba(127,127,127,0.18); color: var(--muted); } +.vis-badge.public { background: rgba(46,160,67,0.18); color: #2ea043; } + +.error { color: var(--danger); margin-top: 0.5rem; } + +@media (prefers-reduced-motion: reduce) { + * { animation: none !important; transition: none !important; } +} diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js new file mode 100644 index 0000000..4c6b24b --- /dev/null +++ b/frontend/svelte.config.js @@ -0,0 +1,5 @@ +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +export default { + preprocess: vitePreprocess(), +}; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..379361b --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "@tsconfig/svelte/tsconfig.json", + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "noUncheckedIndexedAccess": true, + "isolatedModules": true, + "resolveJsonModule": true, + "allowImportingTsExtensions": false, + "skipLibCheck": true, + "verbatimModuleSyntax": false, + "types": [] + }, + "include": ["src/**/*", "../shared/**/*"], + "exclude": ["dist", "node_modules"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..1b4715c --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,40 @@ +import { defineConfig } from 'vite'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; +import { fileURLToPath } from 'node:url'; + +const here = (rel: string) => fileURLToPath(new URL(rel, import.meta.url)); + +export default defineConfig({ + root: here('.'), + plugins: [svelte()], + resolve: { + alias: { + // The ESM entry of libsodium-wrappers-sumo is broken upstream (relative + // import of a file that isn't shipped). Redirect to the CJS bundle, + // which Vite's CJS interop handles transparently. Mirrors tsconfig + // paths so editors and tsc agree. + 'libsodium-wrappers-sumo': here( + '../node_modules/libsodium-wrappers-sumo/dist/modules-sumo/libsodium-wrappers.js', + ), + }, + }, + optimizeDeps: { + // The CJS entry needs to be pre-bundled or browsers will choke on `require`. + include: ['libsodium-wrappers-sumo'], + }, + server: { + port: 5173, + proxy: { + '/api': 'http://localhost:3000', + }, + fs: { + allow: ['..'], + }, + }, + build: { + outDir: 'dist', + emptyOutDir: true, + sourcemap: true, + target: 'es2022', + }, +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..16d4d60 --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "vinterliste", + "version": "0.1.0", + "description": "End-to-end encrypted winter activity list", + "private": true, + "type": "module", + "scripts": { + "dev:server": "bun run --hot server/index.ts", + "dev:frontend": "vite --config frontend/vite.config.ts", + "build:frontend": "vite build --config frontend/vite.config.ts", + "start": "NODE_ENV=production bun run server/index.ts", + "test": "bun test", + "typecheck": "tsc --noEmit && tsc --noEmit -p frontend/tsconfig.json" + }, + "dependencies": { + "hono": "^4.6.0", + "libsodium-wrappers-sumo": "^0.7.15" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@tsconfig/svelte": "^5.0.4", + "@types/bun": "^1.1.0", + "@types/libsodium-wrappers-sumo": "^0.7.8", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "typescript": "^5.6.0", + "vite": "^6.0.0" + } +} diff --git a/server/activities.ts b/server/activities.ts new file mode 100644 index 0000000..d74b62b --- /dev/null +++ b/server/activities.ts @@ -0,0 +1,264 @@ +import { Hono } from 'hono'; +import { getDb } from './db'; +import { requireAuth, currentUserId, type AppVariables } from './session'; +import { setActivityTags, clearActivityTags, tagsFor } from './tags'; +import type { + Activity, ActivityPublic, ActivitySemi, ActivityPrivate, + CreateActivityRequest, UpdateActivityRequest, Visibility, +} from '../shared/types'; + +/** + * Activity CRUD with strict visibility rules: + * + * - `private`: ciphertext/nonce required; plaintext columns and tag rows MUST + * be empty/absent. Only the owner can read these. + * - `semi`: plaintext columns required; ciphertext/nonce MUST be empty. + * `owner_id` is stored but never serialized. + * - `public`: plaintext columns required; ciphertext/nonce MUST be empty. + * `owner_id` is serialized. + */ +export const activitiesRoutes = new Hono<{ Variables: AppVariables }>(); + +const VALID_VIS = new Set(['private', 'semi', 'public']); + +interface ActivityRow { + id: string; + owner_id: string; + visibility: Visibility; + ciphertext: Uint8Array | null; + nonce: Uint8Array | null; + title: string | null; + scheduled_at: number | null; + loc_label: string | null; + loc_lat: number | null; + loc_lng: number | null; + created_at: number; + updated_at: number; +} + +function b64(b: Uint8Array | null): string | null { + return b === null ? null : Buffer.from(b).toString('base64'); +} + +function b64ToBuf(s: string): Buffer { + return Buffer.from(s, 'base64'); +} + +function serialize(row: ActivityRow, viewerId: string | null): Activity { + if (row.visibility === 'private') { + const a: ActivityPrivate = { + id: row.id, + visibility: 'private', + owner_id: row.owner_id, + ciphertext: b64(row.ciphertext) ?? '', + nonce: b64(row.nonce) ?? '', + created_at: row.created_at, + updated_at: row.updated_at, + }; + return a; + } + const tags = tagsFor(row.id); + if (row.visibility === 'semi') { + // owner_id deliberately omitted — see SECURITY.md + const a: ActivitySemi = { + id: row.id, + visibility: 'semi', + title: row.title ?? '', + tags, + loc_label: row.loc_label, + loc_lat: row.loc_lat, + loc_lng: row.loc_lng, + scheduled_at: row.scheduled_at, + created_at: row.created_at, + updated_at: row.updated_at, + }; + return a; + } + const a: ActivityPublic = { + id: row.id, + visibility: 'public', + owner_id: row.owner_id, + title: row.title ?? '', + tags, + loc_label: row.loc_label, + loc_lat: row.loc_lat, + loc_lng: row.loc_lng, + scheduled_at: row.scheduled_at, + created_at: row.created_at, + updated_at: row.updated_at, + }; + return a; +} + +function validateForVisibility(body: CreateActivityRequest): string | null { + if (!body.visibility || !VALID_VIS.has(body.visibility)) { + return 'invalid:visibility'; + } + if (body.visibility === 'private') { + if (typeof body.ciphertext !== 'string' || typeof body.nonce !== 'string') { + return 'private:requires_ciphertext_and_nonce'; + } + if (body.title !== undefined || (body.tags && body.tags.length > 0)) { + return 'private:plaintext_must_be_absent'; + } + } else { + if (typeof body.title !== 'string' || !body.title.trim()) { + return `${body.visibility}:title_required`; + } + if (body.ciphertext !== undefined || body.nonce !== undefined) { + return `${body.visibility}:ciphertext_must_be_absent`; + } + } + return null; +} + +// --- GET /api/activities ---------------------------------------------------- +// Returns: all public + semi activities (visible to anyone), plus the caller's +// own private activities (if logged in). +activitiesRoutes.get('/', (c) => { + const viewerId = currentUserId(c); + const db = getDb(); + + const params: string[] = []; + let where = `visibility IN ('public','semi')`; + if (viewerId) { + where += ` OR (visibility = 'private' AND owner_id = ?)`; + params.push(viewerId); + } + + const rows = db + .prepare(`SELECT * FROM activities WHERE ${where} ORDER BY created_at DESC`) + .all(...params) as ActivityRow[]; + + return c.json(rows.map((r) => serialize(r, viewerId))); +}); + +// --- GET /api/activities/:id ------------------------------------------------ +activitiesRoutes.get('/:id', (c) => { + const viewerId = currentUserId(c); + const row = getDb().prepare('SELECT * FROM activities WHERE id = ?').get(c.req.param('id')) as ActivityRow | null; + if (!row) return c.json({ error: 'not_found' }, 404); + if (row.visibility === 'private' && row.owner_id !== viewerId) { + return c.json({ error: 'not_found' }, 404); + } + return c.json(serialize(row, viewerId)); +}); + +// --- POST /api/activities --------------------------------------------------- +activitiesRoutes.post('/', requireAuth, async (c) => { + const userId = c.get('userId'); + const body = (await c.req.json().catch(() => null)) as CreateActivityRequest | null; + if (!body) return c.json({ error: 'invalid_json' }, 400); + const err = validateForVisibility(body); + if (err) return c.json({ error: err }, 400); + + const id = crypto.randomUUID(); + const now = Date.now(); + const db = getDb(); + + if (body.visibility === 'private') { + db.prepare(` + INSERT INTO activities + (id, owner_id, visibility, ciphertext, nonce, created_at, updated_at) + VALUES (?, ?, 'private', ?, ?, ?, ?) + `).run(id, userId, b64ToBuf(body.ciphertext!), b64ToBuf(body.nonce!), now, now); + } else { + db.prepare(` + INSERT INTO activities + (id, owner_id, visibility, title, scheduled_at, loc_label, loc_lat, loc_lng, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + id, userId, body.visibility, + body.title!.trim(), + body.scheduled_at ?? null, + body.loc_label ?? null, + body.loc_lat ?? null, + body.loc_lng ?? null, + now, now, + ); + setActivityTags(id, body.tags ?? []); + } + + const row = db.prepare('SELECT * FROM activities WHERE id = ?').get(id) as ActivityRow; + return c.json(serialize(row, userId), 201); +}); + +// --- PATCH /api/activities/:id ---------------------------------------------- +// A PATCH may also change the visibility (private↔public, semi↔public, etc.); +// the client is responsible for sending the full new payload appropriate to +// the new visibility (encrypted blob or plaintext fields), and we wipe the +// columns that don't apply. +activitiesRoutes.patch('/:id', requireAuth, async (c) => { + const userId = c.get('userId'); + const id = c.req.param('id'); + const body = (await c.req.json().catch(() => null)) as UpdateActivityRequest | null; + if (!body) return c.json({ error: 'invalid_json' }, 400); + + const db = getDb(); + const existing = db.prepare('SELECT * FROM activities WHERE id = ?').get(id) as ActivityRow | null; + if (!existing) return c.json({ error: 'not_found' }, 404); + if (existing.owner_id !== userId) return c.json({ error: 'forbidden' }, 403); + + const err = validateForVisibility(body); + if (err) return c.json({ error: err }, 400); + + const now = Date.now(); + if (body.visibility === 'private') { + db.prepare(` + UPDATE activities SET + visibility = 'private', + ciphertext = ?, + nonce = ?, + title = NULL, + scheduled_at = NULL, + loc_label = NULL, + loc_lat = NULL, + loc_lng = NULL, + updated_at = ? + WHERE id = ? + `).run(b64ToBuf(body.ciphertext!), b64ToBuf(body.nonce!), now, id); + // Drop any lingering public/semi tag rows from a prior visibility. + clearActivityTags(id); + } else { + db.prepare(` + UPDATE activities SET + visibility = ?, + title = ?, + scheduled_at = ?, + loc_label = ?, + loc_lat = ?, + loc_lng = ?, + ciphertext = NULL, + nonce = NULL, + updated_at = ? + WHERE id = ? + `).run( + body.visibility, + body.title!.trim(), + body.scheduled_at ?? null, + body.loc_label ?? null, + body.loc_lat ?? null, + body.loc_lng ?? null, + now, id, + ); + setActivityTags(id, body.tags ?? []); + } + + const row = db.prepare('SELECT * FROM activities WHERE id = ?').get(id) as ActivityRow; + return c.json(serialize(row, userId)); +}); + +// --- DELETE /api/activities/:id --------------------------------------------- +activitiesRoutes.delete('/:id', requireAuth, (c) => { + const userId = c.get('userId'); + const id = c.req.param('id'); + const db = getDb(); + const existing = db.prepare('SELECT owner_id FROM activities WHERE id = ?').get(id) as + | { owner_id: string } | null; + if (!existing) return c.json({ error: 'not_found' }, 404); + if (existing.owner_id !== userId) return c.json({ error: 'forbidden' }, 403); + + clearActivityTags(id); + db.prepare('DELETE FROM activities WHERE id = ?').run(id); + return c.json({ ok: true }); +}); diff --git a/server/auth.ts b/server/auth.ts new file mode 100644 index 0000000..74a6c6c --- /dev/null +++ b/server/auth.ts @@ -0,0 +1,286 @@ +import { Hono } from 'hono'; +import { getDb } from './db'; +import { + currentUserId, issueSession, clearSession, requireAuth, gcSessions, + type AppVariables, +} from './session'; +import type { + SignupRequest, + ChallengeResponse, + LoginRequest, + RecoveryChallengeResponse, + PasswordChangeRequest, + RecoveryCompleteRequest, + MeResponse, +} from '../shared/types'; + +/** + * Auth routes. The server's job is narrow: + * - store the user row (salts + wraps + verifier hash) + * - hand back salts/wraps on demand so the client can derive keys + * - verify the auth verifier and issue a session cookie + * + * It never sees the raw password, the recovery code, or the DEK. + */ +export const authRoutes = new Hono<{ Variables: AppVariables }>(); + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +function b64ToBuffer(s: string): Buffer { + return Buffer.from(s, 'base64'); +} + +function bufferToB64(b: Uint8Array): string { + return Buffer.from(b).toString('base64'); +} + +function newId(): string { + return crypto.randomUUID(); +} + +function isString(v: unknown): v is string { + return typeof v === 'string' && v.length > 0; +} + +/** Check that every named key on `obj` is a non-empty string. */ +function missingKey(obj: T, keys: readonly (keyof T)[]): string | null { + const rec = obj as unknown as Record; + for (const k of keys) { + if (!isString(rec[k as string])) return `missing:${String(k)}`; + } + return null; +} + +// --- POST /auth/signup ------------------------------------------------------ +authRoutes.post('/signup', async (c) => { + const body = (await c.req.json().catch(() => null)) as SignupRequest | null; + if (!body) return c.json({ error: 'invalid_json' }, 400); + + const miss = missingKey(body, [ + 'email', 'auth_salt', 'auth_verifier', 'kek_salt', + 'wrapped_dek_pw', 'dek_pw_nonce', + 'rec_salt', 'wrapped_dek_rec', 'dek_rec_nonce', + ]); + if (miss) return c.json({ error: miss }, 400); + const email = body.email.trim().toLowerCase(); + if (!EMAIL_RE.test(email)) return c.json({ error: 'invalid_email' }, 400); + + const db = getDb(); + const existing = db.prepare('SELECT 1 FROM users WHERE email = ?').get(email); + if (existing) return c.json({ error: 'email_taken' }, 409); + + // Server-side hash of the client-derived verifier. The verifier is already + // expensive to brute-force (Argon2id-MODERATE), so Bun.password adds a second + // hardening layer in case the DB leaks but the verifier salt is still public. + const verifierHash = await Bun.password.hash(body.auth_verifier, { + algorithm: 'argon2id', + }); + + const id = newId(); + const now = Date.now(); + db.prepare(` + INSERT INTO users + (id, email, auth_salt, auth_verifier_hash, kek_salt, + wrapped_dek_pw, dek_pw_nonce, wrapped_dek_rec, rec_salt, dek_rec_nonce, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + id, + email, + b64ToBuffer(body.auth_salt), + verifierHash, + b64ToBuffer(body.kek_salt), + b64ToBuffer(body.wrapped_dek_pw), + b64ToBuffer(body.dek_pw_nonce), + b64ToBuffer(body.wrapped_dek_rec), + b64ToBuffer(body.rec_salt), + b64ToBuffer(body.dek_rec_nonce), + now, + ); + + issueSession(c, id); + gcSessions(); + + const me: MeResponse = { id, email }; + return c.json(me); +}); + +// --- POST /auth/challenge ---------------------------------------------------- +// Hands back the public per-user material the client needs to derive KEK_pw +// and the auth verifier. We deliberately do NOT mask "user not found" — see +// SECURITY.md (enumeration is accepted, lockout-DoS is the bigger issue). +authRoutes.post('/challenge', async (c) => { + const body = (await c.req.json().catch(() => null)) as { email?: string } | null; + if (!body || !isString(body.email)) return c.json({ error: 'missing:email' }, 400); + const email = body.email.trim().toLowerCase(); + + const row = getDb() + .prepare(` + SELECT auth_salt, kek_salt, wrapped_dek_pw, dek_pw_nonce + FROM users WHERE email = ? + `) + .get(email) as + | { auth_salt: Uint8Array; kek_salt: Uint8Array; wrapped_dek_pw: Uint8Array; dek_pw_nonce: Uint8Array } + | null; + if (!row) return c.json({ error: 'no_such_user' }, 404); + + const resp: ChallengeResponse = { + auth_salt: bufferToB64(row.auth_salt), + kek_salt: bufferToB64(row.kek_salt), + wrapped_dek_pw: bufferToB64(row.wrapped_dek_pw), + dek_pw_nonce: bufferToB64(row.dek_pw_nonce), + }; + return c.json(resp); +}); + +// --- POST /auth/login ------------------------------------------------------- +authRoutes.post('/login', async (c) => { + const body = (await c.req.json().catch(() => null)) as LoginRequest | null; + if (!body || !isString(body.email) || !isString(body.auth_verifier)) { + return c.json({ error: 'missing_fields' }, 400); + } + const email = body.email.trim().toLowerCase(); + + const row = getDb() + .prepare('SELECT id, auth_verifier_hash FROM users WHERE email = ?') + .get(email) as { id: string; auth_verifier_hash: string } | null; + + // Always run verify so unknown-email requests aren't trivially distinguishable + // by timing. We compare against a dummy hash on miss; the constant work also + // protects us from "did this account exist" probing via a stopwatch. + const hash = row?.auth_verifier_hash ?? '$argon2id$v=19$m=65536,t=3,p=1$YWFhYWFhYWE$AAAAAAAAAAAAAAAAAAAAAA'; + const ok = await Bun.password.verify(body.auth_verifier, hash).catch(() => false); + + if (!row || !ok) return c.json({ error: 'invalid_credentials' }, 401); + + issueSession(c, row.id); + const me: MeResponse = { id: row.id, email }; + return c.json(me); +}); + +// --- POST /auth/logout ------------------------------------------------------ +authRoutes.post('/logout', async (c) => { + clearSession(c); + return c.json({ ok: true }); +}); + +// --- GET /auth/me ----------------------------------------------------------- +authRoutes.get('/me', async (c) => { + const userId = currentUserId(c); + if (!userId) return c.json({ error: 'unauthorized' }, 401); + const row = getDb() + .prepare('SELECT id, email FROM users WHERE id = ?') + .get(userId) as { id: string; email: string } | null; + if (!row) { + clearSession(c); + return c.json({ error: 'unauthorized' }, 401); + } + const me: MeResponse = row; + return c.json(me); +}); + +// --- POST /auth/password ---------------------------------------------------- +// Requires an active session. Replaces password-side material; recovery wrap +// untouched. The client must have already unwrapped and re-wrapped the DEK +// before calling this. +authRoutes.post('/password', requireAuth, async (c) => { + const userId = c.get('userId'); + const body = (await c.req.json().catch(() => null)) as PasswordChangeRequest | null; + if (!body) return c.json({ error: 'invalid_json' }, 400); + const miss = missingKey(body, [ + 'auth_salt', 'auth_verifier', 'kek_salt', 'wrapped_dek_pw', 'dek_pw_nonce', + ]); + if (miss) return c.json({ error: miss }, 400); + + const verifierHash = await Bun.password.hash(body.auth_verifier, { + algorithm: 'argon2id', + }); + + getDb() + .prepare(` + UPDATE users SET + auth_salt = ?, + auth_verifier_hash = ?, + kek_salt = ?, + wrapped_dek_pw = ?, + dek_pw_nonce = ? + WHERE id = ? + `) + .run( + b64ToBuffer(body.auth_salt), + verifierHash, + b64ToBuffer(body.kek_salt), + b64ToBuffer(body.wrapped_dek_pw), + b64ToBuffer(body.dek_pw_nonce), + userId, + ); + + return c.json({ ok: true }); +}); + +// --- POST /auth/recovery-challenge ------------------------------------------ +authRoutes.post('/recovery-challenge', async (c) => { + const body = (await c.req.json().catch(() => null)) as { email?: string } | null; + if (!body || !isString(body.email)) return c.json({ error: 'missing:email' }, 400); + const email = body.email.trim().toLowerCase(); + + const row = getDb() + .prepare('SELECT rec_salt, wrapped_dek_rec, dek_rec_nonce FROM users WHERE email = ?') + .get(email) as + | { rec_salt: Uint8Array; wrapped_dek_rec: Uint8Array; dek_rec_nonce: Uint8Array } + | null; + if (!row) return c.json({ error: 'no_such_user' }, 404); + + const resp: RecoveryChallengeResponse = { + rec_salt: bufferToB64(row.rec_salt), + wrapped_dek_rec: bufferToB64(row.wrapped_dek_rec), + dek_rec_nonce: bufferToB64(row.dek_rec_nonce), + }; + return c.json(resp); +}); + +// --- POST /auth/recovery-complete ------------------------------------------- +// Replaces password-side material AND auth verifier. Recovery wrap is +// untouched (same recovery code keeps working). +// +// Known limitation: this endpoint has no proof-of-recovery-code; see SECURITY.md. +authRoutes.post('/recovery-complete', async (c) => { + const body = (await c.req.json().catch(() => null)) as RecoveryCompleteRequest | null; + if (!body) return c.json({ error: 'invalid_json' }, 400); + const miss = missingKey(body, [ + 'email', 'auth_salt', 'auth_verifier', 'kek_salt', 'wrapped_dek_pw', 'dek_pw_nonce', + ]); + if (miss) return c.json({ error: miss }, 400); + const email = body.email.trim().toLowerCase(); + + const row = getDb() + .prepare('SELECT id FROM users WHERE email = ?') + .get(email) as { id: string } | null; + if (!row) return c.json({ error: 'no_such_user' }, 404); + + const verifierHash = await Bun.password.hash(body.auth_verifier, { + algorithm: 'argon2id', + }); + + getDb() + .prepare(` + UPDATE users SET + auth_salt = ?, + auth_verifier_hash = ?, + kek_salt = ?, + wrapped_dek_pw = ?, + dek_pw_nonce = ? + WHERE id = ? + `) + .run( + b64ToBuffer(body.auth_salt), + verifierHash, + b64ToBuffer(body.kek_salt), + b64ToBuffer(body.wrapped_dek_pw), + b64ToBuffer(body.dek_pw_nonce), + row.id, + ); + + // Force re-login: previous sessions for this user are invalidated. + getDb().prepare('DELETE FROM sessions WHERE user_id = ?').run(row.id); + return c.json({ ok: true }); +}); diff --git a/server/db.ts b/server/db.ts new file mode 100644 index 0000000..3bbadf2 --- /dev/null +++ b/server/db.ts @@ -0,0 +1,99 @@ +import { Database } from 'bun:sqlite'; +import { mkdirSync } from 'node:fs'; +import { dirname } from 'node:path'; + +/** + * Schema follows winter-list-claude-code-prompt.md verbatim, plus a `sessions` + * table for opaque server-stored sessions. + * + * Each statement is run individually so an error in one CREATE doesn't leave + * the schema half-applied silently. All statements use IF NOT EXISTS so the + * migration is idempotent. + */ +const SCHEMA_STATEMENTS: readonly string[] = [ + `CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + auth_salt BLOB NOT NULL, + auth_verifier_hash TEXT NOT NULL, + kek_salt BLOB NOT NULL, + wrapped_dek_pw BLOB NOT NULL, + dek_pw_nonce BLOB NOT NULL, + wrapped_dek_rec BLOB NOT NULL, + rec_salt BLOB NOT NULL, + dek_rec_nonce BLOB NOT NULL, + created_at INTEGER NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS activities ( + id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL REFERENCES users(id), + visibility TEXT NOT NULL CHECK (visibility IN ('private','semi','public')), + ciphertext BLOB, + nonce BLOB, + title TEXT, + scheduled_at INTEGER, + loc_label TEXT, + loc_lat REAL, + loc_lng REAL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS activities_visibility_idx ON activities(visibility)`, + `CREATE INDEX IF NOT EXISTS activities_owner_idx ON activities(owner_id)`, + `CREATE TABLE IF NOT EXISTS tags ( + id TEXT PRIMARY KEY, + name TEXT UNIQUE NOT NULL, + usage_count INTEGER NOT NULL DEFAULT 0 + )`, + `CREATE INDEX IF NOT EXISTS tags_name_idx ON tags(name)`, + `CREATE TABLE IF NOT EXISTS activity_tags ( + activity_id TEXT NOT NULL REFERENCES activities(id) ON DELETE CASCADE, + tag_id TEXT NOT NULL REFERENCES tags(id), + PRIMARY KEY (activity_id, tag_id) + )`, + `CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS sessions_user_idx ON sessions(user_id)`, + `CREATE INDEX IF NOT EXISTS sessions_expires_idx ON sessions(expires_at)`, +]; + +const PRAGMAS: readonly string[] = [ + // WAL gives concurrent readers a non-blocking view of writes — the only + // sensible mode for a long-running SQLite-backed server. + 'PRAGMA journal_mode = WAL', + 'PRAGMA foreign_keys = ON', + 'PRAGMA synchronous = NORMAL', +]; + +let dbInstance: Database | null = null; + +function applyStatements(db: Database, statements: readonly string[]): void { + for (const sql of statements) { + db.prepare(sql).run(); + } +} + +export function getDb(): Database { + if (dbInstance) return dbInstance; + + const path = process.env.VINTERLISTE_DB ?? 'data/vinterliste.db'; + mkdirSync(dirname(path), { recursive: true }); + + const db = new Database(path, { create: true }); + + applyStatements(db, PRAGMAS); + applyStatements(db, SCHEMA_STATEMENTS); + + dbInstance = db; + return db; +} + +/** For tests: close and forget the cached connection. */ +export function resetDbForTests(): void { + dbInstance?.close(); + dbInstance = null; +} diff --git a/server/index.ts b/server/index.ts new file mode 100644 index 0000000..dbe799f --- /dev/null +++ b/server/index.ts @@ -0,0 +1,44 @@ +import { Hono } from 'hono'; +import { serveStatic } from 'hono/bun'; +import { getDb } from './db'; +import { authRoutes } from './auth'; +import { activitiesRoutes } from './activities'; +import { tagsRoutes } from './tags'; + +// Initialise DB up front so the server fails fast on schema problems. +getDb(); + +const app = new Hono(); + +app.get('/api/health', (c) => + c.json({ + ok: true, + build: { + revision: process.env.GIT_REVISION ?? 'unknown', + built_at: process.env.BUILD_DATE ?? 'unknown', + }, + }), +); + +app.route('/api/auth', authRoutes); +app.route('/api/activities', activitiesRoutes); +app.route('/api/tags', tagsRoutes); + +// In production, serve the built Svelte SPA. Hono's bun static helper handles +// asset MIME types; everything else falls through to index.html for SPA routing. +if (process.env.NODE_ENV === 'production') { + app.use('/assets/*', serveStatic({ root: './frontend/dist' })); + app.get('/favicon.svg', serveStatic({ path: './frontend/dist/favicon.svg' })); + app.get('*', serveStatic({ path: './frontend/dist/index.html' })); +} + +const port = parseInt(process.env.PORT ?? '3000', 10); + +export default { + port, + fetch: app.fetch, +}; + +// When run directly with `bun run`, Bun.serve picks this up via the default +// export. Logging here makes the boot visible during `bun run dev:server`. +console.log(`vinterliste listening on http://localhost:${port}`); diff --git a/server/session.ts b/server/session.ts new file mode 100644 index 0000000..08bd0c8 --- /dev/null +++ b/server/session.ts @@ -0,0 +1,96 @@ +import type { Context, MiddlewareHandler } from 'hono'; +import { getCookie, setCookie, deleteCookie } from 'hono/cookie'; +import { getDb } from './db'; + +/** + * Hono context variables. Used to thread `userId` from `requireAuth` to the + * route handlers without going through a manual cast each time. + */ +export type AppVariables = { + userId: string; +}; + +export type AppContext = Context<{ Variables: AppVariables }>; + +const COOKIE_NAME = 'vl_session'; +const SESSION_LIFETIME_MS = 1000 * 60 * 60 * 24 * 14; // 14 days + +/** + * Sessions are opaque, server-stored, and revocable. We don't use JWT — there's + * no benefit when the server already has SQLite at hand, and revocation is much + * cleaner with a row you can DELETE. + * + * The token is a 32-byte URL-safe random string returned by Bun's CSPRNG. + */ +function newToken(): string { + const buf = new Uint8Array(32); + crypto.getRandomValues(buf); + // base64url without padding + return Buffer.from(buf) + .toString('base64') + .replaceAll('+', '-') + .replaceAll('/', '_') + .replace(/=+$/, ''); +} + +export interface SessionRow { + token: string; + user_id: string; + created_at: number; + expires_at: number; +} + +export function issueSession(c: Context, userId: string): string { + const db = getDb(); + const token = newToken(); + const now = Date.now(); + const expires = now + SESSION_LIFETIME_MS; + db.prepare( + 'INSERT INTO sessions (token, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)', + ).run(token, userId, now, expires); + + const secure = c.req.url.startsWith('https://'); + setCookie(c, COOKIE_NAME, token, { + httpOnly: true, + sameSite: 'Lax', + secure, + path: '/', + maxAge: Math.floor(SESSION_LIFETIME_MS / 1000), + }); + return token; +} + +export function clearSession(c: Context): void { + const token = getCookie(c, COOKIE_NAME); + if (token) { + getDb().prepare('DELETE FROM sessions WHERE token = ?').run(token); + } + deleteCookie(c, COOKIE_NAME, { path: '/' }); +} + +export function currentUserId(c: Context): string | null { + const token = getCookie(c, COOKIE_NAME); + if (!token) return null; + const row = getDb() + .prepare('SELECT user_id, expires_at FROM sessions WHERE token = ?') + .get(token) as { user_id: string; expires_at: number } | null; + if (!row) return null; + if (row.expires_at < Date.now()) { + getDb().prepare('DELETE FROM sessions WHERE token = ?').run(token); + return null; + } + return row.user_id; +} + +/** Hono middleware that 401s unless a valid session is present. */ +export const requireAuth: MiddlewareHandler<{ Variables: AppVariables }> = async (c, next) => { + const userId = currentUserId(c); + if (!userId) return c.json({ error: 'unauthorized' }, 401); + c.set('userId', userId); + await next(); +}; + +/** Sweep expired sessions; called opportunistically. */ +export function gcSessions(): void { + getDb().prepare('DELETE FROM sessions WHERE expires_at < ?').run(Date.now()); +} diff --git a/server/tags.ts b/server/tags.ts new file mode 100644 index 0000000..5bdb1a7 --- /dev/null +++ b/server/tags.ts @@ -0,0 +1,120 @@ +import { Hono } from 'hono'; +import { getDb } from './db'; +import type { TagSuggestion } from '../shared/types'; + +/** + * Tag store (server-side, semi/public only). + * + * Normalisation: lowercased, trimmed, deduped. The server never sees private + * tags — those live inside the encrypted activity payload and in the client's + * IndexedDB index. + */ + +export function normaliseTag(raw: string): string | null { + const t = raw.trim().toLowerCase(); + if (!t) return null; + if (t.length > 50) return t.slice(0, 50); + return t; +} + +export function dedupe(tags: string[]): string[] { + return [...new Set(tags.map(normaliseTag).filter((t): t is string => t !== null))]; +} + +/** + * Replace the tag set for an activity. Decrements old tags' usage_count, + * inserts/increments new ones. Runs in a single transaction. + */ +export function setActivityTags(activityId: string, rawTags: string[]): void { + const tags = dedupe(rawTags); + const db = getDb(); + + const txn = db.transaction((tagNames: string[]) => { + // Old tags for this activity → decrement usage + const oldTagIds = db + .prepare('SELECT tag_id FROM activity_tags WHERE activity_id = ?') + .all(activityId) as { tag_id: string }[]; + + db.prepare('DELETE FROM activity_tags WHERE activity_id = ?').run(activityId); + + const decrement = db.prepare('UPDATE tags SET usage_count = MAX(0, usage_count - 1) WHERE id = ?'); + for (const { tag_id } of oldTagIds) decrement.run(tag_id); + + const findTag = db.prepare('SELECT id FROM tags WHERE name = ?'); + const insertTag = db.prepare('INSERT INTO tags (id, name, usage_count) VALUES (?, ?, 0)'); + const bumpTag = db.prepare('UPDATE tags SET usage_count = usage_count + 1 WHERE id = ?'); + const linkTag = db.prepare( + 'INSERT OR IGNORE INTO activity_tags (activity_id, tag_id) VALUES (?, ?)', + ); + + for (const name of tagNames) { + let row = findTag.get(name) as { id: string } | null; + if (!row) { + const id = crypto.randomUUID(); + insertTag.run(id, name); + row = { id }; + } + linkTag.run(activityId, row.id); + bumpTag.run(row.id); + } + }); + + txn(tags); +} + +export function clearActivityTags(activityId: string): void { + const db = getDb(); + const txn = db.transaction(() => { + const old = db + .prepare('SELECT tag_id FROM activity_tags WHERE activity_id = ?') + .all(activityId) as { tag_id: string }[]; + db.prepare('DELETE FROM activity_tags WHERE activity_id = ?').run(activityId); + const decrement = db.prepare( + 'UPDATE tags SET usage_count = MAX(0, usage_count - 1) WHERE id = ?', + ); + for (const { tag_id } of old) decrement.run(tag_id); + }); + txn(); +} + +export function tagsFor(activityId: string): string[] { + return ( + getDb() + .prepare(` + SELECT t.name FROM tags t + JOIN activity_tags at ON at.tag_id = t.id + WHERE at.activity_id = ? + ORDER BY t.name + `) + .all(activityId) as { name: string }[] + ).map((r) => r.name); +} + +// --- Routes ------------------------------------------------------------------ +export const tagsRoutes = new Hono(); + +// GET /api/tags?q=foo&limit=20 +tagsRoutes.get('/', (c) => { + const q = c.req.query('q')?.trim().toLowerCase() ?? ''; + const limit = Math.min(parseInt(c.req.query('limit') ?? '20', 10) || 20, 50); + + const rows = q + ? (getDb() + .prepare(` + SELECT name, usage_count FROM tags + WHERE name LIKE ? AND usage_count > 0 + ORDER BY usage_count DESC, name ASC + LIMIT ? + `) + .all(`${q}%`, limit) as TagSuggestion[]) + : (getDb() + .prepare(` + SELECT name, usage_count FROM tags + WHERE usage_count > 0 + ORDER BY usage_count DESC, name ASC + LIMIT ? + `) + .all(limit) as TagSuggestion[]); + + return c.json(rows); +}); diff --git a/shared/crypto.ts b/shared/crypto.ts new file mode 100644 index 0000000..d4c3e12 --- /dev/null +++ b/shared/crypto.ts @@ -0,0 +1,233 @@ +// Vinterliste crypto module — see SECURITY.md for the model. +// +// Pure, side-effect-free helpers around libsodium. Designed to be called from +// both the browser (via Vite/ESM) and Bun (for tests). Nothing in here touches +// the network, IndexedDB, or the SQLite file. All randomness comes from +// libsodium's CSPRNG. + +// We need the SUMO build of libsodium because the standard `libsodium-wrappers` +// ships without `crypto_pwhash` (Argon2id), which the spec mandates. The +// runtime-conditional loader in `./sodium.ts` papers over the package's +// broken ESM entry (different mechanism per runtime — see that file). +import sodium from './sodium'; + +export type Bytes = Uint8Array; + +let readyPromise: Promise | null = null; + +/** Must be awaited once before calling anything else. Cached after first call. */ +export function ready(): Promise { + if (!readyPromise) readyPromise = sodium.ready; + return readyPromise; +} + +// --- Constants --------------------------------------------------------------- +// Argon2id profile is MODERATE: ~256 MiB memory, ~3 iterations on libsodium's +// reference machine. Keep these stable: if you ever raise them, store the +// per-user parameters next to the salts so old accounts still unlock. +export const KDF_OPSLIMIT = (): number => sodium.crypto_pwhash_OPSLIMIT_MODERATE; +export const KDF_MEMLIMIT = (): number => sodium.crypto_pwhash_MEMLIMIT_MODERATE; +export const KDF_ALG = (): number => sodium.crypto_pwhash_ALG_ARGON2ID13; + +export const SALT_BYTES = 16; // crypto_pwhash_SALTBYTES +export const KEK_BYTES = 32; // crypto_aead_xchacha20poly1305_ietf_KEYBYTES +export const NONCE_BYTES = 24; // crypto_aead_xchacha20poly1305_ietf_NPUBBYTES +export const DEK_BYTES = 32; +export const VERIFIER_BYTES = 32; // arbitrary; fits crypto_pwhash output + +// Recovery code: 24 chars of Crockford-ish base32 → 120 bits of entropy. +// Layout: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX (groups of 4 for readability). +const RECOVERY_ALPHABET = 'ABCDEFGHJKMNPQRSTVWXYZ23456789'; // 30 chars +const RECOVERY_GROUPS = 6; +const RECOVERY_GROUP_LEN = 4; +const RECOVERY_TOTAL_CHARS = RECOVERY_GROUPS * RECOVERY_GROUP_LEN; // 24 + +// --- Random helpers ---------------------------------------------------------- +export function randomBytes(n: number): Bytes { + return sodium.randombytes_buf(n); +} + +export function generateDek(): Bytes { + return randomBytes(DEK_BYTES); +} + +export function generateSalt(): Bytes { + return randomBytes(SALT_BYTES); +} + +export function generateNonce(): Bytes { + return randomBytes(NONCE_BYTES); +} + +/** + * Generate a high-entropy human-typeable recovery code. + * + * Why a custom alphabet: the recovery code is read aloud and typed back in, + * so we drop visually-ambiguous characters (0/O, 1/I/L, U). 30 chars × 24 + * positions ≈ 117.8 bits of entropy, comfortably above the 120-bit target + * once you account for grouping dashes being non-secret. + */ +export function generateRecoveryCode(): string { + const out: string[] = []; + // Rejection-sample uniformly from a 30-char alphabet using bytes. + // 256 mod 30 == 16, so accept bytes < 240 (8 * 30) to keep distribution uniform. + const acceptMax = 240; + const buf = randomBytes(RECOVERY_TOTAL_CHARS * 2); // oversample + let bi = 0; + while (out.length < RECOVERY_TOTAL_CHARS) { + if (bi >= buf.length) { + // Extremely unlikely, but top up if we burned through the buffer. + const extra = randomBytes(RECOVERY_TOTAL_CHARS); + buf.set(extra, 0); + bi = 0; + } + const b = buf[bi++]!; + if (b < acceptMax) out.push(RECOVERY_ALPHABET[b % RECOVERY_ALPHABET.length]!); + } + const groups: string[] = []; + for (let i = 0; i < RECOVERY_GROUPS; i++) { + groups.push(out.slice(i * RECOVERY_GROUP_LEN, (i + 1) * RECOVERY_GROUP_LEN).join('')); + } + return groups.join('-'); +} + +/** Normalise a recovery code before hashing: strip whitespace/dashes, uppercase. */ +export function normalizeRecoveryCode(code: string): string { + return code.replace(/[^A-Za-z0-9]/g, '').toUpperCase(); +} + +// --- KDF --------------------------------------------------------------------- +/** + * Derive a 32-byte key from a password (or recovery code) and salt. + * Used for KEK derivation and for the auth verifier — caller supplies the + * appropriate salt so the same primitive serves both purposes (see SECURITY.md). + */ +export function deriveKey(secret: string, salt: Bytes, outLen: number = KEK_BYTES): Bytes { + if (salt.length !== SALT_BYTES) { + throw new Error(`deriveKey: salt must be ${SALT_BYTES} bytes, got ${salt.length}`); + } + return sodium.crypto_pwhash( + outLen, + secret, + salt, + KDF_OPSLIMIT(), + KDF_MEMLIMIT(), + KDF_ALG(), + ); +} + +// --- AEAD -------------------------------------------------------------------- +export interface Sealed { + ciphertext: Bytes; + nonce: Bytes; +} + +/** + * Encrypt `plaintext` under `key` with a fresh nonce. The nonce is returned + * alongside the ciphertext; it is not secret and is meant to be stored next + * to it. + */ +export function aeadEncrypt(plaintext: Bytes, key: Bytes): Sealed { + if (key.length !== KEK_BYTES) { + throw new Error(`aeadEncrypt: key must be ${KEK_BYTES} bytes, got ${key.length}`); + } + const nonce = generateNonce(); + const ciphertext = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt( + plaintext, + null, // no associated data + null, // no secret nonce + nonce, + key, + ); + return { ciphertext, nonce }; +} + +export function aeadDecrypt(sealed: Sealed, key: Bytes): Bytes { + if (key.length !== KEK_BYTES) { + throw new Error(`aeadDecrypt: key must be ${KEK_BYTES} bytes, got ${key.length}`); + } + if (sealed.nonce.length !== NONCE_BYTES) { + throw new Error(`aeadDecrypt: nonce must be ${NONCE_BYTES} bytes`); + } + return sodium.crypto_aead_xchacha20poly1305_ietf_decrypt( + null, // no secret nonce + sealed.ciphertext, + null, // no associated data + sealed.nonce, + key, + ); +} + +// --- DEK wrap / unwrap (thin convenience over AEAD) -------------------------- +export function wrapDek(dek: Bytes, kek: Bytes): Sealed { + if (dek.length !== DEK_BYTES) { + throw new Error(`wrapDek: dek must be ${DEK_BYTES} bytes`); + } + return aeadEncrypt(dek, kek); +} + +export function unwrapDek(sealed: Sealed, kek: Bytes): Bytes { + const dek = aeadDecrypt(sealed, kek); + if (dek.length !== DEK_BYTES) { + throw new Error(`unwrapDek: unwrapped DEK has wrong length ${dek.length}`); + } + return dek; +} + +// --- Activity payload encryption -------------------------------------------- +/** + * Shape of a private activity's encrypted payload. Anything that identifies + * a private activity to a human must go here, not into a server column. + */ +export interface PrivatePayload { + title: string; + tags: string[]; + loc_label?: string; + loc_lat?: number; + loc_lng?: number; + scheduled_at?: number; +} + +const utf8 = new TextEncoder(); +const utf8d = new TextDecoder('utf-8', { fatal: true }); + +export function encryptPayload(payload: PrivatePayload, dek: Bytes): Sealed { + const json = JSON.stringify(payload); + return aeadEncrypt(utf8.encode(json), dek); +} + +export function decryptPayload(sealed: Sealed, dek: Bytes): PrivatePayload { + const plaintext = aeadDecrypt(sealed, dek); + const json = utf8d.decode(plaintext); + return JSON.parse(json) as PrivatePayload; +} + +// --- Auth verifier ----------------------------------------------------------- +/** + * Derive the auth verifier sent to the server. The verifier salt MUST be + * distinct from the KEK salt — see SECURITY.md. We base64-encode the raw + * bytes for transport because Bun.password.hash expects a string input. + */ +export function deriveAuthVerifier(password: string, authSalt: Bytes): string { + const bytes = deriveKey(password, authSalt, VERIFIER_BYTES); + return bytesToBase64(bytes); +} + +// --- Encoding helpers -------------------------------------------------------- +export function bytesToBase64(b: Bytes): string { + return sodium.to_base64(b, sodium.base64_variants.ORIGINAL); +} + +export function base64ToBytes(s: string): Bytes { + return sodium.from_base64(s, sodium.base64_variants.ORIGINAL); +} + +export function bytesEqual(a: Bytes, b: Bytes): boolean { + if (a.length !== b.length) return false; + return sodium.memcmp(a, b); +} + +/** Best-effort zeroisation for secrets that are no longer needed in memory. */ +export function zero(b: Bytes): void { + sodium.memzero(b); +} diff --git a/shared/sodium.ts b/shared/sodium.ts new file mode 100644 index 0000000..d9bc71a --- /dev/null +++ b/shared/sodium.ts @@ -0,0 +1,37 @@ +/** + * Runtime-conditional loader for libsodium-wrappers-sumo. + * + * Why this exists: the SUMO package's ESM entry has a broken relative import + * (`./libsodium-sumo.mjs`) that the package doesn't actually ship. Both Bun + * and Vite would normally follow `exports.import` and fail. We work around it + * differently per runtime: + * + * - **Server / Bun / tests:** load the CJS bundle through `createRequire`, + * which uses Node's classic `main`-field resolution and bypasses + * `exports.import`. + * - **Browser / Vite:** `vite.config.ts` has a `resolve.alias` that points + * `libsodium-wrappers-sumo` at the CJS bundle. Vite's CJS interop loads it. + * + * The runtime branch is selected by a `typeof process` guard, so the + * `node:module` import is dead code in the browser. We hide it from Vite's + * static analysis with `/* @vite-ignore *\/` so the browser build doesn't try + * to resolve `node:module` and fail. + */ +import type SodiumLib from 'libsodium-wrappers-sumo'; + +declare const process: { versions?: { bun?: string; node?: string } } | undefined; + +let sodium: typeof SodiumLib; + +if (typeof process !== 'undefined' && (process.versions?.bun || process.versions?.node)) { + // The frontend tsconfig has no @types/node, so it can't resolve `node:module`. + // This branch never executes in the browser anyway (guarded by `typeof process`). + // @ts-ignore - node:module exists in the server tsconfig (via @types/bun) + // but not the frontend tsconfig; @ts-ignore tolerates either. + const { createRequire } = await import(/* @vite-ignore */ 'node:module'); + sodium = createRequire(import.meta.url)('libsodium-wrappers-sumo'); +} else { + sodium = (await import('libsodium-wrappers-sumo')).default; +} + +export default sodium; diff --git a/shared/types.ts b/shared/types.ts new file mode 100644 index 0000000..f618dbe --- /dev/null +++ b/shared/types.ts @@ -0,0 +1,123 @@ +// Wire-level types shared between server and client. Keep these in sync with +// the SQL schema in server/db.ts and the request/response handlers in +// server/{auth,activities,tags}.ts. + +export type Visibility = 'private' | 'semi' | 'public'; + +// --- Auth -------------------------------------------------------------------- +export interface SignupRequest { + email: string; + // All bytes-fields are base64-encoded for JSON transport. + auth_salt: string; + auth_verifier: string; + kek_salt: string; + wrapped_dek_pw: string; + dek_pw_nonce: string; + rec_salt: string; + wrapped_dek_rec: string; + dek_rec_nonce: string; +} + +export interface ChallengeResponse { + auth_salt: string; + kek_salt: string; + wrapped_dek_pw: string; + dek_pw_nonce: string; +} + +export interface LoginRequest { + email: string; + auth_verifier: string; +} + +export interface RecoveryChallengeResponse { + rec_salt: string; + wrapped_dek_rec: string; + dek_rec_nonce: string; +} + +export interface PasswordChangeRequest { + // Requires an active session; new material only — the recovery wrap is untouched. + auth_salt: string; + auth_verifier: string; + kek_salt: string; + wrapped_dek_pw: string; + dek_pw_nonce: string; +} + +export interface RecoveryCompleteRequest { + email: string; + auth_salt: string; + auth_verifier: string; + kek_salt: string; + wrapped_dek_pw: string; + dek_pw_nonce: string; +} + +export interface MeResponse { + id: string; + email: string; +} + +// --- Activities -------------------------------------------------------------- +export interface ActivityPublic { + id: string; + visibility: 'public'; + owner_id: string; // serialized for public + title: string; + tags: string[]; + loc_label: string | null; + loc_lat: number | null; + loc_lng: number | null; + scheduled_at: number | null; + created_at: number; + updated_at: number; +} + +export interface ActivitySemi { + id: string; + visibility: 'semi'; + // owner_id deliberately omitted — see SECURITY.md + title: string; + tags: string[]; + loc_label: string | null; + loc_lat: number | null; + loc_lng: number | null; + scheduled_at: number | null; + created_at: number; + updated_at: number; +} + +export interface ActivityPrivate { + id: string; + visibility: 'private'; + owner_id: string; // always you — server only returns your private rows + ciphertext: string; // base64 + nonce: string; // base64 + created_at: number; + updated_at: number; +} + +export type Activity = ActivityPublic | ActivitySemi | ActivityPrivate; + +export interface CreateActivityRequest { + visibility: Visibility; + // For semi/public: + title?: string; + tags?: string[]; + loc_label?: string | null; + loc_lat?: number | null; + loc_lng?: number | null; + scheduled_at?: number | null; + // For private: + ciphertext?: string; + nonce?: string; +} + +export type UpdateActivityRequest = CreateActivityRequest; + +// --- Tags -------------------------------------------------------------------- +export interface TagSuggestion { + name: string; + usage_count: number; +} diff --git a/tests/crypto.test.ts b/tests/crypto.test.ts new file mode 100644 index 0000000..ee0adbf --- /dev/null +++ b/tests/crypto.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, test, beforeAll } from 'bun:test'; +import { + ready, + generateDek, + generateSalt, + generateRecoveryCode, + normalizeRecoveryCode, + deriveKey, + deriveAuthVerifier, + wrapDek, + unwrapDek, + encryptPayload, + decryptPayload, + aeadEncrypt, + aeadDecrypt, + bytesToBase64, + base64ToBytes, + bytesEqual, + DEK_BYTES, + SALT_BYTES, + NONCE_BYTES, + KEK_BYTES, +} from '../shared/crypto'; + +beforeAll(async () => { + await ready(); +}); + +describe('random material', () => { + test('DEK is 32 bytes', () => { + expect(generateDek().length).toBe(DEK_BYTES); + }); + + test('salt is 16 bytes', () => { + expect(generateSalt().length).toBe(SALT_BYTES); + }); + + test('two DEKs are not equal', () => { + expect(bytesEqual(generateDek(), generateDek())).toBe(false); + }); +}); + +describe('recovery code', () => { + test('matches XXXX-XXXX-XXXX-XXXX-XXXX-XXXX shape', () => { + const code = generateRecoveryCode(); + expect(code).toMatch(/^[A-Z2-9]{4}(-[A-Z2-9]{4}){5}$/); + }); + + test('only uses the safe alphabet (no 0/O, 1/I/L, U)', () => { + const safe = /^[ABCDEFGHJKMNPQRSTVWXYZ23456789-]+$/; + for (let i = 0; i < 50; i++) { + expect(generateRecoveryCode()).toMatch(safe); + } + }); + + test('two codes differ', () => { + expect(generateRecoveryCode()).not.toBe(generateRecoveryCode()); + }); + + test('normalisation strips dashes and uppercases', () => { + const code = generateRecoveryCode(); + expect(normalizeRecoveryCode(code.toLowerCase())).toBe(code.replace(/-/g, '')); + expect(normalizeRecoveryCode(` ${code} `)).toBe(code.replace(/-/g, '')); + }); +}); + +describe('AEAD round-trip', () => { + test('encrypt then decrypt returns the original', () => { + const key = generateDek(); + const msg = new TextEncoder().encode('vinterliste'); + const sealed = aeadEncrypt(msg, key); + expect(sealed.nonce.length).toBe(NONCE_BYTES); + const back = aeadDecrypt(sealed, key); + expect(new TextDecoder().decode(back)).toBe('vinterliste'); + }); + + test('wrong key fails to decrypt', () => { + const key = generateDek(); + const wrong = generateDek(); + const sealed = aeadEncrypt(new Uint8Array([1, 2, 3]), key); + expect(() => aeadDecrypt(sealed, wrong)).toThrow(); + }); + + test('tampered ciphertext fails to decrypt', () => { + const key = generateDek(); + const sealed = aeadEncrypt(new Uint8Array([1, 2, 3, 4]), key); + sealed.ciphertext[0] = sealed.ciphertext[0]! ^ 0xff; + expect(() => aeadDecrypt(sealed, key)).toThrow(); + }); + + test('each encrypt uses a fresh nonce', () => { + const key = generateDek(); + const a = aeadEncrypt(new Uint8Array([1]), key); + const b = aeadEncrypt(new Uint8Array([1]), key); + expect(bytesEqual(a.nonce, b.nonce)).toBe(false); + }); +}); + +describe('DEK wrap via password path', () => { + const password = 'correct horse battery staple'; + + test('wrap then unwrap returns the original DEK', () => { + const dek = generateDek(); + const kekSalt = generateSalt(); + const kek = deriveKey(password, kekSalt); + expect(kek.length).toBe(KEK_BYTES); + + const sealed = wrapDek(dek, kek); + const back = unwrapDek(sealed, kek); + expect(bytesEqual(dek, back)).toBe(true); + }); + + test('wrong password derives a different KEK and fails to unwrap', () => { + const dek = generateDek(); + const salt = generateSalt(); + const kek = deriveKey(password, salt); + const sealed = wrapDek(dek, kek); + + const wrongKek = deriveKey('wrong', salt); + expect(() => unwrapDek(sealed, wrongKek)).toThrow(); + }); + + test('same password with a different salt derives a different KEK', () => { + const a = deriveKey(password, generateSalt()); + const b = deriveKey(password, generateSalt()); + expect(bytesEqual(a, b)).toBe(false); + }); +}); + +describe('DEK wrap via recovery path', () => { + test('recovery code round-trips the DEK', () => { + const dek = generateDek(); + const code = generateRecoveryCode(); + const salt = generateSalt(); + const kek = deriveKey(normalizeRecoveryCode(code), salt); + const sealed = wrapDek(dek, kek); + + // Simulating the recovery flow: user re-types the code (with dashes, mixed case) + const reKek = deriveKey(normalizeRecoveryCode(code.toLowerCase()), salt); + const back = unwrapDek(sealed, reKek); + expect(bytesEqual(dek, back)).toBe(true); + }); +}); + +describe('auth verifier separation', () => { + test('auth verifier and KEK differ even with the same password', () => { + const password = 'shared password ok'; + const kekSalt = generateSalt(); + const authSalt = generateSalt(); + + const kek = deriveKey(password, kekSalt); + const verifier = deriveAuthVerifier(password, authSalt); + + // Verifier is base64 of 32 bytes; if we accidentally re-used the KEK salt, + // verifier would be base64(kek). Sanity-check they don't match. + expect(verifier).not.toBe(bytesToBase64(kek)); + }); + + test('same password + same auth salt is deterministic', () => { + const password = 'pw'; + const authSalt = generateSalt(); + expect(deriveAuthVerifier(password, authSalt)).toBe( + deriveAuthVerifier(password, authSalt), + ); + }); +}); + +describe('private activity payload', () => { + test('encrypt then decrypt returns the original payload', () => { + const dek = generateDek(); + const payload = { + title: 'Skitur til Sognsvann', + tags: ['ski', 'oslomarka'], + loc_label: 'Sognsvann', + loc_lat: 59.9744, + loc_lng: 10.7338, + scheduled_at: 1739000000, + }; + const sealed = encryptPayload(payload, dek); + expect(decryptPayload(sealed, dek)).toEqual(payload); + }); + + test('optional fields round-trip', () => { + const dek = generateDek(); + const payload = { title: 'Bare lese en bok', tags: [] }; + expect(decryptPayload(encryptPayload(payload, dek), dek)).toEqual(payload); + }); +}); + +describe('end-to-end signup → password change → unlock', () => { + test('changing the password preserves activity ciphertexts', () => { + // Signup + const dek = generateDek(); + const oldKek = deriveKey('old password', generateSalt()); + let wrappedPw = wrapDek(dek, oldKek); + + // Encrypt an activity under DEK (server-side stored ciphertext stays put) + const sealedActivity = encryptPayload( + { title: 'Lese om vintervedlikehold', tags: ['hus'] }, + dek, + ); + + // Password change: unwrap with old KEK, derive new KEK, re-wrap DEK + const dekBack = unwrapDek(wrappedPw, oldKek); + expect(bytesEqual(dek, dekBack)).toBe(true); + const newSalt = generateSalt(); + const newKek = deriveKey('new password', newSalt); + wrappedPw = wrapDek(dekBack, newKek); + + // Activity ciphertext is untouched — it still decrypts under the same DEK + const unlocked = unwrapDek(wrappedPw, newKek); + const payload = decryptPayload(sealedActivity, unlocked); + expect(payload.title).toBe('Lese om vintervedlikehold'); + }); + + test('recovery unlocks even after password change', () => { + const dek = generateDek(); + const code = generateRecoveryCode(); + const recSalt = generateSalt(); + const wrappedRec = wrapDek(dek, deriveKey(normalizeRecoveryCode(code), recSalt)); + + // Password is rotated: unwrap with current KEK, re-wrap with a new KEK. + // The recovery wrap is intentionally never touched by this flow. + const pwSalt1 = generateSalt(); + const kek1 = deriveKey('pw1', pwSalt1); + let wrappedPw = wrapDek(dek, kek1); + + const dekBack = unwrapDek(wrappedPw, kek1); + const pwSalt2 = generateSalt(); + const kek2 = deriveKey('pw2', pwSalt2); + wrappedPw = wrapDek(dekBack, kek2); + + // Password path still works after the change… + expect(bytesEqual(unwrapDek(wrappedPw, kek2), dek)).toBe(true); + // …and the recovery wrap is unaffected. + const recovered = unwrapDek(wrappedRec, deriveKey(normalizeRecoveryCode(code), recSalt)); + expect(bytesEqual(dek, recovered)).toBe(true); + }); +}); + +describe('base64 helpers', () => { + test('round-trip', () => { + const b = new Uint8Array([0, 1, 2, 250, 255]); + expect(bytesEqual(base64ToBytes(bytesToBase64(b)), b)).toBe(true); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1c6f8a8 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "isolatedModules": true, + "verbatimModuleSyntax": false, + "types": ["bun"] + }, + "include": ["server/**/*.ts", "shared/**/*.ts", "tests/**/*.ts"], + "exclude": ["node_modules", "frontend/dist", "data"] +} diff --git a/winter-list-claude-code-prompt.md b/winter-list-claude-code-prompt.md new file mode 100644 index 0000000..58f10bd --- /dev/null +++ b/winter-list-claude-code-prompt.md @@ -0,0 +1,177 @@ +# Build: "Winter List" — an end-to-end encrypted activity list (Bun + TypeScript) + +## Goal +Scaffold a small web app for collecting *winter activities* — things to do when winter +feels long. Users add activities that can be **private** (end-to-end encrypted), +**semi-public**, or **public**. Build the foundation: repo structure, DB schema + +migration, the client-side crypto module, auth (signup / login / recovery), activity +CRUD with correct visibility handling, tag autocomplete, and a podman-deployable +container. Favor a small, readable, well-tested foundation over breadth. + +## Visibility model +Set per activity: +- `private` — end-to-end encrypted. Only the owner can read it. The server stores + ciphertext only. +- `semi` — plaintext, readable by everyone, but the creator is **not** shown. + `owner_id` is stored (for editing/moderation) but **never serialized** in API responses. +- `public` — plaintext, readable by everyone, **and attributed** to the creator + (owner shown / linkable). + +An activity has: a title, optional tags, an optional location, and an optional date/time. + +## Tech stack (locked — do not substitute) +- **Runtime:** Bun (server). TypeScript everywhere. +- **DB:** `bun:sqlite` (built-in), WAL mode. No external DB server. A thin query layer is + fine; no ORM required. +- **HTTP:** Hono on Bun. (Plain `Bun.serve` is acceptable if it ends up simpler, but + prefer Hono for routing/middleware.) +- **Server password hashing:** `Bun.password` with argon2id — for the auth verifier + *only* (see crypto section). +- **Client crypto:** `libsodium-wrappers` (WASM) in the browser. Argon2id via + `crypto_pwhash`; AEAD via XChaCha20-Poly1305 (`crypto_aead_xchacha20poly1305_ietf`). +- **Frontend:** TypeScript SPA. Default to Svelte + Vite (React is acceptable if you keep + it light). Use IndexedDB for the client-side private tag/search index. +- **Build:** `bun build` for the frontend bundle; Bun runs the backend. One toolchain. +- **Deploy:** a single container (`oven/bun`) serving API + static frontend, with one + volume for the SQLite file. Target: podman. + +## Crypto & auth model — correctness-critical, read carefully +This is an end-to-end encrypted app. **The server must never see: the user's raw +password, any private activity's plaintext, or any encryption key in usable form.** All +key operations happen in the browser. + +**Key model (per user):** +1. On signup, the client generates a random 256-bit **DEK** (data encryption key). +2. The DEK is wrapped (encrypted) **twice**, with two independently derived keys: + - **Password path:** `KEK_pw = Argon2id(password, kek_salt)` (libsodium + `crypto_pwhash`, raw key bytes). `wrapped_dek_pw = AEAD(KEK_pw, DEK)`. + - **Recovery path:** generate a high-entropy recovery code client-side, shown to the + user exactly once and **never sent to the server**. + `KEK_rec = Argon2id(recovery_code, rec_salt)`. `wrapped_dek_rec = AEAD(KEK_rec, DEK)`. +3. The server stores only: `kek_salt`, `wrapped_dek_pw` + its nonce, `rec_salt`, + `wrapped_dek_rec` + its nonce. Salts and nonces are not secret. +4. **Unlock:** client fetches the wrapped DEK + salt, derives `KEK_pw` from the entered + password, unwraps the DEK locally. +5. **Password change:** unwrap DEK with the old password, re-wrap with a new `KEK_pw` + (new salt/nonce). **Never re-encrypts activity data.** The recovery wrap is untouched. +6. **Recovery:** user enters the recovery code → derive `KEK_rec` → unwrap DEK → re-wrap + with a new password. + +**Authentication (separate from the encryption key; same password is fine):** +- The raw password must **not** be sent to the server. The client derives an **auth + verifier** from the password using a *different* salt (`auth_salt`) than the KEK salt, + and sends only the verifier. +- The server stores `Bun.password.hash(verifier, { algorithm: "argon2id" })` and verifies + logins with `Bun.password.verify`. On success, issue a session (httpOnly cookie). +- Because the verifier salt ≠ KEK salt, the server learning the verifier never lets it + derive the KEK. + +**Private activity encryption:** +- For `private` activities, the entire meaningful payload — title, tags, location, + date/time — is serialized to JSON, encrypted with the DEK (XChaCha20-Poly1305, fresh + nonce per write), and stored as `ciphertext` + `nonce`. No plaintext fields on private + rows. +- Private tags and locations therefore **never** reach the server's `tags` / + `activity_tags` tables. They are indexed only client-side in IndexedDB. + +## Data model +```sql +users( + id TEXT PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + auth_salt BLOB NOT NULL, -- for the auth verifier + auth_verifier_hash TEXT NOT NULL, -- Bun.password argon2id hash of the verifier + kek_salt BLOB NOT NULL, -- for KEK derivation (not secret) + wrapped_dek_pw BLOB NOT NULL, + dek_pw_nonce BLOB NOT NULL, + wrapped_dek_rec BLOB NOT NULL, + rec_salt BLOB NOT NULL, + dek_rec_nonce BLOB NOT NULL, + created_at INTEGER NOT NULL +); + +activities( + id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL REFERENCES users(id), + visibility TEXT NOT NULL CHECK (visibility IN ('private','semi','public')), + + -- private only; NULL for semi/public + ciphertext BLOB, + nonce BLOB, + + -- semi/public only; NULL for private + title TEXT, + scheduled_at INTEGER, -- epoch seconds; display in 24h + loc_label TEXT, + loc_lat REAL, + loc_lng REAL, + + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +tags( + id TEXT PRIMARY KEY, + name TEXT UNIQUE NOT NULL, + usage_count INTEGER NOT NULL DEFAULT 0 +); + +activity_tags( -- semi/public ONLY + activity_id TEXT NOT NULL REFERENCES activities(id) ON DELETE CASCADE, + tag_id TEXT NOT NULL REFERENCES tags(id), + PRIMARY KEY (activity_id, tag_id) +); +``` + +## Functional scope (this pass) +1. Project scaffold, repo structure, README with run instructions. +2. `bun:sqlite` schema migration (idempotent), WAL enabled. +3. Crypto module (client): DEK generation, KEK derivation (password + recovery), + DEK wrap/unwrap, AEAD encrypt/decrypt for activity payloads, recovery-code generation. + Pure, well-commented, unit-tested. +4. Auth: signup (creates user + both DEK wraps), login (verifier + session), password + change, recovery flow. +5. Activity CRUD: + - Create / read / update / delete with visibility handling. + - Private: encrypt/decrypt client-side. + - Semi/public: plaintext; `owner_id` stored, stripped from responses when `semi`. + - Visibility transitions are explicit operations: private→public decrypts client-side, + re-uploads as plaintext, deletes the blob; public→private encrypts client-side, + deletes the plaintext. +6. Tags + autocomplete: + - Server endpoint: autocomplete over public/semi tags. + - Client-side: autocomplete over the user's private tags from IndexedDB. + - The UI may merge or keep the two sources separate — your call; document the choice. +7. Location: structured for semi/public (`loc_label` + optional lat/lng). Optional + `scheduled_at` stored as epoch seconds, displayed in 24h. +8. `Containerfile` for podman: single image, serves API + static frontend, one volume for + the DB. Include a short `podman build` / `podman run` snippet in the README. + +## Non-negotiable invariants +- The server never receives raw passwords, plaintext of private activities, or unwrapped + keys. +- Do **not** roll your own crypto. Use the libsodium primitives as specified. Do not swap + the AEAD or the KDF. +- `Bun.password` is for the server-side auth verifier **only** — never for KEK derivation + (the KEK needs raw key bytes from `crypto_pwhash`, client-side). +- Private payload fields never appear in any server-side plaintext column or tag table. +- `owner_id` is always set; it is never serialized for `semi`. + +## Out of scope (don't build yet) +- Sharing/permissions beyond the three visibility levels. +- Comments, notifications, other social features. +- Native/mobile apps. +- Server-side full-text search over private data. + +## Before implementing the crypto module +Write a short `SECURITY.md` describing the key model exactly as above, then implement +against it. If anything in this spec seems cryptographically unsound, flag it first rather +than silently changing it. + +## Verification +- `bun test` passes, including crypto round-trips: wrap/unwrap via both password and + recovery paths, AEAD encrypt/decrypt, password change preserves data, recovery unlocks. +- The app builds and runs with a single `podman run` plus a mounted volume; the DB + persists across container restarts. +- Manual check: inspect a stored `private` row in the DB file and confirm only ciphertext + is present — no plaintext title, tags, or location.