# Petal multi-user plan **Status:** Phase 0 (identity plumbing) landed 2026-07-26 in `6901cdb`. Everything below it is proposed, not agreed. This document exists to be argued with. Sections marked **OPEN** are real decisions with real trade-offs, not rhetorical questions — a reviewer should push back on them. Where I have a recommendation I say so and say why. --- ## 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 This is the biggest fork and it should be settled first, because everything in Phase B depends on it. **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:** session lifetime. This is a personal writing tool used daily on trusted devices; a 30-day sliding expiry is kind, an 8-hour one is conventional. I lean long — an editor that logs you out mid-draft is hostile, and the auto-save makes a surprise 401 genuinely costly. Needs a decision. ### 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:** should there be an allowlist? Authentik may host other applications with a broader user set than Petal should have. A `PETAL_ALLOWED_SUBS` env var, or an Authentik group check on a claim, would gate it. Probably yes, cheaply. ### 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:** is that acceptable, or is a small admin endpoint preferable to a script? The existing project convention (`scripts/`, hand-run Python) suggests a script is in keeping. --- ## 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:** is this worth doing before real multi-user, or is it acceptable to ship auth first and treat this as a known limitation? I lean toward doing it *in the same phase as auth*, since 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 **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** (forward-auth vs. in-app OIDC). Everything else follows. 2. Deploy plumbing: Dockerfile, Traefik, real hostname, HTTPS. Auth needs a stable `BASE_URL` and a redirect URI regardless of which option wins. 3. Auth itself: `Resolver` implementation, sessions if applicable, frontend 401 handling. 4. Image store table + migration (same phase, per OPEN #5). 5. Provision the second real account; migrate `local`'s data. 6. `localStorage` namespacing. 7. Per-user language. **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. --- ## 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 1. Forward-auth or in-app OIDC? (OPEN #1 — the one that matters most) 2. Session lifetime, given a daily-use editor with auto-save? (OPEN #2) 3. Allowlist Petal accounts separately from Authentik's user set? (OPEN #3) 4. Migration script vs. admin endpoint for moving `local`'s data? (OPEN #4) 5. Fix the image store alongside auth, or ship auth with it as a known limitation? (OPEN #5) 6. ~~Is there a usable open English↔European-Portuguese dictionary dataset?~~ **Answered: DreamDict**, which covers en/fr/pt-PT/zh. The live question is now *how* to integrate it — HTTP service, build-time extraction, or importing the package and opening `dict.db` read-only. (OPEN #6a; I recommend the third) 7. Does replacing the zh gloss (ECDICT → CC-CEDICT) risk regressing a feature in daily use, and should zh stay on ECDICT until the two are compared? Anything above that reads as settled but shouldn't be is also fair game.