fix(api): meter every authenticated write where the caller is resolved

169 of 364 mutating handlers met no rate limit, every PATCH and DELETE on
an [id] among them. The shared per-account write bucket (300 a minute,
keyed on the acting account) was called by hand in a dozen creates; it is
now charged by the identity resolvers for any POST, PUT, PATCH or DELETE,
once per request, so a route is metered by resolving its caller.

The bulk ingest routes the native client syncs through in bursts
(measurements, workouts, intake, mood and cycle bulk, nutrients, the ECG
sweep) declare writeBucket: "own" and keep their dedicated ceilings, so a
large HealthKit backfill cannot lock the person out of logging a dose. A
read-over-POST stays free.

write-rate-limit-guard fails on any mutating route that neither resolves
its caller through a metering resolver nor calls a limiter, unless it is
on a short reasoned exception list; an opt-out must be on its own reviewed
list and call its own limiter.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 13:53:54 +02:00
1 parent 3e9594b3b8
commit 39d23dbb7d
33 files changed
+730 -216

No files matched your search

+7 -5
View File
@@ -356,10 +356,12 @@ export const stdResponses = {
*
* The batch endpoints have been capped at 60 calls a minute since they were
* written and the per-record siblings were not capped at all, which is exactly
* backwards: the batch endpoint is the one a well-behaved client uses. The
* eleven single-record creates now share one generous per-account bucket, and
* this is the response that names it — a client that meets a ceiling should be
* able to read which one it met without a bug report.
* backwards: the batch endpoint is the one a well-behaved client uses. Every
* authenticated write now shares one generous per-account bucket, charged where
* the caller is resolved (`src/lib/auth/write-meter.ts`), and this is the
* response that names it on the single-record creates that publish it — a
* client that meets a ceiling should be able to read which one it met without
* a bug report.
*
* Spread AFTER `...stdResponses` so it replaces the generic 429 on those
* operations. The headers block is the one the standard 429 already declares.
@@ -371,7 +373,7 @@ export const stdResponses = {
export const recordWriteRateLimitResponse = {
"429": {
description:
"Rate limit exceeded. This route shares one per-account bucket with the other single-record writes — `record-write:<accountId>`, 300 requests per 60 seconds — keyed on the ACTING account, so a delegate burns their own allowance rather than the record owner's. Nothing was written. `meta.errorCode` is `record_write.rate_limited`, which is what tells this refusal apart from a route's own narrower bucket when both can answer 429. The `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` headers describe that bucket; back off on `Retry-After` rather than guessing. Every request counts against the bucket, refused ones included: the check runs before the body is read, so a client looping on a 422 spends the same allowance as one that writes rows. A client with more than a handful of rows to send should use the batch endpoint for its domain instead of looping this one.",
"Rate limit exceeded. This route shares one per-account bucket with every other authenticated write — `record-write:<accountId>`, 300 requests per 60 seconds — keyed on the ACTING account, so a delegate burns their own allowance rather than the record owner's. Only the bulk ingest endpoints, which meter themselves with a dedicated bucket, stay outside it. Nothing was written. `meta.errorCode` is `record_write.rate_limited`, which is what tells this refusal apart from a route's own narrower bucket when both can answer 429. The `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` headers describe that bucket; back off on `Retry-After` rather than guessing. Every request counts against the bucket, refused ones included: the check runs before the body is read, so a client looping on a 422 spends the same allowance as one that writes rows. A client with more than a handful of rows to send should use the batch endpoint for its domain instead of looping this one.",
content: { "application/json": { schema: errorEnvelope } },
headers: stdResponses["429"].headers,
},