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)
This commit is contained in:
2026-08-28 14:59:21 +02:00
parent 23d052278a
commit bf148902ab
395 changed files with 13972 additions and 10635 deletions

View File

@@ -0,0 +1,24 @@
# Media ownership is recorded, not inferred from the entries that wear it
`MediaStoragePort` is a blob store — local filesystem or S3 behind `object_store` — and blobs have no owner. `DELETE /api/v1/media/photos/{id}` therefore authenticated the caller and then threw the id away, so any account could delete any other account's photo or voice memo by naming its uuid.
Ownership could not be checked because nothing recorded it. The only trace was the `entry_photos` join, and that trace does not exist for the case the endpoint is for: a photo uploaded and then dropped before the entry was ever saved.
## Considered Options
- **Infer the owner from `entry_photos` joined to `mood_entries`** — free, and wrong for exactly the uploads the delete endpoint serves. A just-uploaded blob has no entry, so it would be undeletable or unprotected, and which of the two depends on how the absence is read.
- **Namespace the object key by user (`photos/{user_id}/{uuid}`)** — ownership becomes structural and needs no table. It also puts the account id in every media URL the SPA renders, and rewrites every `get`/`delete` signature so the caller must already know the owner, which is the thing being established.
## Consequences
A `media_owners` table records `(kind, media_id, user_id)` at upload, behind a new `MediaOwnershipPort`. The blob store stays ignorant of users, which is correct: object stores do not do authorization.
`MediaRef` carries the kind alongside the uuid so one port serves both photos and voice memos without six near-duplicate methods. Because the kind is part of the key, a `PhotoId` and a `VoiceMemoId` sharing a uuid are still two different objects, and claiming one does not claim the other.
Media restored from a backup is claimed for the restoring account, so a restore does not produce blobs nobody owns.
The migration backfills owners from `entry_photos` and `entry_voice_memos`. Blobs stranded by an abandoned upload predating this table have no owner and cannot be deleted through the API — they are unreachable rather than dangerous, and a sweep for them is work this document does not do.
## The read path is still unauthenticated
`GET /api/v1/media/photos/{id}` takes no bearer token, because the SPA renders photos in `<img src>` and cannot attach one. Access rests on the uuid being unguessable. That is a deliberate capability URL, not an oversight, and it is recorded here because it is indistinguishable from one in the handler. Closing it means fetching blobs through XHR and object URLs, or a cookie scoped to the media routes, and neither is worth doing until media is shared beyond the account that uploaded it.

View File

@@ -0,0 +1,32 @@
# A MoodEntry's instant is stored in one canonical form
`logged_at` is a `DateTime<FixedOffset>` written to a TEXT column with `to_rfc3339()`, so the offset it happened to arrive in went into the column. Every range query compares that column as a string:
```sql
WHERE user_id = ? AND logged_at >= ? AND logged_at <= ? ORDER BY logged_at DESC
```
Text ordering only agrees with instant ordering while every row carries the same offset. `2026-08-25T00:30:00+02:00` sorts after `2026-08-25T00:00:00+00:00` and precedes it in time. Mixed offsets are reachable: `update_profile` lets an account change timezone, and the importer stamps each row with the offset in force on that row's own date, so a year of imported history already spans two.
The in-memory fake could not catch this. It compares `DateTime<FixedOffset>` values, and chrono compares instants — the fake was right and the database was wrong, which is the one direction the fake rules do not warn about.
## Considered Options
- **A second sortable column beside `logged_at`** — keeps the offset a row arrived in. Two representations of one fact, both writable, and nothing forces them to agree.
- **An integer epoch column** — sorts and compares correctly and reads as nothing at all in a `sqlite3` session, on a table that is otherwise legible text.
## Consequences
`sortable_instant` converts to UTC and formats to second precision, and is the single place any instant becomes column text. Writes, range predicates and the cascade's range delete all go through it, so a query cannot be written against one convention and stored data another.
The offset a client sent is not preserved. Per ADR 0001 nothing derives a day boundary from it — the User's timezone does that — so the offset was a rendering, not data. `MoodEntry` carries the instant it was logged, and one instant now has one spelling. The SPA reads `loggedAt` through `new Date(...)` in every place it touches it, so it renders in the viewer's own zone either way.
Second precision is the granularity the importer works in, and it makes two spellings of one moment compare equal.
Migration 015 rewrites existing rows with SQLite's own time parser, which reads the offset suffix and normalizes to UTC, and adds an index on `(user_id, logged_at)` now that the column's order means something.
## Import dedup had to move with it
`import_entries` skipped a row when `(logged_at.to_rfc3339(), mood)` matched a stored entry — a comparison of renderings. Once stored rows read back as `+00:00` and the importer derives `+02:00` for the same instant, that key stops matching and a re-import duplicates the whole file.
`AlreadyHere` keys on the instant and the mood instead, so the same moment is the same entry whichever offset either side is spelled in. It is also updated as rows are accepted, which the old set was not, so a file that repeats a row internally no longer imports it twice.

View File

@@ -0,0 +1,24 @@
# A Reminder remembers the occurrence it sent
`process_due_reminders` decided a reminder was due by subtracting the scheduled time from the local wall clock and accepting a difference of nought to five minutes. The worker polls every sixty seconds. Every reminder therefore fired five times, once per tick, and `Reminder` held no state that could tell the second tick the first had already sent.
The same subtraction failed across midnight. A reminder set for 23:50 was compared against a 00:05 wall clock as a `NaiveTime` difference, which is large and negative, so the sweep that should have delivered it skipped it instead.
## Considered Options
- **Widen the poll interval to match the window** — one line, and it trades duplicate sends for a reminder arriving up to five minutes late while still fixing nothing about midnight. The window and the interval stay coupled, so tuning either reintroduces the bug.
- **A sent-today marker keyed on the local date** — enough for one reminder a day, and wrong for the two-reminders-a-day case the domain language already allows ("one for morning check-in, one for evening").
## Consequences
`Reminder` carries `last_sent_at`, and the question it answers is not "is it roughly this time" but "which scheduled occurrence has been reached, and was that one already sent". `occurrence_reached` resolves the scheduled wall clock into the User's zone, takes it as an instant, and offers it only when the instant has passed, is inside the grace window, and is later than whatever was last sent. `mark_sent` stores the occurrence's own instant, not the moment of sending, so the comparison is against the schedule rather than against the clock.
Yesterday's local date is considered alongside today's, which is what carries a 23:50 reminder over the midnight boundary.
The window is a configured grace rather than a constant, defaulting to thirty minutes. It bounds how late a delivery may still be attempted — a worker restarted at noon does not fire the morning's reminder — and it no longer has to agree with the poll interval, because the duplicate is prevented by the recorded occurrence and not by the arithmetic.
A send that fails is not marked, so the next sweep inside the window tries again. A send that succeeds but whose write fails is logged at error and may repeat, which is the right way round: this is a notification, and one arriving twice costs less than one that never arrives.
## The sweep no longer stops for one account
The loop carried a comment saying one user with no reachable device must not stop the sweep for everyone behind them, and it was half true. A failing `send_reminder` was caught, but a failing `find_by_id` propagated with `?` and abandoned the rest. Reading the account behind a reminder now degrades the same way delivering to it does: logged, skipped, sweep continues.

View File

@@ -0,0 +1,21 @@
# Clearing an account's data empties what it logged, not what it is
`delete_all_user_data` deleted four tables — entries, activities, reminders, daily metrics — and logged "all user data cleared". Cycle starts, rejected metrics and media ownership survived it, and so did every blob the account had uploaded but never attached to an entry.
The tables were missed because the account row stays, so nothing cascades. `delete_user_account` has no such gap: it deletes the users row and `foreign_keys` is on, so `ON DELETE CASCADE` reaches everything hanging off it.
## What counts as data
CompleteBackup already draws the line this needed: it carries everything an account knows and explicitly "no credentials and no RejectedMetrics". Clearing follows the same seam, one table further out.
Emptied: MoodEntries, Activities, Reminders, DailyMetrics, CycleStarts, RejectedMetrics, and the media a User owns. A RejectedMetric is a trace of the account's own readings, so clearing the readings and keeping the complaints about them would be incoherent — it goes, even though a backup does not carry it.
Kept: the account, its UserPreferences, its sessions and push subscriptions, its ApiTokens and its ProviderConnections. A preference is a choice the User made, not something they logged; clearing cycle records while leaving cycle tracking on is a coherent state and turning it off silently is not. Credentials are revoked deliberately, one at a time, and a request to clear a journal is not a request to break every automation writing into it.
## Consequences
The table list is a named constant the cascade loops over, so a new user-scoped table is one line rather than one forgotten `DELETE`. `InMemoryStore` clears the same set through one shared method, because a fake that forgets a table cannot fail the test that would have caught this.
Media is no longer resolved by composing entries and reading their photo ids. `MediaOwnershipPort::owned_by` returns everything the account owns, so a blob uploaded and abandoned before its entry was saved is cleared with the rest — the orphan that the entry-shaped question could never see. `clear_data` and `delete_user` lost their `dimensions` and `entry_query` dependencies as a result; `delete_entries_by_date_range` keeps the entry-shaped path, because it deletes a subset of entries and ownership does not say which entry a blob belongs to.
Ownership is still read before the cascade runs, for the same reason it always was: the cascade deletes the rows that name the blobs.

View File

@@ -0,0 +1,22 @@
# 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.

View File

@@ -0,0 +1,28 @@
# Reading entries is one selection and one window, not four query methods
`MoodEntryQueryPort` grew a method per question: `find_by_user` with an optional limit and offset, `find_by_date_range`, `find_by_mood`, `find_by_activity`. Only the first paged, none counted, and none composed — a client could not ask for "this month's bad days, twenty at a time", and `GET /entries/filter/mood/5` returned every matching entry an account had ever written.
Without a count, `GET /entries` could only be paged by fetching until a short page came back, and a client polling for changes had to refetch the whole journal because `updatedAt` was returned but never queryable.
## Considered Options
- **Add limit, offset and a count to each existing method** — four signatures growing the same four parameters, and still no way to combine two narrowings.
- **Add the paging methods beside the old ones** — no risk to the internal readers, and two ways to ask the same question, which is the shape this document exists to remove.
## Consequences
`EntrySelection` names what to read — account, date range, mood, activity, changed-since — and `Pagination` names the window over it. The port offers `select` and `count` against that pair, and one SQL builder assembles both from the same conditions, so a filter can never apply to the page and not the count.
`find_by_mood` and `find_by_activity` are gone; they were only ever the API's. `find_by_user` became `find_all_by_user` with no window at all, because every remaining caller — backup, extract, import, restore, stats — wants the whole account and said so by passing `None, None`. The two paths are now honestly different: one page of a selection, or everything.
`Pagination::new` refuses a page of nothing, a negative offset, and a page larger than the server serves, so an unbounded read is not expressible rather than merely discouraged. The bound is configuration, reported by `GET /api/v1/server`, not a constant.
`Page` carries the total for the whole selection alongside the items, so `hasMore` is a fact rather than an inference from a short page.
`/entries/filter/mood/{mood}` and `/entries/filter/activity/{id}` stay, and now delegate to the same handler with one parameter preset. They are a convenience over the selection, not a second way to read.
## What a polling client should do
`updatedSince` returns only entries changed after an instant, which is why `updated_at` is indexed. A widget refreshing every minute asks for what changed rather than for the journal, and the answer is usually empty.
This is a poll, not a subscription: an entry deleted since the last poll leaves no trace in the result, so a client that must notice deletions still needs an occasional full read. Recording tombstones to make deletion observable is real work for a case a personal journal has not yet needed.

View File

@@ -0,0 +1,21 @@
# Every refusal is one shape, and the spec says so
A client talking to this API had to parse four different failure bodies: `{"error":{"code","message"}}` from a handler, `{"error":"..."}` from the auth extractor, the same again from the path-id extractor, and axum's plain text when a JSON body would not parse. None of the four appeared in the OpenAPI document, which described zero refusals across sixty-four operations.
Login and refresh — the first two calls any client makes — returned `Json<serde_json::Value>` and were documented with a description and no schema, so a generated client received an untyped blob from the endpoints it needs most.
## Consequences
`ErrorResponse` lives in `api-types` beside every other payload, and `refuse` is the single function that builds one. The auth and path-id rejections call it; `Body` and `Params` replace axum's `Json` and `Query` extractors so a malformed request is refused in the same shape as everything else; the rate limiter's 429 uses it too.
A scope refusal is now 403 rather than 401. The distinction matters to a client: 401 means the credential is not valid, and retrying with the same one is pointless; 403 means it is valid and insufficient, and the answer is a token minted with more.
Every operation declares its refusals, and four contract tests hold the line: every success carries a schema, every operation documents a 4xx, every guarded operation documents both 401 and 403, and every refusal references `ErrorResponse` and nothing else. A new endpoint that skips this fails the suite rather than shipping undocumented.
## The api-types crate says what each type is for
The crate now has three input-and-output modules rather than two: `requests` for bodies a client sends, `params` for query strings, `responses` for bodies it receives. Query parameters were previously mixed among request bodies, which made "what can a client send here" a question about the type's name rather than its module.
Every type carries the suffix of its kind, with no exceptions left — `MoodFrequency` and `ErrorDetail` were the last two, and both were nested payload parts whose names claimed otherwise.
The SPA's zod schemas now mirror the server's type names one for one. That is not cosmetic: aligning them surfaced that `AuthTokenResponse` had modelled `user` as optional to cover both login and refresh, so the SPA had no way to notice the server dropping a field from one of them.