feat(ops): emergency password-reset CLI + deployment docs

New CLI: bun run reset-password <email>

Two modes selected interactively:

- Recovery mode: if you still have the user's recovery code, unwrap
  the existing DEK with it and re-wrap with the new password. No data
  loss; the recovery code stays valid (mirrors /auth/recovery-complete).
- Nuke mode: if both password AND recovery code are gone, generate a
  fresh DEK + new recovery code (printed once), and DELETE the user's
  private activities — their ciphertext is permanently unrecoverable.
  Public/semi/friends rows and engagement (hearts/bookmarks/done) are
  preserved.

Both modes invalidate the user's sessions.

Password length matches the signup/recovery rule (12 chars min).
Wrong-recovery-code path aborts before any DB writes. Hand-rolled
line reader sidesteps a Bun quirk where node:readline only delivers
the first answer when stdin is piped.

Also expand README's "Deployment" section: container snippet stays,
plus new subsections for env vars, TLS termination (with a Caddyfile
example), backup/restore via sqlite3 .backup, the /api/health
healthcheck, upgrade flow, and a walkthrough of the reset CLI.
This commit is contained in:
Ole-Morten Duesund 2026-05-25 20:04:57 +02:00
commit ef02b3f585
3 changed files with 388 additions and 3 deletions

106
README.md
View file

@ -101,7 +101,9 @@ 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)
## Deployment
### Container (podman)
The provided `Containerfile` builds a single image that serves API + frontend
and persists the SQLite database in `/app/data` (one volume).
@ -124,7 +126,107 @@ podman run --replace --name vinterliste \
```
The container exposes `/api/health` for healthchecks and bakes the build date /
git revision into both OCI labels and `/etc/build-info`.
git revision into both OCI labels and `/etc/build-info`. Use `podman run
--replace ...` for redeploys — it's atomic and avoids the "container exists"
race.
### Environment variables
| Variable | Default | Notes |
|--------------------|------------------------|---------------------------------------------------------------|
| `PORT` | `3000` | TCP port the server listens on. |
| `NODE_ENV` | (unset) | Set to `production` to serve `frontend/dist` from the API. |
| `VINTERLISTE_DB` | `data/vinterliste.db` | Path to the SQLite file. Override for an external volume. |
| `PUBLIC_BASE_URL` | (derived from request) | Override the absolute URL used in OpenGraph `og:url` tags. |
There are no secrets to set. Auth verifiers and DEK wraps live in the SQLite
file; session tokens are generated per process and stored server-side, not
signed.
### TLS termination
The app speaks plain HTTP — terminate TLS at a reverse proxy (Caddy, nginx,
Traefik). The session cookie is marked `Secure` when the request was HTTPS
(`X-Forwarded-Proto: https`), so make sure the proxy sets that header.
Sample Caddyfile:
```caddyfile
vinterliste.example.org {
encode zstd gzip
reverse_proxy localhost:3000
}
```
Caddy auto-provisions a Let's Encrypt cert. Other proxies need the cert
configured manually.
### Backup and restore
The SQLite database is the entire app state — user accounts, DEK wraps,
activity ciphertexts, sessions, the lot. Backing it up while the server is
running is safe because of WAL mode:
```bash
# Atomic backup using SQLite's built-in copy
sqlite3 data/vinterliste.db ".backup '/path/to/backup/vinterliste-$(date +%F).db'"
# Or via the container's volume
podman exec vinterliste sqlite3 /app/data/vinterliste.db \
".backup '/app/data/backup-$(date +%F).db'"
```
Plain file copy of the `.db` works too if the server is stopped first. With WAL
files (`.db-wal`, `.db-shm`) present, copy all three or use `.backup`.
To restore: replace the file on disk and restart the server. There are no
out-of-band caches.
### Healthcheck
`GET /api/health` returns `{ ok: true, build: { revision, built_at } }` with
HTTP 200. Hook your monitoring or `HEALTHCHECK` directive at this endpoint.
### Upgrading
1. Build a new image with current `BUILD_DATE` and `GIT_REVISION` args.
2. `podman run --replace` — schema migrations are idempotent
(`CREATE TABLE IF NOT EXISTS …` and `ensureColumn(...)` add new columns
without touching existing data).
3. Verify `/api/health` returns the new `revision`.
4. The `activities` table's CHECK constraint includes all visibility values;
the `friends` visibility added later is migrated in via
`ensureActivitiesCheckIncludesFriends()` (table copy-drop-rename) on
first boot if needed. Take a backup beforehand the first time you upgrade
past a CHECK-constraint change.
### Emergency password reset (CLI)
If an admin has lost access (forgotten password, lost recovery code, etc.) and
can't recover via the UI, the server box has a CLI tool:
```bash
# Inside the container:
podman exec -it vinterliste bun run reset-password admin@example.org
# Or on the host if you're running the server directly:
bun run reset-password admin@example.org
```
It asks one question first: **do you still have this user's recovery code?**
- **Yes → recovery mode.** Behaves exactly like the in-app recovery flow:
unwraps the existing DEK with the recovery code, re-wraps it with the new
password. No data is lost. The recovery code stays valid afterwards.
- **No → nuke mode.** Generates a brand-new DEK + new recovery code and
prints the new code to stdout (write it down — it's shown once). The
user's **private activities are deleted** because their ciphertext was
encrypted with the now-unrecoverable old DEK. Public, semi, friends-only
activities, plus hearts / bookmarks / "gjort" marks, are kept.
Both modes invalidate every existing session for the user, matching the
hygiene of the in-app `/auth/recovery-complete` endpoint. The CLI requires
direct DB access — there is no network exposure of this code path.
## Registration: open, invite-only, or both