Phase 16: Petal authenticates for itself
Petal is now an OIDC client in its own right rather than trusting a header from the proxy. The Phase-0 Resolver seam was the only integration point: main.go picks the session store when Authentik is configured and the static local user otherwise, and no handler or query moved for either. internal/auth gains three pieces. session.go issues an opaque cookie token and stores only its SHA-256, so a database copy yields nothing usable; the 30-day expiry slides on every request, throttled to one write an hour, and logout deletes the row rather than just the cookie. oidc.go runs the authorization-code flow with state, nonce and PKCE, and discovers the provider lazily and on retry — an Authentik outage should block new logins without stopping Petal booting or invalidating live sessions. users.go provisions accounts from the token's claims and gates them on an allowlist that matches emails as well as subject ids, since a subject is an opaque uuid that doesn't exist until someone has already logged in once. Migration 0010 lands sessions, images and users.pair_lang together. The images table closes the capability-URL hole the Phase-0 audit flagged: a hash was previously enough to fetch anyone's picture. Rows are keyed (name, user_id) so one file can have several owners and deduplication survives; a stranger gets 404 rather than 403, the cache header drops to private, and files already on disk are claimed at startup or every image already pasted into a document would 404. On the frontend a single 401 interceptor feeds a warm bilingual sign-in overlay, drawn over a still-visible editor because nothing has been taken away. Behind it is the part that matters: a save that comes back 401 stashes its body to localStorage before anything else and stops the auto-save loop, and reopening that document after signing in merges the draft back and saves it. An expired session must not cost writing. Writing the round-trip test against a stub identity provider turned up a real bug: the one-shot state/nonce/PKCE cookies were cleared in a defer, which runs after the redirect has written the response header, so the clearing Set-Cookie was silently dropped and they lingered for their full ten minutes. Also swaps the emoji favicon for a drawn sakura, which renders as Petal's own rose palette everywhere instead of whatever each platform's font decides, and doubles as the app tile in Authentik. Migration 0010 verified against a VACUUM INTO copy of the live millenia database: counts intact, FTS still matching, the one existing image claimed. Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
This commit is contained in:
+15
-11
@@ -162,20 +162,23 @@ Petal hosted on the parodia.dev VPS; vLLM stays on millenia over headscale. Auth
|
||||
- [x] **Interim edge gate** (not in the original plan; added once the instance was live). Petal authenticates nobody yet — `StaticResolver` hands every request the same `local` user — so on a public host the whole API was open to read/write and image upload. Traefik basic auth holds the door until Phase 16, with `/api/health` exempt on its own higher-priority router. Deleted when OIDC lands.
|
||||
- [x] Acceptance — verified over public HTTPS **with the LLM link down** (it genuinely is): Hunspell dictionaries 200, gloss + word lookup (incl. phonetic) 200, doc create/save, FTS search on 春天, md + docx export, vocab capture/list, read-aloud EN + zh (real mp3 via ffmpeg, cache hit on repeat, 404 for an unconfigured language so the client falls back). `POST /check` → the warm 502 that renders as 小助手在休息. `/api/health` public; HTTP 301 → HTTPS with a valid cert.
|
||||
|
||||
### Phase 16 — Auth (in-app OIDC) + image-store ownership
|
||||
Option B ratified. `go-oidc` + `x/oauth2`; config fields already exist. The `Resolver` seam from Phase 0 is the only integration point.
|
||||
- [ ] OIDC login flow: `/auth/login` → Authentik → `/auth/callback` (state/CSRF checked) → provision user (upsert from `sub`/email/name) → session
|
||||
- [ ] `sessions` table migration (id, user_id, expires_at, created_at, user_agent); opaque cookie (`HttpOnly`, `SameSite=Lax`, `Secure` when https); **30-day sliding expiry**; `/auth/logout` revokes server-side
|
||||
- [ ] Allowlist: `PETAL_ALLOWED_SUBS` env (comma-separated); rejected valid logins get a warm bilingual page, not an error dump
|
||||
- [ ] `SessionResolver` replaces `StaticResolver` in `main.go` (keep `StaticResolver` for dev via env flag)
|
||||
- [ ] Frontend 401 interceptor in `api/client.ts`: halt auto-save, preserve the draft (localStorage keyed by doc id), warm bilingual "请重新登录 · Please sign in again" overlay, resume cleanly after re-login — **a 401 mid-draft must never lose writing**
|
||||
- [ ] **Image store ownership** (same phase, per OPEN #5): `images` table migration (hash, user_id, content_type, size, created_at); fetch joins on caller; dedup preserved (one file, N rows); last-row delete removes the file; backfill existing images to the current user
|
||||
- [ ] `users.pair_lang` column (default `'zh'`) in this phase's provisioning migration — read by Phase 19+, cheap to add now
|
||||
- [ ] Isolation tests: sessions (expiry, revocation, cross-user), images, allowlist paths
|
||||
### Phase 16 — Auth (in-app OIDC) + image-store ownership — code complete (2026-07-27), not yet live
|
||||
Option B ratified. `go-oidc` + `x/oauth2`; config fields already existed. The `Resolver` seam from Phase 0 was the only integration point — no handler or query moved.
|
||||
- [x] OIDC login flow (`internal/auth/oidc.go`): `/auth/login` → Authentik → `/auth/callback` → provision → session. **state** (cookie vs param, constant-time) + **nonce** (ID-token claim vs cookie, so a token minted for another attempt is refused) + **PKCE S256**. Discovery is **lazy and retried**: an Authentik outage blocks new logins but leaves every existing session working, since those need only Petal's own DB — the app must not fail to boot because the IdP is briefly down.
|
||||
- [x] `sessions` table (migration `0010`); opaque token in `petal_session` (`HttpOnly`, `SameSite=Lax`, `Secure` only when `BASE_URL` is https — flagging it on a plain-http dev server makes the browser silently drop it). **The table stores only the token's SHA-256**, so a DB copy yields no usable session. **30-day sliding expiry**, all time math in SQLite `datetime()` (canonical UTC), the extension throttled to one write per hour per session. `/auth/logout` deletes the row, not just the cookie; `RevokeAll` signs one writer out everywhere; expired rows pruned at startup.
|
||||
- [x] Allowlist: `PETAL_ALLOWED_SUBS`, comma-separated. **Matches a subject id *or* an email**, case-insensitively — a deliberate widening of the plan: a subject is an opaque uuid that doesn't exist until first login, so a subject-only list means letting someone in, reading a log line, and editing config. Empty = anyone Authentik authenticates. A rejected valid login gets the warm bilingual "这个 Petal 不是给你写的 · This Petal isn't yours to write in" page and **no provisioned account**.
|
||||
- [x] `main.go` picks the resolver from config: `SessionStore` (which is itself the `Resolver`) when `AUTHENTIK_URL`/id/secret are all set, `StaticResolver(local)` otherwise — so local dev and every pre-auth deployment behave exactly as before. `/auth/*` mounts on the root router, outside `/api`.
|
||||
- [x] Frontend: one 401 interceptor in `api/client.ts` (`UnauthorizedError` + an `onUnauthorized` hook covering `req`, the image upload and the SSE chat stream) → `useSession` → `SignInOverlay` (warm bilingual, editor still visible behind it — nothing has been taken away). **Draft rescue** (`lib/drafts.ts`): a save that 401s stashes its body in `localStorage` keyed by doc id *before* anything else, auto-save then stops (further attempts would only 401 and re-stash), and opening that doc after re-login merges it back and schedules a save. StatusBar says 已保存在本机 · Kept on this device — where the writing is, not what failed. Sidebar footer gains the account + 退出 · Sign out (hidden when the id is still `local`).
|
||||
- [x] **Image store ownership** (OPEN #5): `images` table (`PRIMARY KEY (name, user_id)`) — **one row per owner, not one owner per file**, so the same picture uploaded by two people is still stored once and dedup survives; the file is deleted only with its last row. Fetch joins on the caller and answers **404, not 403** (whether a hash exists is itself information). `Cache-Control` went `public` → `private` — a shared cache must never hand one writer's image to another. Files already on disk are claimed for the local user at startup (idempotent), because a row is now what makes an image fetchable and every picture already pasted into a document would otherwise 404.
|
||||
- [x] `users.pair_lang` (default `'zh'`) added in the same migration; login refreshes email/display name but never touches it — it's Petal's setting, not the IdP's.
|
||||
- [x] Tests: `session_test.go` (lifecycle, expiry + prune, sliding renewal, hash-not-token storage, cross-user non-interchangeability, `RevokeAll`, FK cascade, middleware wiring, user upsert, allowlist matrix) and `oidc_test.go` — **the whole round trip against a stub IdP** (RSA-signed ID tokens, real discovery + JWKS): PKCE challenge present, verifier reaches the token endpoint, state mismatch → 400, **replayed nonce from another attempt → 400**, allowlist refusal → the bilingual 403 with no account created, provider error → 403, already-signed-in login short-circuits home. `images/handler_test.go` gained two-user isolation, cross-user dedup + last-owner file deletion, and backfill idempotency. `web/src/lib/drafts.test.ts` covers the rescue (round-trip, per-doc, take-consumes, expiry, corrupted entry, storage that throws).
|
||||
- **A real bug the round-trip test caught:** the one-shot state/nonce/PKCE cookies were cleared with `defer o.clearTemp(w)` — which runs *after* the redirect has written the response header, so the `Set-Cookie` was silently dropped and they lingered in the browser for their full 10 minutes. Now cleared up front.
|
||||
- Verified: go build/vet/test, tsc, vite build, vitest 76/76 all clean. Migration `0010` applied to **a copy of the live millenia DB** (`VACUUM INTO` snapshot): 10 migrations apply, documents/vocab/versions counts unchanged, FTS search still returns hits, the one existing image claimed, `pair_lang` defaulted. Live smoke against the binary on a throwaway DB: auth-off → `/api/me` is `local` and everything 200s; auth-on → `/api/docs` and `/api/me` 401, `/api/health` still public, `/auth/login` with an unreachable IdP renders the warm 503 page, `/auth/logout` redirects home; a hand-inserted session row → 200 with the cookie, 401 without it, with a bad one, and once expired.
|
||||
- **Not done: this is not live yet.** Registering the provider in Authentik and switching `petal.parodia.dev` over is an outward-facing change on a shared public host, left for the user's go-ahead. `deploy/README.md` §4 is the runbook (provider fields, redirect URI, env, verification, how to force a sign-out). Until it's configured the Traefik basic-auth gate stays — Petal falls back to the single `local` user, so removing the gate before configuring OIDC would open the API to anyone.
|
||||
|
||||
### Phase 17 — Migrate the `local` user
|
||||
Script, app stopped, backup first (OPEN #4). Depends on: she logs in once so her OIDC `sub` exists.
|
||||
- [ ] `scripts/migrate_local_user.*`: single transaction, `PRAGMA foreign_keys=OFF`, re-point `documents`/`tags`/`vocab_words` (versions/suggestions follow parents), delete the empty provisioned row, verify row counts before commit; refuses to run if the app is up or the target has data
|
||||
- [ ] `scripts/migrate_local_user.*`: single transaction, `PRAGMA foreign_keys=OFF`, re-point `documents`/`tags`/`vocab_words` **and `images`** (versions/suggestions follow parents; `images` is new in Phase 16 and carries `user_id` directly — miss it and every pasted picture 404s), delete the empty provisioned row, verify row counts before commit; refuses to run if the app is up or the target has data
|
||||
- [ ] Runbook documented in the script header; dry-run mode
|
||||
|
||||
### Phase 18 — Per-user, per-language client state
|
||||
@@ -228,6 +231,7 @@ Each item independent and small; order within is free (SUGGESTIONS §5–§6).
|
||||
- [x] **Phase 14 — companion warmth + bedtime nag + night mode**: more encouraging phrases, a gentle "go to bed" nudge after 11pm, and a calm dark theme + falling stars at night. ✅ (see Phase 14 above)
|
||||
|
||||
## Session log
|
||||
- 2026-07-27: **Phase 16 built — Petal authenticates for itself** (user: "let's continue the build plan"; box access granted mid-session). New `internal/auth` surface on top of the Phase-0 `Resolver` seam: `session.go` (opaque cookie, **SHA-256-at-rest**, 30-day sliding expiry throttled to one write an hour, revoke/revoke-all/prune), `oidc.go` (login/callback/logout with state + nonce + PKCE, **lazy retried discovery** so an IdP outage can't stop Petal booting or invalidate live sessions), `users.go` (provisioning upsert keyed on `sub`, `/api/me`, allowlist). Migration `0010` lands `sessions`, `images` and `users.pair_lang` together. `main.go` picks the resolver from config, so a laptop build is unchanged. **Image ownership** closes the capability-URL hole flagged in the Phase-0 audit — one row per owner keeps dedup, a stranger gets 404 not 403, `Cache-Control` dropped to `private`, and pre-existing files are claimed at startup or they'd all 404. Frontend: a single 401 interceptor, a warm bilingual sign-in overlay over a still-visible editor, and a **draft rescue** to localStorage so an expired session can't cost writing — the auto-save stashes the body it couldn't send and reclaims it after re-login. **Three deliberate deviations from the plan**, all noted above: the allowlist matches emails as well as subject ids (a subject doesn't exist until first login, so a subject-only list is unusable in advance); `SESSION_SECRET` was dropped from config rather than left unused (nothing signs anything — sessions are opaque and server-side); and image rows are keyed `(name, user_id)` rather than owned singly, which is what preserves deduplication. **A real bug caught by writing the round-trip test rather than by reading the code**: the one-shot state/nonce/PKCE cookies were cleared in a `defer`, i.e. after the redirect had already written the header, so the clearing `Set-Cookie` was silently dropped. Verified: full go/tsc/vite/vitest suites, migration `0010` against a `VACUUM INTO` copy of the live millenia DB (counts intact, FTS still matching, image claimed), and a live smoke against the binary in both auth-off and auth-on modes including a hand-inserted session (valid → 200; absent/forged/expired → 401). **Deliberately not deployed**: registering the provider in Authentik and cutting `petal.parodia.dev` over is outward-facing on a shared public host and waits on the user's go-ahead; `deploy/README.md` §4 is the runbook, and the Traefik basic-auth gate stays until it's done.
|
||||
- 2026-07-27: **Phase 15 finished off on the two boxes** (user granted millenia access mid-session: `ssh reala@192.168.1.212`, and parodia is `ssh reala@100.64.0.1` over headscale). **LLM link**: rather than rebinding vLLM as planned, `deploy/vllm-headscale-proxy.service` (socat) adds a listener on `100.64.0.2` only — `vllm-chat.service` is shared with **Gogobee** and **Open WebUI** (whose endpoint lives in its own DB, not env), so a rebind meant three consumer edits and a 35B reload; the forwarder cost nothing and no downtime. Grammar checkpoint from the VPS now returns real suggestions in ~3s. **Backups**: the user pointed out the VPS already has daily provider VM backups *and* an age-encrypted offsite `parodia-backup` job, so Petal was folded into the latter instead of running a parallel cron — and doing so **exposed a real bug in that job's `sqlite_dump` helper**: Python's `iterdump` does not reproduce an FTS5 virtual table, so any restore would have come back with cross-document search silently missing (fixed with a `VACUUM INTO`-based helper, round-trip verified). The **bigger** find: millenia, which holds her actual writing, had **no scheduled backup at all** — now `petal-backup.timer`, age-encrypted with the parodia public recipient and pushed off-box, neither machine able to decrypt it. **Encryption at rest** (user raised it; correctly): VPS data dir is now LUKS2 covering the DB, images *and* the TTS cache; key on-box as a deliberate availability tradeoff, documented for what it does and doesn't stop. Rehearsing a reboot caught two bugs a clean run would have hidden — the plaintext originals were still on the unencrypted root fs *under* the mount, and `systemd-cryptsetup` wasn't installed so crypttab was being ignored entirely and the volume would never have unlocked at boot. Added a `.volume-ok` guard so an unmounted volume fails loudly instead of serving a blank DB. **Millenia hygiene**: Piper had been dead since the Jul 26 reboot — **26,800+ failed restarts**, read-aloud silently degrading to browser Web Speech — because an OS upgrade moved `/usr/bin/python3` 3.13→3.14 and the venv's `site-packages` went invisible; venv recreated (lands Piper 1.6.0, which is what `TTS_PATH` exists for), both voices verified through Petal. Petal itself was running unsupervised at PPID 1 and is now `petal.service` (verified by `kill -9`); the Piper units got `StartLimitIntervalSec`/`Burst` so a broken service enters `failed` instead of looping forever unnoticed. Remaining: an external uptime-kuma probe (needs the UI), a true VPS reboot test (shared public host, user's call), and millenia is still unencrypted at rest.
|
||||
- 2026-07-26: **Phase 15 complete — Petal is deployed at https://petal.parodia.dev** (user: "let's start this build plan"; scope confirmed as artifacts **plus** the actual deploy, millenia stays canonical, hostname `petal.parodia.dev`). Stack: `Dockerfile` (node → go → alpine; CGO off, so the runtime layer carries only ffmpeg + tzdata), `docker-compose.yml` behind the host's existing Traefik, and **two Piper sidecars** instead of the planned host systemd units — Piper turned out never to have been installed on the VPS and the account has no lingering session, so containers on an internal network with no published ports are both simpler and tighter. **Three real problems found by deploying rather than by planning:** (1) the image's `petal` user (uid 10001) has no claim on a bind-mounted host directory → SQLite `unable to open database file (14)` and a restart loop; the container now runs as the stack directory's owner (still non-root, and the host account keeps write access the backup script needs); (2) piper-tts **1.6.0 moved synthesis from `POST /` to `POST /synthesize`** with an identical body → every read-aloud 405'd; rather than pin both deployments to one Piper release the path became config (`TTS_PATH`, default `/`, so millenia is untouched); (3) once the instance was live it was **a public, writable, unauthenticated API** — Petal authenticates nobody yet, so Traefik basic auth now holds the door until Phase 16, with `/api/health` exempt on its own higher-priority router. Backups: `db.Backup` via **`VACUUM INTO`** (WAL-coherent, no write lock, single file, refuses to overwrite) behind a `-backup` flag so the nightly job snapshots the running container; `deploy/backup-petal.sh` compresses, pushes to millenia with a size check, prunes both sides; cron at 03:15; restore documented and verified by round-tripping an archive through the binary. Tests: `internal/db/backup_test.go` (WAL capture, seeded user survives, no `-wal`/`-shm` companions, refuses an existing destination, missing source), `internal/tts` path-normalisation + configured-path. go build/vet/test clean. **Acceptance verified over public HTTPS with the LLM link genuinely down**: dictionaries, gloss, word lookup + phonetic, doc create/save, CJK FTS search, md/docx export, vocab capture, read-aloud EN + zh (real mp3, cache hit, 404-fallback for an unconfigured language) — all fine; `/check` → the warm 502 that renders as 小助手在休息; health public, HTTP→HTTPS with a valid cert. **Two items outstanding, both needing millenia access I don't have**: vLLM isn't bound to its headscale interface (so no AI pass works yet), and parodia's ssh key isn't authorized on millenia (so backups are VPS-local only — not yet a real off-box backup). Both have one-command fixes in `deploy/README.md` §3 and §5. Also this session: **DreamDict gained Spanish**, so the es pair is no longer gated — folded into Phases 20/21 and the "Later" bucket. Next: **Phase 16 (auth)** — Authentik already runs on the same VPS.
|
||||
- 2026-07-26: **Product direction + execution plan ratified** (user: "make it so, number one"). New `SUGGESTIONS.md` (product rationale for the language-learning direction): the **pair model** — every user gets one (English + X) pair, X ∈ {zh, pt-PT, fr, maybe es}, bilingual UI in the pair, type in either language, direction inferred (no detector: both-dictionaries spellcheck, show-both gloss on collision); **langpacks** keyed by X; **LLM-minimalism** as a standing principle (LLM is garnish, never a gatekeeper — grammar-lite rule pack + embedded miscollocation list planned as code-first layers). Deployment settled: Petal on the **parodia.dev VPS**, vLLM on millenia over **headscale** (the only cross-VPN dependency; Piper is VPS-local). All `MULTIUSER_PLAN.md` OPENs ratified: in-app OIDC (B), 30-day sliding sessions, allowlist, migration script, image-store fix with auth, DreamDict via package import (Option 3, module rename prereq in the dreamdict repo), zh stays on ECDICT until compared. Everything expanded into **Phases 15–22** above with standing rules (isolation tests same-commit, LLM-minimalism, bilingual aesthetic). Ready for implementation handoff starting at Phase 15.
|
||||
|
||||
Reference in New Issue
Block a user