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

+1 -13
View File
@@ -15,13 +15,11 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
import { withIdempotency } from "@/lib/idempotency";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import {
@@ -92,17 +90,7 @@ async function postAllergy(request: NextRequest): Promise<Response> {
// A READ or WRITE delegate reaches neither: the destination is not on their
// Settings list, and this line refuses them with `sharing.access.denied`
// regardless.
const { user, actor } = await requireRecordAuth("manage", "profile");
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
// batch siblings have always been capped; the per-record creates a looping
// client hits were not.
const writeRl = await checkRecordWriteRateLimit(actor.id);
if (!writeRl.allowed) {
return apiError("Too many writes, try again later", 429, {
errorCode: "record_write.rate_limited",
});
}
const { user } = await requireRecordAuth("manage", "profile");
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,