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
380 lines
18 KiB
Markdown
380 lines
18 KiB
Markdown
# Petal multi-user plan
|
|
|
|
**Status:** Phase 0 (identity plumbing) landed 2026-07-26 in `6901cdb`.
|
|
**Phase A (authentication) + Phase C's image store built 2026-07-27** — in-app
|
|
OIDC, server-side sessions, allowlist, provisioning, the frontend 401 path and
|
|
per-owner images. Not yet configured against the live Authentik; see
|
|
`BUILD_PLAN.md` Phase 16 and `deploy/README.md` §4. Phase B (migrating the
|
|
`local` user) still waits on her first real login.
|
|
**All OPEN decisions ratified by the user 2026-07-26** (recommendations
|
|
accepted as written) — see each OPEN for its settled answer. Execution phases
|
|
live in `BUILD_PLAN.md` (Phase 15 onward); product rationale for the language
|
|
work is in `SUGGESTIONS.md`.
|
|
|
|
Settled context that postdates the original draft: Petal will be hosted on the
|
|
**parodia.dev VPS**, reaching vLLM on millenia over **headscale VPN**; Piper
|
|
TTS is already installed on parodia (VPS-local, no VPN hop). The LLM is the
|
|
only cross-VPN dependency, and per the LLM-minimalism principle
|
|
(SUGGESTIONS.md §6) it must never gate essential functionality.
|
|
|
|
---
|
|
|
|
## 1. Where Petal is today
|
|
|
|
Single user, by construction. `db.Open` seeds one row (`users.id = 'local'`) and,
|
|
until this week, every query named that constant directly.
|
|
|
|
What was already right: **the schema has been multi-user-shaped from day one.**
|
|
`documents`, `tags`, and `vocab_words` all carry `user_id`; `document_versions`
|
|
and `suggestions` scope through their parent document. No migration is needed to
|
|
support a second user — only a way to know which user is asking.
|
|
|
|
### What Phase 0 changed
|
|
|
|
- New `internal/auth`: `Middleware(Resolver)` resolves the caller once per API
|
|
request and stores the id in the request context. Handlers read
|
|
`auth.UserID(r.Context())`.
|
|
- `Resolver` is a one-method interface — `Resolve(*http.Request) (string, error)`
|
|
— and is the only thing a real identity provider has to implement.
|
|
- `StaticResolver(db.LocalUserID)` supplies today's single user, so behavior is
|
|
unchanged.
|
|
- `/api` is split into a public group (`/health`, `/version`) and an
|
|
authenticated group (everything else).
|
|
- Two pre-existing access-control gaps fixed: `setStatus` (accept/dismiss) had
|
|
**no ownership check at all**, and `fetchPending` read suggestions by `doc_id`
|
|
alone — which leaked the quoted source sentences.
|
|
- Two-user isolation test suites (`docs`, `suggestions`) mount the same routers
|
|
twice behind two resolvers over one database.
|
|
|
|
**The remaining work is not "make Petal multi-user."** It is "authenticate
|
|
someone, provision them, and clean up the three places where data is still
|
|
global."
|
|
|
|
---
|
|
|
|
## 2. Goals and non-goals
|
|
|
|
**Goals**
|
|
- Two or more people use one Petal instance without seeing each other's writing.
|
|
- The existing local user's data survives, attached to a real account.
|
|
- Adding a user is an operator action, not a code change.
|
|
|
|
**Non-goals (explicitly out, unless a reviewer argues otherwise)**
|
|
- Sharing, collaboration, or multi-author documents. Petal is a private writing
|
|
space; every feature to date assumes one reader. Sharing would change the
|
|
passport's meaning (authorship evidence) and is a product decision, not an
|
|
auth one.
|
|
- Roles, permissions, or an admin UI.
|
|
- Public signup. Accounts are provisioned deliberately.
|
|
|
|
---
|
|
|
|
## 3. Phase A — authentication
|
|
|
|
### OPEN #1: forward-auth vs. in-app OIDC — **SETTLED: Option B (in-app OIDC)**
|
|
|
|
Ratified 2026-07-26. The deciding fact arrived with the deployment plan: Petal
|
|
will live on the public parodia.dev VPS, which is exactly the environment where
|
|
Option A's "must never be reachable except through Traefik" invariant is a
|
|
footgun. Original analysis kept below for the record.
|
|
|
|
**Option A — Traefik forward-auth to an Authentik outpost.**
|
|
Traefik is already in the deferred deploy bucket. Authentik's outpost terminates
|
|
the login, and Petal receives a trusted header (`X-authentik-uid`, plus email and
|
|
name). The `Resolver` becomes ~20 lines: read the header, map to a user id.
|
|
|
|
- *For:* no OIDC library, no session store, no cookie handling, no redirect
|
|
plumbing, no token refresh, no logout endpoint. Petal keeps zero auth code.
|
|
Login/MFA/password reset are entirely Authentik's problem.
|
|
- *Against:* Petal is only secure if it is **never reachable except through
|
|
Traefik**. Anyone who can hit the container directly can forge the header and
|
|
become any user. That's a deployment invariant enforced by network config, not
|
|
by code — and it is exactly the kind of invariant that quietly breaks. It also
|
|
makes local development awkward (no proxy → no identity), though
|
|
`StaticResolver` covers that.
|
|
|
|
**Option B — Petal is an OIDC client itself.**
|
|
`github.com/coreos/go-oidc` + `golang.org/x/oauth2`, a `/auth/callback` route,
|
|
and a signed session cookie. The config fields already exist
|
|
(`AUTHENTIK_URL`, `AUTHENTIK_CLIENT_ID`, `AUTHENTIK_CLIENT_SECRET`,
|
|
`SESSION_SECRET`).
|
|
|
|
- *For:* self-contained and safe to expose directly. No trust-the-proxy
|
|
invariant. Works the same in dev and prod.
|
|
- *Against:* meaningfully more code — a session table or signed-cookie scheme,
|
|
CSRF on the callback, token expiry, logout. Two new dependencies in a project
|
|
that currently has exactly two.
|
|
|
|
**My recommendation: Option B**, but not confidently. The deciding factor for me
|
|
is that "must never be reachable directly" is a footgun that survives long after
|
|
whoever set it up has forgotten, and Petal already holds someone's private
|
|
journals. But if the deployment is definitively a single Traefik-fronted box on a
|
|
LAN and will stay that way, Option A is *much* less code and I'd not object.
|
|
|
|
A reviewer should weigh: how likely is this instance ever exposed beyond the LAN?
|
|
Does the operator want Petal to be independently deployable?
|
|
|
|
### Session handling (assumes Option B)
|
|
|
|
- Server-side sessions in a `sessions` table (id, user_id, expires_at,
|
|
created_at, user_agent), cookie holds an opaque random id.
|
|
Preferred over signed stateless cookies because it makes logout and
|
|
revocation actually work — worth the one table.
|
|
- Cookie: `HttpOnly`, `SameSite=Lax`, `Secure` when `BASE_URL` is https.
|
|
- **OPEN #2 — SETTLED: 30-day sliding expiry** (ratified 2026-07-26). An
|
|
editor that logs you out mid-draft is hostile, and auto-save makes a
|
|
surprise 401 genuinely costly. Sliding: each authenticated request extends
|
|
the session.
|
|
|
|
### The 401 problem
|
|
|
|
Every frontend fetch currently assumes success. Once a session can expire,
|
|
**any** call can return 401 mid-session — including the 1.5s auto-save, which is
|
|
the one that must not fail silently.
|
|
|
|
Proposal: a single interceptor in `web/src/api/client.ts` that, on 401, halts
|
|
auto-save, surfaces a warm bilingual "请重新登录 / Please sign in again" state
|
|
rather than a raw error, and preserves unsaved editor content across the
|
|
re-login (localStorage draft keyed by doc id). This is small but easy to forget,
|
|
and getting it wrong means lost writing.
|
|
|
|
---
|
|
|
|
## 4. Phase B — user provisioning and migration
|
|
|
|
### Provisioning
|
|
|
|
On first successful login, upsert a `users` row from the OIDC claims
|
|
(`sub` → `users.id`, plus email and name). No signup flow; whoever Authentik lets
|
|
in gets an account.
|
|
|
|
**OPEN #3 — SETTLED: yes, allowlist** (ratified 2026-07-26). Authentik may
|
|
host other applications with a broader user set than Petal should have. Gate
|
|
via a `PETAL_ALLOWED_SUBS` env var (or an Authentik group claim check —
|
|
implementer's choice, env var is simpler); a valid login not on the list gets
|
|
a warm bilingual "this Petal isn't yours to write in" page, not a 500.
|
|
|
|
### Migrating the existing `local` user
|
|
|
|
The live database on millenia holds real writing under `user_id = 'local'`. That
|
|
data must end up owned by the wife's real account.
|
|
|
|
Recommended: a migration that **renames** rather than copies — update the
|
|
`users.id` and let `ON UPDATE CASCADE`… except SQLite FKs here are declared
|
|
without `ON UPDATE`, so this needs either a deliberate multi-table update inside
|
|
one transaction (`documents`, `tags`, `vocab_words` — versions and suggestions
|
|
follow their parents) with `PRAGMA foreign_keys=OFF` around it, or an explicit
|
|
one-off admin command.
|
|
|
|
I'd rather do this as a **documented one-off script run with the app stopped and
|
|
a copy of the DB taken first** than as an automatic startup migration, because it
|
|
depends on knowing the new OIDC subject id, which isn't available until that
|
|
person logs in once. Sequence: deploy auth → she logs in → new empty account is
|
|
created → stop app, back up, run script to move `local`'s rows onto her real id,
|
|
delete the empty row → restart.
|
|
|
|
**OPEN #4 — SETTLED: documented one-off script** (ratified 2026-07-26), run
|
|
with the app stopped and a DB backup taken first, per the existing `scripts/`
|
|
convention. No admin endpoint.
|
|
|
|
---
|
|
|
|
## 5. Phase C — the data that is still global
|
|
|
|
Found during the Phase 0 audit. None of these break with two users; all of them
|
|
leak or bleed.
|
|
|
|
### Image store — the real one
|
|
|
|
`internal/images` is a flat content-addressed directory. There is no per-user
|
|
association and no database row at all. Any authenticated user who knows a
|
|
sha256 can fetch any other user's image.
|
|
|
|
That is capability-URL security. Hashes aren't guessable, so this is not an
|
|
emergency — but "unguessable filename" is not access control, and images pasted
|
|
into a private journal are exactly the content that shouldn't rely on it.
|
|
|
|
Proposal: an `images` table (hash, user_id, content_type, created_at, size) with
|
|
the fetch handler joining on the caller. Content addressing is kept — the same
|
|
image uploaded by two users is stored once on disk and simply has two rows, so
|
|
deduplication survives. Deleting the last row referencing a hash removes the
|
|
file.
|
|
|
|
**OPEN #5 — SETTLED: fix it in the same phase as auth** (ratified
|
|
2026-07-26). The moment a second account exists the exposure is real, and the
|
|
fix requires a migration either way.
|
|
|
|
### Frontend `localStorage`
|
|
|
|
`petal.spell.personal` (personal dictionary), `petal.companion` (chosen mascot),
|
|
plus sound and petal-effect preferences are all per-browser. Two users on one
|
|
device share them — and the personal dictionary is the one that matters, since
|
|
it's built from someone's own writing.
|
|
|
|
Cheapest fix: namespace every key by user id once the client knows who it is.
|
|
The honest fix for the dictionary is to move it server-side into a table, which
|
|
also means it follows a user between devices — arguably a feature.
|
|
|
|
### Not affected
|
|
|
|
`export-all` is correctly scoped. The TTS cache is content-addressed audio of
|
|
text the requester supplied, no cross-user inference. The lexicon is a static
|
|
dataset identical for everyone.
|
|
|
|
---
|
|
|
|
## 6. Phase D — per-user language (and DreamDict)
|
|
|
|
Tracked here because it lands on the same `users` row and shouldn't be designed
|
|
twice.
|
|
|
|
**English is always the target language.** What varies is the user's *native*
|
|
language — the one glosses and explanations are written in. Mandarin ships today;
|
|
European Portuguese (pt-PT, explicitly not pt-BR) is wanted; French is possible.
|
|
|
|
### OPEN #6 is answered: DreamDict
|
|
|
|
The original worry here was data sourcing — Petal's gloss comes from ECDICT
|
|
(English↔Chinese), and a pt-PT equivalent of comparable quality and license
|
|
looked like the blocker.
|
|
|
|
`~/git/dreamdict` already solves it, and more completely than expected. It
|
|
supports **en, fr, pt-PT, and zh** (~136k/56k/136k/121k words), and its shape maps
|
|
almost 1:1 onto `lexicon.Result`:
|
|
|
|
| Petal field | DreamDict |
|
|
|---|---|
|
|
| `Gloss` | `Translate(word, "en", L1)` |
|
|
| `Phonetic` | pronunciation (CMU + IPA for en, Wiktionary IPA elsewhere) |
|
|
| `Definitions` | `Define(word, lang)` — curated sources ranked above Wiktionary |
|
|
| `Synonyms` | `Synonyms(word, lang)` |
|
|
|
|
It also carries data Petal has no equivalent for and could use: `Antonyms`,
|
|
`Frequency`, `Difficulty`, and `Etymology`.
|
|
|
|
So Phase D stops being gated on data and becomes an integration decision.
|
|
|
|
### OPEN #6a (new): how to integrate — **SETTLED: Option 3** (ratified 2026-07-26)
|
|
|
|
Import the package, open `dict.db` read-only. Prerequisite stands: DreamDict's
|
|
module path must be renamed (or `replace`-directed) first — **that change
|
|
lives in the dreamdict repo, not this one.** The migration caution below also
|
|
stands: pt-PT/fr wire to DreamDict first; zh stays on ECDICT until compared on
|
|
real lookups. Options kept below for the record.
|
|
|
|
**Option 1 — HTTP client.** Petal calls DreamDict on localhost:7777, exactly the
|
|
pattern already used for Piper TTS (including graceful degradation when it's
|
|
down).
|
|
- *For:* zero coupling, DreamDict updates independently, all endpoints available.
|
|
- *Against:* a second service Petal now depends on at runtime, and the gloss is a
|
|
350ms hover tooltip where "the dictionary service is down" is a visible
|
|
regression from today's always-there embedded data.
|
|
|
|
**Option 2 — build-time extraction.** A script (sibling to the existing
|
|
`scripts/build_gloss.py`) generates Petal's embedded `.json.gz` datasets per
|
|
language from DreamDict's `dict.db`.
|
|
- *For:* preserves the embedded/offline property exactly; no runtime dependency;
|
|
no architectural change at all.
|
|
- *Against:* every language multiplies the binary (the four current gz files are
|
|
already ~11.6 MB); updating the dictionary means rebuilding and redeploying
|
|
Petal; the richer fields are lost unless separately extracted.
|
|
|
|
**Option 3 — import the package, open `dict.db` read-only.** DreamDict's
|
|
`internal/dictionary` is a plain library with `NewReadOnly(dbPath)`, and its only
|
|
dependency is `modernc.org/sqlite` — the same CGO-free driver Petal already uses.
|
|
Petal opens `dict.db` as a second read-only handle beside `petal.db`.
|
|
- *For:* no service, no HTTP, no new dependency, lookups stay local-file fast,
|
|
all four languages at once, and it deletes ~11.6 MB of embedded gz plus the
|
|
ECDICT build scripts. One dictionary, maintained once, shared with GogoBee.
|
|
- *Against:* Petal stops being a self-contained binary in the "just run it" sense
|
|
— `dict.db` has to be deployed alongside. In practice Petal already ships a
|
|
data directory (`petal.db`, images, TTS cache), so this is a smaller loss than
|
|
it first sounds.
|
|
|
|
**My recommendation: Option 3.** It is the only one that gets all four languages,
|
|
keeps lookups offline and instant, and *removes* code rather than adding a
|
|
subsystem. Option 1's runtime dependency buys flexibility Petal doesn't need for
|
|
a dictionary that changes a few times a year.
|
|
|
|
**Prerequisite:** DreamDict's module path is currently `module dreamdict`, which
|
|
isn't fetchable. Importing it needs the module renamed to something like
|
|
`gitea.parodia.dev/drwily/dreamdict` (or a local `replace` directive for
|
|
development). Small, but it must happen first.
|
|
|
|
### Migration caution
|
|
|
|
Whichever option wins, the zh path is **currently working and in daily use**. The
|
|
gloss quality difference between ECDICT and CC-CEDICT is unknown and matters more
|
|
than the architecture.
|
|
|
|
Proposal: introduce DreamDict behind Petal's existing lexicon interface as a
|
|
*provider*, wire pt-PT and fr to it first (nothing to regress — they don't exist
|
|
yet), and keep zh on ECDICT until the two have been compared on real lookups from
|
|
her actual documents. Converge only if quality holds. This also de-risks the whole
|
|
change: if DreamDict turns out to be a poor fit, only the unshipped languages are
|
|
affected.
|
|
|
|
### Still per-user regardless
|
|
|
|
Native language becomes a `users` column, and these become per-user lookups:
|
|
LLM prompt copy (`internal/llm/prompts.go`, currently Mandarin-first), companion
|
|
tips (`tips.ts`), the L1 Piper voice (Piper has pt-PT voices), and the CJK font
|
|
stacks (not needed for Latin-script L1). English-side machinery — nspell en-US,
|
|
the phonetic dataset, the EN voice — is unaffected and stays shared.
|
|
|
|
## 7. Suggested sequence
|
|
|
|
1. ~~Settle OPEN #1~~ **Settled: in-app OIDC.**
|
|
2. Deploy plumbing: Dockerfile, Traefik, real hostname on parodia.dev, HTTPS,
|
|
headscale route to vLLM on millenia (bound to the headscale interface
|
|
only), VPS-local Piper, off-VPS DB backup. Auth needs a stable `BASE_URL`
|
|
and a redirect URI.
|
|
3. Auth itself: OIDC `Resolver`, sessions table (30-day sliding), allowlist,
|
|
frontend 401 handling with draft preservation.
|
|
4. Image store table + migration (same phase, per OPEN #5).
|
|
5. Provision the second real account; migrate `local`'s data (script, app
|
|
stopped, backup first).
|
|
6. `localStorage` namespacing (key by user **and** language — see
|
|
SUGGESTIONS.md §8).
|
|
7. Per-user language pair. **No longer gated on data** — DreamDict covers all
|
|
four languages. Sequence within it: rename DreamDict's module path → wire
|
|
it in as a lexicon provider → pt-PT/fr first → compare zh quality →
|
|
converge if it holds. The pair model and langpack shape are specified in
|
|
SUGGESTIONS.md §1–§3.
|
|
|
|
These are expanded into checkboxed execution phases in `BUILD_PLAN.md`
|
|
(Phase 15 onward) — that file remains the source of truth for progress.
|
|
|
|
---
|
|
|
|
## 8. Risks
|
|
|
|
- **Silent unscoping.** Phase 0 hit this exactly once: `docs.fetch` took a
|
|
`userID` parameter and kept binding `db.LocalUserID` in the query. Unused
|
|
parameters are legal Go — it compiled, `vet` was silent, and every existing
|
|
test passed while the lookup stayed unscoped. Only the two-user isolation test
|
|
caught it. **Every new user-scoped endpoint should get an isolation case in the
|
|
same commit**; the existing suites are the template.
|
|
- **Migrating live data.** The wife's real writing is the thing being moved.
|
|
Back up first, run with the app stopped, verify counts before deleting
|
|
anything.
|
|
- **A 401 mid-draft losing work.** See Phase A.
|
|
- **Scope creep into sharing.** Multi-user and collaboration are different
|
|
products. Adding accounts should not quietly become adding sharing.
|
|
|
|
---
|
|
|
|
## 9. Questions for the reviewer — all answered 2026-07-26
|
|
|
|
1. ~~Forward-auth or in-app OIDC?~~ **In-app OIDC** (OPEN #1).
|
|
2. ~~Session lifetime?~~ **30-day sliding** (OPEN #2).
|
|
3. ~~Allowlist?~~ **Yes**, `PETAL_ALLOWED_SUBS` or group claim (OPEN #3).
|
|
4. ~~Script vs. admin endpoint?~~ **Script**, app stopped, backup first (OPEN #4).
|
|
5. ~~Image store timing?~~ **Same phase as auth** (OPEN #5).
|
|
6. ~~pt-PT dictionary data?~~ **DreamDict**, integrated per **Option 3**
|
|
(import package, read-only `dict.db`; module rename is the prerequisite)
|
|
(OPEN #6a).
|
|
7. ~~zh gloss regression risk?~~ **zh stays on ECDICT** until compared against
|
|
DreamDict on real lookups from her actual documents; converge only if
|
|
quality holds.
|