mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
fix(api): put a ceiling under the single-record writes
The batch endpoints have been capped at 60 calls a minute since they were written. The per-record siblings a naive or older client loops over — the measurement create, the mood entry, both medication-intake writes, a lab result, a custom-metric entry, a cycle day-log, a vaccination, an allergy, a biomarker and an encounter — had no ceiling at all. That is backwards from the risk: the batch endpoint is the one a well-behaved client uses, and the unlimited path is the one a broken loop finds. All eleven share one generous per-account bucket, `record-write`, at 300 requests a minute. Five writes a second sustained for a full minute is beyond any hand-driven session and any sync burst, and every one of these routes is already wrapped in `withIdempotency`, so an honest retry storm is deduplicated before it is counted — the bucket exists to cut off the pathological loop, not to shape traffic. It keys on the ACTING account, the precedent the delegable medication routes set, so a delegate burns their own allowance rather than locking the record's owner out of their own log. The eleven operations publish the bucket name and the cap instead of the generic refusal, on the shared 429 that already declares `Retry-After` and the rate-limit triple. A test pulls the key and the window out of the query the limiter runs and holds the published sentence to them, so the paragraph cannot drift away from the bucket it describes.
This commit is contained in:
1 parent
040b430e73
commit
ab6c1cd917
35 files changed
+804
-358
No files matched your search
+362
-351
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* The single-record write ceiling is the one the contract describes.
|
||||
*
|
||||
* The batch endpoints have been capped at 60 calls a minute since they were
|
||||
* written; the per-record creates a naive or older client loops over were not
|
||||
* capped at all, which is backwards — the batch endpoint is the one a
|
||||
* well-behaved client uses. Eleven of them share one generous per-account
|
||||
* bucket now, and the operations publish its name and its cap so a client that
|
||||
* meets the ceiling can read which one it met.
|
||||
*
|
||||
* A published number is only worth reading if it cannot drift from the code, so
|
||||
* this pulls the bucket key and the limit out of `prisma.$queryRaw` — the same
|
||||
* technique `rate-limit-auth-surface.test.ts` uses — and holds the emitted
|
||||
* OpenAPI description to what came back. Reading both from the same constant
|
||||
* would prove nothing: the sentence is written by hand, deliberately, and this
|
||||
* is what keeps the hand-written half honest.
|
||||
*
|
||||
* The route half is structural: every route in the list below calls the shared
|
||||
* helper. Its limit is that it reads the file rather than the running handler,
|
||||
* so a call placed after an early return would still count as present.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
RECORD_WRITE_BUCKET_PREFIX,
|
||||
RECORD_WRITE_LIMIT,
|
||||
RECORD_WRITE_WINDOW_MS,
|
||||
checkRecordWriteRateLimit,
|
||||
} from "@/lib/rate-limit";
|
||||
import { buildOpenApiDocument } from "@/lib/openapi/registry";
|
||||
import { recordWriteRateLimitResponse } from "@/lib/openapi/routes/shared";
|
||||
|
||||
vi.mock("@/lib/db", () => ({ prisma: { $queryRaw: vi.fn() } }));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* The single-record writes on the shared bucket, paired with the operation
|
||||
* that publishes it. Hand-listed on purpose: the point of the list is that
|
||||
* adding a create without the limiter is a decision somebody has to make, and
|
||||
* a list derived from the routes that already call it could not notice one.
|
||||
*/
|
||||
const ON_THE_SHARED_BUCKET: Array<[route: string, openApiPath: string]> = [
|
||||
["src/app/api/measurements/route.ts", "/api/measurements"],
|
||||
["src/app/api/mood-entries/route.ts", "/api/mood-entries"],
|
||||
["src/app/api/medications/intake/route.ts", "/api/medications/intake"],
|
||||
[
|
||||
"src/app/api/medications/[id]/intake/route.ts",
|
||||
"/api/medications/{id}/intake",
|
||||
],
|
||||
["src/app/api/labs/route.ts", "/api/labs"],
|
||||
[
|
||||
"src/app/api/custom-metrics/[id]/entries/route.ts",
|
||||
"/api/custom-metrics/{id}/entries",
|
||||
],
|
||||
["src/app/api/cycle/day-logs/route.ts", "/api/cycle/day-logs"],
|
||||
["src/app/api/vaccinations/route.ts", "/api/vaccinations"],
|
||||
["src/app/api/allergies/route.ts", "/api/allergies"],
|
||||
["src/app/api/biomarkers/route.ts", "/api/biomarkers"],
|
||||
["src/app/api/encounters/route.ts", "/api/encounters"],
|
||||
];
|
||||
|
||||
/** The published sentence, read from the object the route modules spread. */
|
||||
const published = recordWriteRateLimitResponse["429"].description;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(prisma.$queryRaw).mockReset();
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([
|
||||
{ count: 1, reset_at: new Date(Date.now() + 60_000) },
|
||||
] as never);
|
||||
});
|
||||
|
||||
describe("the shared single-record write bucket", () => {
|
||||
it("keys on the account under the name the contract publishes", async () => {
|
||||
await checkRecordWriteRateLimit("account-7");
|
||||
|
||||
const call = vi.mocked(prisma.$queryRaw).mock.calls.at(-1)!;
|
||||
// `$queryRaw\`INSERT … VALUES (${key}, …)\`` lands the key at index 1 and
|
||||
// the window interval at index 2.
|
||||
expect(call[1]).toBe("record-write:account-7");
|
||||
expect(call[2]).toBe(`${RECORD_WRITE_WINDOW_MS} milliseconds`);
|
||||
expect(published).toContain("`record-write:<accountId>`");
|
||||
expect(`${RECORD_WRITE_BUCKET_PREFIX}:account-7`).toBe(call[1]);
|
||||
});
|
||||
|
||||
it("refuses at the cap the contract publishes", async () => {
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValueOnce([
|
||||
{ count: RECORD_WRITE_LIMIT, reset_at: new Date(Date.now() + 60_000) },
|
||||
] as never);
|
||||
await expect(checkRecordWriteRateLimit("account-7")).resolves.toMatchObject(
|
||||
{ allowed: true, limit: RECORD_WRITE_LIMIT, remaining: 0 },
|
||||
);
|
||||
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValueOnce([
|
||||
{
|
||||
count: RECORD_WRITE_LIMIT + 1,
|
||||
reset_at: new Date(Date.now() + 60_000),
|
||||
},
|
||||
] as never);
|
||||
await expect(checkRecordWriteRateLimit("account-7")).resolves.toMatchObject(
|
||||
{ allowed: false },
|
||||
);
|
||||
|
||||
const windowSeconds = RECORD_WRITE_WINDOW_MS / 1000;
|
||||
expect(published).toContain(
|
||||
`${RECORD_WRITE_LIMIT} requests per ${windowSeconds} seconds`,
|
||||
);
|
||||
});
|
||||
|
||||
it("says the headers describe that bucket, and declares them", () => {
|
||||
expect(Object.keys(recordWriteRateLimitResponse["429"].headers)).toEqual([
|
||||
"Retry-After",
|
||||
"X-RateLimit-Limit",
|
||||
"X-RateLimit-Remaining",
|
||||
"X-RateLimit-Reset",
|
||||
]);
|
||||
expect(published).toContain("Retry-After");
|
||||
});
|
||||
|
||||
it("is called by every route that publishes it", () => {
|
||||
const missing = ON_THE_SHARED_BUCKET.filter(([route]) => {
|
||||
const src = readFileSync(join(process.cwd(), route), "utf8");
|
||||
return !src.includes("checkRecordWriteRateLimit(");
|
||||
}).map(([route]) => route);
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("is published on every operation that answers it", () => {
|
||||
const doc = buildOpenApiDocument() as {
|
||||
paths: Record<
|
||||
string,
|
||||
{ post?: { responses?: Record<string, { description?: string }> } }
|
||||
>;
|
||||
};
|
||||
const unpublished = ON_THE_SHARED_BUCKET.filter(
|
||||
([, path]) =>
|
||||
doc.paths[path]?.post?.responses?.["429"]?.description !== published,
|
||||
).map(([, path]) => path);
|
||||
expect(unpublished).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -15,11 +15,13 @@ 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 {
|
||||
@@ -84,7 +86,15 @@ 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 } = await requireRecordAuth("manage", "profile");
|
||||
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);
|
||||
}
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/rate-limit", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/rate-limit")>()),
|
||||
// The shared single-record write ceiling. Allowed here: this file is about
|
||||
// what the handler does with a body it accepted, not about the bucket.
|
||||
checkRecordWriteRateLimit: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
limit: 300,
|
||||
remaining: 299,
|
||||
resetAt: Date.now() + 60_000,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
biomarker: { findFirst: vi.fn(), create: vi.fn(), findMany: vi.fn() },
|
||||
|
||||
@@ -9,6 +9,7 @@ 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";
|
||||
@@ -95,7 +96,15 @@ 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 } = await requireRecordAuth("write", "labs");
|
||||
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);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/rate-limit", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/rate-limit")>()),
|
||||
// The shared single-record write ceiling. Allowed here: this file is about
|
||||
// what the handler does with a body it accepted, not about the bucket.
|
||||
checkRecordWriteRateLimit: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
limit: 300,
|
||||
remaining: 299,
|
||||
resetAt: Date.now() + 60_000,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
customMetric: { findFirst: vi.fn() },
|
||||
|
||||
@@ -9,6 +9,7 @@ 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";
|
||||
@@ -105,6 +106,14 @@ async function postCustomMetricEntry(
|
||||
// tracked value adds it back in the same diff: the argument for admitting it
|
||||
// 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);
|
||||
}
|
||||
const { id } = await params;
|
||||
|
||||
const metric = await prisma.customMetric.findFirst({
|
||||
|
||||
@@ -34,6 +34,14 @@ vi.mock("@/lib/auth/audit", () => ({
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
rateLimitHeaders: () => ({}),
|
||||
// The shared single-record write ceiling. Allowed here: this file is about
|
||||
// what the handler does with a body it accepted, not about the bucket.
|
||||
checkRecordWriteRateLimit: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
limit: 300,
|
||||
remaining: 299,
|
||||
resetAt: Date.now() + 60_000,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/idempotency", () => ({
|
||||
withIdempotency:
|
||||
|
||||
@@ -26,6 +26,7 @@ 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 {
|
||||
@@ -94,11 +95,19 @@ 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 } = await requireRecordAuth("manage", "cycle");
|
||||
const { user, actor } = 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);
|
||||
}
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
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 {
|
||||
@@ -154,7 +155,15 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postEncounter));
|
||||
|
||||
async function postEncounter(request: NextRequest): Promise<Response> {
|
||||
const { user, grantId } = await requireRecordAuth("write", "profile");
|
||||
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);
|
||||
}
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 32 * 1024,
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/rate-limit", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/rate-limit")>()),
|
||||
// The shared single-record write ceiling. Allowed here: this file is about
|
||||
// what the handler does with a body it accepted, not about the bucket.
|
||||
checkRecordWriteRateLimit: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
limit: 300,
|
||||
remaining: 299,
|
||||
resetAt: Date.now() + 60_000,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
labResult: {
|
||||
|
||||
@@ -10,6 +10,7 @@ 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";
|
||||
@@ -148,7 +149,15 @@ 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 } = await requireRecordAuth("write", "labs");
|
||||
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);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
|
||||
@@ -12,6 +12,17 @@ const SELF = {
|
||||
timezone: "Europe/Berlin",
|
||||
};
|
||||
|
||||
vi.mock("@/lib/rate-limit", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/rate-limit")>()),
|
||||
// The shared single-record write ceiling. Allowed here: this file is about
|
||||
// what the handler does with a body it accepted, not about the bucket.
|
||||
checkRecordWriteRateLimit: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
limit: 300,
|
||||
remaining: 299,
|
||||
resetAt: Date.now() + 60_000,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: (fn: unknown) => fn,
|
||||
requireRecordAuth: vi.fn(async () => ({ user: SELF, actor: SELF })),
|
||||
|
||||
@@ -9,6 +9,17 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/rate-limit", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/rate-limit")>()),
|
||||
// The shared single-record write ceiling. Allowed here: this file is about
|
||||
// what the handler does with a body it accepted, not about the bucket.
|
||||
checkRecordWriteRateLimit: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
limit: 300,
|
||||
remaining: 299,
|
||||
resetAt: Date.now() + 60_000,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
createMeasurementSchema,
|
||||
createBatchMeasurementSchema,
|
||||
@@ -801,6 +802,14 @@ 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);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
|
||||
@@ -22,6 +22,17 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/rate-limit", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/rate-limit")>()),
|
||||
// The shared single-record write ceiling. Allowed here: this file is about
|
||||
// what the handler does with a body it accepted, not about the bucket.
|
||||
checkRecordWriteRateLimit: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
limit: 300,
|
||||
remaining: 299,
|
||||
resetAt: Date.now() + 60_000,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
medication: {
|
||||
|
||||
@@ -18,6 +18,17 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/rate-limit", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/rate-limit")>()),
|
||||
// The shared single-record write ceiling. Allowed here: this file is about
|
||||
// what the handler does with a body it accepted, not about the bucket.
|
||||
checkRecordWriteRateLimit: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
limit: 300,
|
||||
remaining: 299,
|
||||
resetAt: Date.now() + 60_000,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
medication: {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
intakeSchema,
|
||||
listIntakeEventsSchema,
|
||||
@@ -58,6 +59,14 @@ 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);
|
||||
}
|
||||
|
||||
// v1.36.1 follow-up — 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
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/rate-limit", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/rate-limit")>()),
|
||||
// The shared single-record write ceiling. Allowed here: this file is about
|
||||
// what the handler does with a body it accepted, not about the bucket.
|
||||
checkRecordWriteRateLimit: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
limit: 300,
|
||||
remaining: 299,
|
||||
resetAt: Date.now() + 60_000,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
medicationIntakeEvent: {
|
||||
|
||||
@@ -21,6 +21,7 @@ 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";
|
||||
@@ -203,6 +204,14 @@ 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);
|
||||
}
|
||||
|
||||
const { data: body, error } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
|
||||
@@ -31,6 +31,17 @@ const txClient = {
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("@/lib/rate-limit", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/rate-limit")>()),
|
||||
// The shared single-record write ceiling. Allowed here: this file is about
|
||||
// what the handler does with a body it accepted, not about the bucket.
|
||||
checkRecordWriteRateLimit: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
limit: 300,
|
||||
remaining: 299,
|
||||
resetAt: Date.now() + 60_000,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
moodEntry: {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
createMoodEntrySchema,
|
||||
listMoodEntriesSchema,
|
||||
@@ -105,6 +106,14 @@ 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);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
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 {
|
||||
@@ -112,7 +113,15 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postVaccination));
|
||||
|
||||
async function postVaccination(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireRecordAuth("write", "profile");
|
||||
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);
|
||||
}
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 32 * 1024,
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
idempotencyKeyParameter,
|
||||
idempotentWrite,
|
||||
recordRefusal,
|
||||
recordWriteRateLimitResponse,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
|
||||
@@ -125,6 +126,7 @@ export const allergyPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
...recordWriteRateLimitResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
idempotencyKeyParameter,
|
||||
idempotentWrite,
|
||||
recordRefusal,
|
||||
recordWriteRateLimitResponse,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
|
||||
@@ -125,6 +126,7 @@ export const biomarkerPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
...conflict,
|
||||
...stdResponses,
|
||||
...recordWriteRateLimitResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
idempotencyKeyParameter,
|
||||
idempotentWrite,
|
||||
recordRefusal,
|
||||
recordWriteRateLimitResponse,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
|
||||
@@ -314,6 +315,7 @@ export const customMetricPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
...notFound,
|
||||
...stdResponses,
|
||||
...recordWriteRateLimitResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
idempotentWrite,
|
||||
malformedJsonResponse,
|
||||
recordRefusal,
|
||||
recordWriteRateLimitResponse,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
|
||||
@@ -710,6 +711,7 @@ export const cyclePaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
...cycleDisabledOnADelegableRoute,
|
||||
...stdResponses,
|
||||
...recordWriteRateLimitResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
idempotencyKeyParameter,
|
||||
idempotentWrite,
|
||||
recordRefusal,
|
||||
recordWriteRateLimitResponse,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
|
||||
@@ -273,6 +274,7 @@ export const encounterPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
...recordWriteRateLimitResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
idempotencyKeyParameter,
|
||||
idempotentWrite,
|
||||
recordRefusal,
|
||||
recordWriteRateLimitResponse,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
|
||||
@@ -161,6 +162,7 @@ export const labsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
...recordWriteRateLimitResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
measurementSourceEnum,
|
||||
measurementTypeEnum,
|
||||
recordRefusal,
|
||||
recordWriteRateLimitResponse,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
|
||||
@@ -885,6 +886,7 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
// After the spread: `stdResponses` carries a generic 422 that would
|
||||
// otherwise overwrite this one.
|
||||
...stdResponses,
|
||||
...recordWriteRateLimitResponse,
|
||||
"422": {
|
||||
description:
|
||||
"Validation failed, or (`measurement.create.source_not_permitted`) a narrow `measurements:write` credential named `APPLE_HEALTH` as the source. Refused rather than relabelled, so the caller can fix the payload instead of discovering rows it did not ask for.",
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
idempotentWrite,
|
||||
invalidBaseTokenResponse,
|
||||
recordRefusal,
|
||||
recordWriteRateLimitResponse,
|
||||
stdResponses,
|
||||
} from "../shared";
|
||||
|
||||
@@ -289,7 +290,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"401": stdResponses["401"],
|
||||
"429": stdResponses["429"],
|
||||
...recordWriteRateLimitResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1153,6 +1154,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
...recordWriteRateLimitResponse,
|
||||
},
|
||||
},
|
||||
get: {
|
||||
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
invalidBaseTokenResponse,
|
||||
moduleDisabledResponse,
|
||||
recordRefusal,
|
||||
recordWriteRateLimitResponse,
|
||||
stdResponses,
|
||||
updatedAtTokenField,
|
||||
} from "./shared";
|
||||
@@ -1636,6 +1637,7 @@ export const moodPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
...duplicateTimestampResponse,
|
||||
...recordRefusal(),
|
||||
...stdResponses,
|
||||
...recordWriteRateLimitResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -289,6 +289,32 @@ export const stdResponses = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The 429 the shared single-record write bucket answers with.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Spread AFTER `...stdResponses` so it replaces the generic 429 on those
|
||||
* operations. The headers block is the one the standard 429 already declares.
|
||||
*
|
||||
* `record-write-rate-limit-contract.test.ts` holds the numbers in this sentence
|
||||
* to the constants in `src/lib/rate-limit.ts`, so the paragraph cannot drift
|
||||
* away from the bucket it describes.
|
||||
*/
|
||||
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. 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. 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,
|
||||
},
|
||||
};
|
||||
|
||||
// ── Idempotent writes ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
idempotencyKeyParameter,
|
||||
idempotentWrite,
|
||||
recordRefusal,
|
||||
recordWriteRateLimitResponse,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
|
||||
@@ -263,6 +264,7 @@ export const vaccinationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
...recordWriteRateLimitResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -117,6 +117,44 @@ export async function checkAnalyticsReadRateLimit(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared per-account ceiling for the single-record writes.
|
||||
*
|
||||
* The batch endpoints have been capped at 60 calls a minute since they were
|
||||
* written; the per-record siblings a naive or older client loops over were not
|
||||
* capped at all, which is backwards — the batch endpoint is the one a
|
||||
* well-behaved client uses. This is the floor under the other direction:
|
||||
* `POST /api/measurements`, `/api/mood-entries`, both medication-intake writes,
|
||||
* `/api/labs`, a custom-metric entry, a cycle day-log, a vaccination, an
|
||||
* allergy, a biomarker and an encounter.
|
||||
*
|
||||
* One shared bucket, not one per route, for the reason
|
||||
* `checkAnalyticsReadRateLimit` gives: a loop that rotates across ten creates
|
||||
* should still meet a cap. Three hundred a minute is five writes a second
|
||||
* sustained for a full minute — no hand-driven session and no sync burst
|
||||
* reaches it, and every one of these routes is already wrapped in
|
||||
* `withIdempotency`, so an honest retry storm is deduplicated before it is
|
||||
* counted. It exists to cut off the pathological loop, not to shape traffic.
|
||||
*
|
||||
* Keyed on the ACTING account rather than the record, the frozen precedent from
|
||||
* the delegable medication routes: a delegate burns their own allowance rather
|
||||
* than locking the record's owner out of their own log, and cannot collect a
|
||||
* fresh one by switching records.
|
||||
*/
|
||||
export const RECORD_WRITE_BUCKET_PREFIX = "record-write";
|
||||
export const RECORD_WRITE_LIMIT = 300;
|
||||
export const RECORD_WRITE_WINDOW_MS = 60 * 1000;
|
||||
|
||||
export async function checkRecordWriteRateLimit(
|
||||
accountId: string,
|
||||
): Promise<RateLimitResult> {
|
||||
return checkRateLimit(
|
||||
`${RECORD_WRITE_BUCKET_PREFIX}:${accountId}`,
|
||||
RECORD_WRITE_LIMIT,
|
||||
RECORD_WRITE_WINDOW_MS,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.22.0 — per-credential ceiling for the remote MCP endpoint (`/mcp`).
|
||||
* The bucket is keyed by the `<userId>:<tokenId>` binding the Bearer
|
||||
|
||||
Reference in new issue
Block a user