mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
Merge branch 'fix/v1438-api' into release/v1.43.8
This commit is contained in:
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]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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*\(/);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user