Files
k-mood/docs/adr/0020-one-refusal-shape.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

22 lines
2.4 KiB
Markdown

# 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.