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:
24
docs/adr/0014-media-ownership-is-recorded-not-inferred.md
Normal file
24
docs/adr/0014-media-ownership-is-recorded-not-inferred.md
Normal 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.
|
||||
Reference in New Issue
Block a user