Merge branch 'fix/v1438-api' into release/v1.43.8

This commit is contained in:
Marc-André Bombeck committed 2026-10-20 15:39:39 +02:00
commit 4a0db28713
99 files changed
+5219 -1018

No files matched your search

@@ -56,7 +56,11 @@ const CLIENT_FILES = NON_TEST_FILES.filter((rel) => !rel.startsWith("lib/"));
const FEATURE_FLAG_SWEEP = NON_TEST_FILES.filter(
(rel) =>
!rel.startsWith("lib/feature-flags/") && !rel.startsWith("lib/openapi/"),
!rel.startsWith("lib/feature-flags/") &&
!rel.startsWith("lib/openapi/") &&
// The deprecation registry names the route to date its retirement; it
// reads nothing from it.
rel !== "lib/http/deprecation.ts",
);
const read = (rel: string) =>
@@ -40,6 +40,177 @@ function filesMatching(re: RegExp): string[] {
return sourceFiles().filter((rel) => re.test(read(rel)));
}
/**
* The argument text of every call to `callee` in `src`, parens balanced.
*
* The matchers this file used to rely on stopped at the first `)` —
* `requireRecordAuth\([^)]*scope:` read `requireRecordAuth(need(), "x", {
* scope: X })` as having no scope at all — and demanded one spelling of a
* Bearer lookup. A guard whose matcher misses a real call site is green
* because it matched nothing. So calls are read whole: from the `(` after the
* name to its matching `)`, skipping string and template contents, and a
* function DECLARATION of the same name is not a call.
*/
function callArgs(src: string, callee: string): string[] {
const code = stripComments(src);
const out: string[] = [];
const re = new RegExp(`(?<![\\w$.])${callee}\\s*\\(`, "g");
for (let m = re.exec(code); m; m = re.exec(code)) {
if (/function\s*$/.test(code.slice(Math.max(0, m.index - 12), m.index))) {
continue;
}
out.push(argsFrom(code, m.index + m[0].length - 1));
}
return out;
}
/** The text between the `(` at `open` and its matching `)`. */
function argsFrom(code: string, open: number): string {
let depth = 0;
let quote: string | null = null;
let i = open;
for (; i < code.length; i++) {
const ch = code[i];
if (quote) {
if (ch === "\\") i++;
else if (ch === quote) quote = null;
continue;
}
if (ch === '"' || ch === "'" || ch === "`") quote = ch;
else if (ch === "(" || ch === "{" || ch === "[") depth++;
else if (ch === ")" || ch === "}" || ch === "]") {
depth--;
if (depth === 0) break;
}
}
return code.slice(open + 1, i);
}
function stripComments(text: string): string {
return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
}
/** Split an argument list at its top-level commas. */
function topLevelArgs(args: string): string[] {
const parts: string[] = [];
let depth = 0;
let quote: string | null = null;
let start = 0;
for (let i = 0; i < args.length; i++) {
const ch = args[i];
if (quote) {
if (ch === "\\") i++;
else if (ch === quote) quote = null;
continue;
}
if (ch === '"' || ch === "'" || ch === "`") quote = ch;
else if (ch === "(" || ch === "{" || ch === "[") depth++;
else if (ch === ")" || ch === "}" || ch === "]") depth--;
else if (ch === "," && depth === 0) {
parts.push(args.slice(start, i).trim());
start = i + 1;
}
}
const last = args.slice(start).trim();
if (last) parts.push(last);
return parts;
}
/** The scope a `requireAuth` call names: its first argument, or null. */
function requireAuthScopes(src: string): Array<string | null> {
return callArgs(src, "requireAuth").map((args) => {
const first = topLevelArgs(args)[0];
return first === undefined || first === "undefined" ? null : first;
});
}
/** The scopes the `requireRecordAuth` calls in `src` name in their options. */
function recordAuthScopes(src: string): string[] {
const out: string[] = [];
for (const args of callArgs(src, "requireRecordAuth")) {
for (const m of args.matchAll(/\bscope\s*:\s*([A-Za-z_$][\w$]*)/g)) {
out.push(m[1]);
}
}
return out;
}
/**
* Whether `src` turns a Bearer value into an identity: the shared resolver, or
* any `apiToken` lookup (`findUnique`, `findFirst`, `findMany`, their
* `OrThrow` forms, on `prisma` or a transaction) whose arguments name
* `tokenHash`, or raw SQL reading `api_tokens` by `token_hash`.
*/
function resolvesBearer(src: string): boolean {
const code = stripComments(src);
// The shared resolver, and the MCP edge's wrapper around it. The `/mcp`
// route was on the allowlist only because a COMMENT named the resolver;
// with comments stripped it is found by the call it actually makes.
if (/\bresolveBearerToken\b|\bresolveMcpAuthContext\s*\(/.test(code)) {
return true;
}
const lookups =
/\bapiToken\s*\.\s*find(?:Unique|First|Many)(?:OrThrow)?\s*\(/g;
for (const m of code.matchAll(lookups)) {
const args = argsFrom(code, m.index + m[0].length - 1);
if (/\btokenHash\b/.test(args)) return true;
}
// A raw read of the table by hash. An UPDATE that revokes by hash turns
// nothing into an identity and is not one.
return /\bSELECT\b[\s\S]{0,300}?\bFROM\s+"?api_tokens"?[\s\S]{0,300}?\btoken_hash\b/i.test(
code,
);
}
describe("the call matchers read whole calls", () => {
it("finds a scope behind nested parentheses and across lines", () => {
const planted = `const ctx = await requireRecordAuth(
needFor(method()),
"measurements",
{ scope: MEASUREMENTS_WRITE_SCOPE },
);`;
expect(recordAuthScopes(planted)).toEqual(["MEASUREMENTS_WRITE_SCOPE"]);
// The old matcher stopped at the first ")" and saw nothing.
expect(/requireRecordAuth\([^)]*\bscope:/.test(planted)).toBe(false);
});
it("reads the first requireAuth argument and ignores the options", () => {
expect(
requireAuthScopes(
'await requireAuth(WORKOUTS_WRITE_SCOPE, { writeBucket: "own" });\n' +
'await requireAuth(undefined, { writeBucket: "own" });\n' +
"await requireAuth();\n" +
"export async function requireAuth(requiredPermission?: string) {}\n" +
'await requireAuth("health:write");',
),
).toEqual(["WORKOUTS_WRITE_SCOPE", null, null, '"health:write"']);
});
it("recognises every spelling of a Bearer lookup", () => {
for (const planted of [
"prisma.apiToken.findUnique({ where: { tokenHash: h } })",
"prisma.apiToken\n .findUnique({\n where: { userId, tokenHash: hash },\n })",
"tx.apiToken.findFirst({ where: { revokedAt: null, tokenHash: digest(x) } })",
"await prisma.apiToken.findUniqueOrThrow({ where: { tokenHash } })",
"prisma.$queryRaw`SELECT user_id FROM api_tokens WHERE token_hash = ${h}`",
]) {
expect(resolvesBearer(planted), planted).toBe(true);
}
// A lookup by something else is not a Bearer resolution, even with the
// word elsewhere in the file, and neither is a revocation by hash.
expect(
resolvesBearer(
"prisma.apiToken.findMany({ where: { userId } });\nconst tokenHash = 1;",
),
).toBe(false);
expect(
resolvesBearer(
"prisma.$executeRaw`UPDATE api_tokens SET revoked = true WHERE token_hash = ${h}`",
),
).toBe(false);
});
});
describe("T1 — the Bearer resolution set is frozen", () => {
/**
* Files allowed to turn a raw `Authorization: Bearer` value into a user.
@@ -58,6 +229,13 @@ describe("T1 — the Bearer resolution set is frozen", () => {
"lib/mcp/auth.ts",
// The `/mcp` transport, via `resolveMcpAuthContext`.
"app/mcp/route.ts",
// The local stdio MCP entrypoint, via the same `resolveMcpAuthContext`: a
// pasted token for a power-user's own MCP client, run in a plain Node
// process outside the web server. Same edge, same posture as `/mcp`. It
// was a Bearer resolution this guard never saw, because the matcher only
// knew the resolver's own name and the `/mcp` entry above was matched
// through a comment.
"cli/mcp-stdio.ts",
// Hand-rolled Bearer authentication: the external medication-ingest
// surface, which gates on BOTH `medication:ingest` and the per-medication
// `medication:<id>:ingest` grant and never touches `requireAuth`.
@@ -90,9 +268,7 @@ describe("T1 — the Bearer resolution set is frozen", () => {
// `tokenHash` may sit anywhere inside the `where` object — demanding it
// first would let a formatting change (another key ahead of it) walk a
// resolver out of the guard's sight.
const resolvers = filesMatching(
/resolveBearerToken|apiToken\s*\.\s*findUnique\(\s*\{[\s\S]*?where:\s*\{[^}]*?tokenHash/,
);
const resolvers = sourceFiles().filter((rel) => resolvesBearer(read(rel)));
// Non-zero proof: an empty match set must fail rather than agree with an
// emptied allowlist.
@@ -207,18 +383,12 @@ describe("T3 — the mint sites are frozen", () => {
});
describe("T4 — declared scopes are exported constants, never string literals", () => {
it("every requireAuth argument is an identifier", () => {
it("every requireAuth scope argument is an identifier", () => {
const offenders: string[] = [];
for (const rel of sourceFiles()) {
const src = read(rel);
// Call sites only — skip the declaration in `api-handler.ts`, whose
// parameter list is not an argument.
for (const m of src.matchAll(
/(?<!function\s)requireAuth\(\s*([^)\s][^)]*)\)/g,
)) {
const arg = m[1].trim();
if (!/^[A-Za-z_$][\w$]*$/.test(arg)) {
offenders.push(`${rel}: requireAuth(${arg})`);
for (const scope of requireAuthScopes(read(rel))) {
if (scope !== null && !/^[A-Za-z_$][\w$]*$/.test(scope)) {
offenders.push(`${rel}: requireAuth(${scope})`);
}
}
}
@@ -243,16 +413,10 @@ describe("T4 — declared scopes are exported constants, never string literals",
const used = new Set<string>();
for (const rel of sourceFiles()) {
const src = read(rel);
for (const m of src.matchAll(
/(?<!function\s)requireAuth\(\s*([A-Za-z_$][\w$]*)\s*\)/g,
)) {
used.add(m[1]);
}
for (const m of src.matchAll(
/requireRecordAuth\([^)]*\bscope:\s*([A-Za-z_$][\w$]*)/g,
)) {
used.add(m[1]);
for (const scope of requireAuthScopes(src)) {
if (scope !== null) used.add(scope);
}
for (const scope of recordAuthScopes(src)) used.add(scope);
}
expect([...used].sort()).toEqual(DECLARED_SCOPES);
});
@@ -266,9 +430,13 @@ describe("T4b — `documents:write` opens exactly two doors", () => {
// an importer can skip a download. Nothing else: not the list or detail,
// not the original or thumbnail, not bulk, not the AI legs. A third route
// here is a widening and belongs in review.
const declaring = filesMatching(
/requireAuth\(\s*DOCUMENTS_WRITE_SCOPE\s*\)|requireRecordAuth\([^)]*\bscope:\s*DOCUMENTS_WRITE_SCOPE\b/,
);
const declaring = sourceFiles().filter((rel) => {
const src = read(rel);
return (
requireAuthScopes(src).includes("DOCUMENTS_WRITE_SCOPE") ||
recordAuthScopes(src).includes("DOCUMENTS_WRITE_SCOPE")
);
});
expect(declaring).toEqual([
"app/api/documents/inbound/route.ts",
"app/api/documents/inbound/source/route.ts",
@@ -277,7 +445,7 @@ describe("T4b — `documents:write` opens exactly two doors", () => {
// name it.
const upload = read("app/api/documents/inbound/route.ts");
expect(
upload.match(/requireAuth\(\s*DOCUMENTS_WRITE_SCOPE\s*\)/g),
requireAuthScopes(upload).filter((x) => x === "DOCUMENTS_WRITE_SCOPE"),
).toHaveLength(1);
// And the lookup is a read of one key and nothing else: a single GET.
const lookup = read("app/api/documents/inbound/source/route.ts");
@@ -294,13 +462,17 @@ describe("T4c — `workouts:write` opens exactly one door", () => {
// The set of files naming the scope IS its reach. The batch ingest and
// nothing else: not the workout list or detail, which would hand a
// bridge the history it has no business reading back.
const declaring = filesMatching(
/requireAuth\(\s*WORKOUTS_WRITE_SCOPE\s*\)|requireRecordAuth\([^)]*\bscope:\s*WORKOUTS_WRITE_SCOPE\b/,
);
const declaring = sourceFiles().filter((rel) => {
const src = read(rel);
return (
requireAuthScopes(src).includes("WORKOUTS_WRITE_SCOPE") ||
recordAuthScopes(src).includes("WORKOUTS_WRITE_SCOPE")
);
});
expect(declaring).toEqual(["app/api/workouts/batch/route.ts"]);
const batch = read("app/api/workouts/batch/route.ts");
expect(
batch.match(/requireAuth\(\s*WORKOUTS_WRITE_SCOPE\s*\)/g),
requireAuthScopes(batch).filter((x) => x === "WORKOUTS_WRITE_SCOPE"),
).toHaveLength(1);
expect(
[...batch.matchAll(/export const (GET|POST|PUT|PATCH|DELETE)\b/g)].map(
@@ -330,8 +502,8 @@ describe("T5 — a delegable route naming a Bearer scope is frozen", () => {
};
it("only the known routes pass a scope to the record resolver", () => {
const naming = filesMatching(
/requireRecordAuth\([^)]*\bscope:\s*[A-Za-z_$][\w$]*/,
const naming = sourceFiles().filter(
(rel) => recordAuthScopes(read(rel)).length > 0,
);
// Non-zero proof: an empty match set must fail rather than agree with an
// emptied table. This is the leg most likely to rot — the matcher spans a
@@ -343,11 +515,7 @@ describe("T5 — a delegable route naming a Bearer scope is frozen", () => {
it("each names the scope this table says it does", () => {
for (const [rel, scope] of Object.entries(SCOPED_RECORD_ROUTES)) {
const named = [
...read(rel).matchAll(
/requireRecordAuth\([^)]*\bscope:\s*([A-Za-z_$][\w$]*)/g,
),
].map((m) => m[1]);
const named = recordAuthScopes(read(rel));
expect(named).toEqual([scope]);
}
});
+219
View File
@@ -0,0 +1,219 @@
/**
* Structural guard: every capped or paged list gets its page from one helper,
* and every such read has a total order.
*
* The list routes had five answers to "is there more", and two of them lied:
* a `hasMore` computed as `rows.length === limit` reported more on a page that
* was exactly full, and a list cut at its `take` with no signal at all let the
* last row read as the oldest one there is. `listPage`
* (`src/lib/http/list-query.ts`) reads one row past the limit and says
* exactly whether that row exists. This guard holds the tree to it:
*
* 1. a route that answers with `apiListSuccess` gets its cap from
* `listPage`;
* 2. nothing outside the two list modules calls `capRows` or `overfetch` by
* hand;
* 3. a `hasMore` is never a length comparison;
* 4. every read passed to `listPage` orders by something that ends in the
* row id, so a page boundary between two rows that share a sort value
* neither repeats one nor drops one.
*
* The routes are derived from the tree, never listed by hand. Comments are
* stripped first, and the planted cases at the top prove each matcher fires.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { walkSourceFiles } from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
/** The modules allowed to cut a list by hand: the helper and its primitive. */
const LIST_MODULES = new Set([
"lib/api/list-cap.ts",
"lib/http/list-query.ts",
// The MCP tools' name for `listPage`; its reads are checked at its callers.
"lib/mcp/capped.ts",
]);
function stripComments(text: string): string {
return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
}
/** The text of a call's argument list, from the `(` after `name` to its match. */
function callBodies(code: string, name: string): string[] {
const out: string[] = [];
const re = new RegExp(`\\b${name}\\s*\\(`, "g");
for (let m = re.exec(code); m; m = re.exec(code)) {
let depth = 0;
let i = m.index + m[0].length - 1;
const start = i;
for (; i < code.length; i++) {
const ch = code[i];
if (ch === "(") depth++;
else if (ch === ")") {
depth--;
if (depth === 0) break;
}
}
out.push(code.slice(start, i + 1));
}
return out;
}
/** The value of the first `orderBy` in a read, up to its closing bracket. */
function orderByValue(body: string): string | null {
const m = /\borderBy\b\s*(:)?\s*/.exec(body);
if (!m) return null;
// Shorthand `orderBy,` names a variable of the same name.
if (!m[1]) return "orderBy";
let i = m.index + m[0].length;
const open = body[i];
if (open !== "[" && open !== "{") {
const ident = /^[A-Za-z_$][\w$]*/.exec(body.slice(i));
return ident ? ident[0] : null;
}
const close = open === "[" ? "]" : "}";
let depth = 0;
const start = i;
for (; i < body.length; i++) {
if (body[i] === open) depth++;
else if (body[i] === close) {
depth--;
if (depth === 0) break;
}
}
return body.slice(start, i + 1);
}
/** Whether an order ends in the row id, following one variable if named. */
function endsInId(order: string, file: string): boolean {
const literal = (text: string) =>
/^\[[\s\S]*\{\s*id\s*:\s*[^}]+\}\s*,?\s*\]$/.test(text.trim());
if (order.startsWith("[") || order.startsWith("{")) return literal(order);
const decl = new RegExp(
`\\b(?:const|let)\\s+${order}\\b[^=]*=\\s*(\\[[\\s\\S]*?\\]);`,
).exec(file);
return decl ? literal(decl[1]) : false;
}
/**
* A `distinct` read returns one row per key, so ordering by that key alone is
* already total: `distinct: ["analyte"]` with `orderBy: { analyte: "asc" }`.
*/
function ordersByDistinctKey(body: string, order: string): boolean {
const distinct = /\bdistinct\s*:\s*\[\s*["'](\w+)["']\s*\]/.exec(body);
if (!distinct) return false;
return new RegExp(`^\\{\\s*${distinct[1]}\\s*:[^,}]+\\}$`).test(order.trim());
}
describe("capped lists go through listPage", () => {
const files = walkSourceFiles(SRC, { floor: 3000 }).filter(
(file) =>
!file.startsWith("generated/") &&
!file.includes("__tests__/") &&
!/\.test\.tsx?$/.test(file),
);
const read = (file: string) =>
stripComments(readFileSync(join(SRC, file), "utf8"));
const routes = files.filter((f) => /^app\/api\/.+\/route\.tsx?$/.test(f));
it("the matchers fire on planted sources", () => {
const handRolled = `
const found = await prisma.allergy.findMany({ take: overfetch(limit) });
const { rows, cap } = capRows(found, limit);
return apiListSuccess(rows, cap);`;
expect(/\bcapRows\s*\(/.test(handRolled)).toBe(true);
expect(/\blistPage\s*\(/.test(handRolled)).toBe(false);
expect(
/\bhasMore\s*:[^,\n]*\.length\b/.test(
"hasMore: rows.length === q.limit,",
),
).toBe(true);
expect(
/\bhasMore\s*:[^,\n]*\.length\b/.test("hasMore: cap.truncated,"),
).toBe(false);
const noTiebreak = `listPage(limit, (take) =>
prisma.x.findMany({ orderBy: { createdAt: "desc" }, take }))`;
const [body] = callBodies(noTiebreak, "listPage");
expect(endsInId(orderByValue(body)!, noTiebreak)).toBe(false);
const tiebreak = `listPage(limit, (take) =>
prisma.x.findMany({ orderBy: [{ createdAt: "desc" }, { id: "desc" }], take }))`;
const [good] = callBodies(tiebreak, "listPage");
expect(endsInId(orderByValue(good)!, tiebreak)).toBe(true);
// A shorthand `orderBy` resolves through its declaration.
const viaVariable = `const orderBy: X[] = [primaryOrder, { id: order }];
listPage(limit, (take) => prisma.x.findMany({ where, orderBy, take }))`;
const [short] = callBodies(viaVariable, "listPage");
expect(endsInId(orderByValue(short)!, viaVariable)).toBe(true);
// A distinct read is ordered whole by its own key.
const distinctRead = `listPage(limit, (take) =>
prisma.x.findMany({ distinct: ["analyte"], orderBy: { analyte: "asc" }, take }))`;
const [dist] = callBodies(distinctRead, "listPage");
expect(ordersByDistinctKey(dist, orderByValue(dist)!)).toBe(true);
expect(ordersByDistinctKey(dist, '{ createdAt: "desc" }')).toBe(false);
const viaBadVariable = `const orderBy = [primaryOrder];
listPage(limit, (take) => prisma.x.findMany({ where, orderBy, take }))`;
const [bad] = callBodies(viaBadVariable, "listPage");
expect(endsInId(orderByValue(bad)!, viaBadVariable)).toBe(false);
});
it("finds the capped routes", () => {
const capped = routes.filter((f) => /\bapiListSuccess\s*\(/.test(read(f)));
expect(capped.length).toBeGreaterThanOrEqual(15);
});
it("every route answering a capped list gets its cap from listPage", () => {
const unbound = routes.filter((f) => {
const code = read(f);
return (
/\bapiListSuccess\s*\(/.test(code) && !/\blistPage\s*\(/.test(code)
);
});
expect(unbound).toEqual([]);
});
it("nothing cuts a list by hand outside the list modules", () => {
const handRolled = files.filter(
(f) =>
!LIST_MODULES.has(f) && /\b(?:capRows|overfetch)\s*\(/.test(read(f)),
);
expect(handRolled).toEqual([]);
});
it("no hasMore is a length comparison", () => {
const guessed = routes.filter((f) =>
/\bhasMore\s*:[^,\n]*\.length\b/.test(read(f)),
);
expect(guessed).toEqual([]);
});
it("every listPage or capped read orders by something that ends in the row id", () => {
const unordered: string[] = [];
let reads = 0;
for (const f of files) {
if (LIST_MODULES.has(f)) continue;
const code = read(f);
for (const body of [
...callBodies(code, "listPage"),
...callBodies(code, "capped"),
]) {
reads += 1;
const order = orderByValue(body);
if (
!order ||
!(endsInId(order, code) || ordersByDistinctKey(body, order))
) {
unordered.push(f);
}
}
}
expect(reads).toBeGreaterThanOrEqual(18);
expect(unordered).toEqual([]);
});
});
@@ -4,9 +4,11 @@
* 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.
* 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 the operations listed below 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
@@ -15,9 +17,9 @@
* 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.
* The route half is structural: every route in the list below resolves its
* caller through a metering resolver and does not opt out. Its limit is that
* it reads the file rather than the running handler.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
@@ -32,6 +34,7 @@ import {
} from "@/lib/rate-limit";
import { buildOpenApiDocument } from "@/lib/openapi/registry";
import { recordWriteRateLimitResponse } from "@/lib/openapi/routes/shared";
import { RECORD_WRITE_RATE_LIMITED_CODE } from "@/lib/auth/write-meter";
vi.mock("@/lib/db", () => ({ prisma: { $queryRaw: vi.fn() } }));
@@ -121,24 +124,27 @@ describe("the shared single-record write bucket", () => {
expect(published).toContain("Retry-After");
});
it("is called by every route that publishes it", () => {
const missing = ON_THE_SHARED_BUCKET.filter(([route]) => {
it("is charged on the path every route that publishes it takes", () => {
// The bucket is charged by the identity resolvers (`meterAccountWrite`),
// not by each route, so a listed route meets it by resolving its caller
// through one of them and not opting out. `write-rate-limit-guard`
// holds every other mutating route to the same rule.
const unmetered = ON_THE_SHARED_BUCKET.filter(([route]) => {
const src = readFileSync(join(process.cwd(), route), "utf8");
return !src.includes("checkRecordWriteRateLimit(");
return (
!/\brequire(?:Record)?Auth\s*\(/.test(src) ||
/\bwriteBucket\s*:\s*["']own["']/.test(src)
);
}).map(([route]) => route);
expect(missing).toEqual([]);
expect(unmetered).toEqual([]);
});
it("names the code it publishes at every site", () => {
// The prose is not the discriminator: two of these routes consult a second
// bucket of their own, and a client that can only read `error` cannot tell
// which one refused it.
const silent = ON_THE_SHARED_BUCKET.filter(([route]) => {
const src = readFileSync(join(process.cwd(), route), "utf8");
return !src.includes('errorCode: "record_write.rate_limited"');
}).map(([route]) => route);
expect(silent).toEqual([]);
expect(published).toContain("record_write.rate_limited");
it("names the code it publishes", () => {
// The prose is not the discriminator: several of these routes consult a
// second bucket of their own, and a client that can only read `error`
// cannot tell which one refused it.
expect(RECORD_WRITE_RATE_LIMITED_CODE).toBe("record_write.rate_limited");
expect(published).toContain(RECORD_WRITE_RATE_LIMITED_CODE);
});
it("is published on every operation that answers it", () => {
@@ -0,0 +1,220 @@
/**
* Structural guard: every mutating route meets a rate limit.
*
* 169 of 364 mutating handlers met no limiter at all: every PATCH and DELETE
* on an `[id]`, and most single-record creates. The shared per-account write
* bucket now sits on the identity resolvers (`meterAccountWrite`,
* `src/lib/auth/write-meter.ts`), so a route that resolves its caller through
* one of them is metered without knowing it. This guard is what keeps that
* true for the routes added next year:
*
* - a route file exporting POST, PUT, PATCH or DELETE must either resolve
* its caller through a metering resolver, call a limiter of its own, or
* stand in {@link UNMETERED} with the reason;
* - a route that opts out of the shared bucket (`writeBucket: "own"`) must
* stand in {@link OWN_BUCKET} with the reason AND call its own limiter, so
* the opt-out can never mean "no limit";
* - a helper that limits on a route's behalf is accepted only while its own
* source calls a limiter.
*
* The routes are derived from the tree, never listed by hand. The check is
* per file, not per handler: a file whose GET resolves through a metering
* resolver while its POST does not would pass. Comments are stripped first;
* the planted cases at the top prove the matchers fire.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { walkSourceFiles } from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
/** The resolvers in `api-handler.ts` that charge the shared write bucket. */
const METERING_RESOLVERS = [
"requireAuth",
"requireRecordAuth",
"requireActorAuth",
"requireGuardianAuth",
"requireAdmin",
"requireCookieAuth",
"requireFreshMfa",
"requireFreshMfaIfEnrolled",
"requireFreshMfaOrElevationIfEnrolled",
"requireRecentProof",
"requireBearerAuth",
"requireMfaManagementAuth",
];
/** Helpers that call a limiter on the route's behalf, and where they live. */
const LIMITING_HELPERS: Record<string, string[]> = {
claimGate: ["lib/managed-profiles/claim-gate.ts"],
applyWebhookRateLimit: [
"lib/withings/webhook-handler.ts",
"lib/whoop/webhook-handler.ts",
],
};
/** Mutating routes with no limit, each with the reason it needs none. */
const UNMETERED: Record<string, string> = {
"app/api/auth/logout/route.ts":
"Ends the caller's own session or token and nothing else. It is idempotent, writes no record data, and refusing it would keep alive a credential its holder asked to end.",
};
/** Routes metered by a dedicated bucket instead of the shared one. */
const OWN_BUCKET: Record<string, string> = {
"app/api/measurements/batch/route.ts":
"The HealthKit uploader sends up to 60 batches of 500 a minute, and an outbox replay of parked pages bypasses the client pacer; the route's own 60-a-minute batch bucket meters it.",
"app/api/workouts/batch/route.ts":
"The workout importer sends page after page during a catch-up pass; the route's own batch bucket meters it.",
"app/api/medications/intake/bulk/route.ts":
"The Apple Health dose import and the standalone adoption send 200 to 500 intakes per request in sequence; the route's own bulk bucket meters it.",
"app/api/mood-entries/bulk/route.ts":
"The standalone adoption sends 200 entries per request in sequence; the route's own bulk bucket meters it.",
"app/api/cycle/day-logs/bulk/route.ts":
"The cycle HealthKit sweep sends 500-day slices in sequence; the route's own bulk bucket meters it.",
"app/api/nutrients/batch/route.ts":
"The nutrient sync sends 500-row batches in sequence; the route's own batch bucket meters it.",
"app/api/insights/ecg/route.ts":
"The ECG sweep sends one recording per request with no client-side delay, newest first, over the whole HealthKit history; the route's own 600-a-minute ingest bucket meters it.",
};
function stripComments(text: string): string {
return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
}
const WRITES =
/export\s+(?:const|async\s+function)\s+(POST|PUT|PATCH|DELETE)\b/;
const OWN_LIMITER = /\b(?:check\w*RateLimit|checkRateLimit)\s*\(/;
const OPTS_OUT = /\bwriteBucket\s*:\s*["']own["']/;
function callsAny(code: string, names: readonly string[]): boolean {
return names.some((name) => new RegExp(`\\b${name}\\s*\\(`).test(code));
}
function limited(code: string): boolean {
return (
OWN_LIMITER.test(code) || callsAny(code, Object.keys(LIMITING_HELPERS))
);
}
describe("every mutating route meets a rate limit", () => {
const files = walkSourceFiles(SRC, { floor: 3000 }).filter(
(file) => !file.startsWith("generated/") && !file.includes("__tests__/"),
);
const read = (file: string) =>
stripComments(readFileSync(join(SRC, file), "utf8"));
const mutating = files
.filter((f) => /^app\/.+\/route\.tsx?$/.test(f))
.filter((f) => WRITES.test(read(f)));
it("the matchers fire on planted sources", () => {
expect(WRITES.test("export const PATCH = apiHandler(async () => {")).toBe(
true,
);
expect(WRITES.test("export async function DELETE(req) {")).toBe(true);
expect(WRITES.test("export const GET = apiHandler(async () => {")).toBe(
false,
);
expect(
callsAny(
'const { user } = await requireRecordAuth("write", "labs");',
METERING_RESOLVERS,
),
).toBe(true);
// A resolver named in a comment or an import does not count.
expect(
callsAny(
stripComments(
"import { requireAuth } from 'x';\n// requireAuth() here",
),
METERING_RESOLVERS,
),
).toBe(false);
expect(OPTS_OUT.test('requireAuth(SCOPE, { writeBucket: "own" })')).toBe(
true,
);
expect(limited("const rl = await checkRateLimit(`k:${id}`, 5, 60)")).toBe(
true,
);
expect(limited("const rl = rateLimitHeaders(x)")).toBe(false);
});
it("finds the mutating routes", () => {
expect(mutating.length).toBeGreaterThanOrEqual(300);
});
it("meters every mutating route, or names why not", () => {
const unlimited = mutating.filter((f) => {
if (f in UNMETERED) return false;
const code = read(f);
const metered =
callsAny(code, METERING_RESOLVERS) && !OPTS_OUT.test(code);
return !metered && !limited(code);
});
expect(unlimited).toEqual([]);
});
it("holds every opt-out to the reviewed list, and each to its own limiter", () => {
const optedOut = mutating.filter((f) => OPTS_OUT.test(read(f))).sort();
expect(optedOut).toEqual(Object.keys(OWN_BUCKET).sort());
const withoutOwn = optedOut.filter((f) => !OWN_LIMITER.test(read(f)));
expect(withoutOwn).toEqual([]);
});
it("accepts a limiting helper only while it calls a limiter", () => {
for (const [helper, homes] of Object.entries(LIMITING_HELPERS)) {
for (const home of homes) {
const code = read(home);
expect(code, home).toMatch(
new RegExp(`export\\s+async\\s+function\\s+${helper}\\b`),
);
expect(OWN_LIMITER.test(code), home).toBe(true);
}
}
});
it("keeps the exceptions to routes that exist and still need them", () => {
for (const route of Object.keys(UNMETERED)) {
expect(mutating).toContain(route);
const code = read(route);
expect(callsAny(code, METERING_RESOLVERS) || limited(code), route).toBe(
false,
);
}
});
it("charges the bucket in each metering resolver", () => {
const handler = read("lib/api-handler.ts");
for (const name of [
"requireAuth",
"requireActorAuth",
"requireRecordAuth",
"requireGuardianAuth",
"requireAdmin",
"requireCookieAuth",
"requireFreshMfa",
"requireBearerAuth",
]) {
const start = handler.search(
new RegExp(`export\\s+async\\s+function\\s+${name}\\s*\\(`),
);
expect(start, name).toBeGreaterThanOrEqual(0);
const next = handler.indexOf("\nexport ", start + 1);
const body = handler.slice(start, next === -1 ? undefined : next);
expect(body, name).toMatch(/\bmeterAccountWrite\s*\(/);
}
// The three that only delegate reach the meter through `requireAuth`.
for (const name of [
"requireFreshMfaIfEnrolled",
"requireFreshMfaOrElevationIfEnrolled",
"requireRecentProof",
]) {
const start = handler.search(
new RegExp(`export\\s+async\\s+function\\s+${name}\\s*\\(`),
);
const next = handler.indexOf("\nexport ", start + 1);
expect(handler.slice(start, next), name).toMatch(/\brequireAuth\s*\(/);
}
});
});