mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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:
1 parent
3e9594b3b8
commit
39d23dbb7d
33 files changed
+730
-216
No files matched your search
@@ -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,
|
||||
|
||||
@@ -31,7 +31,6 @@ import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { mergeBiomarkers as runMerge } from "@/lib/labs/biomarker-merge";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { destroyedDetails } from "@/lib/sharing/audit-details";
|
||||
import { biomarkerMergeSchema } from "@/lib/validations/biomarker-merge";
|
||||
|
||||
@@ -45,16 +44,9 @@ async function mergeBiomarkers(
|
||||
request: NextRequest,
|
||||
{ params }: RouteParams,
|
||||
): Promise<Response> {
|
||||
const { user, actor } = await requireRecordAuth("manage", "labs");
|
||||
const { user } = await requireRecordAuth("manage", "labs");
|
||||
const { id } = await params;
|
||||
|
||||
const writeRl = await checkRecordWriteRateLimit(actor.id);
|
||||
if (!writeRl.allowed) {
|
||||
return apiError("Too many writes, try again later", 429, {
|
||||
errorCode: "record_write.rate_limited",
|
||||
});
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 4 * 1024,
|
||||
});
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
@@ -76,17 +75,7 @@ async function postBiomarker(request: NextRequest) {
|
||||
// duplicate pre-check and the `@@unique([userId, name])` backstop both scope
|
||||
// to the same resolved id, so a delegate adding a marker the owner already
|
||||
// tracks gets the ordinary 409 rather than a second copy.
|
||||
const { user, actor } = await requireRecordAuth("write", "labs");
|
||||
|
||||
// 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("write", "labs");
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate";
|
||||
import { serialiseCustomMetricEntry } from "@/lib/custom-metrics/custom-metric-store";
|
||||
@@ -108,15 +107,6 @@ async function postCustomMetricEntry(
|
||||
// was never wrong, and the GET arm above stays delegable meanwhile.
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// 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(user.id);
|
||||
if (!writeRl.allowed) {
|
||||
return apiError("Too many writes, try again later", 429, {
|
||||
errorCode: "record_write.rate_limited",
|
||||
});
|
||||
}
|
||||
const { id } = await params;
|
||||
|
||||
const metric = await prisma.customMetric.findFirst({
|
||||
|
||||
@@ -57,7 +57,10 @@ interface EntryResult {
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulk));
|
||||
|
||||
async function postBulk(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
// Metered by this route's own bulk bucket below, not the shared per-account
|
||||
// write bucket: the native client syncs through here in bursts (see
|
||||
// `meterAccountWrite`).
|
||||
const { user } = await requireAuth(undefined, { writeBucket: "own" });
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
import {
|
||||
@@ -95,21 +94,11 @@ async function postDayLog(request: NextRequest): Promise<Response> {
|
||||
// v1.37.0 — MANAGE. The upsert replaces the day, which is what edit means
|
||||
// here; the gate below resolves `user.gender` against the RECORD, so the
|
||||
// module follows the record rather than the caller.
|
||||
const { user, actor } = await requireRecordAuth("manage", "cycle");
|
||||
const { user } = await requireRecordAuth("manage", "cycle");
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
// 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 { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { apiListSuccess } from "@/lib/api/list-cap";
|
||||
import { combinedCap, listPage } from "@/lib/http/list-query";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
@@ -167,17 +166,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postEncounter));
|
||||
|
||||
async function postEncounter(request: NextRequest): Promise<Response> {
|
||||
const { user, actor, grantId } = await requireRecordAuth("write", "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, grantId } = await requireRecordAuth("write", "profile");
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 32 * 1024,
|
||||
|
||||
@@ -203,7 +203,10 @@ export const GET = apiHandler(async () => {
|
||||
* to the same row by construction rather than by a cached response.
|
||||
*/
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
// Metered by this route's own bulk bucket below, not the shared per-account
|
||||
// write bucket: the native client syncs through here in bursts (see
|
||||
// `meterAccountWrite`).
|
||||
const { user } = await requireAuth(undefined, { writeBucket: "own" });
|
||||
// A device ingest carries no model text: switching AI off, or the AI
|
||||
// analysis opt-out, must never refuse a recording.
|
||||
|
||||
|
||||
@@ -33,6 +33,12 @@ vi.mock("@/lib/rate-limit", async (importOriginal) => ({
|
||||
resetAt: new Date(Date.now() + 60_000),
|
||||
})),
|
||||
}));
|
||||
// The real meter, so the ceiling is proven on the path it now takes: the route
|
||||
// resolves its caller, the resolver charges the bucket. The unit setup stubs
|
||||
// the meter for every other route test.
|
||||
vi.mock("@/lib/auth/write-meter", async (importOriginal) =>
|
||||
importOriginal<typeof import("@/lib/auth/write-meter")>(),
|
||||
);
|
||||
const tzMock = vi.hoisted(() => ({ zone: "UTC" }));
|
||||
vi.mock("@/lib/tz/resolver", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/tz/resolver")>()),
|
||||
@@ -216,7 +222,7 @@ describe("POST /api/labs/ocr/commit dedup across the user's day", () => {
|
||||
});
|
||||
|
||||
describe("POST /api/labs/ocr/commit write ceiling", () => {
|
||||
it("charges the shared per-account record-write bucket", async () => {
|
||||
it("charges the shared per-account record-write bucket where the caller is resolved", async () => {
|
||||
const res = await POST(postReq({ rows: [numericRow(95)] }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(checkRecordWriteRateLimit).toHaveBeenCalledWith("user-1");
|
||||
|
||||
@@ -20,7 +20,6 @@ import { fireAndForget } from "@/lib/logging/fire-and-forget";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
@@ -31,7 +30,6 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
|
||||
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
|
||||
import { rememberImportAlias } from "@/lib/labs/biomarker-aliases";
|
||||
@@ -159,17 +157,6 @@ function inBatchKey(
|
||||
async function commitOcrRows(request: NextRequest) {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// Shared per-account write ceiling, the same bucket every other lab write
|
||||
// charges (`POST /api/labs`, `POST /api/biomarkers`). A commit can write a
|
||||
// whole report's rows and mint markers on the way, so it was the one lab
|
||||
// write a looping client could repeat without any ceiling.
|
||||
const writeRl = await checkRecordWriteRateLimit(user.id);
|
||||
if (!writeRl.allowed) {
|
||||
return apiError("Too many writes, try again later", 429, {
|
||||
errorCode: "record_write.rate_limited",
|
||||
});
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 256 * 1024,
|
||||
});
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
|
||||
@@ -156,17 +155,7 @@ async function postLabResult(request: NextRequest) {
|
||||
// it mints it under `user.id`: the marker joins the record's catalogue, not
|
||||
// the caller's, which is the only reading of "add a result to your record"
|
||||
// that leaves the owner with a usable catalogue afterwards.
|
||||
const { user, actor } = await requireRecordAuth("write", "labs");
|
||||
|
||||
// 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("write", "labs");
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
|
||||
@@ -21,7 +21,6 @@ import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
@@ -36,7 +35,6 @@ import {
|
||||
toLifeEventDTO,
|
||||
} from "@/lib/life-events/service";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -53,13 +51,6 @@ export const POST = apiHandler(withIdempotency<[NextRequest]>(postLifeEvent));
|
||||
async function postLifeEvent(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const writeRl = await checkRecordWriteRateLimit(user.id);
|
||||
if (!writeRl.allowed) {
|
||||
return apiError("Too many writes, try again later", 429, {
|
||||
errorCode: "record_write.rate_limited",
|
||||
});
|
||||
}
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
|
||||
@@ -293,7 +293,12 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
// a native login mints. `requireAuth` refuses any acting-account carrier for
|
||||
// every credential shape, so the scoped token is confined to its owner's own
|
||||
// record here without this route adding an arm of its own.
|
||||
const auth = await requireAuth(MEASUREMENTS_WRITE_SCOPE);
|
||||
// Metered by this route's own bulk bucket below, not the shared per-account
|
||||
// write bucket: the native client syncs through here in bursts (see
|
||||
// `meterAccountWrite`).
|
||||
const auth = await requireAuth(MEASUREMENTS_WRITE_SCOPE, {
|
||||
writeBucket: "own",
|
||||
});
|
||||
const { user } = auth;
|
||||
|
||||
// A scoped credential is a bridge — a scale, a home-automation rule — and not
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
createMeasurementSchema,
|
||||
createBatchMeasurementSchema,
|
||||
@@ -403,16 +402,6 @@ async function postMeasurement(request: NextRequest) {
|
||||
const { user } = auth;
|
||||
const scoped = isScopedCredential(auth);
|
||||
|
||||
// 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(auth.actor.id);
|
||||
if (!writeRl.allowed) {
|
||||
return apiError("Too many writes, try again later", 429, {
|
||||
errorCode: "record_write.rate_limited",
|
||||
});
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
intakeSchema,
|
||||
listIntakeEventsSchema,
|
||||
@@ -62,16 +61,6 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
"medications",
|
||||
);
|
||||
|
||||
// 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",
|
||||
});
|
||||
}
|
||||
|
||||
// v1.36.1 — the sibling route `POST /api/medications/intake`
|
||||
// refuses a delegate changing a dose the owner already recorded. This route
|
||||
// reaches the same rows through the slot upsert, so without the same rule it
|
||||
|
||||
@@ -197,7 +197,10 @@ interface EntryResult {
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulk));
|
||||
|
||||
async function postBulk(request: NextRequest): Promise<Response> {
|
||||
const auth = await requireAuth();
|
||||
// Metered by this route's own bulk bucket below, not the shared per-account
|
||||
// write bucket: the native client syncs through here in bursts (see
|
||||
// `meterAccountWrite`).
|
||||
const auth = await requireAuth(undefined, { writeBucket: "own" });
|
||||
const { user } = auth;
|
||||
// A narrow, single-purpose credential (a bridge, an ingest token) is no
|
||||
// Apple Health sync, so it never writes the dose arrival record below. The
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
@@ -212,16 +211,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// notification at the end of this handler.
|
||||
const { user, actor } = await requireRecordAuth("write", "medications");
|
||||
|
||||
// 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 { data: body, error } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
|
||||
@@ -194,7 +194,10 @@ interface EntryResult {
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulk));
|
||||
|
||||
async function postBulk(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
// Metered by this route's own bulk bucket below, not the shared per-account
|
||||
// write bucket: the native client syncs through here in bursts (see
|
||||
// `meterAccountWrite`).
|
||||
const { user } = await requireAuth(undefined, { writeBucket: "own" });
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`mood-entries:bulk:${user.id}`,
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
createMoodEntrySchema,
|
||||
listMoodEntriesSchema,
|
||||
@@ -109,16 +108,6 @@ async function postMoodEntry(request: NextRequest) {
|
||||
// the `externalId` refusal below.
|
||||
const { user, actor } = await requireRecordAuth("manage", "mind");
|
||||
|
||||
// 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 { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
|
||||
@@ -79,7 +79,10 @@ interface EntryResult {
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBatch));
|
||||
|
||||
async function postBatch(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
// Metered by this route's own bulk bucket below, not the shared per-account
|
||||
// write bucket: the native client syncs through here in bursts (see
|
||||
// `meterAccountWrite`).
|
||||
const { user } = await requireAuth(undefined, { writeBucket: "own" });
|
||||
|
||||
// Module gate first: a disabled module must refuse before any body
|
||||
// work, so iOS can branch on the 403 `module.disabled` errorCode and
|
||||
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { customVaccineCreateSchema } from "@/lib/validations/vaccinations";
|
||||
import { toCustomVaccineDTO } from "@/lib/vaccinations/dto";
|
||||
import {
|
||||
@@ -57,16 +56,7 @@ export const GET = apiHandler(async () => {
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user, actor } = await requireRecordAuth("write", "profile");
|
||||
|
||||
// The shared per-account write ceiling, keyed on the ACTOR so a delegate
|
||||
// spends their own allowance (`checkRecordWriteRateLimit`).
|
||||
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("write", "profile");
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 8 * 1024,
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { apiListSuccess } from "@/lib/api/list-cap";
|
||||
import { listPage } from "@/lib/http/list-query";
|
||||
@@ -150,17 +149,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postVaccination));
|
||||
|
||||
async function postVaccination(request: NextRequest): Promise<Response> {
|
||||
const { user, actor } = await requireRecordAuth("write", "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("write", "profile");
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 32 * 1024,
|
||||
|
||||
@@ -239,7 +239,12 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
// native login mints. The only route that names it. `requireAuth` refuses
|
||||
// any acting-account carrier for every credential shape, so the scoped token
|
||||
// is confined to its owner's own record without an arm of its own here.
|
||||
const auth = await requireAuth(WORKOUTS_WRITE_SCOPE);
|
||||
// Metered by this route's own bulk bucket below, not the shared per-account
|
||||
// write bucket: the native client syncs through here in bursts (see
|
||||
// `meterAccountWrite`).
|
||||
const auth = await requireAuth(WORKOUTS_WRITE_SCOPE, {
|
||||
writeBucket: "own",
|
||||
});
|
||||
const { user } = auth;
|
||||
|
||||
// A scoped credential is a bridge, not the native client: the source of
|
||||
|
||||
Reference in new issue
Block a user