Files
k-mood/docs/adr/0018-tokens-grant-scopes-sessions-own-the-account.md
Gabriel Kaszewski bf148902ab spa hardening, offline logging, rate limit fixes
server:
- backup exporter, auth extractors, error shapes, CONTEXT (prior work)
- spa assets served outside the rate limit via route_layer
- requests_per_second went to per_second(), which takes an interval not a
  rate: 50 meant one request per 50s once burst was spent. now converted
  properly. 15/s, burst 60

spa fixes:
- account delete cleared snake_case token keys that were never written
- refresh interceptor could retry forever
- date ranges used local day boundaries stamped +00:00
- "all" period trend plotted one page; calendar days fabricated mood 3
- chart grid invisible: hsl(var(--border)) against rgba tokens
- blob url leak, orphaned media on failed save, devtools in prod bundle
- pt-safe/safe-area-pb classes never existed

spa features:
- offline outbox: entries queue to IndexedDB, replay with backoff, only
  server refusals count against an entry
- drafts persist, quick-log sheet, diary infinite scroll + filters
- route error boundary, stale-chunk recovery, no service worker in dev

a11y + perf:
- mood picker is a radiogroup, activity picker keyboard-operable,
  text alternatives for colour/emoji, locale week start
- dark glass over the bright photo: worst case 1.4:1 -> 4.9-9.6:1
- initial payload 1095->769kB raw, 306->230kB gzip; 38 unused components
  and 5 deps dropped; fonts 218->133kB

53 tests added (43 spa, 10 server)
2026-08-28 15:00:30 +02:00

23 lines
2.5 KiB
Markdown

# A token grants named scopes; only a session owns the account
`ApiToken` carried a single-variant `TokenScope::WriteMetrics`, and only the metric-write routes accepted one. Every other route took `AuthenticatedUser`, which validated a JWT and nothing else. A client that was not the SPA therefore had exactly two options: write metrics, or hold the account's password and log in as the user.
That is the right boundary for an importer and the wrong one for a desktop widget, which needs to read entries and write them and should never be trusted with the password.
## Considered Options
- **Let `AuthenticatedUser` accept any api token** — one line, and it hands every token the whole account, including minting further tokens and restoring a backup over the top of the data. A credential that can mint credentials is not a narrower credential.
- **One scope per token** — matches the column that already existed and forces a widget to juggle two secrets to read and write. The set is the natural unit; a token is a role, not a permission.
## Consequences
`TokenScopes` is a non-empty set, so a token that grants nothing cannot be minted, and an unknown scope name is refused rather than dropped — a typo must not quietly produce a narrower token than the one asked for. A stored set containing a scope this build cannot read makes the whole token unreadable, the same way an unreadable metric row is refused rather than half-understood.
The four scopes name what a client does rather than which routes it calls: `readJournal`, `writeJournal`, `writeMetrics`, `readProfile`. Routes are grouped behind one extractor each — `JournalReader`, `JournalWriter`, `MetricWriter`, `ProfileReader` — so the scope a route needs is visible in its signature and a new route must choose one to compile.
`SessionUser` replaces `AuthenticatedUser` and accepts a JWT only. It guards what no token may reach: the password, the profile, provider credentials, push registration, backup, restore, import, and minting or revoking tokens. The rename is the point — "authenticated" no longer distinguishes the two things that can authenticate.
`writeMetrics` keeps its old meaning exactly, so the token's name still becomes the Provider its metric writes are attributed to. A token without `writeMetrics` never reaches that path and its name is only a label.
Migration 016 backfills every existing token with `writeMetrics`, which is what it already had, and drops the old column. An importer minted before this change keeps working and gains nothing.