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

+774 -211
View File
File diff suppressed because it is too large. Load diff
@@ -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*\(/);
}
});
});
+27 -26
View File
@@ -40,7 +40,8 @@ import { prisma } from "@/lib/db";
import { getAuditLogRetentionDays } from "@/lib/jobs/audit-log-cleanup";
import { annotate } from "@/lib/logging/context";
import { GRANT_PARTY_SELECT } from "@/lib/sharing/grant-view";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage } from "@/lib/http/list-query";
export const dynamic = "force-dynamic";
@@ -99,31 +100,31 @@ export const GET = apiHandler(async () => {
const retentionDays = getAuditLogRetentionDays();
const windowStart = new Date(Date.now() - retentionDays * 86_400_000);
const found = await prisma.auditLog.findMany({
where: {
userId: user.id,
actorUserId: { not: null },
// A switch into one's own record is not a delegation and has nothing to
// report. `actingActorFor` already refuses to stamp one, so this is the
// second end of that rule rather than its only enforcement.
NOT: { actorUserId: user.id },
// The window the copy promises. The purge job makes it true eventually;
// this makes it true now, which matters on an instance whose retention
// was shortened after rows were already written.
createdAt: { gte: windowStart },
},
orderBy: { createdAt: "desc" },
take: overfetch(MAX_ROWS),
select: {
id: true,
actorUserId: true,
action: true,
details: true,
createdAt: true,
},
});
const { rows, cap } = capRows(found, MAX_ROWS);
const { rows, cap } = await listPage(MAX_ROWS, (take) =>
prisma.auditLog.findMany({
where: {
userId: user.id,
actorUserId: { not: null },
// A switch into one's own record is not a delegation and has nothing to
// report. `actingActorFor` already refuses to stamp one, so this is the
// second end of that rule rather than its only enforcement.
NOT: { actorUserId: user.id },
// The window the copy promises. The purge job makes it true eventually;
// this makes it true now, which matters on an instance whose retention
// was shortened after rows were already written.
createdAt: { gte: windowStart },
},
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take,
select: {
id: true,
actorUserId: true,
action: true,
details: true,
createdAt: true,
},
}),
);
const actorIds = [
...new Set(rows.map((r) => r.actorUserId).filter((id) => id !== null)),
+22 -27
View File
@@ -73,7 +73,8 @@ import {
} from "@/lib/sharing/grant-view";
import { GrantError, inviteGrant } from "@/lib/sharing/grants";
import { inviteGrantSchema } from "@/lib/validations/account-sharing";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
import { apiListSuccess } from "@/lib/api/list-cap";
import { combinedCap, listPage } from "@/lib/http/list-query";
/**
* Ten invitations an hour, per account.
@@ -92,28 +93,25 @@ export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "sharing.grants.list" } });
const [givenFound, receivedFound] = await Promise.all([
prisma.accountGrant.findMany({
where: { grantorId: user.id },
orderBy: { createdAt: "desc" },
take: overfetch(MAX_GRANTS_PER_DIRECTION),
include: { grantee: { select: GRANT_PARTY_SELECT } },
}),
prisma.accountGrant.findMany({
where: { granteeId: user.id },
orderBy: { createdAt: "desc" },
take: overfetch(MAX_GRANTS_PER_DIRECTION),
include: { grantor: { select: GRANT_PARTY_SELECT } },
}),
]);
const { rows: given, cap: givenCap } = capRows(
givenFound,
MAX_GRANTS_PER_DIRECTION,
);
const { rows: received, cap: receivedCap } = capRows(
receivedFound,
MAX_GRANTS_PER_DIRECTION,
);
const [{ rows: given, cap: givenCap }, { rows: received, cap: receivedCap }] =
await Promise.all([
listPage(MAX_GRANTS_PER_DIRECTION, (take) =>
prisma.accountGrant.findMany({
where: { grantorId: user.id },
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take,
include: { grantee: { select: GRANT_PARTY_SELECT } },
}),
),
listPage(MAX_GRANTS_PER_DIRECTION, (take) =>
prisma.accountGrant.findMany({
where: { granteeId: user.id },
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take,
include: { grantor: { select: GRANT_PARTY_SELECT } },
}),
),
]);
// Revoked and expired rows come back too. They are the consent record — "who
// had access, from when to when, and who ended it" is the question the table
@@ -122,10 +120,7 @@ export const GET = apiHandler(async () => {
const now = new Date();
// One cap per direction, one signal: either list cut short is a partial
// answer.
const cap = {
truncated: givenCap.truncated || receivedCap.truncated,
limit: MAX_GRANTS_PER_DIRECTION,
};
const cap = combinedCap(givenCap, receivedCap);
return apiListSuccess(
{
given: given.map((g) => toGrantView(g, g.grantee, now)),
+16 -24
View File
@@ -15,13 +15,11 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
import { withIdempotency } from "@/lib/idempotency";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import {
@@ -29,7 +27,8 @@ import {
allergyListQuerySchema,
} from "@/lib/validations/allergy";
import { toAllergyDTO } from "@/lib/records/dto";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage } from "@/lib/http/list-query";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "profile");
@@ -46,16 +45,19 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
const limit = parsed.data.limit ?? 100;
const found = await prisma.allergy.findMany({
where: {
userId: user.id,
deletedAt: null,
...(parsed.data.includeInactive === "false" ? { status: "ACTIVE" } : {}),
},
orderBy: { createdAt: "desc" },
take: overfetch(limit),
});
const { rows, cap } = capRows(found, limit);
const { rows, cap } = await listPage(limit, (take) =>
prisma.allergy.findMany({
where: {
userId: user.id,
deletedAt: null,
...(parsed.data.includeInactive === "false"
? { status: "ACTIVE" }
: {}),
},
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take,
}),
);
annotate({
action: { name: "allergy.list", entity_type: "allergy" },
@@ -88,17 +90,7 @@ async function postAllergy(request: NextRequest): Promise<Response> {
// A READ or WRITE delegate reaches neither: the destination is not on their
// Settings list, and this line refuses them with `sharing.access.denied`
// regardless.
const { user, actor } = await requireRecordAuth("manage", "profile");
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
// batch siblings have always been capped; the per-record creates a looping
// client hits were not.
const writeRl = await checkRecordWriteRateLimit(actor.id);
if (!writeRl.allowed) {
return apiError("Too many writes, try again later", 429, {
errorCode: "record_write.rate_limited",
});
}
const { user } = await requireRecordAuth("manage", "profile");
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
+5 -3
View File
@@ -5,7 +5,7 @@ import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { analyticsQuerySchema } from "@/lib/validations/analytics";
import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import { summarize, wireSummary, type DataPoint } from "@/lib/analytics/trends";
import { readSummariesSlice } from "@/lib/analytics/summaries-slice";
import { loadRecoveryEmpty } from "@/lib/insights/recovery-empty-read";
import { resolveEffectiveBpTargets } from "@/lib/analytics/effective-range";
@@ -500,8 +500,10 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) {
glucoseRows,
(r) => r.glucoseContext,
)) {
glucoseByContext[bucket] = summarize(
ctxRows.map((r): DataPoint => ({ date: r.measuredAt, value: r.value })),
glucoseByContext[bucket] = wireSummary(
summarize(
ctxRows.map((r): DataPoint => ({ date: r.measuredAt, value: r.value })),
),
);
}
@@ -0,0 +1,68 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
auditLog: { findMany: vi.fn(), create: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "u", role: "USER" as const, locale: "en" },
};
const req = (qs = "") =>
new NextRequest(`http://localhost/api/auth/me/security-activity${qs}`);
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.findMany).mockResolvedValue([] as never);
});
describe("GET /api/auth/me/security-activity", () => {
it("reads one row past the requested page, in a total order", async () => {
const res = await GET(req("?limit=10"));
expect(res.status).toBe(200);
const call = vi.mocked(prisma.auditLog.findMany).mock.calls[0][0] as {
take: number;
orderBy: unknown;
};
expect(call.take).toBe(11);
expect(call.orderBy).toEqual([{ createdAt: "desc" }, { id: "desc" }]);
const body = (await res.json()) as {
meta: { truncated: boolean; limit: number };
};
expect(body.meta).toMatchObject({ truncated: false, limit: 10 });
});
it("answers a limit past the published ceiling with a 422 naming it", async () => {
const res = await GET(req("?limit=500"));
expect(res.status).toBe(422);
const body = (await res.json()) as {
meta: { errorCode: string };
details: { issues: Array<{ path: string }> };
};
expect(body.meta.errorCode).toBe("security_activity.invalid");
expect(body.details.issues.map((i) => i.path)).toEqual(["limit"]);
expect(prisma.auditLog.findMany).not.toHaveBeenCalled();
});
});
+29 -21
View File
@@ -14,7 +14,9 @@ import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { securityActivityWhere } from "@/lib/auth/security-activity";
import { maskIp } from "@/lib/auth/device-fingerprint";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage, parseListQuery } from "@/lib/http/list-query";
import { returnAllZodIssues } from "@/lib/api-response";
export const dynamic = "force-dynamic";
@@ -32,28 +34,34 @@ export interface SecurityActivityDTO {
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const limitParam = Number(
request.nextUrl.searchParams.get("limit") ?? DEFAULT_LIMIT,
);
const limit =
Number.isFinite(limitParam) && limitParam > 0
? Math.min(Math.floor(limitParam), MAX_LIMIT)
: DEFAULT_LIMIT;
const found = await prisma.auditLog.findMany({
where: securityActivityWhere(user.id),
orderBy: { createdAt: "desc" },
take: overfetch(limit),
select: {
action: true,
createdAt: true,
location: true,
ipAddress: true,
carrier: true,
},
// The shared list query: an out-of-range limit is a 422 naming it, as on
// every other list, rather than a silent clamp to something not asked for.
const query = parseListQuery(request.nextUrl.searchParams, {
defaultLimit: DEFAULT_LIMIT,
maxLimit: MAX_LIMIT,
});
if (!query.success) {
return returnAllZodIssues(query.error, 422, {
errorCode: "security_activity.invalid",
});
}
const { limit } = query.data;
const { rows, cap } = await listPage(limit, (take) =>
prisma.auditLog.findMany({
where: securityActivityWhere(user.id),
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take,
select: {
action: true,
createdAt: true,
location: true,
ipAddress: true,
carrier: true,
},
}),
);
const { rows, cap } = capRows(found, limit);
const events: SecurityActivityDTO[] = rows.map((r) => ({
action: r.action,
createdAt: r.createdAt.toISOString(),
+1 -9
View File
@@ -31,7 +31,6 @@ import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import { withIdempotency } from "@/lib/idempotency";
import { mergeBiomarkers as runMerge } from "@/lib/labs/biomarker-merge";
import { annotate } from "@/lib/logging/context";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
import { destroyedDetails } from "@/lib/sharing/audit-details";
import { biomarkerMergeSchema } from "@/lib/validations/biomarker-merge";
@@ -45,16 +44,9 @@ async function mergeBiomarkers(
request: NextRequest,
{ params }: RouteParams,
): Promise<Response> {
const { user, actor } = await requireRecordAuth("manage", "labs");
const { user } = await requireRecordAuth("manage", "labs");
const { id } = await params;
const writeRl = await checkRecordWriteRateLimit(actor.id);
if (!writeRl.allowed) {
return apiError("Too many writes, try again later", 429, {
errorCode: "record_write.rate_limited",
});
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 4 * 1024,
});
+1 -12
View File
@@ -9,7 +9,6 @@ import {
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { withIdempotency } from "@/lib/idempotency";
@@ -76,17 +75,7 @@ async function postBiomarker(request: NextRequest) {
// duplicate pre-check and the `@@unique([userId, name])` backstop both scope
// to the same resolved id, so a delegate adding a marker the owner already
// tracks gets the ordinary 409 rather than a second copy.
const { user, actor } = await requireRecordAuth("write", "labs");
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
// batch siblings have always been capped; the per-record creates a looping
// client hits were not.
const writeRl = await checkRecordWriteRateLimit(actor.id);
if (!writeRl.allowed) {
return apiError("Too many writes, try again later", 429, {
errorCode: "record_write.rate_limited",
});
}
const { user } = await requireRecordAuth("write", "labs");
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
@@ -53,6 +53,22 @@ const EXISTING = {
deletedAt: null,
};
/**
* The row as the reads load it: with the newest live entry and the live entry
* count joined on, which the published row carries as `latest` and
* `entryCount`.
*/
const JOINS = {
entries: [
{
value: 42.5,
unit: "kg",
measuredAt: new Date("2026-06-03T07:00:00.000Z"),
},
],
_count: { entries: 7 },
};
const params = (id: string) => ({ params: Promise.resolve({ id }) });
function patchReq(body: unknown): NextRequest {
@@ -77,17 +93,54 @@ beforeEach(() => {
describe("GET /api/custom-metrics/[id]", () => {
it("returns the metric for its owner", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.customMetric.findFirst).mockResolvedValue(
EXISTING as never,
);
vi.mocked(prisma.customMetric.findFirst).mockResolvedValue({
...EXISTING,
...JOINS,
} as never);
const res = await GET(
new NextRequest("http://localhost/api/custom-metrics/cm-1"),
params("cm-1"),
);
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { name: string } | null };
const body = (await res.json()) as {
data: {
name: string;
latest: { value: number; measuredAt: string } | null;
entryCount: number;
} | null;
};
expect(body.data?.name).toBe("Grip strength");
// The published row carries the latest value and the count on its own,
// as it does in the catalogue; the read asks for both joins.
expect(body.data?.latest).toEqual({
value: 42.5,
unit: "kg",
measuredAt: "2026-06-03T07:00:00.000Z",
});
expect(body.data?.entryCount).toBe(7);
const findCall = vi.mocked(prisma.customMetric.findFirst).mock
.calls[0][0] as { include?: Record<string, unknown> };
expect(Object.keys(findCall.include ?? {})).toEqual(["entries", "_count"]);
});
it("answers latest null and a zero count for a metric with no entries", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.customMetric.findFirst).mockResolvedValue({
...EXISTING,
entries: [],
_count: { entries: 0 },
} as never);
const res = await GET(
new NextRequest("http://localhost/api/custom-metrics/cm-1"),
params("cm-1"),
);
const body = (await res.json()) as {
data: { latest: unknown; entryCount: number };
};
expect(body.data.latest).toBeNull();
expect(body.data.entryCount).toBe(0);
});
it("404s a cross-user metric", async () => {
@@ -113,6 +166,7 @@ describe("PATCH /api/custom-metrics/[id]", () => {
);
vi.mocked(prisma.customMetric.update).mockResolvedValue({
...EXISTING,
...JOINS,
targetLow: 20,
targetHigh: 50,
} as never);
@@ -126,6 +180,12 @@ describe("PATCH /api/custom-metrics/[id]", () => {
.calls[0][0] as { data: Record<string, unknown> };
expect(updateCall.data.targetLow).toBe(20);
expect(updateCall.data.targetHigh).toBe(50);
// The edit echo is the same published row as the read.
const body = (await res.json()) as {
data: { entryCount: number; latest: { value: number } | null };
};
expect(body.data.entryCount).toBe(7);
expect(body.data.latest?.value).toBe(42.5);
});
it("persists only the explicit correlation opt-in field", async () => {
@@ -135,6 +195,7 @@ describe("PATCH /api/custom-metrics/[id]", () => {
);
vi.mocked(prisma.customMetric.update).mockResolvedValue({
...EXISTING,
...JOINS,
correlationEnabled: true,
} as never);
@@ -9,7 +9,6 @@ import {
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
import { auditLog } from "@/lib/auth/audit";
import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate";
import { serialiseCustomMetricEntry } from "@/lib/custom-metrics/custom-metric-store";
@@ -108,15 +107,6 @@ async function postCustomMetricEntry(
// was never wrong, and the GET arm above stays delegable meanwhile.
const { user } = await requireAuth();
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
// batch siblings have always been capped; the per-record creates a looping
// client hits were not.
const writeRl = await checkRecordWriteRateLimit(user.id);
if (!writeRl.allowed) {
return apiError("Too many writes, try again later", 429, {
errorCode: "record_write.rate_limited",
});
}
const { id } = await params;
const metric = await prisma.customMetric.findFirst({
+26 -4
View File
@@ -11,7 +11,10 @@ import {
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate";
import { serialiseCustomMetric } from "@/lib/custom-metrics/custom-metric-store";
import {
CUSTOM_METRIC_LATEST_INCLUDE,
serialiseCustomMetricWithLatest,
} from "@/lib/custom-metrics/custom-metric-store";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { updateCustomMetricSchema } from "@/lib/validations/custom-metrics";
@@ -41,8 +44,11 @@ export const GET = apiHandler(
const { user } = await requireRecordAuth("read", "measurements");
const { id } = await params;
// The same joins as the list read: the published row carries the latest
// value and the entry count, on its own as in the catalogue.
const row = await prisma.customMetric.findFirst({
where: { id, deletedAt: null },
include: CUSTOM_METRIC_LATEST_INCLUDE,
});
if (!row || row.userId !== user.id) {
return apiError("Custom metric not found", 404);
@@ -53,7 +59,13 @@ export const GET = apiHandler(
meta: { customMetricId: id },
});
return apiSuccess(serialiseCustomMetric(row));
return apiSuccess(
serialiseCustomMetricWithLatest({
...row,
latest: row.entries[0] ?? null,
entryCount: row._count.entries,
}),
);
},
);
@@ -135,7 +147,11 @@ export const PATCH = apiHandler(
return apiError("targetLow must not exceed targetHigh", 422);
}
const updated = await prisma.customMetric.update({ where: { id }, data });
const updated = await prisma.customMetric.update({
where: { id },
data,
include: CUSTOM_METRIC_LATEST_INCLUDE,
});
await auditLog("customMetric.update", {
userId: user.id,
@@ -149,7 +165,13 @@ export const PATCH = apiHandler(
});
invalidateUserCorrelationPatterns(user.id);
return apiSuccess(serialiseCustomMetric(updated));
return apiSuccess(
serialiseCustomMetricWithLatest({
...updated,
latest: updated.entries[0] ?? null,
entryCount: updated._count.entries,
}),
);
},
);
+5 -10
View File
@@ -11,7 +11,10 @@ import {
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate";
import { serialiseCustomMetricWithLatest } from "@/lib/custom-metrics/custom-metric-store";
import {
CUSTOM_METRIC_LATEST_INCLUDE,
serialiseCustomMetricWithLatest,
} from "@/lib/custom-metrics/custom-metric-store";
import { prisma } from "@/lib/db";
import { withIdempotency } from "@/lib/idempotency";
import { annotate } from "@/lib/logging/context";
@@ -34,15 +37,7 @@ export const GET = apiHandler(async () => {
const rows = await prisma.customMetric.findMany({
where: { userId: user.id, deletedAt: null },
orderBy: { name: "asc" },
include: {
entries: {
where: { deletedAt: null },
orderBy: { measuredAt: "desc" },
take: 1,
select: { value: true, unit: true, measuredAt: true },
},
_count: { select: { entries: { where: { deletedAt: null } } } },
},
include: CUSTOM_METRIC_LATEST_INCLUDE,
});
annotate({
+9 -7
View File
@@ -19,7 +19,8 @@ import { toMenstrualCycleDTO } from "@/lib/cycle/dto";
import { median, mad } from "@/lib/cycle";
import { dayDiff } from "@/lib/cycle/day-math";
import { MIN_CYCLES_TO_PREDICT } from "@/lib/cycle/types";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage } from "@/lib/http/list-query";
const DEFAULT_LIMIT = 24;
@@ -51,12 +52,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
const limit = parsed.data.limit ?? DEFAULT_LIMIT;
const found = await prisma.menstrualCycle.findMany({
where: { userId: user.id, deletedAt: null },
orderBy: { startDate: "desc" },
take: overfetch(limit),
});
const { rows, cap } = capRows(found, limit);
const { rows, cap } = await listPage(limit, (take) =>
prisma.menstrualCycle.findMany({
where: { userId: user.id, deletedAt: null },
orderBy: [{ startDate: "desc" }, { id: "desc" }],
take,
}),
);
// Completed lengths (non-null lengthDays) drive the length stats.
const lengths = rows
+4 -1
View File
@@ -57,7 +57,10 @@ interface EntryResult {
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulk));
async function postBulk(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
// Metered by this route's own bulk bucket below, not the shared per-account
// write bucket: the native client syncs through here in bursts (see
// `meterAccountWrite`).
const { user } = await requireAuth(undefined, { writeBucket: "own" });
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
+1 -12
View File
@@ -26,7 +26,6 @@ import {
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
import { withIdempotency } from "@/lib/idempotency";
import { requireCycleEnabled } from "@/lib/cycle/gate";
import {
@@ -95,21 +94,11 @@ async function postDayLog(request: NextRequest): Promise<Response> {
// v1.37.0 — MANAGE. The upsert replaces the day, which is what edit means
// here; the gate below resolves `user.gender` against the RECORD, so the
// module follows the record rather than the caller.
const { user, actor } = await requireRecordAuth("manage", "cycle");
const { user } = await requireRecordAuth("manage", "cycle");
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
// batch siblings have always been capped; the per-record creates a looping
// client hits were not.
const writeRl = await checkRecordWriteRateLimit(actor.id);
if (!writeRl.allowed) {
return apiError("Too many writes, try again later", 429, {
errorCode: "record_write.rate_limited",
});
}
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
+11 -9
View File
@@ -50,6 +50,7 @@ import {
sanitiseZodIssues,
} from "@/lib/api-response";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import { listPage } from "@/lib/http/list-query";
import { prisma } from "@/lib/db";
import { hashQueryTokens } from "@/lib/documents/content-index";
import {
@@ -710,16 +711,17 @@ export const GET = apiHandler(async (request: Request) => {
// Hardening: the list NEVER fetches the encrypted blob column — a page of
// 50 rows would otherwise drag up to 50 × cap ciphertext bytes per request.
const rows = await prisma.inboundDocument.findMany({
where,
omit: { contentEncrypted: true },
orderBy,
take: limit + 1,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
});
const { rows: page, cap } = await listPage(limit, (take) =>
prisma.inboundDocument.findMany({
where,
omit: { contentEncrypted: true },
orderBy,
take,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
}),
);
const hasMore = rows.length > limit;
const page = hasMore ? rows.slice(0, limit) : rows;
const hasMore = cap.truncated;
const nextCursor = hasMore ? (page[page.length - 1]?.id ?? null) : null;
// Fact tallies for the whole page in ONE grouped count — no per-document
+31 -34
View File
@@ -25,7 +25,8 @@ import {
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
import { apiListSuccess } from "@/lib/api/list-cap";
import { combinedCap, listPage } from "@/lib/http/list-query";
import { withIdempotency } from "@/lib/idempotency";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import {
@@ -109,27 +110,33 @@ export const GET = apiHandler(async (request: NextRequest) => {
// past reads newest-first, which is the opposite direction; resolving both
// here means the client renders two lists instead of splitting and
// re-sorting one, and the ordering lives in one place.
const [upcoming, past] = await Promise.all([
prisma.encounter.findMany({
// `gt` and `gte` are different keys, so the caller's lower bound and this
// arm's floor both survive the spread; only the `lte` pair collided.
where: { ...where, occurredAt: { ...where.occurredAt, gt: now } },
orderBy: [{ occurredAt: "asc" }, { id: "asc" }],
take,
skip,
include: ENCOUNTER_INCLUDE,
}),
prisma.encounter.findMany({
where: {
...where,
occurredAt: { ...where.occurredAt, lte: pastCeiling },
},
orderBy: [{ occurredAt: "desc" }, { id: "desc" }],
take,
skip,
include: ENCOUNTER_INCLUDE,
}),
]);
const [{ rows: upcoming, cap: upcomingCap }, { rows: past, cap: pastCap }] =
await Promise.all([
listPage(take, (overTake) =>
prisma.encounter.findMany({
// `gt` and `gte` are different keys, so the caller's lower bound and
// this arm's floor both survive the spread; only the `lte` pair
// collided.
where: { ...where, occurredAt: { ...where.occurredAt, gt: now } },
orderBy: [{ occurredAt: "asc" }, { id: "asc" }],
take: overTake,
skip,
include: ENCOUNTER_INCLUDE,
}),
),
listPage(take, (overTake) =>
prisma.encounter.findMany({
where: {
...where,
occurredAt: { ...where.occurredAt, lte: pastCeiling },
},
orderBy: [{ occurredAt: "desc" }, { id: "desc" }],
take: overTake,
skip,
include: ENCOUNTER_INCLUDE,
}),
),
]);
// The links ride the list. The card counts what a visit produced, and the
// edit sheet seeds its pickers from the row it was handed — a list without
@@ -152,24 +159,14 @@ export const GET = apiHandler(async (request: NextRequest) => {
upcoming: upcoming.map((row) => toEncounterDTO(row, links.get(row.id))),
past: past.map((row) => toEncounterDTO(row, links.get(row.id))),
};
return apiSuccess(body);
return apiListSuccess(body, combinedCap(upcomingCap, pastCap));
});
// A double-tap on "save" must not file the same visit twice.
export const POST = apiHandler(withIdempotency<[NextRequest]>(postEncounter));
async function postEncounter(request: NextRequest): Promise<Response> {
const { user, actor, grantId } = await requireRecordAuth("write", "profile");
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
// batch siblings have always been capped; the per-record creates a looping
// client hits were not.
const writeRl = await checkRecordWriteRateLimit(actor.id);
if (!writeRl.allowed) {
return apiError("Too many writes, try again later", 429, {
errorCode: "record_write.rate_limited",
});
}
const { user, grantId } = await requireRecordAuth("write", "profile");
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 32 * 1024,
+9 -7
View File
@@ -28,7 +28,8 @@ import {
familyHistoryListQuerySchema,
} from "@/lib/validations/family-history";
import { toFamilyHistoryEntryDTO } from "@/lib/records/dto";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage } from "@/lib/http/list-query";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "profile");
@@ -44,12 +45,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
const limit = parsed.data.limit ?? 100;
const found = await prisma.familyHistoryEntry.findMany({
where: { userId: user.id, deletedAt: null },
orderBy: { createdAt: "desc" },
take: overfetch(limit),
});
const { rows, cap } = capRows(found, limit);
const { rows, cap } = await listPage(limit, (take) =>
prisma.familyHistoryEntry.findMany({
where: { userId: user.id, deletedAt: null },
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take,
}),
);
annotate({
action: { name: "family-history.list", entity_type: "family_history" },
@@ -150,7 +150,10 @@ describe("GET /api/feature-flags", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
FIND.mockResolvedValue(null);
const res = await GET(req());
expect(res.headers.get("Deprecation")).toBe("true");
// RFC 9745: the deprecation date as `@` and Unix seconds — 2026-09-24.
expect(res.headers.get("Deprecation")).toBe("@1790208000");
// RFC 8594: the first day it may stop answering.
expect(res.headers.get("Sunset")).toBe("Wed, 31 Mar 2027 00:00:00 GMT");
expect(res.headers.get("Link")).toBe(
'</api/auth/me>; rel="successor-version"',
);
+7 -6
View File
@@ -12,8 +12,10 @@
* "insightStatus", "documentAi" } } }
*
* The master is applied (every sub-switch reads false when it is off), and a
* switch read that fails answers every switch off. The response carries
* `Deprecation: true` and a `Link` to its successor.
* switch read that fails answers every switch off. The response carries the
* deprecation date, the sunset and a `Link` to its successor
* (`src/lib/http/deprecation.ts`), and the wide event names the client build
* that still asked.
*
* `Cache-Control: private, max-age=60`: switches flip rarely.
*/
@@ -22,9 +24,10 @@ import type { NextRequest } from "next/server";
import { apiHandler, requireActorAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { getAssistantFlags } from "@/lib/feature-flags";
import { deprecated } from "@/lib/http/deprecation";
import { annotate } from "@/lib/logging/context";
export const GET = apiHandler(async (_request: NextRequest) => {
export const GET = apiHandler(async (request: NextRequest) => {
// An actor surface, and the easiest call in the set: the answer comes off
// the `AppSettings` singleton and reads no user row at all, so there is no
// record for a switch to substitute. It stays reachable while a switch is on
@@ -45,7 +48,5 @@ export const GET = apiHandler(async (_request: NextRequest) => {
const response = apiSuccess({ assistant });
response.headers.set("Cache-Control", "private, max-age=60");
response.headers.set("Deprecation", "true");
response.headers.set("Link", '</api/auth/me>; rel="successor-version"');
return response;
return deprecated(response, "/api/feature-flags", request);
});
+26 -22
View File
@@ -32,7 +32,8 @@ import {
illnessEpisodeListQuerySchema,
} from "@/lib/validations/illness";
import { toIllnessEpisodeDTO } from "@/lib/illness/dto";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage } from "@/lib/http/list-query";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "illness");
@@ -59,27 +60,30 @@ export const GET = apiHandler(async (request: NextRequest) => {
const { from, to, q, offset, sortDir } = parsed.data;
const limit = parsed.data.limit ?? 50;
const order = sortDir ?? "desc";
const found = await prisma.illnessEpisode.findMany({
where: {
userId: user.id,
deletedAt: null,
...(parsed.data.includeResolved === "false" ? { resolvedAt: null } : {}),
...(from || to
? {
onsetAt: {
...(from ? { gte: new Date(from) } : {}),
...(to ? { lte: new Date(to) } : {}),
},
}
: {}),
...(q ? { label: { contains: q, mode: "insensitive" as const } } : {}),
},
// `id` breaks ties so offset paging never repeats or drops a row.
orderBy: [{ onsetAt: order }, { id: order }],
take: overfetch(limit),
skip: offset ?? 0,
});
const { rows, cap } = capRows(found, limit);
const { rows, cap } = await listPage(limit, (take) =>
prisma.illnessEpisode.findMany({
where: {
userId: user.id,
deletedAt: null,
...(parsed.data.includeResolved === "false"
? { resolvedAt: null }
: {}),
...(from || to
? {
onsetAt: {
...(from ? { gte: new Date(from) } : {}),
...(to ? { lte: new Date(to) } : {}),
},
}
: {}),
...(q ? { label: { contains: q, mode: "insensitive" as const } } : {}),
},
// `id` breaks ties so offset paging never repeats or drops a row.
orderBy: [{ onsetAt: order }, { id: order }],
take,
skip: offset ?? 0,
}),
);
annotate({
action: { name: "illness.episode.list", entity_type: "illness_episode" },
+26 -21
View File
@@ -47,7 +47,8 @@ import { checkRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
import { persistEcgRecording } from "@/lib/ecg/persist-recording";
import { isoDateTime } from "@/lib/validations/datetime";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage } from "@/lib/http/list-query";
export const dynamic = "force-dynamic";
@@ -134,25 +135,26 @@ export const GET = apiHandler(async () => {
// sync.
const { user } = await requireRecordAuth("manage", "record");
const found = await prisma.ecgRecording.findMany({
where: { userId: user.id },
// Everything EXCEPT `waveformEncrypted` — the list never touches the
// encrypted blob, so no decrypt happens on this path.
select: {
id: true,
recordedAt: true,
durationSeconds: true,
samplingFrequency: true,
sampleCount: true,
averageHeartRate: true,
lead: true,
rhythmClassification: true,
source: true,
},
orderBy: { recordedAt: "desc" },
take: overfetch(MAX_RECORDINGS),
});
const { rows, cap } = capRows(found, MAX_RECORDINGS);
const { rows, cap } = await listPage(MAX_RECORDINGS, (take) =>
prisma.ecgRecording.findMany({
where: { userId: user.id },
// Everything EXCEPT `waveformEncrypted` — the list never touches the
// encrypted blob, so no decrypt happens on this path.
select: {
id: true,
recordedAt: true,
durationSeconds: true,
samplingFrequency: true,
sampleCount: true,
averageHeartRate: true,
lead: true,
rhythmClassification: true,
source: true,
},
orderBy: [{ recordedAt: "desc" }, { id: "desc" }],
take,
}),
);
const recordings = rows.map((r) => ({
id: r.id,
@@ -201,7 +203,10 @@ export const GET = apiHandler(async () => {
* to the same row by construction rather than by a cached response.
*/
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
// Metered by this route's own bulk bucket below, not the shared per-account
// write bucket: the native client syncs through here in bursts (see
// `meterAccountWrite`).
const { user } = await requireAuth(undefined, { writeBucket: "own" });
// A device ingest carries no model text: switching AI off, or the AI
// analysis opt-out, must never refuse a recording.
+21 -19
View File
@@ -29,7 +29,8 @@ import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { EVENT_MEASUREMENT_TYPES } from "@/lib/validations/measurement";
import type { MeasurementType } from "@/generated/prisma/client";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage } from "@/lib/http/list-query";
export const dynamic = "force-dynamic";
@@ -49,24 +50,25 @@ export const GET = apiHandler(async () => {
// The `insights` module is the AI analysis opt-out, and nothing here was
// written by a model.
const found = await prisma.measurement.findMany({
where: {
userId: user.id,
type: { in: EVENT_TYPE_LIST },
deletedAt: null,
},
select: {
id: true,
type: true,
rhythmClassification: true,
measuredAt: true,
source: true,
deviceType: true,
},
orderBy: { measuredAt: "desc" },
take: overfetch(MAX_EVENTS),
});
const { rows, cap } = capRows(found, MAX_EVENTS);
const { rows, cap } = await listPage(MAX_EVENTS, (take) =>
prisma.measurement.findMany({
where: {
userId: user.id,
type: { in: EVENT_TYPE_LIST },
deletedAt: null,
},
select: {
id: true,
type: true,
rhythmClassification: true,
measuredAt: true,
source: true,
deviceType: true,
},
orderBy: [{ measuredAt: "desc" }, { id: "desc" }],
take,
}),
);
const events = rows.map((r) => ({
id: r.id,
@@ -33,6 +33,12 @@ vi.mock("@/lib/rate-limit", async (importOriginal) => ({
resetAt: new Date(Date.now() + 60_000),
})),
}));
// The real meter, so the ceiling is proven on the path it now takes: the route
// resolves its caller, the resolver charges the bucket. The unit setup stubs
// the meter for every other route test.
vi.mock("@/lib/auth/write-meter", async (importOriginal) =>
importOriginal<typeof import("@/lib/auth/write-meter")>(),
);
const tzMock = vi.hoisted(() => ({ zone: "UTC" }));
vi.mock("@/lib/tz/resolver", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/tz/resolver")>()),
@@ -216,7 +222,7 @@ describe("POST /api/labs/ocr/commit dedup across the user's day", () => {
});
describe("POST /api/labs/ocr/commit write ceiling", () => {
it("charges the shared per-account record-write bucket", async () => {
it("charges the shared per-account record-write bucket where the caller is resolved", async () => {
const res = await POST(postReq({ rows: [numericRow(95)] }));
expect(res.status).toBe(200);
expect(checkRecordWriteRateLimit).toHaveBeenCalledWith("user-1");
-13
View File
@@ -20,7 +20,6 @@ import { fireAndForget } from "@/lib/logging/fire-and-forget";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
@@ -31,7 +30,6 @@ import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import { withIdempotency } from "@/lib/idempotency";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
import { rememberImportAlias } from "@/lib/labs/biomarker-aliases";
@@ -159,17 +157,6 @@ function inBatchKey(
async function commitOcrRows(request: NextRequest) {
const { user } = await requireAuth();
// Shared per-account write ceiling, the same bucket every other lab write
// charges (`POST /api/labs`, `POST /api/biomarkers`). A commit can write a
// whole report's rows and mint markers on the way, so it was the one lab
// write a looping client could repeat without any ceiling.
const writeRl = await checkRecordWriteRateLimit(user.id);
if (!writeRl.allowed) {
return apiError("Too many writes, try again later", 429, {
errorCode: "record_write.rate_limited",
});
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 256 * 1024,
});
+1 -12
View File
@@ -10,7 +10,6 @@ import {
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
@@ -156,17 +155,7 @@ async function postLabResult(request: NextRequest) {
// it mints it under `user.id`: the marker joins the record's catalogue, not
// the caller's, which is the only reading of "add a result to your record"
// that leaves the owner with a usable catalogue afterwards.
const { user, actor } = await requireRecordAuth("write", "labs");
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
// batch siblings have always been capped; the per-record creates a looping
// client hits were not.
const writeRl = await checkRecordWriteRateLimit(actor.id);
if (!writeRl.allowed) {
return apiError("Too many writes, try again later", 429, {
errorCode: "record_write.rate_limited",
});
}
const { user } = await requireRecordAuth("write", "labs");
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
-9
View File
@@ -21,7 +21,6 @@ import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
@@ -36,7 +35,6 @@ import {
toLifeEventDTO,
} from "@/lib/life-events/service";
import { annotate } from "@/lib/logging/context";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
@@ -53,13 +51,6 @@ export const POST = apiHandler(withIdempotency<[NextRequest]>(postLifeEvent));
async function postLifeEvent(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
const writeRl = await checkRecordWriteRateLimit(user.id);
if (!writeRl.allowed) {
return apiError("Too many writes, try again later", 429, {
errorCode: "record_write.rate_limited",
});
}
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
+6 -1
View File
@@ -293,7 +293,12 @@ async function postBatch(request: NextRequest): Promise<Response> {
// a native login mints. `requireAuth` refuses any acting-account carrier for
// every credential shape, so the scoped token is confined to its owner's own
// record here without this route adding an arm of its own.
const auth = await requireAuth(MEASUREMENTS_WRITE_SCOPE);
// Metered by this route's own bulk bucket below, not the shared per-account
// write bucket: the native client syncs through here in bursts (see
// `meterAccountWrite`).
const auth = await requireAuth(MEASUREMENTS_WRITE_SCOPE, {
writeBucket: "own",
});
const { user } = auth;
// A scoped credential is a bridge — a scale, a home-automation rule — and not
-11
View File
@@ -17,7 +17,6 @@ import {
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
import {
createMeasurementSchema,
createBatchMeasurementSchema,
@@ -403,16 +402,6 @@ async function postMeasurement(request: NextRequest) {
const { user } = auth;
const scoped = isScopedCredential(auth);
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
// batch siblings have always been capped; the per-record creates a looping
// client hits were not.
const writeRl = await checkRecordWriteRateLimit(auth.actor.id);
if (!writeRl.allowed) {
return apiError("Too many writes, try again later", 429, {
errorCode: "record_write.rate_limited",
});
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
@@ -10,7 +10,6 @@ import {
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
import {
intakeSchema,
listIntakeEventsSchema,
@@ -62,16 +61,6 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
"medications",
);
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
// batch siblings have always been capped; the per-record creates a looping
// client hits were not.
const writeRl = await checkRecordWriteRateLimit(actor.id);
if (!writeRl.allowed) {
return apiError("Too many writes, try again later", 429, {
errorCode: "record_write.rate_limited",
});
}
// v1.36.1 — the sibling route `POST /api/medications/intake`
// refuses a delegate changing a dose the owner already recorded. This route
// reaches the same rows through the slot upsert, so without the same rule it
@@ -39,7 +39,8 @@ import { categoryForEntry } from "@/lib/medications/side-effects/taxonomy";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { encryptNote, shapeSideEffectNotes } from "@/lib/crypto/note-cipher";
import { withIdempotency } from "@/lib/idempotency";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage } from "@/lib/http/list-query";
type RouteParams = { params: Promise<{ id: string }> };
@@ -67,23 +68,24 @@ export const GET = apiHandler(
const { from, to, limit } = parsed.data;
const found = await prisma.medicationSideEffect.findMany({
where: {
userId: user.id,
medicationId: id,
...(from || to
? {
occurredAt: {
...(from ? { gte: from } : {}),
...(to ? { lt: to } : {}),
},
}
: {}),
},
orderBy: { occurredAt: "desc" },
take: overfetch(limit),
});
const { rows: items, cap } = capRows(found, limit);
const { rows: items, cap } = await listPage(limit, (take) =>
prisma.medicationSideEffect.findMany({
where: {
userId: user.id,
medicationId: id,
...(from || to
? {
occurredAt: {
...(from ? { gte: from } : {}),
...(to ? { lt: to } : {}),
},
}
: {}),
},
orderBy: [{ occurredAt: "desc" }, { id: "desc" }],
take,
}),
);
annotate({
action: { name: "medication.sideEffect.list" },
+4 -1
View File
@@ -197,7 +197,10 @@ interface EntryResult {
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulk));
async function postBulk(request: NextRequest): Promise<Response> {
const auth = await requireAuth();
// Metered by this route's own bulk bucket below, not the shared per-account
// write bucket: the native client syncs through here in bursts (see
// `meterAccountWrite`).
const auth = await requireAuth(undefined, { writeBucket: "own" });
const { user } = auth;
// A narrow, single-purpose credential (a bridge, an ingest token) is no
// Apple Health sync, so it never writes the dose arrival record below. The
-11
View File
@@ -21,7 +21,6 @@ import {
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
@@ -212,16 +211,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
// notification at the end of this handler.
const { user, actor } = await requireRecordAuth("write", "medications");
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
// batch siblings have always been capped; the per-record creates a looping
// client hits were not.
const writeRl = await checkRecordWriteRateLimit(actor.id);
if (!writeRl.allowed) {
return apiError("Too many writes, try again later", 429, {
errorCode: "record_write.rate_limited",
});
}
const { data: body, error } = await safeJson(request, {
maxBytes: 64 * 1024,
});
+30 -28
View File
@@ -57,7 +57,8 @@ import type {
MeasurementType,
MeasurementSource,
} from "@/generated/prisma/client";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage } from "@/lib/http/list-query";
function shapeRow(row: {
id: string;
@@ -120,33 +121,34 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
const { instrument, limit, offset } = parsed.data;
const pageSize = limit ?? 100;
const found = await prisma.mentalHealthAssessment.findMany({
where: {
userId: user.id,
deletedAt: null,
// Screeners only: the table also stores AUDIT-C (v1.43), which older
// app builds cannot decode in this list.
instrument: instrument ?? { in: [...SCREENER_INSTRUMENTS] },
},
// Unique tiebreaker — `takenAt` is day-resolution and two instruments
// taken in one sitting tie.
orderBy: [{ takenAt: "desc" }, { id: "desc" }],
take: overfetch(pageSize),
skip: offset ?? 0,
select: {
id: true,
instrument: true,
locale: true,
version: true,
totalScore: true,
severityBand: true,
item9Flagged: true,
crisisShownAt: true,
takenAt: true,
createdAt: true,
},
});
const { rows, cap } = capRows(found, pageSize);
const { rows, cap } = await listPage(pageSize, (take) =>
prisma.mentalHealthAssessment.findMany({
where: {
userId: user.id,
deletedAt: null,
// Screeners only: the table also stores AUDIT-C (v1.43), which older
// app builds cannot decode in this list.
instrument: instrument ?? { in: [...SCREENER_INSTRUMENTS] },
},
// Unique tiebreaker — `takenAt` is day-resolution and two instruments
// taken in one sitting tie.
orderBy: [{ takenAt: "desc" }, { id: "desc" }],
take,
skip: offset ?? 0,
select: {
id: true,
instrument: true,
locale: true,
version: true,
totalScore: true,
severityBand: true,
item9Flagged: true,
crisisShownAt: true,
takenAt: true,
createdAt: true,
},
}),
);
annotate({
action: { name: "mental-health.list" },
meta: { count: rows.length },
@@ -68,7 +68,7 @@ vi.mock("next/headers", () => ({
})),
}));
import { PUT } from "../route";
import { GET, PUT } from "../route";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { getSession } from "@/lib/auth/session";
@@ -138,6 +138,43 @@ beforeEach(() => {
);
});
describe("GET /api/mood-entries/[id] — the list's wire shape", () => {
it("carries the structured tags and rated factors, as the list does", async () => {
vi.mocked(prisma.moodEntry.findFirst).mockResolvedValue({
...EXISTING_ENTRY,
tagLinks: [
{ rating: null, moodTag: { key: "sleep_good", kind: "BINARY" } },
{ rating: 4, moodTag: { key: "energy", kind: "RATED" } },
],
context: null,
} as never);
const res = await GET(
new NextRequest("http://localhost/api/mood-entries/me1"),
ROUTE_CTX,
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
tagKeys: string[];
ratedFactors: Array<{ key: string; rating: number }>;
tagLinks?: unknown;
};
};
expect(body.data.tagKeys).toEqual(["sleep_good"]);
expect(body.data.ratedFactors).toEqual([{ key: "energy", rating: 4 }]);
// The join is projected, never shipped raw.
expect(body.data.tagLinks).toBeUndefined();
const findCall = vi.mocked(prisma.moodEntry.findFirst).mock.calls[0][0] as {
include?: Record<string, unknown>;
};
expect(Object.keys(findCall.include ?? {}).sort()).toEqual([
"context",
"tagLinks",
]);
});
});
describe("PUT /api/mood-entries/[id] — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
// Bad `mood` enum + bad `moodLoggedAt`.
+6 -12
View File
@@ -15,10 +15,11 @@ import { Prisma } from "@/generated/prisma/client";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { moodDateKey } from "@/lib/mood/date-key";
import { MOOD_ENTRY_WIRE_INCLUDE, toMoodEntryWire } from "@/lib/mood/list-read";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { deriveA1, shapeLevelA } from "@/lib/mood/level-a";
import { contextForWire, persistMoodContext } from "@/lib/mood/context";
import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher";
import { encryptNote } from "@/lib/crypto/note-cipher";
import { invalidateUserMood } from "@/lib/cache/invalidate";
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
import {
@@ -50,9 +51,9 @@ export const GET = apiHandler(
// (not `findUnique`) because `deletedAt` is not part of a unique index.
const entry = await prisma.moodEntry.findFirst({
where: { id, deletedAt: null },
// v1.38 — the day context rides with the entry rather than behind a
// second request: the edit surface needs both or neither.
include: { context: true },
// The same joins and the same projection as the list read, so an
// entry opened on its own carries `tagKeys` and `ratedFactors` too.
include: MOOD_ENTRY_WIRE_INCLUDE,
});
if (!entry || entry.userId !== user.id) {
@@ -64,14 +65,7 @@ export const GET = apiHandler(
meta: { moodEntryId: id },
});
const { context, ...row } = entry;
return apiSuccess({
// v1.37 — level-A values under the keys the write path takes.
...shapeLevelA(shapeMoodNote(row)),
tags: parseTags(row.tags),
// v1.38 — lists decoded, note decrypted, ciphertext dropped.
context: context ? contextForWire(context) : null,
});
return apiSuccess(toMoodEntryWire(entry));
},
);
+4 -1
View File
@@ -194,7 +194,10 @@ interface EntryResult {
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulk));
async function postBulk(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
// Metered by this route's own bulk bucket below, not the shared per-account
// write bucket: the native client syncs through here in bursts (see
// `meterAccountWrite`).
const { user } = await requireAuth(undefined, { writeBucket: "own" });
const rl = await checkRateLimit(
`mood-entries:bulk:${user.id}`,
-11
View File
@@ -8,7 +8,6 @@ import {
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
import {
createMoodEntrySchema,
listMoodEntriesSchema,
@@ -109,16 +108,6 @@ async function postMoodEntry(request: NextRequest) {
// the `externalId` refusal below.
const { user, actor } = await requireRecordAuth("manage", "mind");
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
// batch siblings have always been capped; the per-record creates a looping
// client hits were not.
const writeRl = await checkRecordWriteRateLimit(actor.id);
if (!writeRl.allowed) {
return apiError("Too many writes, try again later", 429, {
errorCode: "record_write.rate_limited",
});
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
+4 -1
View File
@@ -79,7 +79,10 @@ interface EntryResult {
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBatch));
async function postBatch(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
// Metered by this route's own bulk bucket below, not the shared per-account
// write bucket: the native client syncs through here in bursts (see
// `meterAccountWrite`).
const { user } = await requireAuth(undefined, { writeBucket: "own" });
// Module gate first: a disabled module must refuse before any body
// work, so iOS can branch on the 403 `module.disabled` errorCode and
@@ -66,7 +66,7 @@ describe("GET /api/personal-records", () => {
expect(body.error).toBeNull();
expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({
where: { userId: "user-1" },
orderBy: { achievedAt: "desc" },
orderBy: [{ achievedAt: "desc" }, { id: "desc" }],
// One row past the limit, so the response can say it was cut.
take: 101,
});
@@ -80,7 +80,7 @@ describe("GET /api/personal-records", () => {
expect(res.status).toBe(200);
expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({
where: { userId: "user-1", metricType: "VO2_MAX" },
orderBy: { achievedAt: "desc" },
orderBy: [{ achievedAt: "desc" }, { id: "desc" }],
// One row past the limit, so the response can say it was cut.
take: 101,
});
@@ -114,7 +114,7 @@ describe("GET /api/personal-records", () => {
expect(res.status).toBe(200);
expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({
where: { userId: "user-1" },
orderBy: { achievedAt: "desc" },
orderBy: [{ achievedAt: "desc" }, { id: "desc" }],
// One row past the limit, so the response can say it was cut.
take: 26,
});
@@ -128,7 +128,7 @@ describe("GET /api/personal-records", () => {
expect(res.status).toBe(200);
expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({
where: { userId: "user-1" },
orderBy: { achievedAt: "desc" },
orderBy: [{ achievedAt: "desc" }, { id: "desc" }],
// One row past the limit, so the response can say it was cut.
take: 501,
});
+12 -10
View File
@@ -38,7 +38,8 @@ import {
resolveRecordBenchmark,
} from "@/lib/personal-records/record-benchmark";
import { toProfileSex } from "@/lib/profile/sex";
import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage } from "@/lib/http/list-query";
export const dynamic = "force-dynamic";
@@ -63,15 +64,16 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
const { metricType, limit } = parsed.data;
const found = await prisma.personalRecord.findMany({
where: {
userId: user.id,
...(metricType ? { metricType } : {}),
},
orderBy: { achievedAt: "desc" },
take: overfetch(limit),
});
const { rows: records, cap } = capRows(found, limit);
const { rows: records, cap } = await listPage(limit, (take) =>
prisma.personalRecord.findMany({
where: {
userId: user.id,
...(metricType ? { metricType } : {}),
},
orderBy: [{ achievedAt: "desc" }, { id: "desc" }],
take,
}),
);
const wantsBand = records.some((r) => mayCarryBenchmark(r.metricType));
const profile = wantsBand
+44 -36
View File
@@ -33,6 +33,8 @@ import {
practitionerListQuerySchema,
} from "@/lib/validations/practitioners";
import { toPractitionerDTO } from "@/lib/practitioners/dto";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage } from "@/lib/http/list-query";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "profile");
@@ -41,6 +43,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
const parsed = practitionerListQuerySchema.safeParse({
q: params.get("q") ?? undefined,
limit: params.get("limit") ?? undefined,
offset: params.get("offset") ?? undefined,
});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
@@ -48,50 +51,55 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
}
const rows = await prisma.practitioner.findMany({
where: {
userId: user.id,
deletedAt: null,
// Three plaintext columns, because a person looks a practice up by
// whichever they remember: the doctor's name on the referral, the
// practice name on the door, or the field they need ("Zahnmedizin").
// Searching only name + practice made the specialty attempt return
// nothing and read as "not in the address book".
...(parsed.data.q
? {
OR: [
{
name: {
contains: parsed.data.q,
mode: "insensitive" as const,
const limit = parsed.data.limit ?? 100;
const { rows, cap } = await listPage(limit, (take) =>
prisma.practitioner.findMany({
where: {
userId: user.id,
deletedAt: null,
// Three plaintext columns, because a person looks a practice up by
// whichever they remember: the doctor's name on the referral, the
// practice name on the door, or the field they need ("Zahnmedizin").
// Searching only name + practice made the specialty attempt return
// nothing and read as "not in the address book".
...(parsed.data.q
? {
OR: [
{
name: {
contains: parsed.data.q,
mode: "insensitive" as const,
},
},
},
{
practice: {
contains: parsed.data.q,
mode: "insensitive" as const,
{
practice: {
contains: parsed.data.q,
mode: "insensitive" as const,
},
},
},
{
specialty: {
contains: parsed.data.q,
mode: "insensitive" as const,
{
specialty: {
contains: parsed.data.q,
mode: "insensitive" as const,
},
},
},
],
}
: {}),
},
orderBy: { name: "asc" },
take: parsed.data.limit ?? 100,
});
],
}
: {}),
},
// Two contacts can share a name; the id keeps the page boundary stable.
orderBy: [{ name: "asc" }, { id: "asc" }],
skip: parsed.data.offset ?? 0,
take,
}),
);
annotate({
action: { name: "practitioner.contact.list", entity_type: "practitioner" },
meta: { count: rows.length },
meta: { count: rows.length, truncated: cap.truncated },
});
return apiSuccess(rows.map(toPractitionerDTO));
return apiListSuccess(rows.map(toPractitionerDTO), cap);
});
// A double-tap on "save" must not mint a second copy of the same practice.
+18 -11
View File
@@ -15,6 +15,8 @@
*/
import { NextRequest } from "next/server";
import { listPage } from "@/lib/http/list-query";
import { prisma } from "@/lib/db";
import {
apiSuccess,
@@ -65,16 +67,18 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (q.from) occurredAt.gte = new Date(q.from);
if (q.to) occurredAt.lte = new Date(q.to);
const rows = await prisma.symptomEvent.findMany({
where: {
userId: user.id,
...(q.from || q.to ? { occurredAt } : {}),
...(q.definitionId ? { definitionId: q.definitionId } : {}),
...(q.episodeId ? { episodeId: q.episodeId } : {}),
},
orderBy: [{ occurredAt: "desc" }, { id: "desc" }],
take: q.limit,
});
const { rows, cap } = await listPage(q.limit, (take) =>
prisma.symptomEvent.findMany({
where: {
userId: user.id,
...(q.from || q.to ? { occurredAt } : {}),
...(q.definitionId ? { definitionId: q.definitionId } : {}),
...(q.episodeId ? { episodeId: q.episodeId } : {}),
},
orderBy: [{ occurredAt: "desc" }, { id: "desc" }],
take,
}),
);
annotate({
action: { name: "symptoms.event.list" },
@@ -84,7 +88,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
return apiSuccess({
events: rows.map(toSymptomEventDTO),
limit: q.limit,
hasMore: rows.length === q.limit,
// Exact: true only when a row past this page exists. `rows.length ===
// limit` said true on a page that was exactly full, and the client then
// asked for a page that held nothing.
hasMore: cap.truncated,
});
});
+1 -11
View File
@@ -31,7 +31,6 @@ import {
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
import { customVaccineCreateSchema } from "@/lib/validations/vaccinations";
import { toCustomVaccineDTO } from "@/lib/vaccinations/dto";
import {
@@ -57,16 +56,7 @@ export const GET = apiHandler(async () => {
});
export const POST = apiHandler(async (request: NextRequest) => {
const { user, actor } = await requireRecordAuth("write", "profile");
// The shared per-account write ceiling, keyed on the ACTOR so a delegate
// spends their own allowance (`checkRecordWriteRateLimit`).
const writeRl = await checkRecordWriteRateLimit(actor.id);
if (!writeRl.allowed) {
return apiError("Too many writes, try again later", 429, {
errorCode: "record_write.rate_limited",
});
}
const { user } = await requireRecordAuth("write", "profile");
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 8 * 1024,
+31 -34
View File
@@ -29,8 +29,9 @@ import {
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
import { withIdempotency } from "@/lib/idempotency";
import { apiListSuccess } from "@/lib/api/list-cap";
import { listPage } from "@/lib/http/list-query";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import {
vaccinationCreateSchema,
@@ -62,6 +63,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
from: params.get("from") ?? undefined,
to: params.get("to") ?? undefined,
limit: params.get("limit") ?? undefined,
offset: params.get("offset") ?? undefined,
});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
@@ -70,29 +72,34 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
const query = parsed.data;
// Full and bounded rather than paged: an Impfpass is a lifetime document but
// a small one, and the default already covers a long life.
// A lifetime document but a small one: the default page already covers a
// long life, and `meta.truncated` says when it does not.
const take = query.limit ?? 300;
const [rows, series] = await Promise.all([
prisma.vaccinationRecord.findMany({
where: {
userId: user.id,
deletedAt: null,
...(query.antigenSlug ? { antigenSlug: query.antigenSlug } : {}),
...(query.from || query.to
? {
occurredAt: {
...(query.from ? { gte: new Date(query.from) } : {}),
...(query.to ? { lte: new Date(query.to) } : {}),
},
}
: {}),
},
orderBy: { occurredAt: "desc" },
take,
include: VACCINATION_INCLUDE,
}),
const [{ rows, cap }, series] = await Promise.all([
listPage(take, (overTake) =>
prisma.vaccinationRecord.findMany({
where: {
userId: user.id,
deletedAt: null,
...(query.antigenSlug ? { antigenSlug: query.antigenSlug } : {}),
...(query.from || query.to
? {
occurredAt: {
...(query.from ? { gte: new Date(query.from) } : {}),
...(query.to ? { lte: new Date(query.to) } : {}),
},
}
: {}),
},
// Two doses on one day are common (a combined appointment); the id
// keeps the page boundary between them stable.
orderBy: [{ occurredAt: "desc" }, { id: "desc" }],
skip: query.offset ?? 0,
take: overTake,
include: VACCINATION_INCLUDE,
}),
),
// Over the WHOLE live set, never over the filtered page: a position is a
// count of what came before it, so deriving it from a window would report
// the oldest dose in that window as the first one ever given.
@@ -135,24 +142,14 @@ export const GET = apiHandler(async (request: NextRequest) => {
),
renewals,
};
return apiSuccess(body);
return apiListSuccess(body, cap);
});
// A double-tap on "save" must not log the same dose twice.
export const POST = apiHandler(withIdempotency<[NextRequest]>(postVaccination));
async function postVaccination(request: NextRequest): Promise<Response> {
const { user, actor } = await requireRecordAuth("write", "profile");
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
// batch siblings have always been capped; the per-record creates a looping
// client hits were not.
const writeRl = await checkRecordWriteRateLimit(actor.id);
if (!writeRl.allowed) {
return apiError("Too many writes, try again later", 429, {
errorCode: "record_write.rate_limited",
});
}
const { user } = await requireRecordAuth("write", "profile");
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 32 * 1024,
+6 -1
View File
@@ -239,7 +239,12 @@ async function postBatch(request: NextRequest): Promise<Response> {
// native login mints. The only route that names it. `requireAuth` refuses
// any acting-account carrier for every credential shape, so the scoped token
// is confined to its owner's own record without an arm of its own here.
const auth = await requireAuth(WORKOUTS_WRITE_SCOPE);
// Metered by this route's own bulk bucket below, not the shared per-account
// write bucket: the native client syncs through here in bursts (see
// `meterAccountWrite`).
const auth = await requireAuth(WORKOUTS_WRITE_SCOPE, {
writeBucket: "own",
});
const { user } = auth;
// A scoped credential is a bridge, not the native client: the source of
@@ -14,7 +14,13 @@
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
import {
apiDelete,
apiGet,
apiGetList,
apiPatch,
apiPost,
} from "@/lib/api/api-fetch";
import { invalidateReminderReads } from "@/hooks/use-measurement-reminders";
import {
invalidateKeys,
@@ -52,6 +58,7 @@ export interface VaccinationWriteBody {
}
const BASE = "/api/vaccinations";
const VACCINATION_PAGE_SIZE = 300;
/**
* The account's immunization log, newest dose first.
@@ -64,12 +71,33 @@ export function useVaccinations(antigenSlug?: string | null, enabled = true) {
return useQuery({
queryKey: queryKeys.vaccinationList(antigenSlug ?? null),
enabled,
queryFn: () =>
apiGet<VaccinationListDTO>(
antigenSlug
? `${BASE}?antigenSlug=${encodeURIComponent(antigenSlug)}`
: BASE,
),
// The route pages and says whether another page exists; the log is read
// to the end. `renewals` is the same on every page, so the first is kept.
queryFn: async () => {
const page = (offset: number) => {
const sp = new URLSearchParams({
limit: String(VACCINATION_PAGE_SIZE),
offset: String(offset),
});
if (antigenSlug) sp.set("antigenSlug", antigenSlug);
return apiGetList<VaccinationListDTO>(
`/api/vaccinations?${sp.toString()}`,
);
};
const first = await page(0);
const vaccinations = [...first.data.vaccinations];
let cap = first.cap;
for (
let offset = VACCINATION_PAGE_SIZE;
cap?.truncated;
offset += VACCINATION_PAGE_SIZE
) {
const next = await page(offset);
vaccinations.push(...next.data.vaccinations);
cap = next.cap;
}
return { ...first.data, vaccinations };
},
});
}
+29 -9
View File
@@ -19,7 +19,13 @@ import {
useQueryClient,
} from "@tanstack/react-query";
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
import {
apiDelete,
apiGet,
apiGetList,
apiPatch,
apiPost,
} from "@/lib/api/api-fetch";
import { invalidateReminderReads } from "@/hooks/use-measurement-reminders";
import {
encounterDependentKeys,
@@ -53,6 +59,7 @@ export interface EncounterWriteBody {
}
const BASE = "/api/encounters";
const ENCOUNTER_PAGE_SIZE = 200;
export function useEncounters(
enabled = true,
@@ -73,14 +80,27 @@ export function useEncounters(
window?.status,
window?.episodeId,
),
queryFn: () => {
const sp = new URLSearchParams();
if (from) sp.set("from", from);
if (to) sp.set("to", to);
if (window?.status) sp.set("status", window.status);
if (window?.episodeId) sp.set("episodeId", window.episodeId);
const qs = sp.toString();
return apiGet<EncounterList>(qs ? `${BASE}?${qs}` : BASE);
// `limit` and `offset` apply to both arms and the route says whether
// either has rows past the page, so this reads to the end: a visit list
// that stopped at the first page showed an old visit as missing.
queryFn: async () => {
const list: EncounterList = { upcoming: [], past: [] };
for (let offset = 0; ; offset += ENCOUNTER_PAGE_SIZE) {
const sp = new URLSearchParams({
limit: String(ENCOUNTER_PAGE_SIZE),
offset: String(offset),
});
if (from) sp.set("from", from);
if (to) sp.set("to", to);
if (window?.status) sp.set("status", window.status);
if (window?.episodeId) sp.set("episodeId", window.episodeId);
const { data: page, cap } = await apiGetList<EncounterList>(
`/api/encounters?${sp.toString()}`,
);
list.upcoming.push(...page.upcoming);
list.past.push(...page.past);
if (!cap?.truncated) return list;
}
},
enabled,
});
+19 -5
View File
@@ -10,7 +10,7 @@
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { apiDelete, apiGetList, apiPatch, apiPost } from "@/lib/api/api-fetch";
import {
encounterDependentKeys,
invalidateKeys,
@@ -31,15 +31,29 @@ export interface PractitionerWriteBody {
}
const BASE = "/api/practitioners";
const PRACTITIONER_PAGE_SIZE = 200;
export function usePractitioners(q?: string, enabled = true) {
const term = q?.trim() || undefined;
return useQuery({
queryKey: queryKeys.practitionerList(term),
queryFn: () =>
apiGet<Practitioner[]>(
term ? `${BASE}?q=${encodeURIComponent(term)}` : BASE,
),
// The route pages and says whether another page exists; the pickers and
// the address book read this as the whole list, so it walks to the end.
queryFn: async () => {
const all: Practitioner[] = [];
for (let offset = 0; ; offset += PRACTITIONER_PAGE_SIZE) {
const sp = new URLSearchParams({
limit: String(PRACTITIONER_PAGE_SIZE),
offset: String(offset),
});
if (term) sp.set("q", term);
const { data: page, cap } = await apiGetList<Practitioner[]>(
`/api/practitioners?${sp.toString()}`,
);
all.push(...page);
if (!cap?.truncated) return all;
}
},
enabled,
});
}
@@ -3,6 +3,7 @@ import {
trendSlope,
detectAnomalies,
summarize,
wireSummary,
type DataPoint,
} from "../trends";
@@ -166,3 +167,19 @@ describe("summarize", () => {
expect(summary.latest).toBe(data[N - 1].value);
});
});
describe("wireSummary", () => {
it("drops the in-process anomaly count and keeps every published statistic", () => {
const data = Array.from({ length: 10 }, (_, i) => ({
date: new Date(Date.UTC(2026, 0, i + 1)),
value: i === 9 ? 400 : 70 + i,
}));
const full = summarize(data);
expect(full).toHaveProperty("anomalyCount");
const wire = wireSummary(full);
expect(wire).not.toHaveProperty("anomalyCount");
const { anomalyCount: _dropped, ...rest } = full;
expect(wire).toEqual(rest);
});
});
+2 -2
View File
@@ -74,7 +74,7 @@ import {
isHourlyMeanTypeSql,
windowMeanSql,
} from "@/lib/measurements/day-mean";
import type { DataSummary } from "@/lib/analytics/trends";
import { wireSummary, type DataSummary } from "@/lib/analytics/trends";
import { measurementTypeEnum } from "@/lib/validations/measurement";
import { annotate } from "@/lib/logging/context";
import { caches, type ServerCache } from "@/lib/cache/server-cache";
@@ -491,7 +491,7 @@ async function withSleepNightTotals(
// dashboard tile reads slope30); recompute them off the night series is
// out of scope here — the per-night `summarize()` already fills slope7 /
// slope30 from the night DataPoints, so use them directly.
slice.summaries.SLEEP_DURATION = summary;
slice.summaries.SLEEP_DURATION = wireSummary(summary);
// Additive, observational: the latest night's main session saw two
// writer buckets with clearly different asleep totals. Round to whole
// minutes, matching the `/api/sleep/night` serialisation.
+14
View File
@@ -156,6 +156,20 @@ export interface DataSummary {
avg30LastYear?: number | null;
}
/**
* A summary as `/api/analytics` publishes it: without `anomalyCount`.
*
* The SQL summaries path cannot compute the figure, so the route's published
* summary has not carried it since v1.37.19. Two of its summaries are still
* built by `summarize()` (the per-night sleep series and the glucose
* meal-context split), and both put it back on the wire until they were run
* through this.
*/
export function wireSummary(summary: DataSummary): DataSummary {
const { anomalyCount: _inProcessOnly, ...wire } = summary;
return wire;
}
export function summarize(data: DataPoint[]): DataSummary {
if (data.length === 0) {
// Empty series — return null for stats so callers can render an explicit
+26
View File
@@ -55,6 +55,7 @@ import {
attachRecordContextEcho,
} from "./sharing/record-session-fence";
import { refuseOnKeyMismatch } from "./boot/key-mismatch-refusal";
import { meterAccountWrite, type WriteMeterOptions } from "./auth/write-meter";
import { acceptIncomingRequestId } from "./http/request-id";
import { withErrorEnvelopeDefaults } from "./api-response";
import {
@@ -545,12 +546,16 @@ export function isScopedCredential(auth: AuthContext): boolean {
*/
export async function requireAuth(
requiredPermission?: string,
options?: Pick<WriteMeterOptions, "writeBucket">,
): Promise<AuthContext> {
const auth = await authenticateCaller(requiredPermission);
const carrier = await readActingCarrier(auth);
if (carrier.kind !== "none") {
throw refuseUndeclaredMode(auth, carrier);
}
// The shared write ceiling, for every write this resolver admits — see
// `meterAccountWrite`.
await meterAccountWrite(auth.user.id, options);
return auth;
}
@@ -866,6 +871,7 @@ export async function requireActorAuth(): Promise<AuthContext> {
auditRefusal(auth, { kind: "header", accountId: header }, "actor_surface");
throw new SharingNotPermittedError();
}
await meterAccountWrite(auth.user.id);
return auth;
}
@@ -913,6 +919,13 @@ export interface RecordAuthOptions {
* above `"read"`.
*/
readonly readOverPost?: true;
/**
* This route meters its writes with a dedicated bucket instead of the shared
* per-account one (`meterAccountWrite`). Frozen per route, with the reason,
* by `write-rate-limit-guard.test.ts`, which also requires the route to call
* its own limiter.
*/
readonly writeBucket?: "own";
}
/**
@@ -966,6 +979,13 @@ export async function requireRecordAuth(
// fence reads, so the invariant is "before the carrier", not "before the
// first database await".
await assertRecordSessionFence(auth);
// The shared write ceiling, charged to the ACTING account before any grant
// is read, so a refused write counts like an admitted one — see
// `meterAccountWrite`.
await meterAccountWrite(auth.user.id, {
readOverPost: options?.readOverPost,
writeBucket: options?.writeBucket,
});
const carrier = await readActingCarrier(auth);
// A narrow, single-purpose Bearer never acts on another record.
@@ -1063,6 +1083,7 @@ export async function requireGuardianAuth(): Promise<RecordAuthContext> {
// a context that has since moved must be refused before the marker is read,
// not after.
await assertRecordSessionFence(auth);
await meterAccountWrite(auth.user.id);
const carrier = await readActingCarrier(auth);
if (carrier.kind === "none") {
@@ -1207,6 +1228,7 @@ export async function requireAdmin(): Promise<AuthContext> {
if (sessionData.user.role !== "ADMIN") {
throw new HttpError(403, "Admin access required", AUTH_ERROR_CODES.admin);
}
await meterAccountWrite(sessionData.user.id);
return { ...sessionData, authMethod: "cookie" };
}
@@ -1233,6 +1255,7 @@ export async function requireCookieAuth(): Promise<AuthContext> {
auth_method: "session",
});
}
await meterAccountWrite(sessionData.user.id);
return { ...sessionData, authMethod: "cookie" };
}
@@ -1343,6 +1366,8 @@ export async function requireFreshMfa(
});
}
await meterAccountWrite(sessionData.user.id);
// The account must hold a credential that can stamp `mfaVerifiedAt` in the
// first place. Without one a step-up-gated action is unreachable by design
// rather than merely refused, so the refusal is named differently
@@ -1661,6 +1686,7 @@ export async function requireBearerAuth(): Promise<BearerAuthContext> {
}
const raw = authHeader.slice(7);
const auth = await authenticateBearer(raw, undefined);
await meterAccountWrite(auth.user.id);
return {
...auth,
apiTokenId: auth.session.id,
+102
View File
@@ -0,0 +1,102 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
// The unit setup stubs the meter for every route test; this file is where the
// real one is pinned.
vi.mock("@/lib/auth/write-meter", async (importOriginal) =>
importOriginal<typeof import("@/lib/auth/write-meter")>(),
);
const state = vi.hoisted(() => ({
event: null as null | { getHttpMethod: () => string | undefined },
allowed: true,
}));
vi.mock("@/lib/logging/context", () => ({
getEvent: () => state.event,
annotate: vi.fn(),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRecordWriteRateLimit: vi.fn(async () => ({
allowed: state.allowed,
limit: 300,
remaining: state.allowed ? 299 : 0,
resetAt: Date.now() + 60_000,
})),
}));
import {
meterAccountWrite,
RECORD_WRITE_RATE_LIMITED_CODE,
} from "../write-meter";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
import { HttpError } from "@/lib/api-errors";
function request(method: string | undefined) {
state.event = { getHttpMethod: () => method };
}
beforeEach(() => {
vi.mocked(checkRecordWriteRateLimit).mockClear();
state.allowed = true;
state.event = null;
});
describe("meterAccountWrite", () => {
it.each(["POST", "PUT", "PATCH", "DELETE", "patch"])(
"charges the acting account for a %s",
async (method) => {
request(method);
await meterAccountWrite("account-1");
expect(checkRecordWriteRateLimit).toHaveBeenCalledWith("account-1");
},
);
it.each(["GET", "HEAD", "OPTIONS"])("leaves a %s alone", async (method) => {
request(method);
await meterAccountWrite("account-1");
expect(checkRecordWriteRateLimit).not.toHaveBeenCalled();
});
it("charges nothing outside a request", async () => {
await meterAccountWrite("account-1");
expect(checkRecordWriteRateLimit).not.toHaveBeenCalled();
});
it("charges one request once, however many resolvers it passes", async () => {
request("POST");
await meterAccountWrite("account-1");
await meterAccountWrite("account-1");
expect(checkRecordWriteRateLimit).toHaveBeenCalledTimes(1);
request("POST");
await meterAccountWrite("account-1");
expect(checkRecordWriteRateLimit).toHaveBeenCalledTimes(2);
});
it("leaves a read-over-POST alone, and only the POST", async () => {
request("POST");
await meterAccountWrite("account-1", { readOverPost: true });
expect(checkRecordWriteRateLimit).not.toHaveBeenCalled();
request("DELETE");
await meterAccountWrite("account-1", { readOverPost: true });
expect(checkRecordWriteRateLimit).toHaveBeenCalledTimes(1);
});
it("leaves a route with its own bucket alone", async () => {
request("POST");
await meterAccountWrite("account-1", { writeBucket: "own" });
expect(checkRecordWriteRateLimit).not.toHaveBeenCalled();
});
it("refuses a spent bucket with 429 and the published code", async () => {
state.allowed = false;
request("PATCH");
const refusal = await meterAccountWrite("account-1").catch((e) => e);
expect(refusal).toBeInstanceOf(HttpError);
expect((refusal as HttpError).statusCode).toBe(429);
expect((refusal as HttpError).errorCode).toBe(
RECORD_WRITE_RATE_LIMITED_CODE,
);
});
});
+88
View File
@@ -0,0 +1,88 @@
/**
* The shared per-account write ceiling, applied where the caller is resolved.
*
* `checkRecordWriteRateLimit` (300 writes a minute per acting account) used to
* be called by hand in a dozen create routes, and the other mutating routes
* had no ceiling at all: of 364 mutating handlers, 169 met no limiter, every
* PATCH and DELETE on an `[id]` among them. A positive list of routes that
* remember to call it can only fall behind the routes that exist, so the
* ceiling now sits on the path every authenticated write already takes: the
* identity resolvers in `src/lib/api-handler.ts` call {@link meterAccountWrite}
* once the caller is known, and it charges the bucket for any request whose
* method writes.
*
* What it does not do:
*
* - charge a read. GET, HEAD and OPTIONS pass untouched, and so does a POST a
* route declared `readOverPost` (a search whose query must not stand in a
* URL).
* - charge one request twice. A route that resolves the caller through two
* helpers (step-up on top of a session) is charged once.
* - meter a route that declared its own bucket. The bulk ingest endpoints
* the native client syncs through have dedicated ceilings sized for that
* volume; charging the shared bucket as well would let a large HealthKit
* backfill lock the person out of logging a single dose by hand. Declaring
* `writeBucket: "own"` is frozen per route by
* `src/__tests__/write-rate-limit-guard.test.ts`, which also requires the
* route to call its own limiter.
*
* The bucket is keyed on the ACTING account, the precedent from the delegable
* medication routes: a delegate spends their own allowance rather than the
* record owner's, and cannot collect a fresh one by switching records. A
* refused request is counted too, since the charge happens before the body is
* read.
*/
import { HttpError } from "@/lib/api-errors";
import { annotate, getEvent } from "@/lib/logging/context";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
/** The code a refusal by the shared write bucket carries. */
export const RECORD_WRITE_RATE_LIMITED_CODE = "record_write.rate_limited";
const WRITE_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
/** Requests already charged, so a second resolver in one request is free. */
const charged = new WeakSet<object>();
export interface WriteMeterOptions {
/** The route answers this POST without writing (see `readOverPost`). */
readOverPost?: true;
/**
* The route meters its writes with a dedicated bucket instead of the shared
* one. Reviewed per route by the write rate-limit guard.
*/
writeBucket?: "own";
}
/**
* Charge the acting account's shared write bucket for this request, or refuse
* it with 429 when the bucket is spent.
*
* Outside a request (no wide event, so no method) there is nothing to charge.
*/
export async function meterAccountWrite(
accountId: string,
options: WriteMeterOptions = {},
): Promise<void> {
const evt = getEvent();
if (!evt) return;
const method = (evt.getHttpMethod() ?? "").toUpperCase();
if (!WRITE_METHODS.has(method)) return;
if (options.readOverPost === true && method === "POST") return;
if (options.writeBucket === "own") {
annotate({ meta: { write_bucket: "own" } });
return;
}
if (charged.has(evt)) return;
charged.add(evt);
const rl = await checkRecordWriteRateLimit(accountId);
if (!rl.allowed) {
annotate({ meta: { record_write_rate_limited: true } });
throw new HttpError(
429,
"Too many writes, try again later",
RECORD_WRITE_RATE_LIMITED_CODE,
);
}
}
@@ -12,8 +12,25 @@
* no rollup, no sync, no FHIR, no insights. Charts read entries LIVE.
*/
import type { Prisma } from "@/generated/prisma/client";
import { readNote } from "@/lib/crypto/note-cipher";
/**
* The joins behind a published custom-metric row: its newest live entry and
* its live entry count. Every read that answers with the row — the
* catalogue, the single metric, the edit echo — includes these, so `latest`
* and `entryCount` cannot be present on one and missing on another.
*/
export const CUSTOM_METRIC_LATEST_INCLUDE = {
entries: {
where: { deletedAt: null },
orderBy: { measuredAt: "desc" },
take: 1,
select: { value: true, unit: true, measuredAt: true },
},
_count: { select: { entries: { where: { deletedAt: null } } } },
} satisfies Prisma.CustomMetricInclude;
/** A custom-metric catalog row as the API serialises it. */
export interface CustomMetricRow {
id: string;
@@ -0,0 +1,95 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
import { annotate } from "@/lib/logging/context";
import { buildOpenApiDocument } from "@/lib/openapi/registry";
import { clientBuildOf, deprecated, DEPRECATIONS } from "../deprecation";
beforeEach(() => vi.mocked(annotate).mockClear());
function request(userAgent?: string): Request {
return new Request("http://localhost/api/feature-flags", {
headers: userAgent ? { "user-agent": userAgent } : {},
});
}
describe("deprecated()", () => {
it("dates the deprecation and the sunset, and names the successor", () => {
const res = deprecated(
new Response(null, { headers: { Link: '</a>; rel="next"' } }),
"/api/feature-flags",
request(),
);
const notice = DEPRECATIONS["/api/feature-flags"];
expect(res.headers.get("Deprecation")).toBe(
`@${Date.parse(`${notice.since}T00:00:00Z`) / 1000}`,
);
expect(new Date(res.headers.get("Sunset")!).toISOString()).toBe(
`${notice.sunset}T00:00:00.000Z`,
);
// A Link the route already set survives beside the successor.
expect(res.headers.get("Link")).toBe(
'</a>; rel="next", </api/auth/me>; rel="successor-version"',
);
});
it("logs which client build still asked", () => {
deprecated(
new Response(null),
"/api/feature-flags",
request("HealthLog-iOS/1.2.0 (dev.healthlog.app)"),
);
expect(annotate).toHaveBeenCalledWith({
meta: {
deprecated_route: "/api/feature-flags",
deprecation_sunset: DEPRECATIONS["/api/feature-flags"].sunset,
client_build: "ios/1.2.0",
},
});
});
it("sunsets every notice after it was deprecated", () => {
for (const notice of Object.values(DEPRECATIONS)) {
expect(Date.parse(notice.sunset)).toBeGreaterThan(
Date.parse(notice.since),
);
}
});
});
describe("clientBuildOf", () => {
it("reads the native build, a browser, anything else, and nothing", () => {
expect(clientBuildOf("HealthLog-iOS/1.3.0 (dev.healthlog.app)")).toBe(
"ios/1.3.0",
);
expect(
clientBuildOf("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"),
).toBe("web");
expect(clientBuildOf("curl/8.4.0")).toBe("other");
expect(clientBuildOf(null)).toBeNull();
});
});
describe("each deprecation is published and applied", () => {
const doc = buildOpenApiDocument() as unknown as {
paths: Record<string, Record<string, { deprecated?: boolean }>>;
};
it.each(Object.keys(DEPRECATIONS))(
"%s is deprecated in the contract and answers through the helper",
(route) => {
const ops = Object.values(doc.paths[route] ?? {});
expect(ops.length).toBeGreaterThan(0);
expect(ops.every((op) => op.deprecated === true)).toBe(true);
const src = readFileSync(
join(process.cwd(), "src/app", route, "route.ts"),
"utf8",
);
expect(src).toContain(`deprecated(response, "${route}", request)`);
},
);
});
+77
View File
@@ -0,0 +1,77 @@
import { describe, expect, it, vi } from "vitest";
import { combinedCap, listPage, parseListQuery } from "../list-query";
describe("listPage", () => {
it("asks for one row past the limit and reports a cut only when that row exists", async () => {
const read = vi.fn(async (take: number) =>
Array.from({ length: Math.min(take, 5) }, (_, i) => i),
);
const cut = await listPage(3, read);
expect(read).toHaveBeenLastCalledWith(4);
expect(cut.rows).toEqual([0, 1, 2]);
expect(cut.cap).toEqual({ truncated: true, limit: 3 });
// Exactly full is complete: five rows, a limit of five.
const full = await listPage(5, read);
expect(full.rows).toHaveLength(5);
expect(full.cap).toEqual({ truncated: false, limit: 5 });
const short = await listPage(9, read);
expect(short.rows).toHaveLength(5);
expect(short.cap.truncated).toBe(false);
});
});
describe("combinedCap", () => {
it("is truncated when any list was cut", () => {
expect(
combinedCap(
{ truncated: false, limit: 50 },
{ truncated: true, limit: 50 },
),
).toEqual({ truncated: true, limit: 50 });
expect(
combinedCap(
{ truncated: false, limit: 50 },
{ truncated: false, limit: 50 },
).truncated,
).toBe(false);
});
});
describe("parseListQuery", () => {
const options = { defaultLimit: 50, maxLimit: 200, offset: true };
it("applies the default and reads both fields", () => {
expect(parseListQuery(new URLSearchParams(), options).data).toEqual({
limit: 50,
offset: 0,
});
expect(
parseListQuery(new URLSearchParams("limit=10&offset=20"), options).data,
).toEqual({ limit: 10, offset: 20 });
});
it("refuses a limit past the ceiling and a negative offset, both at once", () => {
const parsed = parseListQuery(
new URLSearchParams("limit=500&offset=-1"),
options,
);
expect(parsed.success).toBe(false);
expect(parsed.error?.issues.map((i) => i.path[0]).sort()).toEqual([
"limit",
"offset",
]);
});
it("ignores offset on a route that does not page", () => {
expect(
parseListQuery(new URLSearchParams("offset=20"), {
defaultLimit: 50,
maxLimit: 200,
}).data,
).toEqual({ limit: 50, offset: 0 });
});
});
+102
View File
@@ -0,0 +1,102 @@
/**
* How a deprecated route says so: on the wire, and in the operator's log.
*
* `/api/feature-flags` was marked deprecated with a bare `Deprecation: true`
* and no date at all: a client could learn that the route was going, never
* when, and the operator could not tell which builds still called it, so the
* removal criterion ("the first release after the native build that reads
* `ai` ships") could not be checked against anything. A deprecation is now one
* reviewed notice per route, applied with {@link deprecated}:
*
* - `Deprecation: @<unix seconds>` — when it was deprecated (RFC 9745);
* - `Sunset: <HTTP-date>` — from when the route may stop answering
* (RFC 8594);
* - `Link: <successor>; rel="successor-version"` — where to go instead;
* - a wide-event annotation naming the route's notice and the calling
* client build, read off the `User-Agent`, so the log answers "who still
* calls this" before the sunset arrives.
*
* The notices live in {@link DEPRECATIONS}, so the dates are in one place and
* `deprecation.test.ts` holds each route that applies one to the OpenAPI
* `deprecated` flag.
*/
import { annotate } from "@/lib/logging/context";
import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
export interface DeprecationNotice {
/** The day the route was deprecated, `YYYY-MM-DD` (UTC). */
since: string;
/** The first day the route may stop answering, `YYYY-MM-DD` (UTC). */
sunset: string;
/** The path that replaces it, when there is one. */
successor?: string;
}
/**
* Every route that answers as deprecated, with its dates.
*
* `/api/feature-flags`: deprecated in v1.39.0 (2026-09-24), when `ai` on
* `GET /api/auth/me` replaced it. The shipped native build still reads it, so
* the sunset leaves two native release cycles.
*/
export const DEPRECATIONS = {
"/api/feature-flags": {
since: "2026-09-24",
sunset: "2027-03-31",
successor: "/api/auth/me",
},
} as const satisfies Record<string, DeprecationNotice>;
export type DeprecatedRoute = keyof typeof DEPRECATIONS;
/**
* A notice day as the instant the header states. Both headers carry instants,
* and a deprecation published for every client has no one person's day to
* start from, so the day is read as UTC midnight: the label form.
*/
function utcDay(day: string): Date {
if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) {
throw new Error(`Deprecation date must be YYYY-MM-DD, got "${day}"`);
}
return dayKeyAsUtcMidnight(day);
}
/**
* The calling client, coarsely: `ios/<version>` for the native app (its
* `User-Agent` is `HealthLog-iOS/<version> (<bundle id>)`), `web` for a
* browser, `other` for anything else, null with no header.
*/
export function clientBuildOf(userAgent: string | null): string | null {
if (!userAgent) return null;
const ios = /^HealthLog-iOS\/([0-9][0-9A-Za-z.+-]{0,31})/.exec(userAgent);
if (ios) return `ios/${ios[1]}`;
if (/^Mozilla\//.test(userAgent)) return "web";
return "other";
}
/** Mark a response as coming from a deprecated route, and log who asked. */
export function deprecated<R extends Response>(
response: R,
route: DeprecatedRoute,
request: Request,
): R {
const notice: DeprecationNotice = DEPRECATIONS[route];
const since = utcDay(notice.since);
const sunset = utcDay(notice.sunset);
response.headers.set("Deprecation", `@${Math.floor(since.getTime() / 1000)}`);
response.headers.set("Sunset", sunset.toUTCString());
if (notice.successor) {
response.headers.append(
"Link",
`<${notice.successor}>; rel="successor-version"`,
);
}
annotate({
meta: {
deprecated_route: route,
deprecation_sunset: notice.sunset,
client_build: clientBuildOf(request.headers.get("user-agent")),
},
});
return response;
}
+24
View File
@@ -0,0 +1,24 @@
/**
* The `limit` and `offset` query fields of a paged list, safe for any bundle.
*
* Kept apart from `./list-query.ts` (which reaches the response envelope) so a
* request schema in `src/lib/validations/` can use them without pulling server
* code into a client that imports the same schema.
*/
import { z } from "zod";
/** The `limit` field, for a route whose query schema has other fields too. */
export function listLimitField({
defaultLimit,
maxLimit,
}: {
defaultLimit: number;
maxLimit: number;
}) {
return z.coerce.number().int().min(1).max(maxLimit).default(defaultLimit);
}
/** The `offset` field, for a route whose query schema has other fields too. */
export function listOffsetField() {
return z.coerce.number().int().min(0).max(100_000).default(0);
}
+85
View File
@@ -0,0 +1,85 @@
/**
* One way to read a page of a list, and one way to say whether it was cut.
*
* The list routes had grown five answers to "is there more": `meta.truncated`
* from `src/lib/api/list-cap.ts`, a `hasMore` computed as `rows.length ===
* limit` (true on a list that is exactly full, so a client asks for a page
* that does not exist), a `total` from a second count query, a sync cursor,
* and nothing at all. This module is where a capped or paged list gets its
* page, so the signal is computed the same way everywhere:
*
* - {@link parseListQuery} reads `limit` (and `offset`, where the route
* pages) with the route's own default and ceiling;
* - {@link listPage} runs the read for one row past the limit and cuts it
* back, so `cap.truncated` is exact: true only when a row past the page
* exists.
*
* The cap is the one in `list-cap.ts` — this module adds no second signal.
* A route answers with `apiListSuccess(data, cap)` (the envelope's `meta`),
* or, where a route already published a `hasMore` in its body, derives that
* field from `cap.truncated`.
*
* Every read through here must have a total order: the `orderBy` ends in the
* row id, so a page boundary between two rows that share a timestamp does not
* repeat one of them on the next page or drop it. `list-page-guard.test.ts`
* holds every capped list to this module and to that tiebreaker.
*/
import { z } from "zod";
import { capRows, overfetch, type ListCap } from "@/lib/api/list-cap";
import { listLimitField, listOffsetField } from "./list-query-fields";
export interface ListQueryOptions {
/** The limit when the request names none. */
defaultLimit: number;
/** The largest limit a request may ask for. */
maxLimit: number;
/** Whether the route pages with `offset`. */
offset?: boolean;
}
/**
* Read `limit` (and `offset`) from a request's query string.
*
* Returns the Zod result, so the route answers a bad value with the same
* multi-issue 422 it gives every other field.
*/
export function parseListQuery(
params: URLSearchParams,
options: ListQueryOptions,
) {
const schema = z.object({
limit: listLimitField(options),
offset: options.offset ? listOffsetField() : z.literal(0).default(0),
});
return schema.safeParse({
limit: params.get("limit") ?? undefined,
offset: options.offset ? (params.get("offset") ?? undefined) : undefined,
});
}
/**
* Run a list read for one page and say whether rows past it exist.
*
* `read` receives the `take` to pass to Prisma (one past `limit`); the rows
* come back cut to `limit` with the exact cap beside them.
*/
export async function listPage<T>(
limit: number,
read: (take: number) => Promise<readonly T[]>,
): Promise<{ rows: T[]; cap: ListCap }> {
return capRows(await read(overfetch(limit)), limit);
}
/**
* The cap of a response that carries several capped lists at once (the
* encounters' upcoming and past arms, the grants given and received): more
* rows exist when any one of them was cut.
*/
export function combinedCap(...caps: ListCap[]): ListCap {
return {
truncated: caps.some((cap) => cap.truncated),
limit: Math.max(...caps.map((cap) => cap.limit)),
};
}
@@ -53,6 +53,7 @@ import { MCP_TOOLS } from "../tools";
import { buildCoachDataInventory } from "@/lib/ai/coach/tools/inventory";
import { executeCoachTool } from "@/lib/ai/coach/tools/executor";
import { prisma } from "@/lib/db";
import { loadRecordCandidates } from "@/lib/mcp/record-search";
import {
USER_TEXT_FENCE_START,
USER_TEXT_FENCE_END,
@@ -134,6 +135,37 @@ describe("search", () => {
expect(result.results.map((r) => r.id)).toContain("lab:LDL");
expect(result.results.some((r) => r.id.startsWith("med:"))).toBe(false);
});
it("says the search was assembled from a cut read, on every page", async () => {
vi.mocked(buildCoachDataInventory).mockResolvedValue({
entries: [],
window: "90d",
restMode: false,
cycleEnabled: false,
} as never);
vi.mocked(prisma.medication.findMany).mockResolvedValue([]);
vi.mocked(prisma.labResult.findMany).mockResolvedValue([]);
vi.mocked(loadRecordCandidates).mockImplementationOnce(
async (_userId, _query, _origin, options) => {
options?.onTruncated?.("visit");
return [];
},
);
const cut = (await tool("search").run(CTX, { query: "cardio" })) as {
truncated: boolean;
truncatedSources?: string[];
};
expect(cut.truncated).toBe(true);
expect(cut.truncatedSources).toEqual(["visit"]);
const whole = (await tool("search").run(CTX, { query: "cardio" })) as {
truncated: boolean;
truncatedSources?: string[];
};
expect(whole.truncated).toBe(false);
expect(whole).not.toHaveProperty("truncatedSources");
});
it("keeps distinct lab analytes stable across cursor pages", async () => {
vi.mocked(buildCoachDataInventory).mockResolvedValue({
entries: [],
@@ -0,0 +1,79 @@
/**
* A tool that throws answers with a fixed sentence and a code, never with the
* exception's own text.
*
* Without the wrap the SDK hands the model `error.message`, which for a
* database failure is Prisma's prose: the query, the columns, sometimes a value
* from the record. The detail belongs in the operator's log.
*/
import { describe, expect, it, vi } from "vitest";
const event = vi.hoisted(() => ({
setError: vi.fn(),
getRequestId: () => "req-123",
}));
vi.mock("@/lib/db", () => ({
prisma: {
ecgRecording: {
findMany: vi.fn(async () => {
throw new Error(
"Invalid `prisma.ecgRecording.findMany()` invocation: column ecg_recordings.waveform_encrypted does not exist (value: 4711)",
);
}),
},
},
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: () => event,
}));
process.env.APP_URL = "http://localhost";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { createMcpServer } from "../server";
import { MCP_TOOL_FAILED } from "../tool-failure";
import type { McpAuthContext } from "../auth";
const ctx: McpAuthContext = {
userId: "u-1",
tokenId: "t-1",
scopes: ["health:read"],
binding: "u-1:t-1",
canRead: true,
canWrite: false,
};
describe("a failing tool", () => {
it("tells the model a fixed sentence, a code and the request id, and logs the cause", async () => {
const server = createMcpServer(ctx);
const client = new Client({ name: "failure-test", version: "1.0.0" });
const [clientSide, serverSide] = InMemoryTransport.createLinkedPair();
await Promise.all([server.connect(serverSide), client.connect(clientSide)]);
const result = (await client.callTool({
name: "get_ecg_recordings",
arguments: {},
})) as { isError?: boolean; content: Array<{ text: string }> };
await client.close();
await server.close();
expect(result.isError).toBe(true);
const text = result.content.map((c) => c.text).join("\n");
expect(text).not.toMatch(/prisma|invocation|column|waveform|4711/i);
const body = JSON.parse(text) as {
error: { code: string; message: string; requestId?: string };
};
expect(body.error.code).toBe(MCP_TOOL_FAILED);
expect(body.error.requestId).toBe("req-123");
expect(body.error.message).toContain("get_ecg_recordings");
// The cause went to the event, which redacts it on the way to the log.
expect(event.setError).toHaveBeenCalledWith(expect.any(Error));
expect((event.setError.mock.calls[0][0] as Error).message).toContain(
"findMany",
);
});
});
+80 -1
View File
@@ -1110,12 +1110,48 @@ describe("get_visits", () => {
};
expect(arg.where.userId).toBe("user-1");
expect(arg.where.deletedAt).toBeNull();
expect(arg.take).toBe(50);
// One past the cap of 50: the extra row only says whether more exist.
expect(arg.take).toBe(51);
// The window arg narrows the range; the injected userId body field is
// ignored entirely.
expect(arg.where.occurredAt).toBeDefined();
});
it("says when the visit list was cut at its cap, and only then", async () => {
const visit = (n: number) => ({
id: `enc-${n}`,
occurredAt: new Date(Date.UTC(2026, 0, 1) - n * 86_400_000),
status: "DONE",
kind: "ROUTINE",
reasonEncrypted: null,
outcomeEncrypted: null,
bodySiteEncrypted: null,
laterality: null,
practitioner: null,
});
vi.mocked(prisma.encounter.findMany).mockResolvedValueOnce(
Array.from({ length: 51 }, (_, n) => visit(n)) as never,
);
const cut = (await tool("get_visits").run(CTX, {})) as {
visits: unknown[];
truncated: boolean;
limit: number;
};
expect(cut.visits).toHaveLength(50);
expect(cut).toMatchObject({ truncated: true, limit: 50 });
// Exactly full is the whole list.
vi.mocked(prisma.encounter.findMany).mockResolvedValueOnce(
Array.from({ length: 50 }, (_, n) => visit(n)) as never,
);
const full = (await tool("get_visits").run(CTX, {})) as {
visits: unknown[];
truncated: boolean;
};
expect(full.visits).toHaveLength(50);
expect(full.truncated).toBe(false);
});
it("narrows to one practitioner by a case-insensitive name substring", async () => {
vi.mocked(prisma.encounter.findMany).mockResolvedValue([] as never);
await tool("get_visits").run(CTX, { practitioner: "herz" });
@@ -1535,6 +1571,49 @@ describe("get_intraday_pulse — v1.30 coverage review (G2)", () => {
});
});
describe("get_ecg_recordings — the list says when it was cut", () => {
const row = (n: number) => ({
id: `ecg-${n}`,
recordedAt: new Date(Date.UTC(2026, 6, 1) - n * 3_600_000),
durationSeconds: 30,
samplingFrequency: 512,
sampleCount: 15360,
averageHeartRate: 70,
lead: "LEAD_I",
rhythmClassification: "NOT_DETECTED",
source: "APPLE_HEALTH",
});
it("reads one past the cap and reports the cut exactly", async () => {
vi.mocked(prisma.ecgRecording.findMany).mockResolvedValueOnce(
Array.from({ length: 201 }, (_, n) => row(n)) as never,
);
const cut = (await tool("get_ecg_recordings").run(CTX, {})) as {
recordings: unknown[];
truncated: boolean;
limit: number;
};
const call = vi
.mocked(prisma.ecgRecording.findMany)
.mock.calls.at(-1)![0] as {
take: number;
orderBy: unknown;
};
expect(call.take).toBe(201);
expect(call.orderBy).toEqual([{ recordedAt: "desc" }, { id: "desc" }]);
expect(cut.recordings).toHaveLength(200);
expect(cut).toMatchObject({ truncated: true, limit: 200 });
vi.mocked(prisma.ecgRecording.findMany).mockResolvedValueOnce(
Array.from({ length: 200 }, (_, n) => row(n)) as never,
);
const full = (await tool("get_ecg_recordings").run(CTX, {})) as {
truncated: boolean;
};
expect(full.truncated).toBe(false);
});
});
describe("get_ecg_recordings — v1.30 coverage review (G3)", () => {
it("returns metadata-only recordings with the device classification verbatim", async () => {
vi.mocked(prisma.ecgRecording.findMany).mockResolvedValue([
+49
View File
@@ -0,0 +1,49 @@
/**
* A capped MCP read, and the fields that tell the model it was cut.
*
* Several tools stopped at their own ceiling and said nothing: the ECG list at
* 200, the visit history at 50, the booked appointments at 25, the record
* search at a per-kind scan. A model handed the 50 newest visits reads the
* fiftieth as the oldest there is and answers "your first cardiology visit was
* in 2024" from a list that simply ended there.
*
* Same semantics as the REST list cap (`src/lib/api/list-cap.ts`), through the
* same helper: the read asks for one row past the limit, and `truncated` is
* true only when that row exists. A tool spreads {@link capFields} into its
* result and {@link cappedOutput} into its output shape.
*/
import { z } from "zod/v4";
import { listPage } from "@/lib/http/list-query";
import type { ListCap } from "@/lib/api/list-cap-shape";
/** Run a capped read: the rows within `limit`, and whether more exist. */
export function capped<T>(
limit: number,
read: (take: number) => Promise<readonly T[]>,
): Promise<{ rows: T[]; cap: ListCap }> {
return listPage(limit, read);
}
/** The cap as tool-result fields. */
export function capFields(cap: ListCap): { truncated: boolean; limit: number } {
return { truncated: cap.truncated, limit: cap.limit };
}
/**
* The output-shape fields for a capped list. Optional, because a grounded
* `{ present: false }` miss carries no list to have cut.
*/
export const cappedOutput = {
truncated: z
.boolean()
.optional()
.describe(
"True when more records exist than this result lists: the list was cut at `limit`. Do not treat the last record listed as the oldest or the only one; say the list is partial.",
),
limit: z
.number()
.int()
.optional()
.describe("The most records this result could list."),
};
+7 -1
View File
@@ -27,6 +27,7 @@ import { MCP_RESOURCES, MCP_RESOURCE_TEMPLATES } from "./resources";
import { MCP_PROMPTS } from "./prompts";
import { resolveBaseOrigin } from "./oauth/config";
import { advertiseToolSchemasAs2020 } from "./schema-dialect";
import { toolFailure } from "./tool-failure";
export const MCP_SERVER_NAME = "healthlog";
export const MCP_SERVER_VERSION = "1.0.0";
@@ -102,7 +103,12 @@ export function createMcpServer(ctx: McpAuthContext): McpServer {
...(tool.outputShape ? { outputSchema: tool.outputShape } : {}),
},
async (args: Record<string, unknown>) => {
const result = await tool.run(ctx, args ?? {});
let result: unknown;
try {
result = await tool.run(ctx, args ?? {});
} catch (error) {
return toolFailure(tool.name, error);
}
const content = [
{ type: "text" as const, text: JSON.stringify(result) },
];
+52
View File
@@ -0,0 +1,52 @@
/**
* What a model is told when a tool fails, and what the operator is told.
*
* A tool that threw was answered by the MCP SDK with the exception's own
* message as the result text, so a database failure reached the model, and
* through it the person's chat, as Prisma's prose: the query shape, the
* column names, sometimes a value from the record. That text is for the
* operator. The model gets a fixed sentence it can relay, a stable code to
* branch on, and the request id that finds the event in the log; the
* exception goes to the wide event, through the same redaction every other
* error takes.
*/
import { annotate, getEvent } from "@/lib/logging/context";
/** The code a failed tool call carries in its MCP result (not an HTTP `meta.errorCode`). */
export const MCP_TOOL_FAILED = "mcp.tool.failed";
export function toolFailure(
toolName: string,
error: unknown,
): {
isError: true;
content: Array<{ type: "text"; text: string }>;
} {
const evt = getEvent();
const requestId = evt?.getRequestId() ?? null;
if (evt) {
evt.setError(error);
annotate({
action: { name: "mcp.tool.failed" },
meta: { tool: toolName },
});
} else {
// The stdio entrypoint runs outside a request, and stderr is its log.
console.error(`HealthLog MCP: tool ${toolName} failed`, error);
}
return {
isError: true,
content: [
{
type: "text",
text: JSON.stringify({
error: {
code: MCP_TOOL_FAILED,
message: `The ${toolName} tool could not complete this request. Try again later; if it keeps failing, the operator can find the failure by its request id.`,
...(requestId ? { requestId } : {}),
},
}),
},
],
};
}
+131 -72
View File
@@ -70,6 +70,7 @@ import {
import { loadIntradayPulse } from "@/lib/analytics/intraday-pulse-io";
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
import { encodeOffsetCursor, decodeOffsetCursor } from "@/lib/mcp/pagination";
import { capFields, capped, cappedOutput } from "@/lib/mcp/capped";
import { loadAutoTakeRollingAnchors } from "@/lib/medications/auto-take";
import {
computeDisplayDue,
@@ -477,6 +478,15 @@ function searchAndFetchTools(): McpToolDefinition[] {
}),
),
nextCursor: z.string().optional(),
truncated: cappedOutput.truncated.describe(
"True when a source this search reads stopped at its scan ceiling, so older records of that kind were not searched and no cursor reaches them. An empty or short result is then not proof that nothing matches; narrow the query or use the kind's own tool.",
),
truncatedSources: z
.array(z.string())
.optional()
.describe(
"Which sources were cut: medication, lab, visit, condition, document, vaccination.",
),
},
async run(ctx, args) {
const rawQuery =
@@ -526,12 +536,21 @@ function searchAndFetchTools(): McpToolDefinition[] {
isModuleEnabled(ctx.userId, "labs"),
]);
// Sources whose read stopped at its ceiling, so the result can say it
// was assembled from part of the record instead of reading as all of
// it.
const truncatedSources: string[] = [];
const meds = medicationsOn
? await prisma.medication.findMany({
where: { userId: ctx.userId },
select: { id: true, name: true, dose: true },
orderBy: { createdAt: "desc" },
take: SEARCH_RESULT_SCAN_CAP,
? await capped(SEARCH_RESULT_SCAN_CAP, (take) =>
prisma.medication.findMany({
where: { userId: ctx.userId },
select: { id: true, name: true, dose: true },
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take,
}),
).then(({ rows, cap }) => {
if (cap.truncated) truncatedSources.push("medication");
return rows;
})
: [];
for (const med of meds) {
@@ -549,12 +568,18 @@ function searchAndFetchTools(): McpToolDefinition[] {
}
const labs = labsOn
? await prisma.labResult.findMany({
where: { userId: ctx.userId, deletedAt: null },
select: { analyte: true },
distinct: ["analyte"],
orderBy: { analyte: "asc" },
take: SEARCH_RESULT_SCAN_CAP,
? await capped(SEARCH_RESULT_SCAN_CAP, (take) =>
prisma.labResult.findMany({
where: { userId: ctx.userId, deletedAt: null },
select: { analyte: true },
distinct: ["analyte"],
// One row per analyte, so the analyte alone is a total order.
orderBy: { analyte: "asc" },
take,
}),
).then(({ rows, cap }) => {
if (cap.truncated) truncatedSources.push("lab");
return rows;
})
: [];
for (const lab of labs) {
@@ -636,7 +661,9 @@ function searchAndFetchTools(): McpToolDefinition[] {
// v1.39.3 — visits (procedures included), conditions, documents and
// vaccinations, each behind its own module switch.
candidates.push(
...(await loadRecordCandidates(ctx.userId, rawQuery, origin)),
...(await loadRecordCandidates(ctx.userId, rawQuery, origin, {
onTruncated: (kind) => truncatedSources.push(kind),
})),
);
const results = rankCandidates(candidates, rawQuery).map(
@@ -658,6 +685,12 @@ function searchAndFetchTools(): McpToolDefinition[] {
...(hasMore
? { nextCursor: encodeOffsetCursor(offset + SEARCH_PAGE_SIZE) }
: {}),
// Every page says so, not only the last: the cut is in what was
// searched, which no cursor reaches past.
truncated: truncatedSources.length > 0,
...(truncatedSources.length > 0
? { truncatedSources: [...new Set(truncatedSources)].sort() }
: {}),
};
},
},
@@ -1356,6 +1389,12 @@ const getPreventiveCareOutput: z.ZodRawShape = {
}),
)
.optional(),
appointmentsTruncated: cappedOutput.truncated.describe(
"True when more booked appointments exist than `appointments` lists: the list was cut at `appointmentsLimit`, soonest first. Say the list is partial.",
),
appointmentsLimit: cappedOutput.limit.describe(
"The most appointments this result could list.",
),
};
/**
@@ -1392,6 +1431,7 @@ const getVisitsOutput: z.ZodRawShape = {
// "module_disabled" when the illness module is off and the condition
// labels are withheld rather than absent (v1.39.3).
conditionsReason: z.string().optional(),
...cappedOutput,
};
/** Default trailing window for `get_visits` when the caller names none. */
@@ -1529,6 +1569,7 @@ const getEcgRecordingsOutput: z.ZodRawShape = {
}),
)
.optional(),
...cappedOutput,
};
/** `get_labs` behind its module gate: latest-per-biomarker or a history page. */
@@ -2182,21 +2223,25 @@ export const MCP_TOOLS: McpToolDefinition[] = [
// Upcoming appointments ride alongside the Vorsorge due-list, read from
// `Encounter` (a booked visit in the future). They are a NAMED field on
// this result, not a second tool, so "what is coming up" is one answer.
const appointmentRows = await prisma.encounter.findMany({
where: {
userId: ctx.userId,
deletedAt: null,
status: "PLANNED",
occurredAt: { gt: now },
},
orderBy: { occurredAt: "asc" },
take: MAX_APPOINTMENTS,
include: {
practitioner: {
select: { name: true, specialty: true, deletedAt: true },
},
},
});
const { rows: appointmentRows, cap: appointmentCap } = await capped(
MAX_APPOINTMENTS,
(take) =>
prisma.encounter.findMany({
where: {
userId: ctx.userId,
deletedAt: null,
status: "PLANNED",
occurredAt: { gt: now },
},
orderBy: [{ occurredAt: "asc" }, { id: "asc" }],
take,
include: {
practitioner: {
select: { name: true, specialty: true, deletedAt: true },
},
},
}),
);
const appointments = appointmentRows.map((r) => {
const reason = decryptVisitText(r.reasonEncrypted);
// A practitioner the person deleted is no longer named.
@@ -2288,7 +2333,15 @@ export const MCP_TOOLS: McpToolDefinition[] = [
return {
present: true,
...(checkups.length > 0 ? { checkups } : {}),
...(appointments.length > 0 ? { appointments } : {}),
...(appointments.length > 0
? {
appointments,
// The cap describes the appointments only; the checkups are
// read whole.
appointmentsTruncated: appointmentCap.truncated,
appointmentsLimit: appointmentCap.limit,
}
: {}),
};
},
},
@@ -2394,25 +2447,27 @@ export const MCP_TOOLS: McpToolDefinition[] = [
async run(ctx) {
// Device data, served whatever the AI switches or the AI analysis
// opt-out say, like `GET /api/insights/ecg`.
const rows = await prisma.ecgRecording.findMany({
where: { userId: ctx.userId },
// Everything EXCEPT `waveformEncrypted` — mirrors `GET
// /api/insights/ecg`'s own select exactly; no decrypt ever happens
// on this path.
select: {
id: true,
recordedAt: true,
durationSeconds: true,
samplingFrequency: true,
sampleCount: true,
averageHeartRate: true,
lead: true,
rhythmClassification: true,
source: true,
},
orderBy: { recordedAt: "desc" },
take: MAX_ECG_RECORDINGS,
});
const { rows, cap } = await capped(MAX_ECG_RECORDINGS, (take) =>
prisma.ecgRecording.findMany({
where: { userId: ctx.userId },
// Everything EXCEPT `waveformEncrypted` — mirrors `GET
// /api/insights/ecg`'s own select exactly; no decrypt ever happens
// on this path.
select: {
id: true,
recordedAt: true,
durationSeconds: true,
samplingFrequency: true,
sampleCount: true,
averageHeartRate: true,
lead: true,
rhythmClassification: true,
source: true,
},
orderBy: [{ recordedAt: "desc" }, { id: "desc" }],
take,
}),
);
if (rows.length === 0) {
annotate({
@@ -2444,6 +2499,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [
present: true,
classificationSource: "device" as const,
recordings,
...capFields(cap),
};
},
},
@@ -2498,33 +2554,35 @@ export const MCP_TOOLS: McpToolDefinition[] = [
const cutoff = months === null ? null : new Date(now);
cutoff?.setMonth(cutoff.getMonth() - (months ?? 0));
const rows = await prisma.encounter.findMany({
// `userId` is the resolved session's, never an argument — a visit
// belonging to another account is unreachable by construction.
where: {
userId: ctx.userId,
deletedAt: null,
occurredAt: { ...(cutoff ? { gte: cutoff } : {}), lte: now },
...(kind ? { kind } : {}),
...(practitioner
? {
practitioner: {
is: {
name: { contains: practitioner, mode: "insensitive" },
deletedAt: null,
const { rows, cap } = await capped(MAX_VISITS, (take) =>
prisma.encounter.findMany({
// `userId` is the resolved session's, never an argument — a visit
// belonging to another account is unreachable by construction.
where: {
userId: ctx.userId,
deletedAt: null,
occurredAt: { ...(cutoff ? { gte: cutoff } : {}), lte: now },
...(kind ? { kind } : {}),
...(practitioner
? {
practitioner: {
is: {
name: { contains: practitioner, mode: "insensitive" },
deletedAt: null,
},
},
},
}
: {}),
},
orderBy: { occurredAt: "desc" },
take: MAX_VISITS,
include: {
practitioner: {
select: { name: true, specialty: true, deletedAt: true },
}
: {}),
},
},
});
orderBy: [{ occurredAt: "desc" }, { id: "desc" }],
take,
include: {
practitioner: {
select: { name: true, specialty: true, deletedAt: true },
},
},
}),
);
// Absence is explicit. An empty list is { present: false } — "you have
// never recorded a visit" — NEVER { present: true, visits: [] }, which
@@ -2590,6 +2648,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [
present: true,
windowMonths: months,
visits,
...capFields(cap),
...(illnessOn ? {} : { conditionsReason: "module_disabled" }),
};
},
+63 -41
View File
@@ -5,6 +5,7 @@
* client `queryFn` gets from the wire. Ciphertext never leaves this module:
* the note is decrypted onto `note` and the context rides `contextForWire`.
*/
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { shapeMoodNote } from "@/lib/crypto/note-cipher";
import { shapeLevelA } from "@/lib/mood/level-a";
@@ -30,6 +31,66 @@ function parseTags(tags: string | null): string[] {
}
}
/**
* What a read of one entry joins: the structured-tag links with their rating
* and kind, and the day context.
*
* v1.8.5 — the link keys pre-populate the taxonomy picker; v1.12.0 — the
* per-link `rating` + tag `kind` split binary tags from rated factors on
* hydrate; v1.38 — the day context, sparse by design: most rows carry none
* and the join costs nothing on those.
*/
export const MOOD_ENTRY_WIRE_INCLUDE = {
tagLinks: {
select: {
rating: true,
moodTag: { select: { key: true, kind: true } },
},
},
context: true,
} satisfies Prisma.MoodEntryInclude;
type MoodEntryWithWireJoins = Prisma.MoodEntryGetPayload<{
include: typeof MOOD_ENTRY_WIRE_INCLUDE;
}>;
/**
* The one wire shape of a mood entry, for the list and the single-entry
* read alike, so an entry opened on its own carries the same fields as the
* same entry in the list.
*/
export function toMoodEntryWire({
tagLinks,
context,
...e
}: MoodEntryWithWireJoins) {
return {
// v1.23 — decrypt `noteEncrypted` onto `note`, strip the ciphertext.
// v1.37 — the five level-A values under the keys the create path takes,
// so the edit form reads back what it wrote instead of mapping column
// names to wire names by hand. The column spelling is stripped, not
// joined: one number under two names is a choice nobody made.
...shapeLevelA(shapeMoodNote(e)),
tags: parseTags(e.tags),
// v1.8.5 — flat list of binary structured-tag keys attached to the
// entry (rated factors are surfaced separately below).
tagKeys: tagLinks
.filter((link) => link.moodTag.kind !== "RATED")
.map((link) => link.moodTag.key),
// v1.12.0 — rated factors with their per-entry score, so the edit
// form re-renders the sliders without a refetch.
ratedFactors: tagLinks
.filter((link) => link.moodTag.kind === "RATED" && link.rating !== null)
.map((link) => ({
key: link.moodTag.key,
rating: link.rating as number,
})),
// v1.38 — the day context, or null. The ciphertext never leaves the
// server: `contextForWire` reads the note and drops the column.
context: context ? contextForWire(context) : null,
};
}
export async function listMoodEntriesPage(
userId: string,
params: MoodEntryListParams,
@@ -59,51 +120,12 @@ export async function listMoodEntriesPage(
orderBy: { [sortBy]: sortDir },
take: limit,
skip: offset,
// v1.8.5 — include the structured-tag link keys so the edit form
// can pre-populate the taxonomy picker.
// v1.12.0 — also carry the per-link `rating` + the tag `kind` so
// the client can split binary tags from rated factors on hydrate.
include: {
tagLinks: {
select: {
rating: true,
moodTag: { select: { key: true, kind: true } },
},
},
// v1.38 — the day context, so the edit dialog can pre-populate the
// sections without a second round trip per row. Sparse by design: most
// rows carry none and the join costs nothing on those.
context: true,
},
include: MOOD_ENTRY_WIRE_INCLUDE,
}),
prisma.moodEntry.count({ where }),
]);
const entriesWithParsedTags = entries.map(({ tagLinks, context, ...e }) => ({
// v1.23 — decrypt `noteEncrypted` onto `note`, strip the ciphertext.
// v1.37 — the five level-A values under the keys the create path takes,
// so the edit form reads back what it wrote instead of mapping column
// names to wire names by hand. The column spelling is stripped, not
// joined: one number under two names is a choice nobody made.
...shapeLevelA(shapeMoodNote(e)),
tags: parseTags(e.tags),
// v1.8.5 — flat list of binary structured-tag keys attached to the
// entry (rated factors are surfaced separately below).
tagKeys: tagLinks
.filter((link) => link.moodTag.kind !== "RATED")
.map((link) => link.moodTag.key),
// v1.12.0 — rated factors with their per-entry score, so the edit
// form re-renders the sliders without a refetch.
ratedFactors: tagLinks
.filter((link) => link.moodTag.kind === "RATED" && link.rating !== null)
.map((link) => ({
key: link.moodTag.key,
rating: link.rating as number,
})),
// v1.38 — the day context, or null. The ciphertext never leaves the
// server: `contextForWire` reads the note and drops the column.
context: context ? contextForWire(context) : null,
}));
const entriesWithParsedTags = entries.map(toMoodEntryWire);
return {
entries: entriesWithParsedTags,
+1
View File
@@ -392,6 +392,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
],
route: ["route.retired"],
search: ["search.invalid"],
security_activity: ["security_activity.invalid"],
"share-link": [
"share-link.document.decryptFailed",
"share-link.document.notFound",
+4 -2
View File
@@ -1708,7 +1708,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Auth"],
summary: "List recent account-security activity",
description:
"v1.23 — the SHARED security-activity feed: the caller's recent auth + export + deletion audit events with timestamp, resolved location, and a host-masked IP. `limit` query param caps at 100 (default 50). Reuses the AuditLog store; no event detail bodies are surfaced.",
"v1.23 — the SHARED security-activity feed: the caller's recent auth + export + deletion audit events with timestamp, resolved location, and a host-masked IP. `limit` is 1-100 (default 50); a value outside that is a 422 naming it. Reuses the AuditLog store; no event detail bodies are surfaced.",
requestParams: {
query: z.object({
limit: z.coerce
@@ -1717,7 +1717,9 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
.min(1)
.max(100)
.optional()
.describe("Page size; defaults to 50, clamped to 100."),
.describe(
"Page size, 1-100; defaults to 50. Out of range is a 422.",
),
}),
},
responses: {
+5 -4
View File
@@ -45,6 +45,7 @@ import {
errorEnvelope,
idempotencyKeyParameter,
idempotentWrite,
listEnvelope,
recordRefusal,
recordWriteRateLimitResponse,
stdResponses,
@@ -346,7 +347,7 @@ export const encounterPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Records"],
summary: "List visits",
description:
"Returns the caller's live visits, split into `upcoming` and `past`. Bounded and windowed; soft-deleted visits are excluded.",
"Returns the caller's live visits, split into `upcoming` (soonest first) and `past` (newest first). `limit` and `offset` apply to each list; `meta.truncated` is true when either list has rows past this page, so a client reads the next `offset` until it is false. Soft-deleted visits are excluded.",
requestParams: { query: listEncountersQuery },
responses: {
...recordRefusal(),
@@ -354,7 +355,7 @@ export const encounterPaths: NonNullable<ZodOpenApiObject["paths"]> = {
description: "The caller's visits.",
content: {
"application/json": {
schema: dataEnvelope(encounterList, "ListEncountersEnvelope"),
schema: listEnvelope(encounterList, "ListEncountersEnvelope"),
},
},
},
@@ -612,7 +613,7 @@ export const encounterPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Records"],
summary: "List practitioners",
description:
"Returns the caller's address book, name-ordered, with an optional case-insensitive name search.",
"Returns the caller's address book, name-ordered, with an optional case-insensitive search over name, practice and specialty. Paged with `limit` and `offset`; `meta.truncated` says whether another page exists.",
requestParams: { query: listPractitionersQuery },
responses: {
...recordRefusal(),
@@ -620,7 +621,7 @@ export const encounterPaths: NonNullable<ZodOpenApiObject["paths"]> = {
description: "The caller's address book.",
content: {
"application/json": {
schema: dataEnvelope(
schema: listEnvelope(
z.array(practitioner),
"ListPractitionersEnvelope",
),
+160 -39
View File
@@ -32,45 +32,6 @@ import { isoDateTime } from "@/lib/validations/datetime";
/** The retrospective periods the narrative route accepts, read off the engine. */
const NARRATIVE_PERIOD_VALUES = Object.keys(PERIOD_DAYS);
export const insightsComprehensiveResponse = z
.object({
summary: z.string(),
recommendations: z.array(z.record(z.string(), z.unknown())),
citations: z.array(z.record(z.string(), z.unknown())),
warnings: z.array(z.record(z.string(), z.unknown())),
dailyBriefing: z.record(z.string(), z.unknown()).nullable().optional(),
trendAnnotations: z.record(z.string(), z.unknown()).nullable().optional(),
storyboardAnnotations: z
.array(z.record(z.string(), z.unknown()))
.optional(),
metricSource: z.record(z.string(), z.unknown()).optional(),
revalidating: z
.boolean()
.optional()
.describe(
"True when the body is served from last-good cache (stale-while-revalidate) while a fresh aggregation runs in the background. The client keeps polling on `revalidating` (bounded) so the open page converges on the fresh body.",
),
hasProvider: z
.boolean()
.optional()
.describe(
"Whether a configured AI provider exists for the record: provider presence and nothing else. False never means the operator switched AI off or consent is missing; `ai` says that.",
),
ai: z
.object({
briefing: aiCapabilityState,
statusText: aiCapabilityState,
})
.describe(
"Whether the AI parts that decorate the overview can be shown: the daily briefing and the per-metric status notes. Resolved per request, never cached with the body. The overview itself is computed data and loads whatever these say.",
),
})
.meta({
id: "InsightsComprehensiveResponse",
description:
"The Insights overview's computed read. No provider is called on this path; every figure is computed from the record.",
});
// v1.8.7.1 — generic per-HealthKit-metric assessment. The query enum is
// derived from the same registry the route validates against, so the
// spec, the route, and the cache scope cannot drift. The seven
@@ -1999,6 +1960,166 @@ export const dataSummary = z
"Per-measurement-type summary. Every statistic is null on an empty series rather than 0, so a client never charts a zero it was never given. `anomalyCount` and `slope90` were computed here historically and left this wire in v1.37.19 — they are not fields of this shape.",
});
/**
* A summary the overview computes in-process. The same statistics as the
* analytics summary, plus the count of readings the outlier rule flags, which
* only the in-process path can produce. Declared here rather than derived from
* either `DataSummary` copy so its trend component cannot collide with theirs.
*/
const comprehensiveTrend = z.object({
slope: z.number().describe("Least-squares slope in units per day."),
direction: z.enum(["up", "down", "stable"]),
confidence: z.number().describe("R² of the fit, 0..1."),
});
const comprehensiveSummary = z
.object({
count: z.number().int(),
latest: z.number().nullable(),
min: z.number().nullable(),
max: z.number().nullable(),
mean: z.number().nullable(),
median: z.number().nullable(),
avg7: z.number().nullable(),
avg30: z.number().nullable(),
slope7: comprehensiveTrend.nullable(),
slope30: comprehensiveTrend.nullable(),
anomalyCount: z
.number()
.int()
.nonnegative()
.describe("Readings in the series the outlier rule flags."),
avg30LastMonth: z.number().nullable().optional(),
avg30LastYear: z.number().nullable().optional(),
})
.meta({ id: "ComprehensiveMetricSummary" });
const correlationStat = z
.object({
r: z.number(),
strength: z
.enum(["stark", "moderat", "schwach", "keine"])
.describe(
"Strength bucket. The tokens are GERMAN on the wire; treat them as opaque identifiers and localise client-side.",
),
n: z.number().int(),
})
.meta({
id: "CorrelationStat",
description:
"A significant Pearson correlation (n >= 20, p < 0.05). The field holding it is null when the pairing is not significant.",
});
const classification = z.object({
category: z.string(),
color: z.string().describe("A CSS colour token for the web client."),
severity: z.enum(["normal", "elevated", "warning", "danger"]),
});
export const insightsComprehensiveResponse = z
.object({
summaries: z
.record(z.string(), comprehensiveSummary)
.describe(
"Keyed by `MeasurementType`. Only types with readings in the last 90 days are present.",
),
bmi: z.number().nullable(),
bmiClassification: classification.nullable(),
bpClassification: classification.nullable(),
bpPctInTarget: z
.number()
.nullable()
.describe(
"Share of blood-pressure readings inside the target band, 0-100.",
),
bpTargets: z
.object({
sysLow: z.number(),
sysHigh: z.number(),
diaLow: z.number(),
diaHigh: z.number(),
})
.nullable()
.describe("Age-banded targets; null without a date of birth."),
weightBpCorrelation: correlationStat.nullable(),
scatterData: z
.array(z.object({ weight: z.number(), sysBP: z.number() }))
.describe("Weight against systolic pressure, paired by day."),
bpMedicationCorrelation: correlationStat
.extend({ medicationCount: z.number().int() })
.nullable(),
bpMedicationScatterData: z.array(
z.object({ continuityPct: z.number(), sysBP: z.number() }),
),
moodSummary: comprehensiveSummary.nullable(),
moodBpCorrelation: correlationStat.nullable(),
moodBpScatterData: z.array(
z.object({ mood: z.number(), sysBP: z.number() }),
),
moodWeightCorrelation: correlationStat.nullable(),
moodWeightScatterData: z.array(
z.object({ mood: z.number(), weight: z.number() }),
),
moodPulseCorrelation: correlationStat.nullable(),
moodPulseScatterData: z.array(
z.object({ mood: z.number(), pulse: z.number() }),
),
medications: z
.array(
z.object({
id: z.string(),
name: z.string(),
dose: z.string(),
category: z.string(),
categoryLabel: z
.string()
.nullable()
.describe(
"The label of a custom category; null for a built-in one.",
),
compliance7: z.number().describe("Percent, 0-100."),
compliance30: z.number().describe("Percent, 0-100."),
streak: z.number().int(),
taken7: z.number().int(),
skipped7: z.number().int(),
missed7: z.number().int(),
}),
)
.describe("Cadence-aware compliance per active medication."),
alerts: z.array(
z.object({
level: z.enum(["success", "info", "warning", "danger"]),
title: z.string(),
message: z.string(),
}),
),
hasProvider: z
.boolean()
.describe(
"Whether a configured AI provider exists for the record: provider presence and nothing else. False never means the operator switched AI off or consent is missing; `ai` says that.",
),
dataSpanDays: z.number().int().nonnegative(),
totalMeasurements: z.number().int().nonnegative(),
revalidating: z
.boolean()
.describe(
"True when the body is served from last-good cache (stale-while-revalidate) while a fresh aggregation runs in the background. The client keeps polling on `revalidating` (bounded) so the open page converges on the fresh body.",
),
ai: z
.object({
briefing: aiCapabilityState,
statusText: aiCapabilityState,
})
.describe(
"Whether the AI parts that decorate the overview can be shown: the daily briefing and the per-metric status notes. Resolved per request, never cached with the body. The overview itself is computed data and loads whatever these say.",
),
})
.meta({
id: "InsightsComprehensiveResponse",
description:
"The Insights overview's computed read. No provider is called on this path; every figure is computed from the record. The model-written briefing is not part of it; it is read from `GET /api/insights/generate`.",
});
const lastSeenSlot = z
.object({
lastSeenAt: isoDateTime(),
+16 -3
View File
@@ -290,6 +290,13 @@ export const measurementResource = z
.optional()
.describe("External-system dedup key; null for manual entries."),
externalSourceVersion: z.string().nullable().optional(),
aggregationProvenance: z
.string()
.nullable()
.optional()
.describe(
"Authority of an aggregate stored under a `stats:` identity: `HEALTHKIT_STATISTICS`, `EXPORT_XML_SOURCE_MAX` or `LEGACY_UNKNOWN`. Null on an ordinary sample row. Decode unknown values leniently.",
),
glucoseContext: z
.string()
.nullable()
@@ -450,7 +457,11 @@ const sleepSessionResource = z.object({
.describe(
"True when the source has no per-stage onset timestamps and the server synthesised a contiguous timeline in a fixed physiological order (WHOOP). The client renders the hypnogram but labels it an approximate layout and never recomputes. False for real-series sources (Apple Health / Withings / Fitbit).",
),
stages: z.record(sleepStageEnum, z.number().int().nonnegative()),
stages: z
.partialRecord(sleepStageEnum, z.number().int().nonnegative())
.describe(
"Minutes per stage. Only the stages the night recorded are present; an absent stage is not zero minutes, it was not measured.",
),
segments: z.array(sleepSegmentResource),
sourceDiscrepancy: z
.object({
@@ -757,8 +768,10 @@ const seriesBatchRowSchema = z.object({
const seriesBatchResponse = z
.object({
series: z
.record(measurementTypeEnum, z.array(seriesBatchRowSchema))
.describe("Per-type daily series, keyed by MeasurementType."),
.partialRecord(measurementTypeEnum, z.array(seriesBatchRowSchema))
.describe(
"Per-type daily series, keyed by MeasurementType. Only the requested types are present.",
),
})
.meta({
id: "MeasurementsSeriesBatchResponse",
@@ -13,6 +13,8 @@ import {
MEDICATION_CATEGORY_VALUES,
MEDICATION_CONTAINER_TYPE_VALUES,
MEDICATION_TREATMENT_CLASS_VALUES,
doseWindowEntrySchema,
injectionSiteEnum,
} from "@/lib/validations/medication";
import { medicationExtractionSchema } from "@/lib/ai/coach/medication-extract-prompt";
import {
@@ -223,6 +225,13 @@ export const medicationScheduleResource = z
.describe(
'v1.7.0 cyclic "off" weeks. Only meaningful when `scheduleType` is CYCLIC; null otherwise.',
),
doseWindows: z
.array(doseWindowEntrySchema)
.nullable()
.optional()
.describe(
"Per-dose on-time windows as stored. Null when every dose time keeps the default derivation.",
),
})
.meta({
id: "MedicationSchedule",
@@ -233,9 +242,54 @@ export const medicationScheduleResource = z
export const medicationResource = z
.object({
id: z.string(),
userId: z.string().optional().describe("The record the row belongs to."),
name: z.string(),
dose: z.string(),
treatmentClass: medicationTreatmentClassEnum,
deliveryForm: z
.enum(["ORAL", "INJECTION", "OTHER"])
.optional()
.describe(
"How the medication is taken. A one-time injection is `oneShot` with `INJECTION`.",
),
trackInjectionSites: z
.boolean()
.optional()
.describe(
"Whether intakes record an injection site. Only offered for `INJECTION`.",
),
allowedInjectionSites: z
.array(injectionSiteEnum)
.optional()
.describe("The sites the rotation offers; empty means every site."),
externalSource: z
.literal("APPLE_HEALTH")
.nullable()
.optional()
.describe(
"v1.32.25 — provenance of the medication. `APPLE_HEALTH` marks the row a read-only mirror of the iOS 26+ HealthKit Medications sync; NULL is a native HealthLog medication.",
),
externalId: z
.string()
.nullable()
.optional()
.describe(
"The source's own identifier for a mirrored medication, paired with `externalSource`; null for a native one.",
),
lowStockNotifiedAt: isoDateTime()
.nullable()
.optional()
.describe(
"When the low-stock alert last fired for this medication; null when it has not since the stock was last topped up. Server-written.",
),
lowStockNotifiedThresholdDays: z
.number()
.int()
.nullable()
.optional()
.describe(
"The runway threshold, in days, that alert fired at. Server-written.",
),
dosesPerUnit: z
.number()
.int()
@@ -371,6 +425,12 @@ export const medicationListEntry = medicationResource
// even though the shared base leaves them optional for the write paths.
nextDueAt: isoDateTime().nullable(),
nextDueOverdue: z.boolean(),
nextDueScheduleId: z
.string()
.nullable()
.describe(
"The schedule `nextDueAt` belongs to; null when nothing is due.",
),
remindersActive: z
.boolean()
.describe(
+18 -1
View File
@@ -394,7 +394,7 @@ export const metaPaths: NonNullable<ZodOpenApiObject["paths"]> = {
summary: "The operator's assistant switches (deprecated)",
deprecated: true,
description:
'Deprecated: read `ai` on GET /api/auth/me instead, which resolves each AI capability for the record from the switches, the record\'s modules, the provider, consent and who is asking. This route projects the operator switches alone and is removed in the first release after the native build that reads `ai` ships. Responses carry `Deprecation: true` and `Link: </api/auth/me>; rel="successor-version"`. The master is applied server-side, so every sub-switch is already false when it is off.\n\n' +
'Deprecated: read `ai` on GET /api/auth/me instead, which resolves each AI capability for the record from the switches, the record\'s modules, the provider, consent and who is asking. This route projects the operator switches alone and is removed in the first release after the native build that reads `ai` ships. Responses carry `Deprecation` (the date it was deprecated, as `@<unix seconds>`), `Sunset` (the first day it may stop answering) and `Link: </api/auth/me>; rel="successor-version"`. The master is applied server-side, so every sub-switch is already false when it is off.\n\n' +
"An ACTOR surface: it answers about the deployment, not about a record, so it keeps answering while the caller is acting on somebody else's — the Coach launcher and the assistant chrome are gated on it, and a refusal here would delete a piece of the shell rather than a piece of the data. A request that attaches the per-request account selector (the `AccountSelector` header parameter) is refused with 403 `sharing.not_permitted` rather than quietly answered.\n\n" +
"Served with `Cache-Control: private, max-age=60`: an operator's toggle propagates within a minute, and the flag read stays off the hot mount path in the meantime.",
responses: {
@@ -408,6 +408,23 @@ export const metaPaths: NonNullable<ZodOpenApiObject["paths"]> = {
),
},
},
headers: {
Deprecation: {
description:
"When this route was deprecated, as an RFC 9745 date: `@` and Unix seconds.",
schema: { type: "string" as const, pattern: "^@[0-9]+$" },
},
Sunset: {
description:
"The first day this route may stop answering (RFC 8594 HTTP-date).",
schema: { type: "string" as const },
},
Link: {
description:
'The successor, as `</api/auth/me>; rel="successor-version"`.',
schema: { type: "string" as const },
},
},
},
"403": {
description:
+6
View File
@@ -1192,6 +1192,12 @@ const moodAggregatesResponse = z
.array(z.object({ x: z.number(), y: z.number() }))
.describe("Mood (x) versus metric (y), paired by day."),
n: z.number().int().describe("Paired-day count."),
suppressed: z
.enum(["insufficientPairs", "notSignificant"])
.optional()
.describe(
"Why `result` is null: too few paired days, or a pairing that did not reach significance. Absent when `result` is set.",
),
}),
)
.describe(
+12
View File
@@ -383,6 +383,18 @@ const notificationPrefsResolved = z
nudgeFrequency: z
.enum(["weekly", "biweekly"])
.describe("Nudge frequency cap: one per 7 or per 14 days."),
nudgeAiComposed: z
.boolean()
.describe(
"Opt-in for model-written nudge copy. Off by default; the deterministic template is always the fallback.",
),
}),
measurementReminder: z.object({
clientManaged: z
.boolean()
.describe(
"True when the iOS app owns local check-up reminders; the server-side measurement-reminder APNs send is then suppressed. Default false.",
),
}),
content: z.object({
detail: z
+4 -1
View File
@@ -128,7 +128,10 @@ const profileSettings = z
gender: z.enum(["MALE", "FEMALE", "OTHER"]).nullable(),
locale: z.enum(["de", "en", "es", "fr", "it", "pl", "ko", "nl"]).nullable(),
timezone: z.string(),
unitPreference: z.enum(["metric", "imperial"]),
unitPreference: z
.enum(["metric", "imperial"])
.nullable()
.describe("Null when the record never chose one: metric applies."),
timeFormat: z.enum(["AUTO", "H12", "H24"]),
dateFormat: z.enum(["AUTO", "DMY", "MDY", "YMD"]),
})
+7 -5
View File
@@ -356,10 +356,12 @@ export const stdResponses = {
*
* 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.
* backwards: the batch endpoint is the one a 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 this is the
* response that names it on the single-record creates that publish 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.
@@ -371,7 +373,7 @@ export const stdResponses = {
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. `meta.errorCode` is `record_write.rate_limited`, which is what tells this refusal apart from a route's own narrower bucket when both can answer 429. 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. Every request counts against the bucket, refused ones included: the check runs before the body is read, so a client looping on a 422 spends the same allowance as one that writes rows. A client with more than a handful of rows to send should use the batch endpoint for its domain instead of looping this one.",
"Rate limit exceeded. This route shares one per-account bucket with every other authenticated write — `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. Only the bulk ingest endpoints, which meter themselves with a dedicated bucket, stay outside it. Nothing was written. `meta.errorCode` is `record_write.rate_limited`, which is what tells this refusal apart from a route's own narrower bucket when both can answer 429. 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. Every request counts against the bucket, refused ones included: the check runs before the body is read, so a client looping on a 422 spends the same allowance as one that writes rows. 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,
},
+1 -1
View File
@@ -119,7 +119,7 @@ const symptomEventList = z
.meta({
id: "SymptomEventList",
description:
"A page of occurrences, newest first. `hasMore` is true when the page is full; page on with `to` set to the oldest `occurredAt` seen.",
"A page of occurrences, newest first. `hasMore` is true exactly when an occurrence past this page exists; page on with `to` set to the oldest `occurredAt` seen.",
});
const deletedResponse = z
+3 -2
View File
@@ -43,6 +43,7 @@ import {
recordRefusal,
recordWriteRateLimitResponse,
stdResponses,
listEnvelope,
} from "./shared";
import { isoDateTime } from "@/lib/validations/datetime";
@@ -413,7 +414,7 @@ export const vaccinationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Records"],
summary: "List vaccinations",
description:
"Returns the caller's live doses, newest first, each with its series resolved per component antigen. Soft-deleted doses are excluded.",
"Returns the caller's live doses, newest first, each with its series resolved per component antigen over the whole record, never over the page. Paged with `limit` (default 300) and `offset`; `meta.truncated` says whether another page exists. Soft-deleted doses are excluded.",
requestParams: { query: listVaccinationsQuery },
responses: {
...recordRefusal(),
@@ -421,7 +422,7 @@ export const vaccinationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
description: "The caller's immunization history.",
content: {
"application/json": {
schema: dataEnvelope(vaccinationList, "ListVaccinationsEnvelope"),
schema: listEnvelope(vaccinationList, "ListVaccinationsEnvelope"),
},
},
},
+9 -11
View File
@@ -136,25 +136,23 @@ export async function checkEnvironmentBackfillRateLimit(
}
/**
* Shared per-account ceiling for the single-record writes.
* Shared per-account ceiling for every authenticated write.
*
* 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.
* well-behaved client uses. This was first the floor under a dozen creates,
* called by hand; it is now charged where the caller is resolved
* (`meterAccountWrite`, `src/lib/auth/write-meter.ts`), so every POST, PUT,
* PATCH and DELETE an authenticated caller sends counts against it, except on
* the bulk ingest routes that meter themselves.
*
* One shared bucket, not one per route, for the reason
* `checkAnalyticsReadRateLimit` gives: a loop that rotates across ten creates
* `checkAnalyticsReadRateLimit` gives: a loop that rotates across ten routes
* 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. Ten of the eleven are wrapped in `withIdempotency`, so an honest
* retry storm is deduplicated before it is counted; `POST
* /api/medications/intake` is not, because it updates a named `intakeId`
* rather than creating a row, so a replay writes the status that is already
* there. It exists to cut off the pathological loop, not to shape traffic.
* reaches it. It exists to cut off the pathological loop, not to shape
* traffic.
*
* A refused request is counted too: the check runs before the body is read,
* so a client looping on a 422 spends its allowance without writing a row.
+14 -1
View File
@@ -983,7 +983,20 @@ async function endGrant(
(current.expiresAt === null || current.expiresAt > new Date());
if (!actorOwnsGrant || !isActiveManagedGuardian) return end();
return reduceManagedProfileGuardian(db, current.grantorId, end);
// `current` was read before the record lock. A concurrent change that holds
// the lock first (the Guardian's account deletion ends this grant) commits
// in between, and counting under the lock would then refuse with "add
// another Guardian" for a grant that is no longer active, instead of saying
// it has already ended. So the grant is re-read under the lock; only a grant
// still live there is a Guardian whose removal the count has to allow.
return withManagedProfileLock(db, current.grantorId, async () => {
const live = await db.accountGrant.findFirst({
where: { id: grantId, revokedAt: null },
select: { id: true },
});
if (!live) return end();
return reduceManagedProfileGuardian(db, current.grantorId, end);
});
}
/**
+7
View File
@@ -10,6 +10,8 @@
*/
import { z } from "zod/v4";
import { listOffsetField } from "@/lib/http/list-query-fields";
export const practitionerCreateSchema = z
.object({
name: z.string().min(1).max(160),
@@ -50,6 +52,11 @@ export const practitionerListQuerySchema = z
.object({
q: z.string().min(1).max(160).optional(),
limit: z.coerce.number().int().min(1).max(200).optional(),
offset: listOffsetField()
.optional()
.describe(
"Rows to skip, for reading the address book page by page; `meta.truncated` says whether another page exists.",
),
})
.strict();
+6
View File
@@ -24,6 +24,7 @@
* moment the catalogue is authoritative, because the picker produced it.
*/
import { z } from "zod/v4";
import { listOffsetField } from "@/lib/http/list-query-fields";
import {
ANTIGEN_SLUGS,
@@ -195,6 +196,11 @@ export const vaccinationListQuerySchema = z
from: isoDateTime().optional(),
to: isoDateTime().optional(),
limit: z.coerce.number().int().min(1).max(500).optional(),
offset: listOffsetField()
.optional()
.describe(
"Doses to skip, for reading the log page by page; `meta.truncated` says whether another page exists.",
),
})
.strict()
.refine((q) => !(q.from && q.to) || q.from <= q.to, {
+824
View File
@@ -0,0 +1,824 @@
/**
* One account with something in every section, for the GET contract run.
*
* Rows go in through the app's own write routes wherever one exists, so the
* fixture carries exactly what a client would have created — encrypted
* columns encrypted, derived columns derived — rather than what a hand-written
* Prisma literal guesses. Prisma is used directly only where no route writes
* the row (imported recordings, job ledgers, conversations).
*
* A list that comes back empty parses against almost any schema, so the point
* of the seed is that the lists the contract run reads are not empty.
*/
import { NextRequest } from "next/server";
import { join } from "node:path";
import type { PrismaClient } from "@/generated/prisma/client";
export interface ContractFixture {
userId: string;
sessionId: string;
/** A second session of the same account, switched into the managed profile. */
guardianSessionId: string;
guardianRecordEpoch: number;
managedProfileId: string;
/** Writes that did not land; the contract run reports them first. */
seedFailures: string[];
/** The path parameters for a published path, or null when none is seeded. */
pathParams(path: string): Record<string, string> | null;
/** A seeded id by name, for query fixtures. */
id(name: string): string;
}
export const CONTRACT_USER_ID = "user-openapi-get-contract";
const MANAGED_PROFILE_ID = "user-openapi-get-contract-managed";
type Handler = (req: NextRequest, ctx: unknown) => Promise<Response>;
function routeFile(path: string): string {
return join(process.cwd(), "src/app", path, "route.ts");
}
/**
* Call one route handler in-process, as the seeded session.
*
* `path` is the file-system path under `src/app` (`api/allergies`,
* `api/medications/[id]/courses`); `params` fills its dynamic segments.
*/
export async function callRoute(
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE",
path: string,
options: {
body?: unknown;
params?: Record<string, string>;
query?: Record<string, string>;
} = {},
): Promise<{ status: number; body: unknown }> {
const mod = (await import(/* @vite-ignore */ routeFile(path))) as Record<
string,
Handler | undefined
>;
const handler = mod[method];
if (!handler) throw new Error(`${path} exports no ${method}`);
let urlPath = `/${path}`;
for (const [name, value] of Object.entries(options.params ?? {})) {
urlPath = urlPath.replace(`[${name}]`, encodeURIComponent(value));
}
const url = new URL(`http://localhost${urlPath}`);
for (const [key, value] of Object.entries(options.query ?? {})) {
url.searchParams.set(key, value);
}
const init: { method: string; body?: string; headers?: HeadersInit } = {
method,
};
if (options.body !== undefined) {
init.body = JSON.stringify(options.body);
init.headers = { "content-type": "application/json" };
}
const res = await handler(new NextRequest(url, init), {
params: Promise.resolve(options.params ?? {}),
});
const text = await res.text();
let body: unknown = text;
try {
body = JSON.parse(text);
} catch {
// not JSON; keep the text for the failure message
}
return { status: res.status, body };
}
const DAY = 24 * 60 * 60 * 1000;
function daysAgo(n: number, hour = 8): Date {
const d = new Date(Date.now() - n * DAY);
d.setUTCHours(hour, 0, 0, 0);
return d;
}
function dayKey(d: Date): string {
return d.toISOString().slice(0, 10);
}
export async function seedContractAccount(
prisma: PrismaClient,
): Promise<ContractFixture> {
await prisma.user.create({
data: {
id: CONTRACT_USER_ID,
username: "openapi-get-contract",
email: "openapi-get-contract@example.test",
gender: "FEMALE",
timezone: "UTC",
role: "ADMIN",
dateOfBirth: new Date("1985-04-12T00:00:00.000Z"),
heightCm: 170,
},
});
const session = await prisma.session.create({
data: {
userId: CONTRACT_USER_ID,
expiresAt: new Date(Date.now() + 6 * 60 * 60 * 1000),
},
});
const { cookieJar } = await import("./mock-next-headers");
cookieJar.set("healthlog_session", session.id);
const ids: Record<string, string> = {};
const failures: string[] = [];
/** Write through a route; on success return `data`, on failure note it. */
async function write(
label: string,
method: "POST" | "PUT" | "PATCH",
path: string,
body: unknown,
params?: Record<string, string>,
): Promise<Record<string, unknown> | null> {
try {
const res = await callRoute(method, path, { body, params });
if (res.status >= 200 && res.status < 300) {
const data = (res.body as { data?: unknown }).data;
return (data ?? {}) as Record<string, unknown>;
}
failures.push(
`${label}: ${res.status} ${JSON.stringify(res.body).slice(0, 600)}`,
);
} catch (error) {
failures.push(`${label}: threw ${(error as Error).message}`);
}
return null;
}
function keep(name: string, data: Record<string, unknown> | null, at = "id") {
const value = data?.[at];
if (typeof value === "string") ids[name] = value;
}
// ── Modules that are off by default ────────────────────────────────────
await write("modules", "PATCH", "api/auth/me/modules", {
nutrients: true,
timeline: true,
});
// ── Measurements: ninety days of the common series ─────────────────────
const rows: Array<{
type: string;
value: number;
unit: string;
measuredAt: Date;
glucoseContext?: string;
}> = [];
for (let n = 0; n < 90; n++) {
rows.push(
{
type: "WEIGHT",
value: 72 + Math.sin(n / 7),
unit: "kg",
measuredAt: daysAgo(n, 7),
},
{
type: "BLOOD_PRESSURE_SYS",
value: 122 + (n % 9),
unit: "mmHg",
measuredAt: daysAgo(n, 8),
},
{
type: "BLOOD_PRESSURE_DIA",
value: 79 + (n % 5),
unit: "mmHg",
measuredAt: daysAgo(n, 8),
},
{
type: "PULSE",
value: 64 + (n % 7),
unit: "bpm",
measuredAt: daysAgo(n, 8),
},
{
type: "RESTING_HEART_RATE",
value: 58 + (n % 4),
unit: "bpm",
measuredAt: daysAgo(n, 6),
},
{
type: "HEART_RATE_VARIABILITY",
value: 40 + (n % 11),
unit: "ms",
measuredAt: daysAgo(n, 6),
},
{
type: "ACTIVITY_STEPS",
value: 6000 + ((n * 977) % 5000),
unit: "count",
measuredAt: daysAgo(n, 20),
},
{
type: "SLEEP_DURATION",
value: 400 + (n % 60),
unit: "min",
measuredAt: daysAgo(n, 6),
},
{
type: "BODY_FAT",
value: 24 + (n % 3) / 2,
unit: "%",
measuredAt: daysAgo(n, 7),
},
{
type: "OXYGEN_SATURATION",
value: 97,
unit: "%",
measuredAt: daysAgo(n, 6),
},
{
type: "BLOOD_GLUCOSE",
value: 92 + (n % 13),
unit: "mg/dL",
measuredAt: daysAgo(n, 9),
glucoseContext: "FASTING",
},
);
}
await prisma.measurement.createMany({
data: rows.map((row, i) => ({
userId: CONTRACT_USER_ID,
type: row.type as never,
value: row.value,
unit: row.unit,
measuredAt: row.measuredAt,
source: "MANUAL",
externalId: `contract-${i}`,
...(row.glucoseContext
? { glucoseContext: row.glucoseContext as never }
: {}),
})),
});
const firstMeasurement = await prisma.measurement.findFirst({
where: { userId: CONTRACT_USER_ID, type: "WEIGHT" },
orderBy: { measuredAt: "desc" },
});
if (firstMeasurement) ids.measurement = firstMeasurement.id;
// ── Mood ───────────────────────────────────────────────────────────────
const binaryTag = await prisma.moodTag.findFirst({
where: { userId: null, kind: { not: "RATED" } },
});
const ratedTag = await prisma.moodTag.findFirst({
where: { userId: null, kind: "RATED" },
});
for (let n = 0; n < 20; n++) {
const mood = await write("mood", "POST", "api/mood-entries", {
date: dayKey(daysAgo(n + 1)),
mood: ["GUT", "OKAY", "SUPER_GUT", "SCHLECHT"][n % 4],
moodLoggedAt: daysAgo(n + 1, 21).toISOString(),
note: n === 0 ? "A contract note" : undefined,
...(n === 0 && binaryTag ? { tagKeys: [binaryTag.key] } : {}),
...(n === 0 && ratedTag
? { ratedFactors: [{ key: ratedTag.key, rating: 3 }] }
: {}),
});
if (n === 0) keep("mood", mood);
}
// ── Medication ─────────────────────────────────────────────────────────
const medication = await write("medication", "POST", "api/medications", {
name: "Contract compound",
dose: "10 mg",
schedules: [
{
time: "08:00",
windowStart: "07:00",
windowEnd: "10:00",
daysOfWeek: [0, 1, 2, 3, 4, 5, 6],
},
],
});
keep("medication", medication);
// ── Records ────────────────────────────────────────────────────────────
keep(
"allergy",
await write("allergy", "POST", "api/allergies", {
substance: "Pollen",
category: "ENVIRONMENT",
type: "ALLERGY",
status: "ACTIVE",
reaction: "Sneezing",
}),
);
keep(
"familyHistory",
await write("family-history", "POST", "api/family-history", {
relationship: "MOTHER",
condition: "Hypertension",
}),
);
keep(
"practitioner",
await write("practitioner", "POST", "api/practitioners", {
name: "Dr. Example",
specialty: "General practice",
}),
);
keep(
"encounter",
await write("encounter", "POST", "api/encounters", {
kind: "ROUTINE",
occurredAt: daysAgo(30).toISOString(),
reason: "Check-up",
}),
);
keep(
"vaccination",
await write("vaccination", "POST", "api/vaccinations", {
vaccineName: "Tetanus",
occurredAt: daysAgo(200).toISOString(),
}),
);
keep(
"illness",
await write("illness", "POST", "api/illness/episodes", {
label: "Cold",
type: "INFECTION",
onsetAt: daysAgo(14).toISOString(),
}),
);
const assessment = await write(
"assessment",
"POST",
"api/mental-health/assessments",
{ instrument: "PHQ9", items: [0, 1, 0, 1, 0, 0, 1, 0, 0] },
);
keep(
"assessment",
(assessment?.assessment as Record<string, unknown> | undefined) ?? null,
);
keep(
"customMetric",
await write("custom-metric", "POST", "api/custom-metrics", {
name: "Contract metric",
unit: "pts",
}),
);
if (ids.customMetric) {
await write(
"custom-metric-entry",
"POST",
"api/custom-metrics/[id]/entries",
{ value: 3, measuredAt: daysAgo(1).toISOString() },
{ id: ids.customMetric },
);
}
keep(
"lab",
await write("lab", "POST", "api/labs", {
analyte: "HbA1c",
value: 5.4,
unit: "%",
takenAt: daysAgo(40).toISOString(),
}),
);
keep(
"reminder",
await write("reminder", "POST", "api/measurement-reminders", {
type: "WEIGHT",
label: "Morning weight",
time: "07:30",
intervalDays: 1,
}),
);
await write("cycle-day", "POST", "api/cycle/day-logs", {
date: dayKey(daysAgo(3)),
loggedAt: daysAgo(3, 9).toISOString(),
flow: "MEDIUM",
});
await write("symptom-definition", "POST", "api/symptoms/definitions", {
label: "Headache",
});
const definitions = await callRoute("GET", "api/symptoms/definitions");
const definition = (definitions.body as { data?: unknown }).data as
Array<{ id: string }> | { definitions?: Array<{ id: string }> } | undefined;
const definitionId = Array.isArray(definition)
? definition[0]?.id
: definition?.definitions?.[0]?.id;
if (definitionId) {
await write("symptom", "POST", "api/symptoms/events", {
definitionId,
occurredAt: daysAgo(2).toISOString(),
intensity: 4,
});
} else {
failures.push(
`symptom: no definition ${JSON.stringify(definitions.body).slice(0, 300)}`,
);
}
await write("life-event", "POST", "api/life-events", {
title: "Moved house",
category: "HOME",
startDate: dayKey(daysAgo(60)),
precision: "DAY",
});
// Creating a managed profile asks for a fresh second factor, which a seeded
// session cannot give; the profile and its MANAGE grant go in directly.
await prisma.user.create({
data: {
id: MANAGED_PROFILE_ID,
username: "openapi-get-contract-managed",
email: null,
role: "USER",
managedProfileAt: new Date(),
},
});
await prisma.accountGrant.create({
data: {
grantorId: MANAGED_PROFILE_ID,
granteeId: CONTRACT_USER_ID,
access: "MANAGE",
acceptedAt: new Date(Date.now() - 60_000),
},
});
ids.managedProfile = MANAGED_PROFILE_ID;
// ── Medication detail ──────────────────────────────────────────────────
if (ids.medication) {
const medication = { id: ids.medication };
await write(
"side-effect",
"POST",
"api/medications/[id]/side-effects",
{ entry: "NAUSEA", severity: 2, occurredAt: daysAgo(5).toISOString() },
medication,
);
await prisma.medicationIntakeEvent.createMany({
data: Array.from({ length: 14 }, (_, n) => ({
userId: CONTRACT_USER_ID,
medicationId: ids.medication,
scheduledFor: daysAgo(n + 1, 8),
takenAt: n % 5 === 0 ? null : daysAgo(n + 1, 8),
skipped: n % 5 === 0,
source: "WEB" as const,
})),
});
const intakeJob = await prisma.medicationIntakeImportJob.create({
data: {
recordUserId: CONTRACT_USER_ID,
actorUserId: CONTRACT_USER_ID,
medicationId: ids.medication,
payload: {},
progress: {
processed: 0,
total: 3,
imported: 0,
skippedByReason: {},
skipDetails: [],
skippedDetailsOmitted: 0,
touchedDays: [],
rollupProcessed: 0,
},
},
});
ids.intakeImportJob = intakeJob.id;
const doseJob = await prisma.medicationIntakeImportJob.create({
data: {
recordUserId: CONTRACT_USER_ID,
actorUserId: CONTRACT_USER_ID,
payload: {},
progress: {
processed: 0,
total: 3,
imported: 0,
skippedByReason: {},
skipDetails: [],
skippedDetailsOmitted: 0,
touchedDays: [],
rollupProcessed: 0,
},
},
});
ids.doseHistoryImportJob = doseJob.id;
}
if (ids.medication) {
await write(
"inventory",
"POST",
"api/medications/[id]/inventory",
{ unitsTotal: 30, containerType: "BLISTER" },
{ id: ids.medication },
);
}
await write("cycle-symptom", "POST", "api/cycle/symptoms/custom", {
label: "Contract symptom",
});
await write("device", "POST", "api/devices", {
token: "contract-device-token",
bundleId: "dev.healthlog.contract",
apnsToken: "a".repeat(64),
apnsEnvironment: "sandbox",
appVersion: "1.0",
model: "iPhone",
});
await write("share-link", "POST", "api/share-links", {
label: "For the practice",
rangeStart: daysAgo(60).toISOString(),
expiresAt: new Date(Date.now() + 7 * 86_400_000).toISOString(),
});
await write("mcp-token", "POST", "api/mcp/tokens", {
name: "Contract connector",
scope: "read",
});
// A device's irregular-rhythm notification: the EVENT row the rhythm
// timeline reads.
await prisma.measurement.create({
data: {
userId: CONTRACT_USER_ID,
type: "IRREGULAR_RHYTHM_NOTIFICATION",
value: 1,
unit: "count",
measuredAt: daysAgo(6, 3),
source: "APPLE_HEALTH",
externalId: "contract-rhythm-1",
rhythmClassification: "IRREGULAR",
},
});
// ── Workouts ───────────────────────────────────────────────────────────
const workouts = await write("workouts", "POST", "api/workouts/batch", {
workouts: [1, 3, 6].map((n) => ({
sportType: "running",
startedAt: daysAgo(n, 17).toISOString(),
endedAt: new Date(daysAgo(n, 17).getTime() + 40 * 60_000).toISOString(),
totalEnergyKcal: 420,
totalDistanceM: 6500,
avgHeartRate: 148,
externalId: `contract-workout-${n}`,
})),
});
void workouts;
const workout = await prisma.workout.findFirst({
where: { userId: CONTRACT_USER_ID },
});
if (workout) ids.workout = workout.id;
// ── Biomarker, a lab against it ────────────────────────────────────────
keep(
"biomarker",
await write("biomarker", "POST", "api/biomarkers", {
name: "Contract marker",
unit: "mg/dL",
lowerBound: 10,
upperBound: 50,
}),
);
if (ids.biomarker) {
await write("lab-on-biomarker", "POST", "api/labs", {
biomarkerId: ids.biomarker,
value: 31,
unit: "mg/dL",
takenAt: daysAgo(20).toISOString(),
});
}
if (ids.illness) {
await write(
"illness-day",
"POST",
"api/illness/episodes/[id]/day-logs",
{ date: dayKey(daysAgo(13)), functionalImpact: 2, feverC: 38.1 },
{ id: ids.illness },
);
}
await write("custom-vaccine", "POST", "api/vaccinations/custom", {
name: "Contract vaccine",
components: ["tetanus"],
});
await write("coach-reminder", "POST", "api/coach/reminders", {
note: "Check weight trend",
});
// ── ECG: a recording through the persistence path the importers use ───
const { persistEcgRecording } = await import("@/lib/ecg/persist-recording");
const ecg = await persistEcgRecording(
{
userId: CONTRACT_USER_ID,
source: "APPLE_HEALTH",
externalRecordingId: "contract-ecg-1",
recordedAt: daysAgo(4, 10),
samples: Array.from({ length: 512 }, (_, i) =>
Math.round(Math.sin(i / 8) * 400),
),
samplingFrequency: 512,
averageHeartRate: 66,
rhythmClassification: "NOT_DETECTED",
},
prisma,
);
ids.ecg = ecg.id;
// ── A Coach conversation with an exchange in it ────────────────────────
const { createConversation, appendMessage } =
await import("@/lib/ai/coach/persistence");
const conversation = await createConversation({
userId: CONTRACT_USER_ID,
title: "Contract conversation",
});
ids.conversation = conversation.id;
await appendMessage({
conversationId: conversation.id,
role: "user",
content: "How is my blood pressure?",
});
const answer = await appendMessage({
conversationId: conversation.id,
role: "assistant",
content: "It has been steady over the last month.",
});
ids.message = answer.id;
// ── A document, through the same ingest the upload route calls ────────
const { ingestDocument } = await import("@/lib/documents/ingest");
const pdf = Buffer.from(
"%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n2 0 obj<</Type/Pages/Kids[]/Count 0>>endobj\ntrailer<</Root 1 0 R>>\n%%EOF\n",
);
const ingested = await ingestDocument({
userId: CONTRACT_USER_ID,
scoped: false,
ipAddress: null,
bytes: pdf,
filename: "contract.pdf",
title: "Contract letter",
kind: null,
documentDate: dayKey(daysAgo(10)),
episodeIds: [],
encounterIds: [],
sourceSystem: "PAPERLESS",
sourceId: "4711",
sourceInstance: null,
aiDeferred: true,
sourceKeyChecked: false,
limits: { maxFileBytes: 10 * 1024 * 1024, quotaBytes: 100 * 1024 * 1024 },
documentAi: async () => ({
available: false,
reason: "no_provider",
onDeviceAllowed: false,
}),
});
if (ingested.kind === "stored") ids.document = ingested.document.id;
else failures.push(`document: ingest answered ${ingested.kind}`);
// ── An AI run ──────────────────────────────────────────────────────────
const { createAiRun } = await import("@/lib/documents/ai-runs/store");
if (ids.document) {
ids.aiRun = await createAiRun({
userId: CONTRACT_USER_ID,
kind: "DOCUMENT_INDEX",
documentId: ids.document,
params: { mode: "vision", mime: "application/pdf" },
});
}
// ── Import ledger rows ─────────────────────────────────────────────────
const importJob = await prisma.importJob.create({
data: {
userId: CONTRACT_USER_ID,
uploadBytes: 2048,
status: "completed",
completedAt: daysAgo(1),
},
});
ids.importJob = importJob.id;
// ── A stored backup of this very account, and a restore job for it ─────
const { storeBackupBlob } = await import("@/lib/export/store-backup-blob");
const { streamFullBackupJson } =
await import("@/lib/export/full-backup-stream");
const stored = await storeBackupBlob(
prisma,
{ userId: CONTRACT_USER_ID, type: "WEEKLY_AUTO", preview: () => null },
async (out) => {
await streamFullBackupJson(prisma, CONTRACT_USER_ID, out, {
purpose: "disaster-recovery",
});
},
);
ids.backup = stored.id;
const restore = await prisma.backupRestoreJob.create({
data: {
userId: CONTRACT_USER_ID,
actorUserId: CONTRACT_USER_ID,
backupId: stored.id,
backupDigest: "contract-digest",
status: "completed",
},
});
ids.restoreJob = restore.id;
// Fixed keys for the parameters that are not row ids.
ids.today = dayKey(daysAgo(1));
ids.paperless = "paperless";
ids.profileFamily = "profile";
const guardianSession = await prisma.session.create({
data: {
userId: CONTRACT_USER_ID,
expiresAt: new Date(Date.now() + 6 * 60 * 60 * 1000),
actingAsUserId: MANAGED_PROFILE_ID,
recordEpoch: 1,
},
});
return {
userId: CONTRACT_USER_ID,
sessionId: session.id,
guardianSessionId: guardianSession.id,
guardianRecordEpoch: guardianSession.recordEpoch,
managedProfileId: MANAGED_PROFILE_ID,
seedFailures: failures,
id(name) {
const value = ids[name];
if (value === undefined) throw new Error(`no seeded ${name}`);
return value;
},
pathParams(path) {
const names = [...path.matchAll(/\{([^}]+)\}/g)].map((m) => m[1]);
if (names.length === 0) return {};
const value = PATH_PARAM_SOURCES[path];
if (!value) return null;
const out: Record<string, string> = {};
for (const name of names) {
const source = value[name];
const resolved = source ? ids[source] : undefined;
if (resolved === undefined) return null;
out[name] = resolved;
}
return out;
},
};
}
/** Which seeded id fills which path parameter. */
const PATH_PARAM_SOURCES: Record<string, Record<string, string>> = {
"/api/measurements/{id}": { id: "measurement" },
"/api/mood-entries/{id}": { id: "mood" },
"/api/medications/{id}": { id: "medication" },
"/api/medications/{id}/glp1": { id: "medication" },
"/api/medications/{id}/api-endpoint": { id: "medication" },
"/api/medications/{id}/phase-config": { id: "medication" },
"/api/medications/{id}/side-effects": { id: "medication" },
"/api/medications/{id}/courses": { id: "medication" },
"/api/medications/{id}/intake": { id: "medication" },
"/api/medications/{id}/inventory": { id: "medication" },
"/api/medications/{id}/cadence": { id: "medication" },
"/api/medications/{id}/compliance": { id: "medication" },
"/api/medications/{id}/efficacy": { id: "medication" },
"/api/medications/{id}/dose-history": { id: "medication" },
"/api/medications/{id}/schedule-revisions": { id: "medication" },
"/api/allergies/{id}": { id: "allergy" },
"/api/family-history/{id}": { id: "familyHistory" },
"/api/practitioners/{id}": { id: "practitioner" },
"/api/encounters/{id}": { id: "encounter" },
"/api/vaccinations/{id}": { id: "vaccination" },
"/api/illness/episodes/{id}": { id: "illness" },
"/api/illness/episodes/{id}/day-logs": { id: "illness" },
"/api/illness/episodes/{id}/correlation": { id: "illness" },
"/api/mental-health/assessments/{id}": { id: "assessment" },
"/api/custom-metrics/{id}": { id: "customMetric" },
"/api/custom-metrics/{id}/entries": { id: "customMetric" },
"/api/labs/{id}": { id: "lab" },
"/api/measurement-reminders/{id}": { id: "reminder" },
"/api/measurement-reminders/{id}/history": { id: "reminder" },
"/api/managed-profiles/{id}": { id: "managedProfile" },
"/api/managed-profiles/{id}/guardians": { id: "managedProfile" },
"/api/managed-profiles/{id}/handover": { id: "managedProfile" },
"/api/workouts/{id}": { id: "workout" },
"/api/biomarkers/{id}": { id: "biomarker" },
"/api/insights/ecg/{id}": { id: "ecg" },
"/api/insights/chat/{id}": { id: "conversation" },
"/api/insights/chat/{id}/messages/{messageId}/results": {
id: "conversation",
messageId: "message",
},
"/api/insights/chat/{id}/messages/{messageId}/trail": {
id: "conversation",
messageId: "message",
},
"/api/documents/inbound/{id}": { id: "document" },
"/api/documents/inbound/{id}/chat": { id: "document" },
"/api/ai-runs/{id}": { id: "aiRun" },
"/api/import/apple-health-export/{jobId}/status": { jobId: "importJob" },
"/api/medications/{id}/intake/import/{jobId}/status": {
id: "medication",
jobId: "intakeImportJob",
},
"/api/medications/intake/dose-history-import/{jobId}/status": {
jobId: "doseHistoryImportJob",
},
"/api/admin/backups/{id}/download": { id: "backup" },
"/api/admin/backups/restores/{jobId}": { jobId: "restoreJob" },
"/api/documents/sources/{system}/tags": { system: "paperless" },
"/api/documents/sources/{system}/search": { system: "paperless" },
"/api/day/{date}": { date: "today" },
"/api/record-settings/{family}": { family: "profileFamily" },
};
@@ -84,6 +84,12 @@ vi.mock("@/lib/rate-limit", () => ({
remaining: 9,
resetAt: Date.now() + 3600_000,
})),
// The shared write ceiling every authenticated POST is charged against.
checkRecordWriteRateLimit: vi.fn(async () => ({
allowed: true,
remaining: 299,
resetAt: Date.now() + 60_000,
})),
}));
beforeEach(async () => {
@@ -771,7 +771,18 @@ describe("managed profile lifecycle (real Postgres)", () => {
),
]);
expect([200, 404]).toContain(renounced.status);
// Either order is a correct serialization: the renunciation lands first
// (200), or the deletion ends the grant first and the renunciation finds
// it gone (404) or already ended (409 `sharing.renounce.already_ended`).
// Which one wins depends only on timing, and the write ceiling charged
// ahead of the renunciation shifts that timing.
expect([200, 404, 409]).toContain(renounced.status);
if (renounced.status === 409) {
const body = (await renounced.clone().json()) as {
meta?: { errorCode?: string };
};
expect(body.meta?.errorCode).toBe("sharing.renounce.already_ended");
}
expect(deleted.status).toBe(200);
await expect(
Promise.all([
@@ -818,7 +829,18 @@ describe("managed profile lifecycle (real Postgres)", () => {
),
]);
expect([200, 404]).toContain(renounced.status);
// Either order is a correct serialization: the renunciation lands first
// (200), or the deletion ends the grant first and the renunciation finds
// it gone (404) or already ended (409 `sharing.renounce.already_ended`).
// Which one wins depends only on timing, and the write ceiling charged
// ahead of the renunciation shifts that timing.
expect([200, 404, 409]).toContain(renounced.status);
if (renounced.status === 409) {
const body = (await renounced.clone().json()) as {
meta?: { errorCode?: string };
};
expect(body.meta?.errorCode).toBe("sharing.renounce.already_ended");
}
expect(deleted.status).toBe(200);
await expect(
Promise.all([
@@ -0,0 +1,478 @@
/**
* Every published GET, called for real and read against the schema that
* publishes it.
*
* `openapi-response-shape.test.ts` asked this question of two responses, the
* two that had already been caught drifting. Everything else in the gate
* proves one end at a time: `openapi:check` holds the YAML to the registry,
* the route-coverage guard holds the registry to the route files, and neither
* ever looks at a body. A schema that disagrees with what its handler sends
* passes all of them, and the native client — which decodes against the
* contract, not against the handler — is the first thing to notice.
*
* So the operation list here is derived from the registry, not written down:
* every path with a `get` whose 2xx response publishes an `application/json`
* schema. Each is called through its route module's own `GET` export as one
* seeded account, and the body is parsed with the published schema. Two
* failures count:
*
* - the parse fails: a required field is missing or has the wrong type;
* - the parse drops something: Zod strips undeclared keys rather than
* refusing them, so the parsed value is compared back to the raw body and
* any key that did not survive is a field the server sends and the
* contract does not declare. Loose objects (`meta` on the error envelope,
* a few deliberately open maps) keep unknown keys by design, so this half
* does not reach inside them.
*
* An operation that cannot be called meaningfully in-process sits in
* {@link NOT_CALLED} with its reason. The list is the exception, not the
* selection: a new GET is checked the day it is registered, and the floor at
* the bottom fails if the checked set ever shrinks to something that proves
* little.
*/
import { NextRequest } from "next/server";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { beforeAll, describe, expect, it, vi } from "vitest";
import type { z } from "zod";
import { assertRecordContext, cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
import { seedContractAccount, type ContractFixture } from "./contract-seed";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
// The health route reports the worker it shares the process with. There is
// none in a test process, which is the published 503; a running one is the
// 200 the schema describes.
vi.mock("@/lib/jobs/worker-status", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/jobs/worker-status")>();
return {
...actual,
getWorkerStatus: () => ({
...actual.getWorkerStatus(),
running: true,
lastHeartbeat: new Date().toISOString(),
}),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
// No request leaves the process. An operation that needs the network to
// answer belongs in NOT_CALLED with that reason; one that merely tries and
// copes with the failure is checked on the path it takes when it cannot.
vi.mock("@/lib/safe-fetch", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/safe-fetch")>();
return {
...actual,
safeFetch: vi.fn(async () => {
throw new actual.SafeFetchError("offline in the contract run", "network");
}),
};
});
/**
* Operations that are published and not called, each with the reason.
*
* A reason has to be about the operation, not about the fixture: "needs a
* seeded X" is a fixture to write, not an exception to take.
*/
const NOT_CALLED: Record<string, string> = {
"/api/insights/generate":
"Generates a briefing with the account's AI provider on the request path; with no provider it answers the published 422, and with one it would call the model.",
"/api/environment/geocode":
"A pass-through to the operator's geocoding service; the body is the service's answer.",
"/api/dawarich/places":
"Reads places from the account's own Dawarich instance; unconnected it answers the published 409.",
"/api/documents/sources/{system}/tags":
"Reads tags from the operator's document archive; with none configured it answers the published 404.",
"/api/documents/sources/{system}/search":
"Searches the operator's document archive; with none configured it answers the published 404.",
};
/** The least number of operations that must actually be called and parsed. */
const CHECKED_FLOOR = 235;
/**
* The queries each operation is called with. An operation with a required
* parameter needs an entry; an operation whose optional parameters select a
* different body shape gets one variant per shape, because each is a separate
* promise the schema makes.
*/
function queriesFor(
path: string,
fx: ContractFixture,
): Array<Record<string, string>> {
const from = new Date(Date.now() - 60 * 86_400_000).toISOString();
const to = new Date().toISOString();
const day = (n: number) =>
new Date(Date.now() - n * 86_400_000).toISOString().slice(0, 10);
switch (path) {
case "/api/cycle/day-logs":
return [{ date: day(3) }];
case "/api/measurements":
return [{}, { groupBy: "day", type: "WEIGHT" }, { dayKey: day(2) }];
case "/api/measurements/series-batch":
return [{ types: "WEIGHT,PULSE,ACTIVITY_STEPS", from, to }];
case "/api/measurements/series":
return [
{ kind: "weight" },
{ kind: "bloodPressure" },
{ kind: "sleep" },
{ kind: "glucose" },
];
case "/api/medications/intake":
return [{ scope: "today" }, { scope: "compliance" }];
case "/api/insights/coach-read":
return [{ metric: "WEIGHT" }];
case "/api/insights/narrative":
return [{ period: "week" }];
case "/api/analytics/range":
return [{ type: "WEIGHT", range: "30d" }];
case "/api/insights/metric-status":
return [{ metric: "RESTING_HEART_RATE" }];
case "/api/insights/biomarker-assessment":
return [{ biomarkerId: fx.id("biomarker") }];
case "/api/insights/derived":
return [
{ metric: "BMI" },
{ metric: "VITALS_BASELINE", type: "RESTING_HEART_RATE" },
{ metric: "HEALTH_SCORE" },
];
case "/api/insights/derived/batch":
return [{ metrics: "BMI,HRV_BALANCE,VITALS_BASELINE:PULSE" }];
case "/api/insights/score-history":
return [{ score: "HEALTH_SCORE", days: "30" }];
case "/api/mood/linked-context":
return [{ date: day(2) }];
case "/api/encounters/suggest":
case "/api/vaccinations/suggest":
return [{ anchor: new Date(Date.now() - 30 * 86_400_000).toISOString() }];
case "/api/nutrients/daily":
return [{ nutrient: "vitamin_d" }];
case "/api/day/index":
return [{ from: day(14), to: day(0) }];
case "/api/documents/inbound/source":
return [{ sourceSystem: "PAPERLESS", sourceId: "4711" }];
case "/api/documents/sources/{system}/search":
return [{ q: "letter" }];
default:
return [{}];
}
}
/**
* Operations served to a guardian inside a managed profile rather than to an
* account on its own record. They are called with the session switched into
* the seeded managed profile.
*/
const AS_GUARDIAN = new Set([
"/api/record-settings",
"/api/record-settings/integrations",
"/api/record-settings/{family}",
]);
interface PublishedGet {
path: string;
statuses: Map<number, z.ZodType>;
requiredQuery: string[];
}
type Operation = {
responses?: Record<
string,
{ content?: Record<string, { schema?: z.ZodType }> }
>;
requestParams?: { query?: z.ZodType };
};
function requiredQueryKeys(schema: z.ZodType | undefined): string[] {
if (!schema) return [];
const shape = (schema as unknown as { shape?: Record<string, z.ZodType> })
.shape;
if (!shape) return [];
return Object.entries(shape)
.filter(([, field]) => !field.safeParse(undefined).success)
.map(([key]) => key);
}
async function publishedGets(): Promise<PublishedGet[]> {
const { openApiPaths } = await import("@/lib/openapi/routes");
const out: PublishedGet[] = [];
for (const [path, item] of Object.entries(
openApiPaths as Record<string, { get?: Operation }>,
)) {
const op = item.get;
if (!op?.responses) continue;
const statuses = new Map<number, z.ZodType>();
for (const [code, response] of Object.entries(op.responses)) {
const status = Number(code);
if (!(status >= 200 && status < 300)) continue;
const schema = response.content?.["application/json"]?.schema;
if (schema) statuses.set(status, schema);
}
if (statuses.size === 0) continue;
out.push({
path,
statuses,
requiredQuery: requiredQueryKeys(op.requestParams?.query),
});
}
return out;
}
function routeFile(path: string): string {
return join(
process.cwd(),
"src/app",
path.replace(/\{([^}]+)\}/g, "[$1]"),
"route.ts",
);
}
/**
* Every place the parsed value differs from the raw body: a key the parse
* dropped (undeclared on the wire) or a value it rewrote.
*/
function strippedOrRewritten(raw: unknown, parsed: unknown, at = ""): string[] {
if (raw === null || typeof raw !== "object") {
return Object.is(raw, parsed) ? [] : [`${at || "."}: rewritten`];
}
if (Array.isArray(raw)) {
if (!Array.isArray(parsed)) return [`${at || "."}: rewritten`];
const out: string[] = [];
// One element of each array is enough to name the field; reporting the
// same undeclared key a thousand times buries the next finding.
const seen = new Set<string>();
raw.forEach((item, i) => {
for (const finding of strippedOrRewritten(item, parsed[i], `${at}[]`)) {
if (!seen.has(finding)) {
seen.add(finding);
out.push(finding);
}
}
});
return out;
}
if (parsed === null || typeof parsed !== "object") {
return [`${at || "."}: rewritten`];
}
const out: string[] = [];
const p = parsed as Record<string, unknown>;
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
if (!(key in p)) {
out.push(`${at}.${key}: undeclared`);
continue;
}
out.push(...strippedOrRewritten(value, p[key], `${at}.${key}`));
}
return out;
}
interface Finding {
path: string;
problem: string;
}
let fixture: ContractFixture;
beforeAll(async () => {
// Web Push configured, so the VAPID key route answers with a key.
const { generateVAPIDKeys } = await import("web-push");
const keys = generateVAPIDKeys();
vi.stubEnv("VAPID_PUBLIC_KEY", keys.publicKey);
vi.stubEnv("VAPID_PRIVATE_KEY", keys.privateKey);
vi.stubEnv("VAPID_SUBJECT", "mailto:operator@example.test");
const prisma = getPrismaClient();
const { setGlobalBoss } = await import("@/lib/jobs/boss-instance");
// A producer is present, as in a running web process; nothing is sent.
setGlobalBoss({ send: async () => null } as never);
await truncateAllTables(prisma);
cookieJar.clear();
headerJar.clear();
fixture = await seedContractAccount(prisma);
}, 300_000);
/** Put the cookie (and the record assertion) in place for one call. */
function enter(path: string): void {
headerJar.clear();
if (AS_GUARDIAN.has(path)) {
cookieJar.set("healthlog_session", fixture.guardianSessionId);
assertRecordContext(fixture.guardianRecordEpoch, fixture.managedProfileId);
} else {
cookieJar.set("healthlog_session", fixture.sessionId);
}
}
/** Whether a body carries arrays and every one of them is empty. */
function onlyEmptyArrays(value: unknown): boolean {
let arrays = 0;
let filled = 0;
const walk = (v: unknown) => {
if (Array.isArray(v)) {
arrays += 1;
if (v.length > 0) filled += 1;
v.forEach(walk);
} else if (v && typeof v === "object") {
Object.values(v).forEach(walk);
}
};
walk(value);
return arrays > 0 && filled === 0;
}
const emptyBodies: string[] = [];
/** Call one operation once and report what disagrees with its schema. */
async function check(
op: PublishedGet,
query: Record<string, string>,
): Promise<{ called: boolean; findings: string[] }> {
const params = fixture.pathParams(op.path);
if (params === null) {
return { called: false, findings: ["no fixture for path params"] };
}
const missing = op.requiredQuery.filter((key) => !(key in query));
if (missing.length > 0) {
return {
called: false,
findings: [`no fixture for required query ${missing.join(", ")}`],
};
}
let concrete = op.path;
for (const [name, value] of Object.entries(params)) {
concrete = concrete.replace(`{${name}}`, encodeURIComponent(value));
}
const url = new URL(`http://localhost${concrete}`);
for (const [key, value] of Object.entries(query)) {
url.searchParams.set(key, value);
}
const mod = (await import(/* @vite-ignore */ routeFile(op.path))) as {
GET?: (req: NextRequest, ctx: unknown) => Promise<Response>;
};
if (!mod.GET) return { called: false, findings: ["route exports no GET"] };
enter(op.path);
let res: Response;
try {
res = await mod.GET(new NextRequest(url), {
params: Promise.resolve(params),
});
} catch (error) {
return { called: false, findings: [`threw: ${(error as Error).message}`] };
}
const schema = op.statuses.get(res.status);
if (!schema) {
const text = await res.text();
return {
called: false,
findings: [
`status ${res.status} is not a published 2xx: ${text.slice(0, 300)}`,
],
};
}
const body: unknown = await res.json();
if (process.env.CONTRACT_DUMP?.split(",").includes(op.path)) {
const { appendFileSync } = await import("node:fs");
appendFileSync(
`${process.env.CONTRACT_REPORT}.bodies`,
`${op.path}\n${JSON.stringify(body, null, 1).slice(0, 20000)}\n\n`,
);
}
if (onlyEmptyArrays(body)) emptyBodies.push(op.path);
const parsed = schema.safeParse(body);
if (!parsed.success) {
return {
called: true,
findings: parsed.error.issues
.slice(0, 8)
.map(
(issue) =>
`${issue.path.join(".") || "."}: ${issue.code} ${issue.message}`,
),
};
}
return { called: true, findings: strippedOrRewritten(body, parsed.data) };
}
describe("every published GET answers what its schema says", () => {
it("parses each response against the registered schema and drops nothing", async () => {
const operations = await publishedGets();
if (process.env.CONTRACT_REPORT) {
const { rmSync } = await import("node:fs");
rmSync(process.env.CONTRACT_REPORT, { force: true });
}
const findings: Finding[] = fixture.seedFailures.map((problem) => ({
path: "(seed)",
problem,
}));
let checked = 0;
for (const op of operations) {
if (op.path in NOT_CALLED) continue;
if (!existsSync(routeFile(op.path))) {
findings.push({ path: op.path, problem: "no route file" });
continue;
}
let calledOnce = false;
for (const query of queriesFor(op.path, fixture)) {
const label = Object.keys(query).length
? `${op.path}?${new URLSearchParams(query).toString()}`
: op.path;
const result = await check(op, query);
calledOnce ||= result.called;
for (const problem of result.findings) {
findings.push({ path: label, problem });
}
}
if (calledOnce) checked += 1;
}
if (process.env.CONTRACT_REPORT) {
const { writeFileSync } = await import("node:fs");
writeFileSync(
process.env.CONTRACT_REPORT,
JSON.stringify({ checked, findings, emptyBodies }, null, 2),
);
}
expect(findings).toEqual([]);
expect(checked).toBeGreaterThanOrEqual(CHECKED_FLOOR);
}, 600_000);
it("leaves no exception that the run could have called", async () => {
const published = new Set((await publishedGets()).map((op) => op.path));
// An exception for an operation that is no longer published is a stale
// entry that would quietly excuse its successor under the same path.
expect(
Object.keys(NOT_CALLED).filter((path) => !published.has(path)),
).toEqual([]);
});
});
+28
View File
@@ -325,6 +325,34 @@ describe("symptom occurrences", () => {
).toBe(0);
});
it("says hasMore only when an occurrence past the page exists", async () => {
const id = await defineSymptom("Headache");
for (const occurredAt of [
"2026-09-01T09:30:00.000Z",
"2026-09-02T09:30:00.000Z",
]) {
expect(
(await logEvent({ definitionId: id, intensity: 3, occurredAt })).status,
).toBe(201);
}
const { GET } = await import("@/app/api/symptoms/events/route");
const page = async (limit: number) =>
(
await json<Envelope<{ events: unknown[]; hasMore: boolean }>>(
await call(GET, "GET", `/api/symptoms/events?limit=${limit}`),
)
).data;
// A page that is exactly full is the whole list: no page follows it.
const full = await page(2);
expect(full.events).toHaveLength(2);
expect(full.hasMore).toBe(false);
const cut = await page(1);
expect(cut.events).toHaveLength(1);
expect(cut.hasMore).toBe(true);
});
it("refuses an intensity outside 0-10 and a time in the future", async () => {
const id = await defineSymptom("Aura");
expect((await logEvent({ definitionId: id, intensity: 11 })).status).toBe(
+185
View File
@@ -0,0 +1,185 @@
/**
* The shared write ceiling, against Postgres, on routes that never call it.
*
* The bucket is charged where the caller is resolved, so the routes below
* carry no limiter of their own; a PATCH and a DELETE on an `[id]` met none
* before. What has to hold: a write is counted, a read is not, a spent bucket
* refuses with the published code and the headers that describe it and
* writes nothing, and a bulk route on its own bucket keeps working while the
* shared one is spent.
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
import {
RECORD_WRITE_BUCKET_PREFIX,
RECORD_WRITE_LIMIT,
} from "@/lib/rate-limit";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
const USER_ID = "user-write-meter";
const BUCKET = `${RECORD_WRITE_BUCKET_PREFIX}:${USER_ID}`;
async function bucketCount(): Promise<number> {
const row = await getPrismaClient().rateLimit.findUnique({
where: { key: BUCKET },
});
return row?.count ?? 0;
}
async function spendBucket(): Promise<void> {
await getPrismaClient().rateLimit.upsert({
where: { key: BUCKET },
create: {
key: BUCKET,
count: RECORD_WRITE_LIMIT,
resetAt: new Date(Date.now() + 60_000),
},
update: {
count: RECORD_WRITE_LIMIT,
resetAt: new Date(Date.now() + 60_000),
},
});
}
let allergyId: string;
beforeEach(async () => {
const prisma = getPrismaClient();
await truncateAllTables(prisma);
cookieJar.clear();
headerJar.clear();
await prisma.user.create({
data: {
id: USER_ID,
username: "write-meter",
email: "write-meter@example.test",
timezone: "UTC",
},
});
const session = await prisma.session.create({
data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) },
});
cookieJar.set("healthlog_session", session.id);
const allergy = await prisma.allergy.create({
data: {
userId: USER_ID,
substance: "Pollen",
category: "ENVIRONMENT",
type: "ALLERGY",
status: "ACTIVE",
},
});
allergyId = allergy.id;
});
function patchAllergy(body: unknown) {
return import("@/app/api/allergies/[id]/route").then(({ PATCH }) =>
PATCH(
new NextRequest(`http://localhost/api/allergies/${allergyId}`, {
method: "PATCH",
body: JSON.stringify(body),
headers: { "content-type": "application/json" },
}),
{ params: Promise.resolve({ id: allergyId }) },
),
);
}
describe("the shared write bucket on routes without a limiter of their own", () => {
it("counts a PATCH and a DELETE, and not a GET", async () => {
const { GET, DELETE } = await import("@/app/api/allergies/[id]/route");
const read = await GET(
new NextRequest(`http://localhost/api/allergies/${allergyId}`),
{ params: Promise.resolve({ id: allergyId }) },
);
expect(read.status).toBe(200);
expect(await bucketCount()).toBe(0);
expect((await patchAllergy({ substance: "Birch pollen" })).status).toBe(
200,
);
expect(await bucketCount()).toBe(1);
const gone = await DELETE(
new NextRequest(`http://localhost/api/allergies/${allergyId}`, {
method: "DELETE",
}),
{ params: Promise.resolve({ id: allergyId }) },
);
expect(gone.status).toBe(200);
expect(await bucketCount()).toBe(2);
});
it("refuses a write once the bucket is spent, says why, and writes nothing", async () => {
await spendBucket();
const res = await patchAllergy({ substance: "Birch pollen" });
expect(res.status).toBe(429);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("record_write.rate_limited");
expect(Number(res.headers.get("Retry-After"))).toBeGreaterThan(0);
expect(res.headers.get("X-RateLimit-Limit")).toBe(
String(RECORD_WRITE_LIMIT),
);
const row = await getPrismaClient().allergy.findUniqueOrThrow({
where: { id: allergyId },
});
expect(row.substance).toBe("Pollen");
});
it("keeps a bulk route on its own bucket working while the shared one is spent", async () => {
await spendBucket();
const { POST } = await import("@/app/api/measurements/batch/route");
const res = await POST(
new NextRequest("http://localhost/api/measurements/batch", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
entries: [
{
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
value: 72.4,
unit: "kg",
startDate: new Date(Date.now() - 3_600_000).toISOString(),
endDate: new Date(Date.now() - 3_600_000).toISOString(),
externalId: "uuid-write-meter-weight-1",
},
],
}),
}),
);
expect(res.status, await res.clone().text()).toBeLessThan(300);
expect(
await getPrismaClient().measurement.count({ where: { userId: USER_ID } }),
).toBe(1);
});
});
+17 -1
View File
@@ -18,7 +18,7 @@
* Server-side translation (`getServerTranslator`, `shared-resolve`) holds
* every catalog on its own and needs no priming.
*/
import { afterEach } from "vitest";
import { afterEach, vi } from "vitest";
import { settleBackgroundTasks } from "@/lib/logging/background-tasks";
import { primeMessages } from "@/lib/i18n/load-locale";
import enMessages from "./messages/en.json";
@@ -43,3 +43,19 @@ primeMessages("en", enMessages);
afterEach(async () => {
await settleBackgroundTasks();
});
/**
* The shared write ceiling is charged by every identity resolver on a write
* (`src/lib/auth/write-meter.ts`), which is a database round-trip most route
* tests have no fake for: their Prisma mock carries the models the handler
* touches and nothing else. The meter is therefore a no-op in unit tests by
* default. Its own behaviour is pinned by
* `src/lib/auth/__tests__/write-meter.test.ts`, which loads the real module,
* and against Postgres by `tests/integration/write-meter.test.ts`; a route test
* that wants the refusal mocks `meterAccountWrite` to throw.
*/
vi.mock("@/lib/auth/write-meter", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/auth/write-meter")>();
return { ...actual, meterAccountWrite: vi.fn(async () => undefined) };
});