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.
This commit is contained in:
commit
47963c9225
39 changed files with 4007 additions and 0 deletions
12
.dockerignore
Normal file
12
.dockerignore
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
node_modules
|
||||
frontend/node_modules
|
||||
frontend/dist
|
||||
data
|
||||
.git
|
||||
.github
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
.env
|
||||
.env.local
|
||||
.DS_Store
|
||||
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
node_modules/
|
||||
frontend/dist/
|
||||
data/
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
.env
|
||||
.env.local
|
||||
.DS_Store
|
||||
133
CLAUDE.md
Normal file
133
CLAUDE.md
Normal file
|
|
@ -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 `<html>`). 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.
|
||||
67
Containerfile
Normal file
67
Containerfile
Normal file
|
|
@ -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"]
|
||||
161
README.md
Normal file
161
README.md
Normal file
|
|
@ -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`)
|
||||
215
SECURITY.md
Normal file
215
SECURITY.md
Normal file
|
|
@ -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.
|
||||
236
bun.lock
Normal file
236
bun.lock
Normal file
|
|
@ -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=="],
|
||||
}
|
||||
}
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="nb">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>Vinterliste</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
73
frontend/src/App.svelte
Normal file
73
frontend/src/App.svelte
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { ready } from './lib/crypto';
|
||||
import { api, ApiError } from './lib/api';
|
||||
import { session, setSession } from './lib/session.svelte';
|
||||
import { logout } from './lib/auth';
|
||||
import Login from './components/Login.svelte';
|
||||
import Signup from './components/Signup.svelte';
|
||||
import Recovery from './components/Recovery.svelte';
|
||||
import Home from './components/Home.svelte';
|
||||
|
||||
type View = 'login' | 'signup' | 'recovery' | 'home' | 'loading';
|
||||
let view: View = $state('loading');
|
||||
|
||||
onMount(async () => {
|
||||
await ready();
|
||||
try {
|
||||
const me = await api.me();
|
||||
// We have an active server session but no DEK — the user reloaded the
|
||||
// page. Force them through the login screen so we can re-unlock.
|
||||
view = 'login';
|
||||
// Pre-fill the email field on the login form.
|
||||
defaultEmail = me.email;
|
||||
await api.logout(); // drop the stale server session
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) view = 'login';
|
||||
else view = 'login';
|
||||
}
|
||||
});
|
||||
|
||||
let defaultEmail: string = $state('');
|
||||
|
||||
function onAuthed() {
|
||||
view = 'home';
|
||||
}
|
||||
|
||||
async function onLogout() {
|
||||
await logout();
|
||||
view = 'login';
|
||||
}
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<nav class="top">
|
||||
<h1 style="margin: 0;">Vinterliste</h1>
|
||||
{#if session.user}
|
||||
<div class="row">
|
||||
<span class="muted">{session.user.email}</span>
|
||||
<button onclick={onLogout}>Logg ut</button>
|
||||
</div>
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
{#if view === 'loading'}
|
||||
<p class="muted">Laster …</p>
|
||||
{:else if view === 'login'}
|
||||
<Login
|
||||
defaultEmail={defaultEmail}
|
||||
onAuthed={onAuthed}
|
||||
onWantSignup={() => (view = 'signup')}
|
||||
onWantRecovery={() => (view = 'recovery')}
|
||||
/>
|
||||
{:else if view === 'signup'}
|
||||
<Signup onAuthed={onAuthed} onWantLogin={() => (view = 'login')} />
|
||||
{:else if view === 'recovery'}
|
||||
<Recovery
|
||||
onAuthed={onAuthed}
|
||||
onWantLogin={() => (view = 'login')}
|
||||
/>
|
||||
{:else}
|
||||
<Home />
|
||||
{/if}
|
||||
</main>
|
||||
119
frontend/src/components/ActivityForm.svelte
Normal file
119
frontend/src/components/ActivityForm.svelte
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
<script lang="ts">
|
||||
import { api } from '../lib/api';
|
||||
import { session } from '../lib/session.svelte';
|
||||
import {
|
||||
encryptPayload, bytesToBase64,
|
||||
type PrivatePayload,
|
||||
} from '../lib/crypto';
|
||||
import { privateTagIndex } from '../lib/tagIndex';
|
||||
import TagInput from './TagInput.svelte';
|
||||
import type { Activity, Visibility, CreateActivityRequest } from '../../../shared/types';
|
||||
|
||||
interface Props {
|
||||
onCreated: (a: Activity) => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
let { onCreated, onCancel }: Props = $props();
|
||||
|
||||
let visibility: Visibility = $state('private');
|
||||
let title = $state('');
|
||||
let tags: string[] = $state([]);
|
||||
let locLabel = $state('');
|
||||
let scheduled = $state(''); // <input type="datetime-local"> string
|
||||
let error: string | null = $state(null);
|
||||
let busy = $state(false);
|
||||
|
||||
function scheduledEpoch(): number | null {
|
||||
if (!scheduled) return null;
|
||||
const d = new Date(scheduled);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return Math.floor(d.getTime() / 1000);
|
||||
}
|
||||
|
||||
async function submit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
error = null;
|
||||
if (!title.trim()) {
|
||||
error = 'Tittel er påkrevd.';
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
|
||||
try {
|
||||
let body: CreateActivityRequest;
|
||||
if (visibility === 'private') {
|
||||
if (!session.dek) {
|
||||
error = 'Du må være logget inn for å lage en privat oppføring.';
|
||||
busy = false;
|
||||
return;
|
||||
}
|
||||
const payload: PrivatePayload = {
|
||||
title: title.trim(),
|
||||
tags,
|
||||
loc_label: locLabel || undefined,
|
||||
scheduled_at: scheduledEpoch() ?? undefined,
|
||||
};
|
||||
const sealed = encryptPayload(payload, session.dek);
|
||||
body = {
|
||||
visibility,
|
||||
ciphertext: bytesToBase64(sealed.ciphertext),
|
||||
nonce: bytesToBase64(sealed.nonce),
|
||||
};
|
||||
} else {
|
||||
body = {
|
||||
visibility,
|
||||
title: title.trim(),
|
||||
tags,
|
||||
loc_label: locLabel || null,
|
||||
scheduled_at: scheduledEpoch(),
|
||||
};
|
||||
}
|
||||
|
||||
const created = await api.createActivity(body);
|
||||
// Mirror private tags into the client index.
|
||||
if (visibility === 'private') await privateTagIndex.record(tags);
|
||||
onCreated(created);
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<form onsubmit={submit} class="card" aria-labelledby="new-h">
|
||||
<h2 id="new-h">Ny vinteraktivitet</h2>
|
||||
|
||||
<label for="vis">Synlighet</label>
|
||||
<select id="vis" bind:value={visibility}>
|
||||
<option value="private">Privat (ende-til-ende-kryptert)</option>
|
||||
<option value="semi">Halv-offentlig (uten navn)</option>
|
||||
<option value="public">Offentlig (med navn)</option>
|
||||
</select>
|
||||
|
||||
<label for="title">Tittel</label>
|
||||
<input id="title" type="text" bind:value={title} required />
|
||||
|
||||
<div id="tags-label" style="margin: 0.75rem 0 0.25rem; font-weight: 500;">Etiketter</div>
|
||||
<div role="group" aria-labelledby="tags-label">
|
||||
<TagInput visibility={visibility} bind:tags onChange={(t) => (tags = t)} />
|
||||
</div>
|
||||
|
||||
<label for="loc">Sted (valgfritt)</label>
|
||||
<input id="loc" type="text" bind:value={locLabel}
|
||||
placeholder="f.eks. Sognsvann, Oslo" />
|
||||
|
||||
<label for="sched">Tidspunkt (valgfritt)</label>
|
||||
<input id="sched" type="datetime-local" bind:value={scheduled} />
|
||||
|
||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||||
|
||||
<div class="row" style="margin-top: 1rem;">
|
||||
<button class="primary" type="submit" disabled={busy}>
|
||||
{busy ? 'Lagrer …' : 'Legg til'}
|
||||
</button>
|
||||
{#if onCancel}
|
||||
<button type="button" onclick={onCancel}>Avbryt</button>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
113
frontend/src/components/ActivityRow.svelte
Normal file
113
frontend/src/components/ActivityRow.svelte
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
<script lang="ts">
|
||||
import { session } from '../lib/session.svelte';
|
||||
import {
|
||||
decryptPayload, base64ToBytes,
|
||||
type PrivatePayload,
|
||||
} from '../lib/crypto';
|
||||
import { api } from '../lib/api';
|
||||
import { privateTagIndex } from '../lib/tagIndex';
|
||||
import type { Activity } from '../../../shared/types';
|
||||
|
||||
interface Props {
|
||||
activity: Activity;
|
||||
onDeleted: (id: string) => void;
|
||||
}
|
||||
let { activity, onDeleted }: Props = $props();
|
||||
|
||||
let decrypted: PrivatePayload | null = $state(null);
|
||||
let decryptError: string | null = $state(null);
|
||||
|
||||
$effect(() => {
|
||||
if (activity.visibility !== 'private') {
|
||||
decrypted = null;
|
||||
decryptError = null;
|
||||
return;
|
||||
}
|
||||
if (!session.dek) {
|
||||
decryptError = 'Lås opp med passordet ditt for å lese.';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
decrypted = decryptPayload(
|
||||
{
|
||||
ciphertext: base64ToBytes(activity.ciphertext),
|
||||
nonce: base64ToBytes(activity.nonce),
|
||||
},
|
||||
session.dek,
|
||||
);
|
||||
decryptError = null;
|
||||
} catch (e) {
|
||||
decryptError = 'Klarte ikke å dekryptere denne oppføringen.';
|
||||
}
|
||||
});
|
||||
|
||||
function formatDate(epochSeconds: number | null | undefined): string {
|
||||
if (!epochSeconds) return '';
|
||||
const d = new Date(epochSeconds * 1000);
|
||||
return d.toLocaleString('nb-NO', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
|
||||
});
|
||||
}
|
||||
|
||||
const isOwner = $derived(
|
||||
activity.visibility !== 'semi'
|
||||
? session.user?.id === activity.owner_id
|
||||
: false, // semi never serializes owner; we can't tell from the row alone
|
||||
);
|
||||
|
||||
async function del() {
|
||||
if (!confirm('Slett denne oppføringen?')) return;
|
||||
// For private, release tags from the local index before deletion.
|
||||
if (activity.visibility === 'private' && decrypted) {
|
||||
await privateTagIndex.release(decrypted.tags);
|
||||
}
|
||||
await api.deleteActivity(activity.id);
|
||||
onDeleted(activity.id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<article class="card" aria-labelledby={`act-${activity.id}-h`}>
|
||||
{#if activity.visibility === 'private'}
|
||||
{#if decrypted}
|
||||
<h3 id={`act-${activity.id}-h`} style="display: flex; align-items: center;">
|
||||
{decrypted.title}
|
||||
<span class="vis-badge private">Privat</span>
|
||||
</h3>
|
||||
{#if decrypted.tags.length}
|
||||
<div>
|
||||
{#each decrypted.tags as t}<span class="tag private">{t}</span>{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if decrypted.loc_label}<p class="muted">📍 {decrypted.loc_label}</p>{/if}
|
||||
{#if decrypted.scheduled_at}<p class="muted">🕒 {formatDate(decrypted.scheduled_at)}</p>{/if}
|
||||
{:else if decryptError}
|
||||
<p class="error">{decryptError}</p>
|
||||
{:else}
|
||||
<p class="muted">Dekrypterer …</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<h3 id={`act-${activity.id}-h`} style="display: flex; align-items: center;">
|
||||
{activity.title}
|
||||
<span class="vis-badge {activity.visibility}">
|
||||
{activity.visibility === 'semi' ? 'Anonym' : 'Offentlig'}
|
||||
</span>
|
||||
</h3>
|
||||
{#if activity.tags.length}
|
||||
<div>
|
||||
{#each activity.tags as t}<span class="tag">{t}</span>{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if activity.loc_label}<p class="muted">📍 {activity.loc_label}</p>{/if}
|
||||
{#if activity.scheduled_at}<p class="muted">🕒 {formatDate(activity.scheduled_at)}</p>{/if}
|
||||
{#if activity.visibility === 'public'}
|
||||
<p class="muted" style="font-size: 0.8rem;">Lagt til av bruker {activity.owner_id.slice(0, 8)}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if isOwner || activity.visibility === 'private'}
|
||||
<div class="row" style="margin-top: 0.5rem;">
|
||||
<button class="danger" type="button" onclick={del}>Slett</button>
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
87
frontend/src/components/Home.svelte
Normal file
87
frontend/src/components/Home.svelte
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '../lib/api';
|
||||
import { session } from '../lib/session.svelte';
|
||||
import ActivityForm from './ActivityForm.svelte';
|
||||
import ActivityRow from './ActivityRow.svelte';
|
||||
import type { Activity } from '../../../shared/types';
|
||||
|
||||
let activities: Activity[] = $state([]);
|
||||
let loading = $state(true);
|
||||
let showForm = $state(false);
|
||||
let error: string | null = $state(null);
|
||||
|
||||
onMount(load);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
try {
|
||||
activities = await api.listActivities();
|
||||
} catch (e) {
|
||||
error = 'Kunne ikke laste oppføringer.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onCreated(a: Activity) {
|
||||
activities = [a, ...activities];
|
||||
showForm = false;
|
||||
}
|
||||
|
||||
function onDeleted(id: string) {
|
||||
activities = activities.filter((a) => a.id !== id);
|
||||
}
|
||||
|
||||
// Split into the three sections defined in the spec — "mine privat" first,
|
||||
// then anonyme, then offentlige.
|
||||
const myPrivate = $derived(activities.filter((a) => a.visibility === 'private'));
|
||||
const semi = $derived(activities.filter((a) => a.visibility === 'semi'));
|
||||
const pub = $derived(activities.filter((a) => a.visibility === 'public'));
|
||||
</script>
|
||||
|
||||
<section aria-label="Aktiviteter">
|
||||
<div class="row" style="justify-content: space-between; margin-bottom: 1rem;">
|
||||
<p class="muted" style="margin: 0;">
|
||||
Velkommen, {session.user?.email}. Her er aktivitetene dine for vinteren.
|
||||
</p>
|
||||
{#if !showForm}
|
||||
<button class="primary" onclick={() => (showForm = true)}>Ny aktivitet</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showForm}
|
||||
<ActivityForm onCreated={onCreated} onCancel={() => (showForm = false)} />
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<p class="muted">Laster …</p>
|
||||
{:else if error}
|
||||
<p class="error">{error}</p>
|
||||
{:else}
|
||||
{#if myPrivate.length}
|
||||
<h2>Dine private</h2>
|
||||
{#each myPrivate as a (a.id)}
|
||||
<ActivityRow activity={a} onDeleted={onDeleted} />
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if semi.length}
|
||||
<h2>Anonyme</h2>
|
||||
{#each semi as a (a.id)}
|
||||
<ActivityRow activity={a} onDeleted={onDeleted} />
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if pub.length}
|
||||
<h2>Offentlige</h2>
|
||||
{#each pub as a (a.id)}
|
||||
<ActivityRow activity={a} onDeleted={onDeleted} />
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if !myPrivate.length && !semi.length && !pub.length}
|
||||
<p class="muted">Ingen aktiviteter ennå. Trykk «Ny aktivitet» for å starte.</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
71
frontend/src/components/Login.svelte
Normal file
71
frontend/src/components/Login.svelte
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<script lang="ts">
|
||||
import { login } from '../lib/auth';
|
||||
import { ApiError } from '../lib/api';
|
||||
|
||||
interface Props {
|
||||
defaultEmail?: string;
|
||||
onAuthed: () => void;
|
||||
onWantSignup: () => void;
|
||||
onWantRecovery: () => void;
|
||||
}
|
||||
let { defaultEmail = '', onAuthed, onWantSignup, onWantRecovery }: Props = $props();
|
||||
|
||||
// `defaultEmail` is intentionally used only for the initial value. The warning
|
||||
// about referencing a non-state value in `$state(...)` is the right shape of
|
||||
// warning, but the wrong target here — we don't want reactive bidirectional
|
||||
// tracking of the prop.
|
||||
// svelte-ignore state_referenced_locally
|
||||
let email = $state(defaultEmail);
|
||||
let password = $state('');
|
||||
let error: string | null = $state(null);
|
||||
let busy = $state(false);
|
||||
|
||||
async function submit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
error = null;
|
||||
busy = true;
|
||||
try {
|
||||
await login(email.trim().toLowerCase(), password);
|
||||
password = '';
|
||||
onAuthed();
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
error = 'Feil epost eller passord.';
|
||||
} else if (err instanceof ApiError && err.status === 404) {
|
||||
error = 'Ingen bruker med den eposten.';
|
||||
} else if (err instanceof Error && err.message.includes('decrypt')) {
|
||||
// libsodium throws a generic decryption error — that means the password
|
||||
// derived a KEK that doesn't unwrap. We landed here despite the server
|
||||
// accepting the verifier, which shouldn't happen unless the DB is
|
||||
// tampered with. Treat as wrong password.
|
||||
error = 'Klarte ikke å låse opp dataene dine.';
|
||||
} else {
|
||||
error = 'Innlogging feilet.';
|
||||
}
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<form onsubmit={submit} class="card" aria-labelledby="login-h">
|
||||
<h2 id="login-h">Logg inn</h2>
|
||||
|
||||
<label for="login-email">Epost</label>
|
||||
<input id="login-email" type="email" autocomplete="username"
|
||||
bind:value={email} required />
|
||||
|
||||
<label for="login-password">Passord</label>
|
||||
<input id="login-password" type="password" autocomplete="current-password"
|
||||
bind:value={password} required />
|
||||
|
||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||||
|
||||
<div class="row" style="margin-top: 1rem;">
|
||||
<button class="primary" type="submit" disabled={busy}>
|
||||
{busy ? 'Logger inn …' : 'Logg inn'}
|
||||
</button>
|
||||
<button type="button" onclick={onWantSignup}>Opprett konto</button>
|
||||
<button type="button" onclick={onWantRecovery}>Bruk gjenopprettingskode</button>
|
||||
</div>
|
||||
</form>
|
||||
75
frontend/src/components/Recovery.svelte
Normal file
75
frontend/src/components/Recovery.svelte
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<script lang="ts">
|
||||
import { recover } from '../lib/auth';
|
||||
import { ApiError } from '../lib/api';
|
||||
|
||||
interface Props {
|
||||
onAuthed: () => void;
|
||||
onWantLogin: () => void;
|
||||
}
|
||||
let { onAuthed, onWantLogin }: Props = $props();
|
||||
|
||||
let email = $state('');
|
||||
let code = $state('');
|
||||
let newPassword = $state('');
|
||||
let confirm = $state('');
|
||||
let error: string | null = $state(null);
|
||||
let busy = $state(false);
|
||||
|
||||
async function submit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
error = null;
|
||||
if (newPassword.length < 12) {
|
||||
error = 'Det nye passordet må være minst 12 tegn.';
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirm) {
|
||||
error = 'Passordene må være like.';
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
try {
|
||||
await recover(email.trim().toLowerCase(), code, newPassword);
|
||||
onAuthed();
|
||||
} catch (err) {
|
||||
// libsodium throws a generic decrypt error if the code is wrong.
|
||||
if (err instanceof Error && err.message.toLowerCase().includes('decrypt')) {
|
||||
error = 'Feil gjenopprettingskode.';
|
||||
} else if (err instanceof ApiError && err.status === 404) {
|
||||
error = 'Ingen bruker med den eposten.';
|
||||
} else {
|
||||
error = 'Gjenoppretting feilet.';
|
||||
}
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<form onsubmit={submit} class="card" aria-labelledby="rec-h">
|
||||
<h2 id="rec-h">Gjenopprett konto</h2>
|
||||
<p class="muted">Skriv inn gjenopprettingskoden du fikk da du opprettet kontoen, og velg et nytt passord.</p>
|
||||
|
||||
<label for="rec-email">Epost</label>
|
||||
<input id="rec-email" type="email" autocomplete="username" bind:value={email} required />
|
||||
|
||||
<label for="rec-code">Gjenopprettingskode</label>
|
||||
<input id="rec-code" type="text" autocomplete="off" spellcheck="false"
|
||||
bind:value={code} required />
|
||||
|
||||
<label for="rec-pw">Nytt passord</label>
|
||||
<input id="rec-pw" type="password" autocomplete="new-password" minlength="12"
|
||||
bind:value={newPassword} required />
|
||||
|
||||
<label for="rec-confirm">Bekreft nytt passord</label>
|
||||
<input id="rec-confirm" type="password" autocomplete="new-password" minlength="12"
|
||||
bind:value={confirm} required />
|
||||
|
||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||||
|
||||
<div class="row" style="margin-top: 1rem;">
|
||||
<button class="primary" type="submit" disabled={busy}>
|
||||
{busy ? 'Gjenoppretter …' : 'Sett nytt passord'}
|
||||
</button>
|
||||
<button type="button" onclick={onWantLogin}>Tilbake til innlogging</button>
|
||||
</div>
|
||||
</form>
|
||||
83
frontend/src/components/Signup.svelte
Normal file
83
frontend/src/components/Signup.svelte
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
<script lang="ts">
|
||||
import { signup } from '../lib/auth';
|
||||
import { ApiError } from '../lib/api';
|
||||
|
||||
interface Props {
|
||||
onAuthed: () => void;
|
||||
onWantLogin: () => void;
|
||||
}
|
||||
let { onAuthed, onWantLogin }: Props = $props();
|
||||
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let confirm = $state('');
|
||||
let error: string | null = $state(null);
|
||||
let busy = $state(false);
|
||||
let recoveryCode: string | null = $state(null);
|
||||
|
||||
async function submit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
error = null;
|
||||
if (password.length < 12) {
|
||||
error = 'Passordet må være minst 12 tegn. (Det er den eneste måten å hente dataene dine på — uten det trenger du gjenopprettingskoden.)';
|
||||
return;
|
||||
}
|
||||
if (password !== confirm) {
|
||||
error = 'Passordene må være like.';
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
try {
|
||||
const { recoveryCode: code } = await signup(email.trim().toLowerCase(), password);
|
||||
recoveryCode = code;
|
||||
password = '';
|
||||
confirm = '';
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 409) error = 'Den eposten er allerede i bruk.';
|
||||
else error = 'Kontooppretting feilet.';
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if recoveryCode}
|
||||
<section class="card" aria-labelledby="rc-h">
|
||||
<h2 id="rc-h">Skriv ned gjenopprettingskoden din</h2>
|
||||
<p>
|
||||
Denne koden lar deg låse opp dataene dine om du glemmer passordet. Vi
|
||||
lagrer den <strong>ikke</strong> på serveren — skriver du ikke ned
|
||||
koden nå, vil dataene være borte for godt om passordet forsvinner.
|
||||
</p>
|
||||
<code class="recovery-code" aria-label="Gjenopprettingskode">{recoveryCode}</code>
|
||||
<button class="primary" onclick={onAuthed}>Jeg har skrevet den ned</button>
|
||||
</section>
|
||||
{:else}
|
||||
<form onsubmit={submit} class="card" aria-labelledby="signup-h">
|
||||
<h2 id="signup-h">Opprett konto</h2>
|
||||
<p class="muted">
|
||||
Vi krypterer alt du markerer som <em>privat</em> i nettleseren din. Serveren
|
||||
ser aldri passordet ditt eller innholdet i private oppføringer.
|
||||
</p>
|
||||
|
||||
<label for="signup-email">Epost</label>
|
||||
<input id="signup-email" type="email" autocomplete="username" bind:value={email} required />
|
||||
|
||||
<label for="signup-password">Passord (minst 12 tegn)</label>
|
||||
<input id="signup-password" type="password" autocomplete="new-password"
|
||||
minlength="12" bind:value={password} required />
|
||||
|
||||
<label for="signup-confirm">Bekreft passord</label>
|
||||
<input id="signup-confirm" type="password" autocomplete="new-password"
|
||||
minlength="12" bind:value={confirm} required />
|
||||
|
||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||||
|
||||
<div class="row" style="margin-top: 1rem;">
|
||||
<button class="primary" type="submit" disabled={busy}>
|
||||
{busy ? 'Oppretter …' : 'Opprett konto'}
|
||||
</button>
|
||||
<button type="button" onclick={onWantLogin}>Jeg har allerede en konto</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
122
frontend/src/components/TagInput.svelte
Normal file
122
frontend/src/components/TagInput.svelte
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Tag input with autocomplete. Suggestions come from two sources:
|
||||
* - public/semi tags (the server's `tags` table) via `/api/tags?q=…`
|
||||
* - the user's private tags via IndexedDB
|
||||
*
|
||||
* For a private activity we only show the IndexedDB source (private tags
|
||||
* never round-trip through the server). For semi/public, we show server
|
||||
* suggestions; we still show the user's private matches too, labelled, so
|
||||
* they can keep a consistent vocabulary across visibility — but only the
|
||||
* private ones the user is explicitly accepting will be visible to others.
|
||||
*
|
||||
* Decision (documented in CLAUDE.md): the two sources are merged but
|
||||
* clearly labelled in the dropdown so the user knows where each match
|
||||
* comes from. A "private-only" suggestion offered for a public activity
|
||||
* becomes public the moment the user accepts it — the label warns them.
|
||||
*/
|
||||
import { api } from '../lib/api';
|
||||
import { privateTagIndex } from '../lib/tagIndex';
|
||||
import type { Visibility } from '../../../shared/types';
|
||||
|
||||
interface Props {
|
||||
visibility: Visibility;
|
||||
tags: string[];
|
||||
onChange: (tags: string[]) => void;
|
||||
}
|
||||
let { visibility, tags = $bindable(), onChange }: Props = $props();
|
||||
|
||||
let input = $state('');
|
||||
let serverHits: { name: string; usage_count: number }[] = $state([]);
|
||||
let privateHits: { name: string; count: number }[] = $state([]);
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function add(name: string) {
|
||||
const n = name.trim().toLowerCase();
|
||||
if (!n) return;
|
||||
if (tags.includes(n)) return;
|
||||
tags = [...tags, n];
|
||||
onChange(tags);
|
||||
input = '';
|
||||
serverHits = [];
|
||||
privateHits = [];
|
||||
}
|
||||
|
||||
function remove(name: string) {
|
||||
tags = tags.filter((t) => t !== name);
|
||||
onChange(tags);
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault();
|
||||
add(input);
|
||||
} else if (e.key === 'Backspace' && !input && tags.length) {
|
||||
remove(tags[tags.length - 1]!);
|
||||
}
|
||||
}
|
||||
|
||||
function onType(e: Event) {
|
||||
input = (e.target as HTMLInputElement).value;
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(query, 120);
|
||||
}
|
||||
|
||||
async function query() {
|
||||
const q = input.trim();
|
||||
if (!q) { serverHits = []; privateHits = []; return; }
|
||||
|
||||
privateHits = await privateTagIndex.suggest(q, 10).catch(() => []);
|
||||
|
||||
if (visibility !== 'private') {
|
||||
serverHits = await api.tagSuggestions(q, 10).catch(() => []);
|
||||
} else {
|
||||
serverHits = [];
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="row" role="group" aria-label="Etiketter">
|
||||
{#each tags as t (t)}
|
||||
<span class="tag">
|
||||
{t}
|
||||
<button type="button" aria-label="Fjern {t}"
|
||||
style="margin-left: 0.3rem; padding: 0 0.3rem;"
|
||||
onclick={() => remove(t)}>×</button>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
oninput={onType}
|
||||
onkeydown={onKey}
|
||||
placeholder="Legg til etikett, trykk Enter"
|
||||
aria-label="Ny etikett"
|
||||
/>
|
||||
|
||||
{#if serverHits.length || privateHits.length}
|
||||
<div class="card" style="margin-top: 0.25rem;" role="listbox" aria-label="Forslag">
|
||||
{#each serverHits as s (s.name)}
|
||||
<button type="button"
|
||||
style="display: block; width: 100%; text-align: left; border: none; background: transparent; padding: 0.25rem 0;"
|
||||
onclick={() => add(s.name)}>
|
||||
{s.name}
|
||||
<span class="muted">offentlig · {s.usage_count}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{#each privateHits as p (p.name)}
|
||||
{#if !serverHits.find((s) => s.name === p.name)}
|
||||
<button type="button"
|
||||
style="display: block; width: 100%; text-align: left; border: none; background: transparent; padding: 0.25rem 0;"
|
||||
onclick={() => add(p.name)}>
|
||||
{p.name}
|
||||
<span class="muted">
|
||||
{visibility === 'private' ? 'privat' : 'kun din'} · {p.count}
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
72
frontend/src/lib/api.ts
Normal file
72
frontend/src/lib/api.ts
Normal file
|
|
@ -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<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
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<MeResponse>('/auth/signup', { method: 'POST', body: JSON.stringify(body) }),
|
||||
challenge: (email: string) =>
|
||||
http<ChallengeResponse>('/auth/challenge', { method: 'POST', body: JSON.stringify({ email }) }),
|
||||
login: (body: LoginRequest) =>
|
||||
http<MeResponse>('/auth/login', { method: 'POST', body: JSON.stringify(body) }),
|
||||
logout: () => http<{ ok: true }>('/auth/logout', { method: 'POST' }),
|
||||
me: () => http<MeResponse>('/auth/me'),
|
||||
passwordChange: (body: PasswordChangeRequest) =>
|
||||
http<{ ok: true }>('/auth/password', { method: 'POST', body: JSON.stringify(body) }),
|
||||
recoveryChallenge: (email: string) =>
|
||||
http<RecoveryChallengeResponse>('/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<Activity[]>('/activities'),
|
||||
createActivity: (body: CreateActivityRequest) =>
|
||||
http<Activity>('/activities', { method: 'POST', body: JSON.stringify(body) }),
|
||||
updateActivity: (id: string, body: UpdateActivityRequest) =>
|
||||
http<Activity>(`/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<TagSuggestion[]>(`/tags?q=${encodeURIComponent(q)}&limit=${limit}`),
|
||||
};
|
||||
186
frontend/src/lib/auth.ts
Normal file
186
frontend/src/lib/auth.ts
Normal file
|
|
@ -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<SignupResult> {
|
||||
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<MeResponse> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
4
frontend/src/lib/crypto.ts
Normal file
4
frontend/src/lib/crypto.ts
Normal file
|
|
@ -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';
|
||||
30
frontend/src/lib/session.svelte.ts
Normal file
30
frontend/src/lib/session.svelte.ts
Normal file
|
|
@ -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<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;
|
||||
}
|
||||
121
frontend/src/lib/tagIndex.ts
Normal file
121
frontend/src/lib/tagIndex.ts
Normal file
|
|
@ -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<IDBDatabase> {
|
||||
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<T>(
|
||||
mode: IDBTransactionMode,
|
||||
fn: (store: IDBObjectStore) => Promise<T> | T,
|
||||
): Promise<T> {
|
||||
const db = await openDb();
|
||||
return await new Promise<T>((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<void> {
|
||||
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<void> {
|
||||
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<TagRow[]> {
|
||||
const prefix = normalise(q);
|
||||
return withStore('readonly', (store) => new Promise<TagRow[]>((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<void> {
|
||||
await withStore('readwrite', (store) => {
|
||||
store.clear();
|
||||
});
|
||||
},
|
||||
};
|
||||
8
frontend/src/main.ts
Normal file
8
frontend/src/main.ts
Normal file
|
|
@ -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 });
|
||||
158
frontend/src/styles.css
Normal file
158
frontend/src/styles.css
Normal file
|
|
@ -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; }
|
||||
}
|
||||
5
frontend/svelte.config.js
Normal file
5
frontend/svelte.config.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
export default {
|
||||
preprocess: vitePreprocess(),
|
||||
};
|
||||
19
frontend/tsconfig.json
Normal file
19
frontend/tsconfig.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
40
frontend/vite.config.ts
Normal file
40
frontend/vite.config.ts
Normal file
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
29
package.json
Normal file
29
package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
264
server/activities.ts
Normal file
264
server/activities.ts
Normal file
|
|
@ -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<Visibility>(['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 });
|
||||
});
|
||||
286
server/auth.ts
Normal file
286
server/auth.ts
Normal file
|
|
@ -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<T extends object>(obj: T, keys: readonly (keyof T)[]): string | null {
|
||||
const rec = obj as unknown as Record<string, unknown>;
|
||||
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 });
|
||||
});
|
||||
99
server/db.ts
Normal file
99
server/db.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
44
server/index.ts
Normal file
44
server/index.ts
Normal file
|
|
@ -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}`);
|
||||
96
server/session.ts
Normal file
96
server/session.ts
Normal file
|
|
@ -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());
|
||||
}
|
||||
120
server/tags.ts
Normal file
120
server/tags.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
233
shared/crypto.ts
Normal file
233
shared/crypto.ts
Normal file
|
|
@ -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<void> | null = null;
|
||||
|
||||
/** Must be awaited once before calling anything else. Cached after first call. */
|
||||
export function ready(): Promise<void> {
|
||||
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);
|
||||
}
|
||||
37
shared/sodium.ts
Normal file
37
shared/sodium.ts
Normal file
|
|
@ -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;
|
||||
123
shared/types.ts
Normal file
123
shared/types.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
246
tests/crypto.test.ts
Normal file
246
tests/crypto.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
177
winter-list-claude-code-prompt.md
Normal file
177
winter-list-claude-code-prompt.md
Normal file
|
|
@ -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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue