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)
23 lines
2.5 KiB
Markdown
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.
|