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:
Ole-Morten Duesund 2026-05-25 12:27:14 +02:00
commit 47963c9225
39 changed files with 4007 additions and 0 deletions

67
Containerfile Normal file
View 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"]