Petal ran as a single hardcoded user, with db.LocalUserID named directly
at ~35 query sites. That made the caller's identity a compile-time
constant scattered across every package — nothing a real login could
replace without touching all of them.
New internal/auth moves it into the request context:
- Middleware(Resolver) resolves the caller once per API request
- handlers read auth.UserID(r.Context()) instead of naming a user
- Resolver is the seam an Authentik session check drops into
- StaticResolver(db.LocalUserID) keeps Petal single-user today
Behavior is unchanged. UserID returns "" rather than panicking when the
middleware is absent, so a mis-wired route fails closed: every query is
WHERE user_id = ?, which then matches nothing.
main.go splits /api into a public group (/health, /version) and an
authenticated group for everything else — a monitoring probe must not
need a session.
Two pre-existing access-control gaps fixed while threading, both
harmless with one user and not with two:
- setStatus (accept/dismiss) updated a suggestion by bare id with no
ownership check at all
- listForDoc/fetchPending read a document's suggestions by doc_id
alone; a suggestion quotes the sentence it corrects, so that leaked
the source prose
Both now scope through documents.user_id.
Tests: internal/auth covers the context round-trip, the absent-context
case, and both 401 paths. Two-user isolation suites in docs and
suggestions mount the same routers twice behind two resolvers over one
database and assert a stranger gets 404 on every id-taking path, sees
nothing in list/search, and leaves the owner's data untouched.
Those suites earned their keep immediately: docs.fetch gained a userID
parameter but kept binding db.LocalUserID in the query. Unused
parameters are legal Go, so it compiled clean, vet was silent, and every
existing test passed while the lookup stayed unscoped.
Still global, out of scope and flagged in BUILD_PLAN.md: the image store
has no per-user association, and frontend localStorage keys are
per-browser rather than per-account.
Backend:
- Extract shared internal/httputil (WriteJSON/ErrorJSON/BadRequest/
ServerError); drop the triple-duplicated helpers in docs, suggestions,
vocab. ServerError now logs the real error and returns a generic 500 so
raw DB/internal errors never reach the client.
- vocab capture: validate doc_id ownership (blank -> none, unknown -> 400
instead of a leaked FK 500); rune-safe clamp word/gloss/definition/
phonetic/example.
- vocab review(): wrap the read-modify-write in a transaction (TOCTOU).
- /api request-size cap via MaxBytesReader middleware (2 MiB), exempting
/api/images (own 10 MiB limit).
Frontend:
- StatusBar: drive the checking/voicing/collocating indicators from one
array; llmDown uses !anyBusy.
- Slide-overs: new useFocusTrap hook (focus-in, Tab trap, focus-restore)
on GardenPanel + HistoryPanel, both role=dialog/aria-modal/aria-label.
- speech.ts: export stopSpeech(); GardenPanel cancels audio on unmount.
Tests: add doc_id-validation and field-clamp coverage; full suite green.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
Cross-document FTS5 search (trigram tokenizer for EN + space-free CJK, kept in
sync by triggers, back-filled from existing docs). GET /api/search uses the FTS
index for queries >=3 runes and a LIKE fallback for 1-2 (so 2-char Chinese words
resolve); snippets are built in Go with rune-aware boundaries and sentinel
highlights.
Tags: user-scoped tags + document_tags join (both cascade), idempotent
create/assign, per-tag doc counts. Doc list and search carry each doc's tags
(one tagsByDoc query). Frontend: useTags, TagChip/TagPicker/SearchBox, rewritten
DocList with chips + filter bar + search.
Tablet/touch: responsive sidebar drawer (hamburger + scrim <768px), coarse-
pointer tap targets, tap-to-open + outside-pointerdown-close for suggestion
cards.
Warm LLM-down state: useCheckpoint llmDown flag drives a gentle bilingual
StatusBar note (writing still saves locally).
Migration 0004 (tags + FTS). Tests: tags lifecycle, search EN/CJK/LIKE/update-
reindex. go build/vet/test, tsc, vite all clean; verified live on deployment host.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd