Merge remote-tracking branch 'origin/main' into fix/private-notification-origins

# Conflicts:
#	src/app/api/settings/ntfy/route.ts
#	src/app/api/settings/webhook/route.ts
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 18:07:55 +02:00
commit 71175290af
255 files changed
+12654 -1141

No files matched your search

@@ -24,6 +24,16 @@
* domain outside the vocabulary other than `record` fails the run outright,
* because the table is keyed on the vocabulary and would silently lack it.
*
* What it deliberately does not hold, stated so nobody reads more into a green
* run than it earns: the table is EXISTENTIAL per section. It answers "does
* any route in this section accept this need", and within a section the answer
* varies by verb — `POST /api/encounters` is WRITE while `POST /api/allergies`
* beside it is MANAGE, both under `profile`. So this guard proves the table
* and the routes agree about the section, never that one control's own verb is
* covered. That is the control's job: it asks the hook for its own verb class,
* and a control that read "my section is writable, therefore my create is"
* would be wrong in three of the four sections marked writable.
*
* Mutation checks, run:
* - set `mind.write` to `true` in the table → "agrees with the routes" red;
* - change `requireRecordAuth("manage", "mind")` in `mood-entries/route.ts`
+271
View File
@@ -0,0 +1,271 @@
/**
* The published `meta.errorCode` list is the list the server actually emits.
*
* `meta.errorCode` is the mechanism this API chose for machine-readable errors
* and the envelope tells a client to branch on it — but of the two hundred and
* forty-two codes in the tree, sixteen appeared anywhere in the contract. A
* client had to discover each one by triggering the error, and had no way at
* all to learn a new one had appeared. The catalogue publishes them; this holds
* the catalogue to the code, in both directions:
*
* - A code emitted and not listed is today's defect repeating itself, and it
* is also how a fifth naming convention would get in unnoticed.
* - A code listed and no longer emitted is a promise the server stopped
* keeping, which is worse than an omission: a client can write a branch for
* it and wait forever.
*
* What it reads: every `.ts` under `src/app/api` outside a `__tests__`
* directory, and then every module those files import, transitively, so a code
* built one or five hops away from the route that answers with it is read
* where it is written. Import specifiers are resolved the way the app resolves
* them (`@/…` and relative paths, `.ts` / `.tsx` / `index.*`); anything that
* leaves `src` is not followed, and the generated Prisma client is never
* opened. Comment lines are stripped first, so a code quoted in prose is not
* mistaken for one that ships.
*
* This used to be the route tree plus a hand-kept list of library modules, and
* the hand-kept list is exactly what it sounds like: the dose-history import
* built five codes in a module nobody had thought to add, so the guard read
* neither the codes nor the file and agreed the catalogue was complete. The
* import graph is not hand-kept — a new module reached by a route is swept the
* first time a route imports it.
*
* Its limits, stated rather than implied. A code assembled at runtime from a
* template cannot be enumerated at all, and the one family that does so is
* named in the catalogue's own doc comment. A code that reaches the envelope
* through a variable is found where the values are declared, which means a
* literal in a constant the sweep's shapes do not cover would still be missed:
* a constant that holds wire codes is named `*_CODE` / `*_CODES` for that
* reason. And `IntegrationStatus.errorCode` is a different field with the same
* name — it records an upstream HTTP status in the sync ledger and never
* reaches a response — so the two ledger writers that take it are cut out of
* the text before it is matched.
*/
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { dirname, join, relative, resolve, sep } from "node:path";
import { describe, expect, it } from "vitest";
import { ERROR_CODE_CATALOGUE } from "@/lib/openapi/error-codes";
const SRC_ROOT = join(process.cwd(), "src");
const APP_ROOT = join(SRC_ROOT, "app");
/** Never opened: the generated Prisma client is megabytes of machine output. */
const GENERATED_ROOT = join(SRC_ROOT, "generated");
/**
* The sync ledger's writers. `IntegrationStatus.errorCode` records an upstream
* HTTP status and never reaches a response envelope, so their arguments are cut
* out before a code is looked for. A third writer would surface as an
* undocumented code rather than as silence, which is the direction to fail in.
*/
const LEDGER_WRITERS = ["recordSyncFailure", "parkIntegrationAtReauth"];
function tsFiles(dir: string): string[] {
const found: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === "__tests__") continue;
found.push(...tsFiles(full));
continue;
}
if (entry.name.endsWith(".ts")) found.push(full);
}
return found;
}
/** Resolve one import specifier the way the app's path alias resolves it. */
function resolveSpecifier(specifier: string, fromFile: string): string | null {
let base: string;
if (specifier.startsWith("@/")) base = join(SRC_ROOT, specifier.slice(2));
else if (specifier.startsWith("."))
base = resolve(dirname(fromFile), specifier);
else return null;
for (const candidate of [
`${base}.ts`,
`${base}.tsx`,
join(base, "index.ts"),
join(base, "index.tsx"),
]) {
if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
}
return null;
}
/** Every module a route reaches, however many hops away it is written. */
function modulesUnderTheRoutes(): string[] {
const queue = tsFiles(APP_ROOT);
const seen = new Set(queue);
for (let i = 0; i < queue.length; i++) {
const file = queue[i];
const source = readFileSync(file, "utf8");
const specifiers = [
...source.matchAll(
/(?:^|\n)\s*(?:import|export)[\s\S]{0,400}?from\s*"([^"]+)"/g,
),
...source.matchAll(/import\(\s*"([^"]+)"\s*\)/g),
];
for (const match of specifiers) {
const resolved = resolveSpecifier(match[1], file);
if (!resolved) continue;
if (resolved.startsWith(GENERATED_ROOT)) continue;
if (resolved.includes(`${sep}__tests__${sep}`)) continue;
if (seen.has(resolved)) continue;
seen.add(resolved);
queue.push(resolved);
}
}
return [...seen];
}
/** Cut the sync-ledger writers' arguments out, parenthesis-balanced. */
function withoutLedgerWrites(source: string): string {
let text = source;
for (const writer of LEDGER_WRITERS) {
for (;;) {
const call = text.indexOf(`${writer}(`);
if (call === -1) break;
const open = call + writer.length;
let depth = 0;
let close = text.length;
for (let j = open; j < text.length; j++) {
if (text[j] === "(") depth++;
else if (text[j] === ")") {
depth--;
if (depth === 0) {
close = j + 1;
break;
}
}
}
text = text.slice(0, call) + text.slice(close);
}
}
return text;
}
/** Drop comment lines so a code quoted in prose is not read as one that ships. */
function withoutComments(source: string): string {
return source
.split("\n")
.filter((line) => {
const trimmed = line.trim();
return !(
trimmed.startsWith("*") ||
trimmed.startsWith("//") ||
trimmed.startsWith("/*")
);
})
.join("\n");
}
/** The shapes a code is written in across the tree. */
function codesIn(source: string): string[] {
const found: string[] = [];
// `errorCode: "x"`, `errorCode = "x"`, `readonly errorCode = "x"`, and the
// annotated `errorCode: string = "x"` on the step-up error's constructor.
for (const m of source.matchAll(
/errorCode\s*(?::\s*[A-Za-z.[\]|" ]+)?\s*[:=]\s*"([^"]+)"/g,
)) {
found.push(m[1]);
}
// A single shared constant: `export const MODULE_DISABLED_ERROR_CODE = "…"`.
for (const m of source.matchAll(/\b[A-Z][A-Z0-9_]*_CODE\s*=\s*"([^"]+)"/g)) {
found.push(m[1]);
}
// A family in one object: `export const AUTH_ERROR_CODES = { … }`, or in one
// tuple: `export const AUTO_EXPORT_FATAL_ERROR_CODES = [ … ] as const`. The
// tuple is how a union type of codes is declared, and that union is what an
// envelope relays when it writes `errorCode: parsed.fatal.reason`. Only the
// `_ERROR_CODES` spelling is read in tuple form: plenty of unrelated
// catalogues (nutrients, say) are lists of codes that are not error codes.
for (const m of source.matchAll(/\b([A-Z][A-Z0-9_]*_CODES)\s*=\s*([{[])/g)) {
const [, name, opener] = m;
if (opener === "[" && !name.endsWith("_ERROR_CODES")) continue;
const closer = opener === "{" ? "}" : "]";
const start = source.indexOf(opener, m.index);
let depth = 0;
let end = start;
for (let j = start; j < source.length; j++) {
if (source[j] === opener) depth++;
else if (source[j] === closer) {
depth--;
if (depth === 0) {
end = j;
break;
}
}
}
const body = source.slice(start, end);
const values =
opener === "{"
? body.matchAll(/:\s*"([^"]+)"/g)
: body.matchAll(/"([^"]+)"/g);
for (const value of values) found.push(value[1]);
}
return found;
}
/** Every code the tree emits, mapped to the files that emit it. */
function emittedCodes(): Map<string, string[]> {
const emitted = new Map<string, string[]>();
for (const file of modulesUnderTheRoutes()) {
const source = withoutLedgerWrites(
withoutComments(readFileSync(file, "utf8")),
);
for (const code of codesIn(source)) {
const where = relative(process.cwd(), file).split(sep).join("/");
const seen = emitted.get(code);
if (seen) {
if (!seen.includes(where)) seen.push(where);
} else {
emitted.set(code, [where]);
}
}
}
return emitted;
}
const emitted = emittedCodes();
const published = new Set(Object.values(ERROR_CODE_CATALOGUE).flat());
describe("the published errorCode catalogue", () => {
it("finds the codes it is meant to judge", () => {
// A matcher that stopped matching would otherwise agree with an empty
// catalogue. Two hundred and twenty-nine when this was written, read out
// of the fourteen hundred modules the route tree reaches.
expect(emitted.size).toBeGreaterThan(200);
});
it("lists every code the server emits", () => {
const undocumented = [...emitted.entries()]
.filter(([code]) => !published.has(code))
.map(([code, files]) => `${code} (${files[0]})`)
.sort();
expect(undocumented).toEqual([]);
});
it("emits every code it lists", () => {
const stale = [...published].filter((code) => !emitted.has(code)).sort();
expect(stale).toEqual([]);
});
it("files every code under the surface its own prefix names", () => {
const misfiled: string[] = [];
for (const [surface, codes] of Object.entries(ERROR_CODE_CATALOGUE)) {
for (const code of codes) {
const expected = code.includes(".")
? code.slice(0, code.indexOf("."))
: "(unprefixed)";
if (expected !== surface) misfiled.push(`${code} under ${surface}`);
}
}
expect(misfiled).toEqual([]);
});
it("keeps no duplicate between two surfaces", () => {
const all = Object.values(ERROR_CODE_CATALOGUE).flat();
expect(all.length).toBe(new Set(all).size);
});
});
+67
View File
@@ -73,3 +73,70 @@ export function walkSourceFiles(
}
return files;
}
/**
* A source file with its comments removed, line comments FIRST.
*
* The order is the whole reason this is shared rather than re-spelled per
* guard. Every private copy in this directory strips block comments first,
* with one non-greedy `/\*[\s\S]*?\*\//` pass, and that pass cannot tell a
* real block opener from a `/*` that happens to sit inside a line comment.
* This tree has one: `src/app/mood/page-client.tsx` explains its gate with
* "Every `/api/mood-entries/*` route also enforces the gate", and the `/*` in
* that path opened a fake block that swallowed the next 2.2 kB of real code —
* the module gate three lines below it included. A guard reading that source
* was searching a file with a hole in it and reporting a clean sweep.
*
* So each line is walked once, left to right: whichever opener comes first
* wins, a line comment ends the line, and a block comment carries its state to
* the next one. Line-comment detection needs `//` at the start of a line or
* after whitespace, so a URL inside a string survives.
*
* Its honest limit: it is a scanner, not a parser. A `/*` inside a string
* literal still opens a block, and a guard that needs more than this needs a
* real parse.
*/
export function stripComments(source: string): string {
const lines: string[] = [];
let inBlock = false;
for (const line of source.split("\n")) {
let rest = line;
let kept = "";
while (rest.length > 0) {
if (inBlock) {
const closes = rest.indexOf("*/");
if (closes === -1) break;
rest = rest.slice(closes + 2);
inBlock = false;
continue;
}
const lineComment = rest.search(/(^|\s)\/\//);
const blockComment = rest.indexOf("/*");
if (
blockComment !== -1 &&
(lineComment === -1 || blockComment < lineComment)
) {
kept += rest.slice(0, blockComment);
rest = rest.slice(blockComment + 2);
inBlock = true;
continue;
}
if (lineComment !== -1) {
kept += rest.slice(0, rest.indexOf("//", lineComment));
break;
}
kept += rest;
break;
}
lines.push(kept);
}
return lines.join("\n");
}
@@ -0,0 +1,158 @@
/**
* A body that will not parse is always a 400.
*
* `safeJson` has answered 400 since it was written and roughly two hundred and
* twenty routes go through it; `apiHandler`'s own `SyntaxError` branch answers
* 400 as well. Seven `/api/auth/me/*` writes hand-rolled `await req.json()` in
* a try/catch and threw 422 for the same condition, so a client's "my
* serializer produced garbage" branch had to accept two statuses on a subset of
* routes it could not predict — and the subset was invisible from the outside.
*
* The guard reads every hand-rolled parse out of the route tree rather than
* listing them, so a route that grows one later is judged too. The `catch` is
* read on its own: a `try` that parses a body and then refuses with anything
* other than 400 fails here.
*
* Its limit: a route that parses a body somewhere other than inside a `try`
* whose `catch` builds the refusal — a shared helper, a promise chain — is
* invisible to it. `safeJson` remains the documented entry, and the assertion
* on the helper below is what pins the status the rest of the tree inherits.
*/
import { readFileSync, readdirSync } from "node:fs";
import { join, relative, sep } from "node:path";
import { describe, expect, it } from "vitest";
import { safeJson } from "@/lib/api-response";
const API_ROOT = join(process.cwd(), "src", "app", "api");
/**
* Hand-rolled parses that must keep a different status, with the reason.
*
* `/api/auth/me/health-score-config` is here because the published contract
* names its answer: the operation's own 422 description spells out that a body
* which is not JSON at all comes back with the bare message
* `health-score-config.body.invalid_json`. Moving it to 400 would change a
* promise a shipped client was generated against, so the status stays and the
* change is a contract decision of its own rather than a cleanup.
*/
const ALLOWLIST: Record<string, { status: number; reason: string }> = {
"src/app/api/auth/me/health-score-config/route.ts": {
status: 422,
reason:
"The 422 and its exact message are written into the operation's published description.",
},
};
/** Every `route.ts` under `src/app/api`, tests excluded. */
function routeFiles(dir: string): string[] {
const found: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === "__tests__") continue;
found.push(...routeFiles(full));
continue;
}
if (entry.name === "route.ts") found.push(full);
}
return found;
}
/** The brace-balanced block starting at the `{` at or after `from`. */
function block(src: string, from: number): { text: string; end: number } {
let i = from;
while (i < src.length && src[i] !== "{") i++;
let depth = 0;
for (let j = i; j < src.length; j++) {
if (src[j] === "{") depth++;
else if (src[j] === "}") {
depth--;
if (depth === 0) return { text: src.slice(i, j + 1), end: j + 1 };
}
}
return { text: src.slice(i), end: src.length };
}
interface ParseRefusal {
route: string;
line: number;
status: number | null;
}
/** Every `try { … .json() … } catch { … }` under the route tree. */
function scanHandRolledParses(): ParseRefusal[] {
const found: ParseRefusal[] = [];
for (const file of routeFiles(API_ROOT)) {
const src = readFileSync(file, "utf8");
for (const m of src.matchAll(/\btry\s*\{/g)) {
const start = m.index ?? 0;
const tryBlock = block(src, start);
if (!/(?:req|request)\w*\.json\(\)/.test(tryBlock.text)) continue;
const after = src.slice(tryBlock.end);
const catchAt = after.match(/^\s*catch\s*(?:\([^)]*\)\s*)?\{/);
if (!catchAt) continue;
const catchBlock = block(after, catchAt.index ?? 0);
const status = catchBlock.text.match(/\b(4\d\d|5\d\d)\b/);
found.push({
route: relative(process.cwd(), file).split(sep).join("/"),
line: src.slice(0, start).split("\n").length,
status: status ? Number(status[1]) : null,
});
}
}
return found;
}
describe("malformed JSON answers one status", () => {
const parses = scanHandRolledParses();
it("finds the hand-rolled parses it is meant to judge", () => {
// Eight when the guard was written. A matcher that stops matching would
// otherwise report a clean pass it did not earn.
expect(parses.length).toBeGreaterThanOrEqual(8);
});
it("refuses an unparseable body with 400", () => {
const wrong = parses
.filter((parse) => {
const allowed = ALLOWLIST[parse.route];
if (allowed) return parse.status !== allowed.status;
return parse.status !== 400;
})
.map((parse) => `${parse.route}:${parse.line} -> ${parse.status}`);
expect(wrong).toEqual([]);
});
it("keeps no stale allowlist entries", () => {
const stale = Object.keys(ALLOWLIST).filter(
(route) => !parses.some((parse) => parse.route === route),
);
expect(stale).toEqual([]);
});
it("is the status the shared helper already answers with", async () => {
const request = new Request("https://example.test/api/anything", {
method: "POST",
headers: { "content-type": "application/json" },
body: "{ not json",
});
const result = await safeJson(request);
expect(result.error?.status).toBe(400);
expect(await result.error?.clone().json()).toMatchObject({
data: null,
error: "Invalid JSON body",
});
});
it("keeps the wrong-content-type refusal separate at 415", async () => {
const request = new Request("https://example.test/api/anything", {
method: "POST",
headers: { "content-type": "text/plain" },
body: "{}",
});
const result = await safeJson(request);
expect(result.error?.status).toBe(415);
});
});
@@ -0,0 +1,120 @@
/**
* A module gate decides from the RECORD's map, never from "am I a delegate".
*
* Every surface that paints a module reads `user?.modules?.X`, and a run of
* them used to widen that read with `inSharedRecord || …`. The prefix was
* right when it was written: `GET /api/auth/me` published the ACTOR's module
* map, so a delegate's own preferences would have closed a page in somebody
* else's record. Since the map became the record's, masked to what the grant
* opens, the same prefix fails open in the other direction — the navigation
* correctly drops the entry while a typed URL still renders the page for a
* record whose owner switched the module off, and the routes behind these
* pages carry no module gate of their own, so the page works.
*
* A DISCOVERED set rather than a named one. The first version of this guard
* froze the six files one sweep happened to find; three more were carrying the
* identical expression at the time, and a named list is structurally unable to
* say so. So the sweep walks every component under `src/app` and
* `src/components`, and the property is about the SHAPE: no boolean `||` may
* join `inSharedRecord` to a module read. The window the matcher searches
* cannot cross a `;`, a `{` or a `}`, so it sees one expression at a time and
* a delegate check elsewhere in the same file is none of its business.
*
* A structural check rather than a render, because the thing that regressed is
* one `||`: reaching these pages in a unit test would mean standing up the
* auth query, the router and the shell, and none of that is where the defect
* lives.
*
* Its limit, stated: it holds the two spellings of the OR. A gate that widened
* itself some other way — a ternary, a helper returning the delegate flag —
* would pass. The non-vacuity floor below is what keeps a broken walker from
* reading as a clean sweep.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { stripComments, walkSourceFiles } from "./helpers/source-files";
const ROOT = process.cwd();
/** Floors pinned below the real counts, so a broken walk throws. */
const ROOTS: ReadonlyArray<[string, number]> = [
["src/app", 100],
["src/components", 700],
];
/** The module read every gated surface makes. */
const MODULE_READ = "user?.modules?.";
/**
* The two spellings of the widening, in one expression each.
*
* `[^;{}]*` is the whole point: it cannot cross a statement end or a block
* boundary, so a file that reads the module map in one place and asks about
* the delegate in another does not match.
*/
const WIDENED = [
/inSharedRecord\s*\|\|[^;{}]*user\?\.modules\?\./,
/user\?\.modules\?\.[^;{}]*\|\|\s*inSharedRecord/,
];
/** Every `.tsx` under the roots, with its comment-stripped source. */
function componentSources(): Array<{ file: string; source: string }> {
const out: Array<{ file: string; source: string }> = [];
for (const [root, floor] of ROOTS) {
const files = walkSourceFiles(join(ROOT, root), {
floor,
extensions: [".tsx"],
});
for (const rel of files) {
if (rel.includes("__tests__/")) continue;
out.push({
file: `${root}/${rel}`,
source: stripComments(readFileSync(join(ROOT, root, rel), "utf8")),
});
}
}
return out;
}
describe("a module gate reads the record's map alone", () => {
const sources = componentSources();
const gated = sources.filter(({ source }) => source.includes(MODULE_READ));
it("discovers a real set of module-gated surfaces", () => {
// The empty-match trap: a walker that stopped walking, or a spelling of
// the module read that moved, would make every assertion below vacuous.
expect(sources.length, "no component was walked at all").toBeGreaterThan(
500,
);
expect(
gated.length,
"no surface was found reading the module map",
).toBeGreaterThan(20);
// Spot checks, so a sweep that found twenty of the wrong files still
// fails: these are module-gated pages by construction.
const files = new Set(gated.map((entry) => entry.file));
for (const file of [
"src/app/vaccinations/page.tsx",
"src/app/labs/page.tsx",
"src/app/mood/page-client.tsx",
"src/components/documents/documents-view.tsx",
]) {
expect(files, `${file} was not discovered`).toContain(file);
}
});
it("finds no gate widened by the delegate flag", () => {
const widened = gated
.filter(({ source }) => WIDENED.some((pattern) => pattern.test(source)))
.map(({ file }) => file);
expect(
widened,
`these surfaces widen a module gate for a delegate; the record's masked map already answers for both cases:\n${widened
.map((file) => ` ❌ ${file}`)
.join("\n")}`,
).toEqual([]);
});
});
+72 -11
View File
@@ -45,16 +45,16 @@
* - Comments are stripped before matching. `.../guardians/route.ts` names both
* fence helpers in a docblock explaining why it uses neither, and counting
* prose would classify an actor surface as delegable.
* - It covers the PUBLISHED surface, which is not the whole delegable surface.
* 116 route modules resolve the fence; 90 of them are in the OpenAPI document
* and 26 have never been (mood entries, the record-settings tree, the
* medication intake tree, several insight reads). That gap predates this
* release and is not closed here — "frozen" means the published contract is
* complete and consistent, not that every delegable route is described. What
* this guard does guarantee is that one of those 26 cannot be published
* without its refusal, because publishing it puts it in scope on the next run.
* - It covered the PUBLISHED surface and not the whole delegable one. That was
* the honest limit at v1.37.0, when 116 route modules resolved the fence and
* 26 of them were in no OpenAPI document at all. The gap is closed on this
* tree, so the limit is now an assertion rather than a caveat: the last leg
* below recomputes the fenced modules from the route sources and requires
* every one of them to be published. Two counts is one too many, so it names
* no number — a hard-coded figure is what turned the paragraph it replaces
* into a false claim about the contract.
*/
import { readFileSync, existsSync } from "node:fs";
import { readdirSync, readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
@@ -63,6 +63,8 @@ import { openApiPaths } from "@/lib/openapi/routes";
import { SHARING_ACCESS_DENIED_DESCRIPTION } from "@/lib/openapi/routes/shared";
import { RETIRED_ROUTES } from "@/lib/http/retired-routes";
import { stripComments } from "./helpers/source-files";
/** Published paths that deliberately have no handler behind them. */
const retiredPaths = new Set(RETIRED_ROUTES.map((route) => route.path));
@@ -80,8 +82,40 @@ interface PublishedOperation {
handlerSource: string;
}
function stripComments(text: string): string {
return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|\s)\/\/.*$/gm, "$1");
/**
* Every `route.ts` under `src/app/api` whose handlers resolve the record fence.
*
* Comments stripped first, for the reason the operation matcher strips them:
* one module names both fence helpers in a docblock explaining why it uses
* neither.
*/
function fencedRouteModules(): string[] {
const out: string[] = [];
const walk = (dir: string) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
walk(full);
continue;
}
if (entry.name !== "route.ts") continue;
const source = stripComments(readFileSync(full, "utf8"));
if (/require(RecordAuth|GuardianAuth)\s*\(/.test(source)) {
out.push(full);
}
}
};
walk(join(ROOT, "src/app/api"));
return out;
}
/** `…/src/app/api/labs/[id]/route.ts` → `/api/labs/{id}`. */
function apiPathFor(moduleFile: string): string {
return moduleFile
.slice(join(ROOT, "src/app").length)
.replaceAll("\\", "/")
.replace(/\/route\.ts$/, "")
.replace(/\[([^\]]+)\]/g, "{$1}");
}
function routeModulePath(apiPath: string): string {
@@ -334,6 +368,33 @@ describe("the sharing refusal every delegable path publishes", () => {
}
});
it("publishes every module that resolves the fence", () => {
// The reverse direction of the leg above, and the one the docblock used to
// apologise for: that one holds every PUBLISHED fenced operation to the
// refusal, and says nothing about a fenced module nobody published. A
// module missing from the document is a delegable surface a generated
// client cannot see and a reader of the contract cannot find, which is
// exactly how the mood-entry tree and the record-settings tree stayed
// invisible for a release.
const modules = fencedRouteModules();
expect(
modules.length,
"no fenced route module was discovered — the matcher found nothing",
).toBeGreaterThan(100);
const published = new Set(Object.keys(openApiPaths));
const missing = modules
.map((rel) => apiPathFor(rel))
.filter((apiPath) => !published.has(apiPath));
expect(
missing,
`these route modules resolve the record fence and are in no published path:\n${missing
.map((apiPath) => ` ❌ ${apiPath}`)
.join("\n")}`,
).toEqual([]);
});
it("carries the refusal into the generated document", () => {
const spec = readFileSync(join(ROOT, "docs/api/openapi.yaml"), "utf8");
expect(spec).toContain(SHARING_ACCESS_DENIED_DESCRIPTION.slice(0, 60));
@@ -51,6 +51,31 @@ describe("proxy.ts DEMO_MODE mutation allowlist", () => {
expect(res.status).not.toBe(403);
});
it("permits the three setup-flow writes so the demo can finish onboarding", () => {
// v1.39 (C1) — the design spec requires the demo to complete the same flow
// with preset answers, and none of the three creates health data: closed
// enum answers, the step ledger, and the module map derived from them.
for (const [path, method] of [
["/api/onboarding/answers", "PATCH"],
["/api/onboarding/complete", "POST"],
["/api/onboarding/restart", "POST"],
] as const) {
expect(
proxy(makeRequest(path, method)).status,
`${method} ${path} is blocked in the demo`,
).not.toBe(403);
}
});
it("does not drag in a sibling verb on an allowlisted onboarding path", () => {
// The allowlist pins method per path, so admitting the answers PATCH must
// not open a DELETE somebody adds to that route later.
expect(proxy(makeRequest("/api/onboarding/answers", "DELETE")).status).toBe(
403,
);
expect(proxy(makeRequest("/api/onboarding/step", "POST")).status).toBe(403);
});
it("still blocks a health-data mutation (POST /api/measurements)", () => {
const res = proxy(makeRequest("/api/measurements", "POST"));
expect(res.status).toBe(403);
@@ -0,0 +1,156 @@
/**
* The single-record write ceiling is the one the contract describes.
*
* The batch endpoints have been capped at 60 calls a minute since they were
* written; the per-record creates a naive or older client loops over were not
* capped at all, which is backwards — the batch endpoint is the one a
* well-behaved client uses. Eleven of them share one generous per-account
* bucket now, and the operations publish its name and its cap so a client that
* meets the ceiling can read which one it met.
*
* A published number is only worth reading if it cannot drift from the code, so
* this pulls the bucket key and the limit out of `prisma.$queryRaw` — the same
* technique `rate-limit-auth-surface.test.ts` uses — and holds the emitted
* OpenAPI description to what came back. Reading both from the same constant
* would prove nothing: the sentence is written by hand, deliberately, and this
* is what keeps the hand-written half honest.
*
* The route half is structural: every route in the list below calls the shared
* helper. Its limit is that it reads the file rather than the running handler,
* so a call placed after an early return would still count as present.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
RECORD_WRITE_BUCKET_PREFIX,
RECORD_WRITE_LIMIT,
RECORD_WRITE_WINDOW_MS,
checkRecordWriteRateLimit,
} from "@/lib/rate-limit";
import { buildOpenApiDocument } from "@/lib/openapi/registry";
import { recordWriteRateLimitResponse } from "@/lib/openapi/routes/shared";
vi.mock("@/lib/db", () => ({ prisma: { $queryRaw: vi.fn() } }));
import { prisma } from "@/lib/db";
/**
* The single-record writes on the shared bucket, paired with the operation
* that publishes it. Hand-listed on purpose: the point of the list is that
* adding a create without the limiter is a decision somebody has to make, and
* a list derived from the routes that already call it could not notice one.
*/
const ON_THE_SHARED_BUCKET: Array<[route: string, openApiPath: string]> = [
["src/app/api/measurements/route.ts", "/api/measurements"],
["src/app/api/mood-entries/route.ts", "/api/mood-entries"],
["src/app/api/medications/intake/route.ts", "/api/medications/intake"],
[
"src/app/api/medications/[id]/intake/route.ts",
"/api/medications/{id}/intake",
],
["src/app/api/labs/route.ts", "/api/labs"],
[
"src/app/api/custom-metrics/[id]/entries/route.ts",
"/api/custom-metrics/{id}/entries",
],
["src/app/api/cycle/day-logs/route.ts", "/api/cycle/day-logs"],
["src/app/api/vaccinations/route.ts", "/api/vaccinations"],
["src/app/api/allergies/route.ts", "/api/allergies"],
["src/app/api/biomarkers/route.ts", "/api/biomarkers"],
["src/app/api/encounters/route.ts", "/api/encounters"],
];
/** The published sentence, read from the object the route modules spread. */
const published = recordWriteRateLimitResponse["429"].description;
beforeEach(() => {
vi.mocked(prisma.$queryRaw).mockReset();
vi.mocked(prisma.$queryRaw).mockResolvedValue([
{ count: 1, reset_at: new Date(Date.now() + 60_000) },
] as never);
});
describe("the shared single-record write bucket", () => {
it("keys on the account under the name the contract publishes", async () => {
await checkRecordWriteRateLimit("account-7");
const call = vi.mocked(prisma.$queryRaw).mock.calls.at(-1)!;
// `$queryRaw\`INSERT … VALUES (${key}, …)\`` lands the key at index 1 and
// the window interval at index 2.
expect(call[1]).toBe("record-write:account-7");
expect(call[2]).toBe(`${RECORD_WRITE_WINDOW_MS} milliseconds`);
expect(published).toContain("`record-write:<accountId>`");
expect(`${RECORD_WRITE_BUCKET_PREFIX}:account-7`).toBe(call[1]);
});
it("refuses at the cap the contract publishes", async () => {
vi.mocked(prisma.$queryRaw).mockResolvedValueOnce([
{ count: RECORD_WRITE_LIMIT, reset_at: new Date(Date.now() + 60_000) },
] as never);
await expect(checkRecordWriteRateLimit("account-7")).resolves.toMatchObject(
{ allowed: true, limit: RECORD_WRITE_LIMIT, remaining: 0 },
);
vi.mocked(prisma.$queryRaw).mockResolvedValueOnce([
{
count: RECORD_WRITE_LIMIT + 1,
reset_at: new Date(Date.now() + 60_000),
},
] as never);
await expect(checkRecordWriteRateLimit("account-7")).resolves.toMatchObject(
{ allowed: false },
);
const windowSeconds = RECORD_WRITE_WINDOW_MS / 1000;
expect(published).toContain(
`${RECORD_WRITE_LIMIT} requests per ${windowSeconds} seconds`,
);
});
it("says the headers describe that bucket, and declares them", () => {
expect(Object.keys(recordWriteRateLimitResponse["429"].headers)).toEqual([
"Retry-After",
"X-RateLimit-Limit",
"X-RateLimit-Remaining",
"X-RateLimit-Reset",
]);
expect(published).toContain("Retry-After");
});
it("is called by every route that publishes it", () => {
const missing = ON_THE_SHARED_BUCKET.filter(([route]) => {
const src = readFileSync(join(process.cwd(), route), "utf8");
return !src.includes("checkRecordWriteRateLimit(");
}).map(([route]) => route);
expect(missing).toEqual([]);
});
it("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("is published on every operation that answers it", () => {
const doc = buildOpenApiDocument() as {
paths: Record<
string,
{ post?: { responses?: Record<string, { description?: string }> } }
>;
};
const unpublished = ON_THE_SHARED_BUCKET.filter(
([, path]) =>
doc.paths[path]?.post?.responses?.["429"]?.description !== published,
).map(([, path]) => path);
expect(unpublished).toEqual([]);
});
});
@@ -161,12 +161,11 @@ describe("what the canonical readers make of the same payload", () => {
expect(capabilities.sections).toEqual(fixture.sections);
expect(capabilities.recordKind).toBe(fixture.recordKind);
expect(capabilities.inSharedRecord).toBe(true);
// Resolved server-side and rendered, never re-derived: `canAdd` follows
// `canWrite`, and `canManage` follows the published `manageableDomains`
// list rather than the level — a delegate's management reach is the
// server's answer, section by section.
// Resolved server-side and rendered, never re-derived: `canWrite` is the
// published level, and both per-section answers follow the published
// lists rather than the level — a delegate's reach is the server's
// answer, section by section.
expect(capabilities.canWrite).toBe(fixture.canWrite);
expect(capabilities.canAdd).toBe(fixture.canWrite);
const manageable = active?.manageableDomains ?? [];
expect(capabilities.canManage).toBe(manageable.length > 0);
expect(manageable.length > 0).toBe(fixture.level === "manage");
@@ -109,6 +109,10 @@ const PINNED_AFFORDANCES: Record<
"src/components/admin/module-availability-section.tsx": {
"toast.success": 1,
},
// Reads a ledger, writes nothing: the wash marks how fresh one account's
// off-host copy is, and the state it paints green came out of the database
// rather than out of a request this card just made.
"src/components/admin/offhost-backups-section.tsx": { "text-success": 1 },
"src/components/admin/recent-audit-preview.tsx": {
CheckCircle2: 2,
"text-success": 1,
@@ -0,0 +1,265 @@
/**
* Every 422 raised by a Zod `safeParse` names the fields it refused.
*
* `returnAllZodIssues` / `apiValidationError` emit `details.issues` — one entry
* per rejected field — so a client with three bad fields fixes all three in one
* round-trip instead of three. Most routes use them. Fifty-nine refusal sites
* did not: they returned the correct 422 with a single opaque sentence, and a
* client could not map the failure onto a field. Twelve of those put a dotted
* machine token in `error` instead of a sentence, so `error` was sometimes text
* to show a person and sometimes a token that must not be shown, with nothing
* in the payload to tell them apart.
*
* The matcher is per-site, not per-file, because per-file is what let this
* grow: a route that used the helper once and a bare `apiError(..., 422)`
* somewhere else in the same file looked compliant to a `grep -l` sweep. So
* every `if (!<result>.success)` block whose identifier came from a `safeParse`
* in that file is read on its own, and only the blocks that answer 422 are
* judged — a 400, a 401 or a 403 raised off a parse result is a different
* refusal and is left alone.
*
* The second half of the same audit item is the dotted token in `error`, and
* it is checked by its own sweep at the bottom of this file rather than by the
* per-site matcher above: `report-selection.leaves.unknown` was decided by
* `if (!minted.ok)` rather than by a parse result, so the block matcher could
* not see it while the string it answered with was the defect itself. The
* token sweep reads the refused string, not the condition that chose it, so a
* site cannot hide behind the shape of its own `if`.
*
* Its limits, stated so nobody reads more into a green run than is there:
* a refusal built somewhere other than the `if (!x.success)` block that decided
* it — a helper called with the error, a status assembled from a variable — is
* invisible to the issue-list half, and so is a route that hands the `ZodError`
* to shared code outside `src/app/api`. It catches the shape this defect
* actually took, and the count assertions below are what keep it from passing
* on a matcher that silently stopped matching anything.
*/
import { readFileSync, readdirSync } from "node:fs";
import { join, relative, sep } from "node:path";
import { describe, expect, it } from "vitest";
const API_ROOT = join(process.cwd(), "src", "app", "api");
/**
* Routes that answer a shape refusal without the issue list, each with the
* reason it must differ. Empty today: every 422 raised off a `safeParse` under
* `src/app/api` carries `details.issues`. An entry here is a decision, not a
* backlog item — write why the list cannot ship, not when it will.
*/
const ALLOWLIST: Record<string, string> = {};
/** Every `route.ts` under `src/app/api`, tests excluded. */
function routeFiles(dir: string): string[] {
const found: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === "__tests__") continue;
found.push(...routeFiles(full));
continue;
}
if (entry.name === "route.ts") found.push(full);
}
return found;
}
/**
* The `{ … }` (or single statement) that follows an `if (…)`, brace-balanced.
*
* A refusal is often one statement with no braces — `if (!parsed.success)
* return apiError("Invalid data", 422);` — so both forms are read.
*/
function refusalBlock(src: string, afterCondition: number): string {
let i = afterCondition;
while (i < src.length && /\s/.test(src[i])) i++;
if (src[i] === "{") {
let depth = 0;
for (let j = i; j < src.length; j++) {
if (src[j] === "{") depth++;
else if (src[j] === "}") {
depth--;
if (depth === 0) return src.slice(i, j + 1);
}
}
return src.slice(i);
}
const end = src.indexOf(";", i);
return src.slice(i, end === -1 ? src.length : end + 1);
}
interface RefusalSite {
route: string;
line: number;
carriesIssues: boolean;
}
/** Every 422 refusal decided by a `safeParse` result, across the route tree. */
function scanRefusalSites(): RefusalSite[] {
const sites: RefusalSite[] = [];
for (const file of routeFiles(API_ROOT)) {
const src = readFileSync(file, "utf8");
// Identifiers bound to a parse result. Both call styles are in the tree:
// `schema.safeParse(body)` and the standalone `z.safeParse(schema, body)`.
const parsed = new Set<string>();
for (const m of src.matchAll(
/(?:const|let)\s+([A-Za-z0-9_$]+)\s*=\s*(?:await\s+)?[^;]*?safeParse\s*\(/g,
)) {
parsed.add(m[1]);
}
if (parsed.size === 0) continue;
for (const m of src.matchAll(
/if\s*\(\s*!\s*([A-Za-z0-9_$]+)\.success\s*\)/g,
)) {
if (!parsed.has(m[1])) continue;
const block = refusalBlock(src, (m.index ?? 0) + m[0].length);
// `returnAllZodIssues` and `apiValidationError` answer 422 unless told
// otherwise, so a block that calls one is a 422 site whether or not the
// number is spelled: a future `refuse422(parsed.error)` helper would
// otherwise be invisible for the same reason.
const carriesIssues = /returnAllZodIssues|apiValidationError/.test(block);
if (!/\b422\b/.test(block) && !carriesIssues) continue;
if (
/\b(?:400|401|403|409|413|415|429|500|502)\b/.test(block) &&
!/\b422\b/.test(block)
)
continue;
sites.push({
route: relative(process.cwd(), file).split(sep).join("/"),
line: src.slice(0, m.index).split("\n").length,
carriesIssues,
});
}
}
return sites;
}
describe("Zod shape refusals carry the issue list", () => {
const sites = scanRefusalSites();
it("finds the refusal sites it is meant to judge", () => {
// A matcher that stops matching would otherwise report a clean zero it did
// not earn. Two hundred and forty-eight sites when this was written, so
// the old floor of 40 would have survived losing five matches in six.
expect(sites.length).toBeGreaterThan(200);
});
it("answers every 422 with details.issues", () => {
const missing = sites
.filter((site) => !site.carriesIssues && !(site.route in ALLOWLIST))
.map((site) => `${site.route}:${site.line}`);
expect(missing).toEqual([]);
});
it("keeps no stale allowlist entries", () => {
const stale = Object.keys(ALLOWLIST).filter(
(route) => !sites.some((site) => site.route === route),
);
expect(stale).toEqual([]);
});
});
/**
* The dotted token in `error`, wherever it is refused from.
*
* `error` is the field a client shows a person. A 422 that puts
* `report-selection.leaves.unknown` there hands the UI a string it must not
* render and gives it nothing else to branch on. That site was decided by
* `if (!minted.ok)`, not by a parse result, so the per-site matcher above ran
* past it — which is the argument for reading the refused string directly:
* whatever chose the refusal, the string is the defect.
*
* The inventory below is frozen and may only shrink. Each entry says why the
* token is still the whole `error` string; a new one fails until somebody
* either writes a sentence or writes the reason.
*/
const TOKEN_IN_ERROR_422: Record<string, string> = {
"src/app/api/auth/me/coach-prefs/route.ts coach-prefs.body.invalid_shape":
"The status does not move for a shape refusal, so moving the string too would be an unforced second wire change; `meta.errorCode` now carries the same token.",
"src/app/api/auth/me/report-selection/route.ts report-selection.body.invalid_shape":
"Same shape refusal, same reason.",
"src/app/api/auth/me/source-priority/route.ts source-priority.body.invalid_shape":
"Same shape refusal, same reason.",
"src/app/api/insights/chat/messages/[id]/feedback/route.ts feedback.body.invalid":
"Same shape refusal, same reason.",
"src/app/api/insights/chat/route.ts coach.request.invalid":
"Same shape refusal, same reason.",
"src/app/api/auth/me/health-score-config/route.ts health-score-config.body.invalid_json":
"The published 422 description spells this exact body out as what a non-JSON request earns. It is the one operation whose contract promises the token, so the token is what it answers with.",
"src/app/api/coach/reminder-suggestions/route.ts coach.suggestion.unknownCadence":
"Predates this sweep and carries no `meta.errorCode` yet; the Coach reminder surfaces move as one piece or not at all.",
"src/app/api/coach/reminders/route.ts coach.reminder.invalidWhen":
"Predates this sweep, same surface.",
"src/app/api/coach/reminders/[id]/route.ts coach.reminder.invalidWhen":
"Predates this sweep, same surface.",
"src/app/api/coach/suggested-actions/route.ts coach.action.unknownInterval":
"Predates this sweep, same surface.",
"src/app/api/coach/suggested-actions/route.ts coach.reminder.invalidWhen":
"Predates this sweep, same surface.",
};
/** A dotted machine token: no spaces, at least one dot, no sentence in sight. */
const DOTTED_TOKEN = /^[a-z][A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]+)+$/;
/** Every `.ts` under `src/app/api`, tests excluded — not only `route.ts`. */
function apiFiles(dir: string): string[] {
const found: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === "__tests__") continue;
found.push(...apiFiles(full));
continue;
}
if (entry.name.endsWith(".ts")) found.push(full);
}
return found;
}
/** Every 422 whose `error` string is a dotted token, keyed `<file> <token>`. */
function tokenRefusals(): string[] {
const found: string[] = [];
for (const file of apiFiles(API_ROOT)) {
const src = readFileSync(file, "utf8");
const route = relative(process.cwd(), file).split(sep).join("/");
const shapes = [
/apiError\(\s*"([^"]+)"\s*,\s*422/g,
/apiValidationError\(\s*"([^"]+)"/g,
/new HttpError\(\s*422\s*,\s*"([^"]+)"/g,
];
for (const shape of shapes) {
for (const m of src.matchAll(shape)) {
if (!DOTTED_TOKEN.test(m[1])) continue;
const key = `${route} ${m[1]}`;
if (!found.includes(key)) found.push(key);
}
}
}
return found;
}
describe("a 422 does not answer with a machine token where the sentence goes", () => {
const refusals = tokenRefusals();
it("finds the sites it is meant to judge", () => {
// Eleven when this was written, twelve before the report-selection leaf
// refusal was rewritten. A matcher that stopped matching would agree with
// an empty inventory.
expect(refusals.length).toBeGreaterThan(8);
});
it("keeps the token in `error` only where a reason is written down", () => {
const unexplained = refusals
.filter((key) => !(key in TOKEN_IN_ERROR_422))
.sort();
expect(unexplained).toEqual([]);
});
it("keeps no stale inventory entry", () => {
const stale = Object.keys(TOKEN_IN_ERROR_422)
.filter((key) => !refusals.includes(key))
.sort();
expect(stale).toEqual([]);
});
});
+4
View File
@@ -25,6 +25,7 @@ import { CoachFeedbackSection } from "@/components/admin/coach-feedback-section"
import { ApiTokenOverviewSection } from "@/components/admin/api-token-overview-section";
import { AppLogPreviewSection } from "@/components/admin/app-log-preview-section";
import { BackupsSection } from "@/components/admin/backups-section";
import { OffhostBackupsSection } from "@/components/admin/offhost-backups-section";
import { DangerZoneSection } from "@/components/admin/danger-zone-section";
import { EncryptionSection } from "@/components/admin/encryption-section";
import { GeneralSettingsSection } from "@/components/admin/general-settings-section";
@@ -144,9 +145,12 @@ export function AdminSectionRenderer({
</SectionFrame>
);
case "backups":
// Two cards, two independent failure modes: the copies held in this
// database, then the copies that reached the operator's bucket.
return (
<SectionFrame>
<BackupsSection />
<OffhostBackupsSection />
</SectionFrame>
);
case "encryption":
@@ -259,6 +259,10 @@ function sourceClient() {
},
]),
},
// The setup answers. Absent for this fixture's account, which is the
// ordinary case: the export has to answer for a record that never entered
// the flow without inventing one.
onboardingRecord: { findUnique: vi.fn().mockResolvedValue(null) },
// The score as it was SHOWN on a local day. Not recomputable: today's
// number always comes from today's rows, so once the readings behind this
// day moved, nothing can reproduce what it said.
@@ -57,6 +57,7 @@ import {
import { restoreProfileData } from "@/lib/export/profile-backup";
import { restoreIntradayProfileData } from "@/lib/export/intraday-profile-backup";
import { restoreHealthScoreData } from "@/lib/export/health-score-backup";
import { restoreOnboardingData } from "@/lib/export/onboarding-backup";
import { restoreVisitsData } from "@/lib/export/visits-backup";
import { restoreVaccinationsData } from "@/lib/export/vaccinations-backup";
import { restoreSensitiveData } from "@/lib/export/sensitive-backup";
@@ -111,6 +112,7 @@ interface RestoreResponse {
correlationPatterns: number;
intradayProfiles: number;
healthScoreRecords: number;
onboardingRecords: number;
practitioners: number;
encounters: number;
encounterLinks: number;
@@ -1377,6 +1379,14 @@ const handler = apiHandler(
payload,
);
// The needs-based setup answers. Both ends live in
// `src/lib/export/onboarding-backup.ts`, same as the three above.
const onboardingCleared = await restoreOnboardingData(
tx,
ownerId,
payload,
);
const biomarkerByName = new Map<string, string>();
const restoredBiomarkerIds = new Set<string>();
for (const biomarker of payload.biomarkers) {
@@ -1979,6 +1989,7 @@ const handler = apiHandler(
correlationPatterns: profileCleared.correlationPatterns,
intradayProfiles: intradayCleared.intradayProfiles,
healthScoreRecords: healthScoreCleared.healthScoreRecords,
onboardingRecords: onboardingCleared.onboardingRecords,
practitioners: visitsCleared.practitioners,
encounters: visitsCleared.encounters,
encounterLinks: visitsCleared.encounterLinks,
@@ -0,0 +1,199 @@
/**
* `GET /api/admin/backups` — the off-host leg.
*
* The rows the weekly pass writes into this database were already covered by
* the page that lists them. What was not covered is the question this response
* now answers: per account, when did an encrypted copy last reach the
* operator's bucket, and is that inside what the nightly schedule promises.
*
* The bucket is never touched here, and that is the assertion that matters —
* the verdicts come from the ledger the worker writes, so an operator whose
* worker holds no listing grant on the bucket still gets a true answer.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAdmin: vi.fn(async () => ({ user: { id: "admin-1" } })),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/jobs/job-failures", () => ({
readLastQueueRun: vi.fn(async () => null),
}));
const queryRawMock = vi.fn();
const findManyMock = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
$queryRaw: (...a: unknown[]) => queryRawMock(...a),
user: { findMany: (...a: unknown[]) => findManyMock(...a) },
},
}));
const configuredMock = vi.fn(() => true);
vi.mock("@/lib/jobs/offhost-backup", () => ({
offhostBackupConfigured: () => configuredMock(),
}));
import { GET } from "../route";
import type { BackupsList } from "@/types/backups";
const HOUR = 3_600_000;
async function read(): Promise<BackupsList> {
const res = await (GET as unknown as () => Promise<Response>)();
expect(res.status).toBe(200);
return (await res.json()).data as BackupsList;
}
beforeEach(() => {
vi.clearAllMocks();
configuredMock.mockReturnValue(true);
queryRawMock.mockResolvedValue([]);
findManyMock.mockResolvedValue([]);
});
describe("GET /api/admin/backups — off-host freshness", () => {
it("rates every account against the nightly schedule without listing the bucket", async () => {
const now = Date.now();
findManyMock.mockResolvedValue([
{
id: "u1",
username: "account-one",
offhostBackupState: {
lastAttemptAt: new Date(now - 3 * HOUR),
lastSuccessAt: new Date(now - 3 * HOUR),
// The column is BigInt — an object may be 80 GB — and the wire type
// is a plain number.
sizeBytes: BigInt(4096),
},
},
{
id: "u2",
username: "account-two",
offhostBackupState: {
lastAttemptAt: new Date(now - 31 * HOUR),
lastSuccessAt: new Date(now - 31 * HOUR),
sizeBytes: BigInt(8192),
},
},
{
id: "u3",
username: "account-three",
offhostBackupState: {
lastAttemptAt: new Date(now - 100 * HOUR),
lastSuccessAt: new Date(now - 100 * HOUR),
sizeBytes: BigInt(128),
},
},
{
id: "u4",
username: "account-four",
// Walked by a run, nothing landed.
offhostBackupState: {
lastAttemptAt: new Date(now - 2 * HOUR),
lastSuccessAt: null,
sizeBytes: null,
},
},
// No run has recorded this one at all — the state of every account on
// the morning the ledger ships.
{ id: "u5", username: "account-five", offhostBackupState: null },
]);
const body = await read();
expect(body.offhost.configured).toBe(true);
expect(body.offhost.periodHours).toBe(24);
// Worst first, alphabetical within a verdict. The database hands them
// back by username; the one account that needs attention must not be
// wherever the alphabet leaves it on a hundred-account cohort.
expect(
body.offhost.rows.map((row) => [row.username, row.freshness]),
).toEqual([
["account-three", "stale"],
["account-two", "due"],
["account-four", "never"],
["account-five", "unknown"],
["account-one", "fresh"],
]);
const byName = new Map(body.offhost.rows.map((row) => [row.username, row]));
expect(byName.get("account-one")?.sizeBytes).toBe(4096);
expect(typeof byName.get("account-one")?.sizeBytes).toBe("number");
expect(byName.get("account-one")?.ageHours).toBe(3);
expect(byName.get("account-four")?.lastSuccessAt).toBeNull();
expect(byName.get("account-four")?.sizeBytes).toBeNull();
expect(byName.get("account-four")?.lastAttemptAt).not.toBeNull();
// `unknown` is the honest one: the ledger has no history for this
// account, which is not the same claim as "the bucket is empty".
expect(byName.get("account-five")?.lastAttemptAt).toBeNull();
});
it("says so when neither the environment nor the ledger knows of any upload", async () => {
configuredMock.mockReturnValue(false);
findManyMock.mockResolvedValue([
{ id: "u1", username: "account-one", offhostBackupState: null },
]);
const body = await read();
expect(body.offhost).toEqual({
configured: false,
periodHours: 24,
rows: [],
});
});
it("lets a ledger row outrank this process's own environment", async () => {
// A hand-rolled split deployment can give BACKUP_S3_* to the worker and
// not to the web process. The rows are proof uploads are happening;
// saying "nothing leaves this host" over them would be this process
// reporting on a job it cannot see.
configuredMock.mockReturnValue(false);
findManyMock.mockResolvedValue([
{
id: "u1",
username: "account-one",
offhostBackupState: {
lastAttemptAt: new Date(),
lastSuccessAt: new Date(),
sizeBytes: BigInt(4096),
},
},
]);
const body = await read();
expect(body.offhost.configured).toBe(true);
expect(body.offhost.rows.map((row) => row.freshness)).toEqual(["fresh"]);
});
it("never ships a credential or an object key with the verdicts", async () => {
findManyMock.mockResolvedValue([
{
id: "u1",
username: "account-one",
offhostBackupState: {
lastAttemptAt: new Date(),
lastSuccessAt: new Date(),
sizeBytes: BigInt(1),
},
},
]);
const body = await read();
expect(Object.keys(body.offhost.rows[0] ?? {}).sort()).toEqual([
"ageHours",
"freshness",
"lastAttemptAt",
"lastSuccessAt",
"sizeBytes",
"userId",
"username",
]);
expect(JSON.stringify(body.offhost)).not.toMatch(/bucket|endpoint|secret/i);
});
});
+98 -2
View File
@@ -13,6 +13,12 @@
* Without those two the page cannot tell a working backup from one that
* stopped six weeks ago — every row it lists has a perfectly ordinary
* timestamp either way.
*
* And alongside both, the off-host leg: one row per account saying when this
* host last put that account's encrypted copy in the operator's bucket and how
* stale that is against the nightly schedule. It comes from the ledger the
* worker writes, never from a bucket listing — the answer has to hold on a host
* whose worker holds no listing grant on the bucket.
*/
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
@@ -21,10 +27,20 @@ import { annotate } from "@/lib/logging/context";
import { DATA_BACKUP_QUEUE } from "@/lib/jobs/data-backup-policy";
import { readLastQueueRun } from "@/lib/jobs/job-failures";
import { summariseBackupSchedule } from "@/lib/jobs/backup-schedule-status";
import { offhostBackupConfigured } from "@/lib/jobs/offhost-backup";
import {
classifyOffhostBackup,
OFFHOST_BACKUP_PERIOD_HOURS,
type OffhostBackupFreshness,
} from "@/lib/jobs/offhost-backup-freshness";
// v1.4.41 W-ORG — `BackupRow` / `BackupsList` moved to `src/types/backups.ts`
// so callers (in particular `components/admin/backups-section.tsx`) don't
// have to reach across the component → route-handler layer boundary.
import type { BackupRow, BackupsList } from "@/types/backups";
import type {
BackupRow,
BackupsList,
OffhostAccountRow,
} from "@/types/backups";
export const dynamic = "force-dynamic";
@@ -67,6 +83,79 @@ export const GET = apiHandler(async () => {
createdAt: row.created_at.toISOString(),
}));
// The off-host leg, per account, read from the ledger the nightly worker
// writes rather than from the bucket. The page must be able to answer "which
// account has no recent copy off-host" on a host whose worker holds the
// three grants the runbook documents and no listing grant at all, and a
// listing call from a page render would put the operator's credentials on
// the request path of every page view.
const now = new Date();
const offhostAccounts = await prisma.user.findMany({
select: {
id: true,
username: true,
offhostBackupState: {
select: {
lastAttemptAt: true,
lastSuccessAt: true,
sizeBytes: true,
},
},
},
orderBy: { username: "asc" },
});
// Evidence beats this process's environment. `offhostBackupConfigured()`
// reads the variables of whichever process serves this request, and a
// hand-rolled split that gives `BACKUP_S3_*` only to the worker would make
// the web process say "nothing leaves this host" over a ledger full of
// fresh rows. A row in that table is proof an upload happened; no
// environment read can outrank it.
const configured =
offhostBackupConfigured() ||
offhostAccounts.some((account) => account.offhostBackupState !== null);
const offhostRows: OffhostAccountRow[] = offhostAccounts.map((account) => {
const state = account.offhostBackupState;
const verdict = classifyOffhostBackup({
lastAttemptAt: state?.lastAttemptAt ?? null,
lastSuccessAt: state?.lastSuccessAt ?? null,
now,
});
return {
userId: account.id,
username: account.username,
lastAttemptAt: state?.lastAttemptAt.toISOString() ?? null,
lastSuccessAt: state?.lastSuccessAt?.toISOString() ?? null,
// BigInt column -> number on the wire. The value is bytes of one
// object, capped by the uploader at 80 GB, so it is nowhere near
// Number.MAX_SAFE_INTEGER and JSON has no BigInt.
sizeBytes:
state?.sizeBytes === null || state?.sizeBytes === undefined
? null
: Number(state.sizeBytes),
ageHours: verdict.ageHours,
freshness: verdict.freshness,
};
});
// Worst first. Sorting by username puts the one account that needs
// attention wherever the alphabet happens to leave it, which on a
// hundred-account cohort is the same "one bad row hides in the crowd"
// problem this card was built to end, moved out of a count and into a list.
const FRESHNESS_ORDER: Record<OffhostBackupFreshness, number> = {
stale: 0,
due: 1,
never: 2,
unknown: 3,
fresh: 4,
};
offhostRows.sort(
(a, b) =>
FRESHNESS_ORDER[a.freshness] - FRESHNESS_ORDER[b.freshness] ||
a.username.localeCompare(b.username),
);
const payload: BackupsList = {
rows: list,
// Matches the retention window the backup-prune job enforces so the
@@ -77,8 +166,15 @@ export const GET = apiHandler(async () => {
.filter((row) => row.type === SCHEDULED_BACKUP_TYPE)
.map((row) => row.created_at),
lastRun: await readLastQueueRun(DATA_BACKUP_QUEUE),
now: new Date(),
now,
}),
offhost: {
configured,
periodHours: OFFHOST_BACKUP_PERIOD_HOURS,
// An unconfigured host has nothing to list, and the card says so rather
// than painting an empty table that reads as "no problems".
rows: configured ? offhostRows : [],
},
};
return apiSuccess(payload);
+37
View File
@@ -16,9 +16,32 @@ import {
invalidateServerDefaultTimezone,
isValidTimezone,
} from "@/lib/tz/resolver";
import { summariseGlitchtipDelivery } from "@/lib/monitoring/glitchtip-delivery";
export const dynamic = "force-dynamic";
/** The delivery verdict, flattened onto the settings payload. */
function summariseGlitchtipDeliveryFields(state: {
lastOkAt?: Date | null;
lastFailureAt?: Date | null;
lastFailureReason?: string | null;
}) {
const summary = summariseGlitchtipDelivery(
{
lastOkAt: state.lastOkAt ?? null,
lastFailureAt: state.lastFailureAt ?? null,
lastFailureReason: state.lastFailureReason ?? null,
},
new Date(),
);
return {
glitchtipReportsDelivering: summary.reportsDelivering,
glitchtipEverDelivered: summary.everDelivered,
glitchtipLastFailureReason: summary.lastFailureReason,
glitchtipDeliveryWindowHours: summary.windowHours,
};
}
export const GET = apiHandler(async () => {
await requireAdmin();
annotate({ action: { name: "admin.settings.get" } });
@@ -48,6 +71,15 @@ export const GET = apiHandler(async () => {
glitchtipEnabled: settings?.glitchtipEnabled ?? false,
glitchtipDsn: settings?.glitchtipDsn ?? null,
glitchtipEnvironment: settings?.glitchtipEnvironment ?? "production",
// The delivery outcome, not the intent, and resolved here rather than on
// the client: a DSN that is set and parses says a target was typed, only
// this says something left the host, and whether it left recently enough
// is a question about a clock that belongs with the ledger.
...summariseGlitchtipDeliveryFields({
lastOkAt: settings?.glitchtipLastOkAt ?? null,
lastFailureAt: settings?.glitchtipLastFailureAt ?? null,
lastFailureReason: settings?.glitchtipLastFailureReason ?? null,
}),
reminderLateMinutes: settings?.reminderLateMinutes ?? 120,
reminderMissedMinutes: settings?.reminderMissedMinutes ?? 240,
// Document vault limits (per-file cap + per-user quota default). BigInt
@@ -274,6 +306,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
glitchtipEnabled: settings.glitchtipEnabled,
glitchtipDsn: settings.glitchtipDsn,
glitchtipEnvironment: settings.glitchtipEnvironment ?? "production",
...summariseGlitchtipDeliveryFields({
lastOkAt: settings.glitchtipLastOkAt,
lastFailureAt: settings.glitchtipLastFailureAt,
lastFailureReason: settings.glitchtipLastFailureReason,
}),
reminderLateMinutes: settings.reminderLateMinutes,
reminderMissedMinutes: settings.reminderMissedMinutes,
documentMaxFileBytes: settings.documentMaxFileBytes,
@@ -2,10 +2,12 @@ import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { encrypt } from "@/lib/crypto";
@@ -58,7 +60,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
const parsed = generateSchema.safeParse(body ?? {});
if (!parsed.success) {
return apiError("Invalid request", 422);
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
const existing = await prisma.appSettings.findUnique({
+12 -2
View File
@@ -6,7 +6,13 @@ import {
type AITestOverride,
} from "@/lib/ai/provider";
import { singleUserTurn } from "@/lib/ai/types";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import {
apiError,
apiSuccess,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import {
@@ -69,7 +75,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (error) return error;
const parsed = aiTestOverrideSchema.safeParse(data);
if (!parsed.success) {
return apiError("Invalid override payload", 422);
return apiValidationError(
"Invalid override payload",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
override = parsed.data;
}
+13 -1
View File
@@ -15,11 +15,13 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
import { withIdempotency } from "@/lib/idempotency";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import {
@@ -84,7 +86,17 @@ async function postAllergy(request: NextRequest): Promise<Response> {
// A READ or WRITE delegate reaches neither: the destination is not on their
// Settings list, and this line refuses them with `sharing.access.denied`
// regardless.
const { user } = await requireRecordAuth("manage", "profile");
const { user, actor } = await requireRecordAuth("manage", "profile");
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
// batch siblings have always been capped; the per-record creates a looping
// client hits were not.
const writeRl = await checkRecordWriteRateLimit(actor.id);
if (!writeRl.allowed) {
return apiError("Too many writes, try again later", 429, {
errorCode: "record_write.rate_limited",
});
}
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
+11 -2
View File
@@ -37,7 +37,12 @@
*/
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import {
apiSuccess,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
@@ -80,7 +85,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
return apiError("identifier required", 422);
return apiValidationError(
"identifier required",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
const identifier = parsed.data.identifier;
+11 -2
View File
@@ -3,7 +3,12 @@ import { loginPasswordSchema } from "@/lib/validations/auth";
import { verifyPassword } from "@/lib/auth/password";
import { auditLog } from "@/lib/auth/audit";
import { hashToken } from "@/lib/auth/hmac";
import { apiError, safeJson } from "@/lib/api-response";
import {
apiError,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { NextRequest, NextResponse } from "next/server";
@@ -55,7 +60,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
const parsed = loginPasswordSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid credentials", 422);
return apiValidationError(
"Invalid credentials",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
const { email, password } = parsed.data;
+21 -4
View File
@@ -15,8 +15,13 @@
* helper. The Coach prompt builder + snapshot builder both read this
* row on every turn — there's no caching layer to invalidate.
*/
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import {
@@ -57,7 +62,9 @@ export const PUT = apiHandler(async (req: Request) => {
try {
body = await req.json();
} catch {
throw new HttpError(422, "coach-prefs.body.invalid_json");
return apiError("Invalid JSON body", 400, {
errorCode: "coach-prefs.body.invalid_json",
});
}
// v1.32.22 (M4) — pull the optimistic-concurrency base token off before the
@@ -74,7 +81,17 @@ export const PUT = apiHandler(async (req: Request) => {
action: { name: "auth.me.coach-prefs.put.invalid" },
meta: { issues: parsed.error.issues.length },
});
throw new HttpError(422, "coach-prefs.body.invalid_shape");
// The dotted token keeps its place in `error`: this refusal keeps its 422,
// so moving the string would be a wire change with nothing forcing it —
// the `invalid_json` siblings moved only because their status moved to 400
// anyway. `meta.errorCode` publishes the same token in the field a machine
// code belongs in, and `details.issues` names the fields that were refused.
return apiValidationError(
"coach-prefs.body.invalid_shape",
sanitiseZodIssues(parsed.error.issues),
422,
{ errorCode: "coach-prefs.body.invalid_shape" },
);
}
// Persist the canonical defaulted form so the column shape stays
+1 -1
View File
@@ -65,7 +65,7 @@ export const PATCH = apiHandler(async (req: Request) => {
try {
body = await req.json();
} catch {
return apiError("Invalid JSON body", 422, {
return apiError("Invalid JSON body", 400, {
errorCode: "cycle-prefs.body.invalid_json",
});
}
@@ -212,7 +212,11 @@ describe("PATCH /api/auth/me/disable-coach", () => {
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("rejects malformed JSON with 422", async () => {
// 400, not the 422 this used to pin. A body that will not parse is a
// client-side serialisation fault, and `safeJson` plus roughly two hundred
// and twenty other routes have always answered 400 for it; the dotted token
// moved to `meta.errorCode`, which is where a machine code belongs.
it("rejects malformed JSON with 400", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const req = new Request("http://localhost/api/auth/me/disable-coach", {
@@ -222,7 +226,11 @@ describe("PATCH /api/auth/me/disable-coach", () => {
});
const res = await (PATCH as (r: Request) => Promise<Response>)(req);
expect(res.status).toBe(422);
expect(res.status).toBe(400);
expect(await res.json()).toMatchObject({
error: "Invalid JSON body",
meta: { errorCode: "disable-coach.body.invalid_json" },
});
expect(prisma.user.update).not.toHaveBeenCalled();
});
+4 -2
View File
@@ -21,7 +21,7 @@
*/
import { z } from "zod";
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
@@ -78,7 +78,9 @@ export const PATCH = apiHandler(async (req: Request) => {
try {
body = await req.json();
} catch {
throw new HttpError(422, "disable-coach.body.invalid_json");
return apiError("Invalid JSON body", 400, {
errorCode: "disable-coach.body.invalid_json",
});
}
const parsed = patchBodySchema.safeParse(body);
+7 -1
View File
@@ -20,8 +20,10 @@ import {
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
@@ -63,7 +65,11 @@ export const POST = apiHandler(async (req: Request) => {
}
const parsed = mfaDisableSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid request", 422);
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
const factor = await verifyMfaFactor(
@@ -25,8 +25,10 @@ import {
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
@@ -78,7 +80,11 @@ export const POST = apiHandler(async (req: Request) => {
}
const parsed = totpConfirmSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid code", 422);
return apiValidationError(
"Invalid code",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
// Decrypt is fail-closed — a missing/rotated key throws rather than
@@ -13,8 +13,10 @@ import { apiHandler, requireMfaManagementAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
@@ -39,7 +41,11 @@ export const PATCH = apiHandler(
const parsed = mfaWebauthnRenameSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid request", 422);
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
const existing = await prisma.webauthnMfaCredential.findUnique({
@@ -11,8 +11,10 @@ import { apiHandler, requireMfaManagementAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
@@ -34,7 +36,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
const parsed = mfaWebauthnRegisterVerifySchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid request", 422);
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
const { challengeId, credential, name } = parsed.data;
@@ -285,7 +285,11 @@ describe("PATCH /api/auth/me/modules", () => {
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("rejects malformed JSON with 422", async () => {
// 400, not the 422 this used to pin. A body that will not parse is a
// client-side serialisation fault, and `safeJson` plus roughly two hundred
// and twenty other routes have always answered 400 for it; the dotted token
// moved to `meta.errorCode`, which is where a machine code belongs.
it("rejects malformed JSON with 400", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const req = new Request("http://localhost/api/auth/me/modules", {
method: "PATCH",
@@ -293,7 +297,11 @@ describe("PATCH /api/auth/me/modules", () => {
headers: { "Content-Type": "application/json" },
});
const res = await (PATCH as (r: Request) => Promise<Response>)(req);
expect(res.status).toBe(422);
expect(res.status).toBe(400);
expect(await res.json()).toMatchObject({
error: "Invalid JSON body",
meta: { errorCode: "modules.body.invalid_json" },
});
expect(prisma.user.update).not.toHaveBeenCalled();
});
+4 -2
View File
@@ -25,7 +25,7 @@
* carries it. The semantics are a DISABLED allowlist: a key set to
* `false` disables that module; `true` (or absence) leaves it enabled.
*/
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
@@ -84,7 +84,9 @@ export const PATCH = apiHandler(async (req: Request) => {
try {
body = await req.json();
} catch {
throw new HttpError(422, "modules.body.invalid_json");
return apiError("Invalid JSON body", 400, {
errorCode: "modules.body.invalid_json",
});
}
// v1.32.22 (M3) — strip the optimistic-concurrency base token BEFORE the
@@ -324,7 +324,11 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
);
});
it("rejects malformed JSON with 422", async () => {
// 400, not the 422 this used to pin. A body that will not parse is a
// client-side serialisation fault, and `safeJson` plus roughly two hundred
// and twenty other routes have always answered 400 for it; the dotted token
// moved to `meta.errorCode`, which is where a machine code belongs.
it("rejects malformed JSON with 400", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const req = new Request("http://localhost/api/auth/me/notification-prefs", {
@@ -334,7 +338,11 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
});
const res = await (PATCH as (r: Request) => Promise<Response>)(req);
expect(res.status).toBe(422);
expect(res.status).toBe(400);
expect(await res.json()).toMatchObject({
error: "Invalid JSON body",
meta: { errorCode: "notification-prefs.body.invalid_json" },
});
expect(prisma.user.update).not.toHaveBeenCalled();
});
@@ -23,7 +23,7 @@
* extra round-trip. Rate-limit is intentionally generous (60/min) —
* matches the disable-coach route which is the closest analogue.
*/
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
@@ -86,7 +86,9 @@ export const PATCH = apiHandler(async (req: Request) => {
try {
body = await req.json();
} catch {
throw new HttpError(422, "notification-prefs.body.invalid_json");
return apiError("Invalid JSON body", 400, {
errorCode: "notification-prefs.body.invalid_json",
});
}
// v1.32.22 (M1) — pull the optimistic-concurrency base token off the body
+33 -5
View File
@@ -16,8 +16,14 @@
* Replaces `GET`/`PUT /api/auth/me/doctor-report-prefs`, whose column nothing
* outside that route ever read.
*/
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { apiSuccess, getClientIp } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma, toJson } from "@/lib/db";
@@ -48,7 +54,9 @@ export const PUT = apiHandler(async (req: Request) => {
try {
body = await req.json();
} catch {
throw new HttpError(422, "report-selection.body.invalid_json");
return apiError("Invalid JSON body", 400, {
errorCode: "report-selection.body.invalid_json",
});
}
const parsed = savedReportProfileSchema.safeParse(body ?? {});
@@ -57,7 +65,17 @@ export const PUT = apiHandler(async (req: Request) => {
action: { name: "auth.me.report-selection.put.invalid" },
meta: { issues: parsed.error.issues.length },
});
throw new HttpError(422, "report-selection.body.invalid_shape");
// The dotted token keeps its place in `error`: this refusal keeps its 422,
// so moving the string would be a wire change with nothing forcing it —
// the `invalid_json` siblings moved only because their status moved to 400
// anyway. `meta.errorCode` publishes the same token in the field a machine
// code belongs in, and `details.issues` names the fields that were refused.
return apiValidationError(
"report-selection.body.invalid_shape",
sanitiseZodIssues(parsed.error.issues),
422,
{ errorCode: "report-selection.body.invalid_shape" },
);
}
const minted = selectionFromRequest(parsed.data);
@@ -66,7 +84,17 @@ export const PUT = apiHandler(async (req: Request) => {
action: { name: "auth.me.report-selection.put.invalid" },
meta: { unknownLeaves: minted.error.unknownLeaves },
});
throw new HttpError(422, "report-selection.leaves.unknown");
// Not a Zod refusal, so there is no issue list to carry — but the same
// rule applies: a sentence in `error`, the machine token in
// `meta.errorCode`, and the leaves that were refused named beside it.
return apiError(
"Report selection names leaves this build does not know",
422,
{
errorCode: "report-selection.leaves.unknown",
unknownLeaves: minted.error.unknownLeaves,
},
);
}
// Persist the canonical ordering rather than the caller's, so two clients
+102 -29
View File
@@ -54,6 +54,14 @@ import {
resolveModuleMap,
getOperatorModuleAvailability,
} from "@/lib/modules/gate";
import {
emptyOnboardingNeeds,
readHeldUnitPreferences,
} from "@/lib/onboarding/needs";
import {
loadOnboardingRecordRow,
toOnboardingStateDto,
} from "@/lib/onboarding/needs-store";
import { parseTourProgress } from "@/lib/onboarding/tour-progress";
import { parseNotificationPrefs } from "@/lib/validations/notification-prefs";
import { resolveAccountAccess } from "@/lib/sharing/account-access";
@@ -107,35 +115,63 @@ export const GET = apiHandler(async () => {
// no switch at all, and the reason the own-record payload is untouched.
const sections = accountAccess.active?.sections ?? null;
const [record, cycleProfile, resolvedModules, moduleAvailability] =
await Promise.all([
// The actor's own row is already in hand; a switched session needs the
// record's `gender`, which is the column the cycle gate derives from.
recordId === user.id
? Promise.resolve({ gender: user.gender })
: prisma.user.findUnique({
where: { id: recordId },
select: { gender: true },
}),
prisma.cycleProfile.findUnique({
where: { userId: recordId },
select: { cycleTrackingEnabled: true },
}),
// v1.18.0 — resolved module enable/disable map for every toggleable
// module. cycle/coach reflect their real delegated state (the cycle
// gate / disableCoach + operator assistant flag); the rest read the
// disabled-allowlist `modulePreferencesJson`. Default-on. Clients
// hide a whole module surface end-to-end when its key is `false`.
resolveModuleMap(recordId),
// v1.18.0 — operator-layer availability map (server-wide kill-switch).
// The resolved `modules` map above already AND-s this in, so it cannot
// distinguish operator-off from user-off. The Modules hub needs that
// distinction to render an operator-disabled module as a read-only
// "disabled server-wide" row instead of a live toggle that no-ops.
// Server-WIDE, so it is the same map for every record and is not
// re-scoped here.
getOperatorModuleAvailability(),
]);
const [
record,
cycleProfile,
resolvedModules,
moduleAvailability,
onboardingRow,
] = await Promise.all([
// The actor's own row is already in hand; a switched session needs the
// record's `gender`, which is the column the cycle gate derives from.
recordId === user.id
? Promise.resolve({
gender: user.gender,
glucoseUnit: user.glucoseUnit,
unitPreference: user.unitPreference,
})
: prisma.user.findUnique({
where: { id: recordId },
select: {
gender: true,
// v1.39 (C1) — the record's own unit columns, for the setup
// flow's "never re-ask a value the account holds" rule.
glucoseUnit: true,
unitPreference: true,
},
}),
prisma.cycleProfile.findUnique({
where: { userId: recordId },
select: { cycleTrackingEnabled: true },
}),
// v1.18.0 — resolved module enable/disable map for every toggleable
// module. cycle/coach reflect their real delegated state (the cycle
// gate / disableCoach + operator assistant flag); the rest read the
// disabled-allowlist `modulePreferencesJson`. Default-on. Clients
// hide a whole module surface end-to-end when its key is `false`.
resolveModuleMap(recordId),
// v1.18.0 — operator-layer availability map (server-wide kill-switch).
// The resolved `modules` map above already AND-s this in, so it cannot
// distinguish operator-off from user-off. The Modules hub needs that
// distinction to render an operator-disabled module as a read-only
// "disabled server-wide" row instead of a live toggle that no-ops.
// Server-WIDE, so it is the same map for every record and is not
// re-scoped here.
getOperatorModuleAvailability(),
// v1.39 (C1) — the needs-based setup state for the RECORD, like the two
// module fields above and for the same reason: it says what that record
// tracks, and its only consumer reads it beside that record's reading
// count, medication count and integration state. Ordering a record's rows
// by somebody else's answers is the one thing this field must not do.
//
// The three WRITE routes stay actor-only (`requireAuth()` refuses outright
// under a switch) until a guardian's route into a managed record's setup
// arrives with the rest of that record's configuration (#939 / C2). A read
// that is wider than the write is the honest shape here: the storage is
// keyed by record already, and a delegate seeing the record's own setup
// state is what makes the dashboard they are looking at coherent.
loadOnboardingRecordRow(prisma, recordId),
]);
// The masking step, and the one place the record scoping is narrowed rather
// than resolved. `active` is set for ANY live grant — a READ grant scoped to
@@ -149,6 +185,25 @@ export const GET = apiHandler(async () => {
// exactly as it was, and `moduleAccess` is the same answer with the reason
// attached — the record's own switch, the grant's edge, or the operator's.
// Every client that only wants "paint it or not" keeps reading the boolean.
// v1.39 (C1) — the published steps carry the Q6 resolution: a record whose
// account already holds both unit preferences reads `units` as `done`, so
// the flow never re-asks a value the account holds.
const recordOnboarding = toOnboardingStateDto(
onboardingRow,
readHeldUnitPreferences({
glucoseUnit: record?.glucoseUnit ?? null,
unitPreference: record?.unitPreference ?? null,
}),
);
// The answers name the domains the record tracks, which is exactly what the
// module map below is masked for. A SCOPED grant therefore gets the shape of
// the flow — how far it got, what it ended on — with the answers emptied;
// an unscoped grant, and the record's own session, get them whole.
const onboarding =
sections === null
? recordOnboarding
: { ...recordOnboarding, needs: emptyOnboardingNeeds() };
const { modules, moduleAccess } = buildModuleDisclosure(
resolvedModules,
moduleAvailability,
@@ -186,6 +241,24 @@ export const GET = apiHandler(async () => {
timezone: user.timezone,
onboardingCompletedAt: user.onboardingCompletedAt,
onboardingTourCompleted: user.onboardingTourCompleted,
// v1.39 (C1) — the needs-based setup flow, published additively:
// `steps` (the nine stable ids and their `pending | done | skipped`
// status), `needs` (the answers as given), `completedAt` (the flow's OWN
// completion, distinct from `onboardingCompletedAt` above, which keeps its
// meaning as the first-run redirect's gate) and `firstResult` (the one
// task the flow offered, and whether it produced its result).
//
// Record-scoped, like `modules` and `cycleTrackingEnabled`: it says what
// the record tracks, and the checklist reads it beside that record's
// counts. Under a scoped grant the answers are emptied for the reason the
// module map is masked.
//
// Always present, never null: a record that never entered the flow reads
// as empty answers with nine `pending` steps, so a client branches on the
// step statuses rather than on the field's existence. A client that meets
// a step id it does not know skips it — the list is the contract, not the
// client's own copy of it.
onboarding,
// v1.18.6 (DISC-02) — one-time medical-disclaimer acknowledgment. Null
// when never acknowledged; the onboarding welcome gate reads this to
// decide whether to require the acknowledgment before "Get started".
+22 -4
View File
@@ -18,8 +18,14 @@
* decides which source's rows the cached targets / derived / analytics
* payloads were built from.
*/
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { apiSuccess, getClientIp } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -48,7 +54,9 @@ export const PUT = apiHandler(async (req: Request) => {
try {
body = await req.json();
} catch {
throw new HttpError(422, "source-priority.body.invalid_json");
return apiError("Invalid JSON body", 400, {
errorCode: "source-priority.body.invalid_json",
});
}
const parsed = sourcePrioritySchema.safeParse(body ?? {});
@@ -57,7 +65,17 @@ export const PUT = apiHandler(async (req: Request) => {
action: { name: "auth.me.source-priority.put.invalid" },
meta: { issues: parsed.error.issues.length },
});
throw new HttpError(422, "source-priority.body.invalid_shape");
// The dotted token keeps its place in `error`: this refusal keeps its 422,
// so moving the string would be a wire change with nothing forcing it —
// the `invalid_json` siblings moved only because their status moved to 400
// anyway. `meta.errorCode` publishes the same token in the field a machine
// code belongs in, and `details.issues` names the fields that were refused.
return apiValidationError(
"source-priority.body.invalid_shape",
sanitiseZodIssues(parsed.error.issues),
422,
{ errorCode: "source-priority.body.invalid_shape" },
);
}
// v1.4.25 W10 reconcile (security M-3): capture the previous shape
+11 -2
View File
@@ -17,7 +17,12 @@
* limited per IP on top of the per-ticket cap.
*/
import { NextRequest } from "next/server";
import { apiError, safeJson } from "@/lib/api-response";
import {
apiError,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
@@ -61,7 +66,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
const parsed = mfaVerifySchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid request", 422);
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
const { mfaTicket, method, code, rememberDevice } = parsed.data;
@@ -13,7 +13,13 @@
*/
import { NextRequest } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
import {
apiError,
apiSuccess,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
@@ -45,7 +51,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
const parsed = mfaWebauthnLoginOptionsSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid request", 422);
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
const challenge = await loadActiveChallenge(parsed.data.mfaTicket);
+11 -2
View File
@@ -16,7 +16,12 @@
*/
import { NextRequest } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { apiError, safeJson } from "@/lib/api-response";
import {
apiError,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -59,7 +64,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
const parsed = mfaWebauthnLoginVerifySchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid request", 422);
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
const { mfaTicket, challengeId, credential, rememberDevice } = parsed.data;
+11 -2
View File
@@ -20,7 +20,12 @@
* `requireFreshMfa` step-up.
*/
import { NextRequest } from "next/server";
import { apiError, safeJson } from "@/lib/api-response";
import {
apiError,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
@@ -83,7 +88,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
const parsed = nativeHandoffTokenSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid request", 422);
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
const { code, codeVerifier } = parsed.data;
+11 -2
View File
@@ -23,7 +23,12 @@
* `requireFreshMfa` step-up, so an SSO login can never satisfy step-up.
*/
import { NextRequest } from "next/server";
import { apiError, safeJson } from "@/lib/api-response";
import {
apiError,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
@@ -89,7 +94,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
const parsed = oidcNativeTokenSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid request", 422);
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
const { code, codeVerifier } = parsed.data;
+12 -2
View File
@@ -18,7 +18,13 @@
*/
import { NextRequest } from "next/server";
import { apiHandler, requireBearerAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
import {
apiError,
apiSuccess,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
@@ -50,7 +56,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
const parsed = stepUpOptionsSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid request", 422);
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
if (parsed.data.method === "webauthn") {
+7 -1
View File
@@ -33,8 +33,10 @@ import { apiHandler, requireBearerAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
@@ -112,7 +114,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
const parsed = stepUpMintSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid request", 422);
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
const method: StepUpMethod = parsed.data.method;
@@ -1,6 +1,17 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/rate-limit", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/rate-limit")>()),
// The shared single-record write ceiling. Allowed here: this file is about
// what the handler does with a body it accepted, not about the bucket.
checkRecordWriteRateLimit: vi.fn(async () => ({
allowed: true,
limit: 300,
remaining: 299,
resetAt: Date.now() + 60_000,
})),
}));
vi.mock("@/lib/db", () => ({
prisma: {
biomarker: { findFirst: vi.fn(), create: vi.fn(), findMany: vi.fn() },
+12 -1
View File
@@ -9,6 +9,7 @@ import {
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { withIdempotency } from "@/lib/idempotency";
@@ -95,7 +96,17 @@ async function postBiomarker(request: NextRequest) {
// duplicate pre-check and the `@@unique([userId, name])` backstop both scope
// to the same resolved id, so a delegate adding a marker the owner already
// tracks gets the ordinary 409 rather than a second copy.
const { user } = await requireRecordAuth("write", "labs");
const { user, actor } = await requireRecordAuth("write", "labs");
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
// batch siblings have always been capped; the per-record creates a looping
// client hits were not.
const writeRl = await checkRecordWriteRateLimit(actor.id);
if (!writeRl.allowed) {
return apiError("Too many writes, try again later", 429, {
errorCode: "record_write.rate_limited",
});
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
@@ -1,6 +1,17 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/rate-limit", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/rate-limit")>()),
// The shared single-record write ceiling. Allowed here: this file is about
// what the handler does with a body it accepted, not about the bucket.
checkRecordWriteRateLimit: vi.fn(async () => ({
allowed: true,
limit: 300,
remaining: 299,
resetAt: Date.now() + 60_000,
})),
}));
vi.mock("@/lib/db", () => ({
prisma: {
customMetric: { findFirst: vi.fn() },
@@ -9,6 +9,7 @@ import {
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
import { auditLog } from "@/lib/auth/audit";
import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate";
import { serialiseCustomMetricEntry } from "@/lib/custom-metrics/custom-metric-store";
@@ -105,6 +106,16 @@ async function postCustomMetricEntry(
// tracked value adds it back in the same diff: the argument for admitting it
// was never wrong, and the GET arm above stays delegable meanwhile.
const { user } = await requireAuth();
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
// batch siblings have always been capped; the per-record creates a looping
// client hits were not.
const writeRl = await checkRecordWriteRateLimit(user.id);
if (!writeRl.allowed) {
return apiError("Too many writes, try again later", 429, {
errorCode: "record_write.rate_limited",
});
}
const { id } = await params;
const metric = await prisma.customMetric.findFirst({
@@ -34,6 +34,14 @@ vi.mock("@/lib/auth/audit", () => ({
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
rateLimitHeaders: () => ({}),
// The shared single-record write ceiling. Allowed here: this file is about
// what the handler does with a body it accepted, not about the bucket.
checkRecordWriteRateLimit: vi.fn(async () => ({
allowed: true,
limit: 300,
remaining: 299,
resetAt: Date.now() + 60_000,
})),
}));
vi.mock("@/lib/idempotency", () => ({
withIdempotency:
+20 -4
View File
@@ -18,7 +18,12 @@ import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiError, apiSuccess } from "@/lib/api-response";
import {
apiError,
apiSuccess,
apiValidationError,
sanitiseZodIssues,
} from "@/lib/api-response";
import { requireCycleEnabled } from "@/lib/cycle/gate";
import { cycleCalendarQuerySchema } from "@/lib/validations/cycle";
import {
@@ -55,9 +60,14 @@ export const GET = apiHandler(async (request: NextRequest) => {
to: url.searchParams.get("to") ?? undefined,
});
if (!parsed.success) {
return apiError("Invalid calendar query", 422, {
errorCode: "cycle.calendar.invalid",
});
return apiValidationError(
"Invalid calendar query",
sanitiseZodIssues(parsed.error.issues),
422,
{
errorCode: "cycle.calendar.invalid",
},
);
}
const tz = user.timezone ?? DEFAULT_TIMEZONE;
@@ -111,6 +121,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
// Apple Watch wrist/skin temperature feeds the temperature-trend
// ovulation layer. Read the WRIST_TEMPERATURE measurements as nightly
// values; the engine derives the trailing-mean deviation itself.
//
// This is a measurements read on a route declared under `cycle`, and it
// needs no section predicate for one reason: no value from it reaches the
// response. It goes into `predictCycle` and what comes out the other side
// is a shifted date. The crosstab next door reads the same channel and IS
// fenced, because there the values are the answer.
prisma.measurement.findMany({
where: {
userId: user.id,
+13 -4
View File
@@ -12,7 +12,11 @@ import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiError, apiSuccess } from "@/lib/api-response";
import {
apiSuccess,
apiValidationError,
sanitiseZodIssues,
} from "@/lib/api-response";
import { requireCycleEnabled } from "@/lib/cycle/gate";
import { cycleHistoryQuerySchema } from "@/lib/validations/cycle";
import { toMenstrualCycleDTO } from "@/lib/cycle/dto";
@@ -39,9 +43,14 @@ export const GET = apiHandler(async (request: NextRequest) => {
limit: url.searchParams.get("limit") ?? undefined,
});
if (!parsed.success) {
return apiError("Invalid history query", 422, {
errorCode: "cycle.cycles.invalid",
});
return apiValidationError(
"Invalid history query",
sanitiseZodIssues(parsed.error.issues),
422,
{
errorCode: "cycle.cycles.invalid",
},
);
}
const limit = parsed.data.limit ?? DEFAULT_LIMIT;
+12 -1
View File
@@ -26,6 +26,7 @@ import {
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
import { withIdempotency } from "@/lib/idempotency";
import { requireCycleEnabled } from "@/lib/cycle/gate";
import {
@@ -94,11 +95,21 @@ async function postDayLog(request: NextRequest): Promise<Response> {
// v1.37.0 — MANAGE. The upsert replaces the day, which is what edit means
// here; the gate below resolves `user.gender` against the RECORD, so the
// module follows the record rather than the caller.
const { user } = await requireRecordAuth("manage", "cycle");
const { user, actor } = await requireRecordAuth("manage", "cycle");
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
// batch siblings have always been capped; the per-record creates a looping
// client hits were not.
const writeRl = await checkRecordWriteRateLimit(actor.id);
if (!writeRl.allowed) {
return apiError("Too many writes, try again later", 429, {
errorCode: "record_write.rate_limited",
});
}
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
+51 -29
View File
@@ -16,6 +16,7 @@
*/
import { prisma } from "@/lib/db";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, apiError } from "@/lib/api-response";
import { requireCycleEnabled } from "@/lib/cycle/gate";
@@ -50,7 +51,7 @@ const WINDOW_DAYS = 365;
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireRecordAuth("read", "cycle");
const { user, grantId } = await requireRecordAuth("read", "cycle");
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
@@ -68,6 +69,19 @@ export const GET = apiHandler(async () => {
const today = moodDateKey(new Date(), tz);
const from = addDays(today, -WINDOW_DAYS);
// The crosstab is a cycle surface that reads two other sections. The phase
// labels are cycle data; the outcome columns are not. `lutealAvg`,
// `follicularAvg` and `delta` ship resting heart rate, HRV, sleep duration,
// steps, weight, temperature, glucose and the mood score in display units,
// and a grant scoped to the cycle alone was never consent for any of them.
// So the two foreign channels are fenced at the query, not at the response:
// a section the caller is outside of is never read, so nothing downstream
// can leak it back — the rows simply do not exist, exactly as they do not
// for a record with no measurements.
const visible = await actingDomainVisibility(prisma, grantId);
const seesMeasurements = visible("measurements");
const seesMind = visible("mind");
const [
cycles,
dayLogRows,
@@ -116,25 +130,28 @@ export const GET = apiHandler(async () => {
select: { measuredAt: true, value: true },
}),
// The outcome metrics the phase contrast compares — soft-delete-scoped,
// canonical-source deduped per day inside `metricDayMap`.
prisma.measurement.findMany({
where: {
userId: user.id,
deletedAt: null,
type: { in: PHASE_CROSSTAB_METRIC_TYPES },
measuredAt: {
gte: new Date(Date.parse(`${from}T00:00:00Z`)),
},
},
orderBy: { measuredAt: "asc" },
select: {
type: true,
value: true,
measuredAt: true,
source: true,
deviceType: true,
},
}),
// canonical-source deduped per day inside `metricDayMap`. Skipped whole
// for a caller outside `measurements`.
seesMeasurements
? prisma.measurement.findMany({
where: {
userId: user.id,
deletedAt: null,
type: { in: PHASE_CROSSTAB_METRIC_TYPES },
measuredAt: {
gte: new Date(Date.parse(`${from}T00:00:00Z`)),
},
},
orderBy: { measuredAt: "asc" },
select: {
type: true,
value: true,
measuredAt: true,
source: true,
deviceType: true,
},
})
: [],
prisma.user.findUnique({
where: { id: user.id },
select: { sourcePriorityJson: true },
@@ -142,15 +159,18 @@ export const GET = apiHandler(async () => {
// MOOD outcome (QA HIGH): mood lives in MoodEntry (1–5 score), not a
// Measurement row, so read it here and inject it into the crosstab as a
// synthetic MOOD_CHANNEL_KEY measurement (same FDR / day-floor guards).
prisma.moodEntry.findMany({
where: {
userId: user.id,
deletedAt: null,
moodLoggedAt: { gte: new Date(Date.parse(`${from}T00:00:00Z`)) },
},
orderBy: { moodLoggedAt: "asc" },
select: { score: true, moodLoggedAt: true },
}),
// Skipped whole for a caller outside `mind`.
seesMind
? prisma.moodEntry.findMany({
where: {
userId: user.id,
deletedAt: null,
moodLoggedAt: { gte: new Date(Date.parse(`${from}T00:00:00Z`)) },
},
orderBy: { moodLoggedAt: "asc" },
select: { score: true, moodLoggedAt: true },
})
: [],
]);
// The latest open cycle runs to the predicted next-period start so the
@@ -239,6 +259,8 @@ export const GET = apiHandler(async () => {
lagged_discovered: lagged.discovered.length,
lagged_pairs_tested: lagged.pairsTested,
symptom_patterns: symptomPatterns.length,
measurements_visible: seesMeasurements,
mind_visible: seesMind,
},
});
+19 -2
View File
@@ -26,6 +26,7 @@ import {
} from "@/lib/api-response";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { withIdempotency } from "@/lib/idempotency";
import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit } from "@/lib/rate-limit";
import { requireCycleEnabled } from "@/lib/cycle/gate";
@@ -67,7 +68,23 @@ export const GET = apiHandler(async () => {
return apiSuccess({ symptoms });
});
export const POST = apiHandler(async (request: NextRequest) => {
/**
* Wrapped in `withIdempotency`: a custom symptom is minted from the client's
* offline outbox under the same `Idempotency-Key`, and nothing else here would
* catch the replay. The row's key is minted fresh per request
* (`custom:<uuid>`), and the label it would duplicate lives in
* `labelEncrypted` — AES-GCM with a per-write IV, so no unique index can be
* put on it. A replay after a lost success response therefore wrote a SECOND
* row carrying the same label, up to the fifty-row cap: a silent duplicate in
* the person's own symptom vocabulary rather than a conflict anything refused.
* The wrapper answers the first attempt's 201 instead, and the in-flight 409 it
* defines is the only 409 this route can produce.
*/
export const POST = apiHandler(
withIdempotency<[NextRequest]>(postCustomSymptom),
);
async function postCustomSymptom(request: NextRequest): Promise<Response> {
// v1.37.0 — MANAGE. The record's own symptom vocabulary, which the day-log
// writes the level admits need in order to say anything.
const { user, actor } = await requireRecordAuth("manage", "cycle");
@@ -151,4 +168,4 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
201,
);
});
}
@@ -24,7 +24,13 @@
import { type NextRequest } from "next/server";
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
import {
apiError,
apiSuccess,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
@@ -93,9 +99,14 @@ export const POST = apiHandler(
if (jsonError) return jsonError;
const parsed = documentChatRequestSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid document chat request", 422, {
errorCode: "documents.chat.invalid",
});
return apiValidationError(
"Invalid document chat request",
sanitiseZodIssues(parsed.error.issues),
422,
{
errorCode: "documents.chat.invalid",
},
);
}
const { conversationId, message, locale: bodyLocale } = parsed.data;
@@ -231,9 +242,14 @@ export const GET = apiHandler(
limit: url.searchParams.get("limit") ?? undefined,
});
if (!parsed.success) {
return apiError("Invalid chat history query", 422, {
errorCode: "documents.chat.invalid",
});
return apiValidationError(
"Invalid chat history query",
sanitiseZodIssues(parsed.error.issues),
422,
{
errorCode: "documents.chat.invalid",
},
);
}
// With a conversationId → that one thread's messages (must hold this document).
@@ -30,8 +30,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
@@ -236,9 +238,14 @@ async function handleTextExtract(
const parsed = inboundTextExtractSchema.safeParse(body);
if (!parsed.success) {
await refundDocumentAiSlot(userId);
return apiError("Invalid document text payload", 422, {
errorCode: "documents.inbound.extractFailed",
});
return apiValidationError(
"Invalid document text payload",
sanitiseZodIssues(parsed.error.issues),
422,
{
errorCode: "documents.inbound.extractFailed",
},
);
}
const dateKey = buildDateKey();
@@ -26,8 +26,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
@@ -154,9 +156,14 @@ async function handleTextIndex(
const parsed = inboundTextExtractSchema.safeParse(body);
if (!parsed.success) {
await refundDocumentAiSlot(userId);
return apiError("Invalid document text payload", 422, {
errorCode: "documents.inbound.extractFailed",
});
return apiValidationError(
"Invalid document text payload",
sanitiseZodIssues(parsed.error.issues),
422,
{
errorCode: "documents.inbound.extractFailed",
},
);
}
const { tokenCount } = await upsertContentIndex({
+10 -3
View File
@@ -13,8 +13,10 @@ import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -143,9 +145,14 @@ export const PATCH = apiHandler(
const parsed = documentUpdateSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid document update", 422, {
errorCode: "documents.inbound.invalidUpdate",
});
return apiValidationError(
"Invalid document update",
sanitiseZodIssues(parsed.error.issues),
422,
{
errorCode: "documents.inbound.invalidUpdate",
},
);
}
const existing = await prisma.inboundDocument.findFirst({
@@ -18,8 +18,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
@@ -148,9 +150,14 @@ async function handleTextSuggest(
const parsed = inboundTextExtractSchema.safeParse(body);
if (!parsed.success) {
await refundDocumentAiSlot(userId);
return apiError("Invalid document text payload", 422, {
errorCode: "documents.inbound.extractFailed",
});
return apiValidationError(
"Invalid document text payload",
sanitiseZodIssues(parsed.error.issues),
422,
{
errorCode: "documents.inbound.extractFailed",
},
);
}
const dateKey = buildDateKey();
@@ -25,8 +25,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard";
@@ -297,9 +299,14 @@ async function handleTextSummary(
const parsed = inboundTextExtractSchema.safeParse(body);
if (!parsed.success) {
await refundDocumentAiSlot(userId);
return apiError("Invalid document text payload", 422, {
errorCode: "documents.inbound.extractFailed",
});
return apiValidationError(
"Invalid document text payload",
sanitiseZodIssues(parsed.error.issues),
422,
{
errorCode: "documents.inbound.extractFailed",
},
);
}
// `mode=text` over posted OCR text is a pure echo — the text IS the
+23 -7
View File
@@ -33,7 +33,13 @@ import {
requireRecordAuth,
type AuthContext,
} from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
sanitiseZodIssues,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { hashQueryTokens } from "@/lib/documents/content-index";
@@ -236,9 +242,14 @@ async function processUpload(
encounterIds: rawEncounterIds.length > 0 ? rawEncounterIds : undefined,
});
if (!parsed.success) {
return apiError("Invalid document metadata", 422, {
errorCode: "documents.inbound.invalidMetadata",
});
return apiValidationError(
"Invalid document metadata",
sanitiseZodIssues(parsed.error.issues),
422,
{
errorCode: "documents.inbound.invalidMetadata",
},
);
}
let buffer: Buffer;
@@ -518,9 +529,14 @@ export const GET = apiHandler(async (request: Request) => {
kind: kinds.length > 0 ? kinds : undefined,
});
if (!parsed.success) {
return apiError("Invalid list query", 422, {
errorCode: "documents.inbound.invalidQuery",
});
return apiValidationError(
"Invalid list query",
sanitiseZodIssues(parsed.error.issues),
422,
{
errorCode: "documents.inbound.invalidQuery",
},
);
}
const {
q,
+2 -4
View File
@@ -27,10 +27,8 @@ import {
} from "@/lib/api-response";
import { encounterLinkSchema } from "@/lib/validations/encounters";
import { linkTargets, unlinkTargets } from "@/lib/links";
import {
actingDomainVisibility,
loadEncounterLinks,
} from "@/lib/encounters/service";
import { loadEncounterLinks } from "@/lib/encounters/service";
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
type RouteParams = { params: Promise<{ id: string }> };
+1 -1
View File
@@ -22,12 +22,12 @@ import { encounterKindLabel } from "@/lib/encounters/kind-label";
import { toEncounterDTO } from "@/lib/encounters/dto";
import {
ENCOUNTER_INCLUDE,
actingDomainVisibility,
loadEncounterLinks,
resolveOwnerNotificationContext,
shouldHaveReminder,
} from "@/lib/encounters/service";
import { reanchorAppointmentReminder } from "@/lib/encounters/appointment-reminder";
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
type RouteParams = { params: Promise<{ id: string }> };
+1 -1
View File
@@ -35,7 +35,6 @@ import { encounterKindLabel } from "@/lib/encounters/kind-label";
import { toEncounterDTO } from "@/lib/encounters/dto";
import {
ENCOUNTER_INCLUDE,
actingDomainVisibility,
applyEncounterLinks,
closeCheckupForVisit,
findOwnAppointmentReminderId,
@@ -54,6 +53,7 @@ import {
type RestoreSkipLog,
} from "@/lib/export/restore-skips";
import type { Prisma } from "@/generated/prisma/client";
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
type RouteParams = { params: Promise<{ id: string }> };
+13 -2
View File
@@ -25,6 +25,7 @@ import {
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
import { withIdempotency } from "@/lib/idempotency";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import {
@@ -35,7 +36,6 @@ import { encounterKindLabel } from "@/lib/encounters/kind-label";
import { toEncounterDTO, type EncounterListDTO } from "@/lib/encounters/dto";
import {
ENCOUNTER_INCLUDE,
actingDomainVisibility,
applyEncounterLinks,
loadEncounterLinksForMany,
closeCheckupForVisit,
@@ -50,6 +50,7 @@ import {
summarizeRestoreSkips,
type RestoreSkipLog,
} from "@/lib/export/restore-skips";
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
export const GET = apiHandler(async (request: NextRequest) => {
const { user, grantId } = await requireRecordAuth("read", "profile");
@@ -154,7 +155,17 @@ export const GET = apiHandler(async (request: NextRequest) => {
export const POST = apiHandler(withIdempotency<[NextRequest]>(postEncounter));
async function postEncounter(request: NextRequest): Promise<Response> {
const { user, grantId } = await requireRecordAuth("write", "profile");
const { user, actor, grantId } = await requireRecordAuth("write", "profile");
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
// batch siblings have always been capped; the per-record creates a looping
// client hits were not.
const writeRl = await checkRecordWriteRateLimit(actor.id);
if (!writeRl.allowed) {
return apiError("Too many writes, try again later", 429, {
errorCode: "record_write.rate_limited",
});
}
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 32 * 1024,
@@ -28,6 +28,7 @@ vi.mock("@/lib/db", () => ({
// `buildIntradayProfileBackupSection`.
intradayCumulativeProfile: { findMany: vi.fn().mockResolvedValue([]) },
healthScoreRecord: { findMany: vi.fn().mockResolvedValue([]) },
onboardingRecord: { findUnique: vi.fn().mockResolvedValue(null) },
// The visit tables. Empty here: this file asserts the sections it seeds,
// and an account with no visits is the ordinary case the builder must
// still answer for.
@@ -26,6 +26,7 @@ vi.mock("@/lib/db", () => ({
// `buildIntradayProfileBackupSection`.
intradayCumulativeProfile: { findMany: vi.fn().mockResolvedValue([]) },
healthScoreRecord: { findMany: vi.fn().mockResolvedValue([]) },
onboardingRecord: { findUnique: vi.fn().mockResolvedValue(null) },
// The visit tables. Empty here: this file asserts the sections it seeds,
// and an account with no visits is the ordinary case the builder must
// still answer for.
@@ -19,6 +19,28 @@ vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/integrations/status", () => ({ markDisconnected: vi.fn() }));
vi.mock("@/lib/api-response", () => ({
// The parse-refusal pair the routes now answer with: the same envelope
// plus the per-field issue list.
sanitiseZodIssues: (
issues: { path: (string | number)[]; code: string; message: string }[],
) =>
issues.map((issue) => ({
path: issue.path.join("."),
code: issue.code,
message: issue.message,
})),
apiValidationError: (
error: string,
issues: unknown[],
status: number,
options?: { errorCode?: string },
) => ({
data: null,
error,
status,
details: { issues },
meta: options?.errorCode ? { errorCode: options.errorCode } : undefined,
}),
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
apiError: (error: string, status: number) => ({ data: null, error, status }),
safeJson: async (req: NextRequest) => {
+11 -2
View File
@@ -2,7 +2,12 @@ import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { isP2025 } from "@/lib/prisma-errors";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import {
apiSuccess,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { encrypt } from "@/lib/crypto";
import { markDisconnected } from "@/lib/integrations/status";
@@ -47,7 +52,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const result = z.safeParse(fitbitCredentialsSchema, body);
if (!result.success) {
return apiError("Client ID and Client Secret are required", 422);
return apiValidationError(
"Client ID and Client Secret are required",
sanitiseZodIssues(result.error.issues),
422,
);
}
await prisma.user.update({
+11 -2
View File
@@ -2,7 +2,12 @@ import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { isP2025 } from "@/lib/prisma-errors";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import {
apiSuccess,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { encrypt } from "@/lib/crypto";
import { markDisconnected } from "@/lib/integrations/status";
@@ -47,7 +52,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const result = z.safeParse(googleHealthCredentialsSchema, body);
if (!result.success) {
return apiError("Client ID and Client Secret are required", 422);
return apiValidationError(
"Client ID and Client Secret are required",
sanitiseZodIssues(result.error.issues),
422,
);
}
// Field-by-field assignment — never spread the parsed object, so no future
+5 -2
View File
@@ -3,10 +3,12 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { NextRequest } from "next/server";
@@ -175,8 +177,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (jsonError) return jsonError;
const parsed = importSchema.safeParse(body);
if (!parsed.success) {
return apiError(
return apiValidationError(
parsed.error.issues[0]?.message ?? "Invalid import payload",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
@@ -10,7 +10,13 @@
import { type NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
import {
apiError,
apiSuccess,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
@@ -65,9 +71,14 @@ export const POST = apiHandler(
if (jsonError) return jsonError;
const parsed = coachAttachmentCreateSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid attachment request", 422, {
errorCode: "coach.fenced.invalid",
});
return apiValidationError(
"Invalid attachment request",
sanitiseZodIssues(parsed.error.issues),
422,
{
errorCode: "coach.fenced.invalid",
},
);
}
const { documentId } = parsed.data;
+14 -4
View File
@@ -15,7 +15,12 @@
import { type NextRequest } from "next/server";
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { apiError, safeJson } from "@/lib/api-response";
import {
apiError,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
@@ -57,9 +62,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (jsonError) return jsonError;
const parsed = fencedChatRequestSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid fenced chat request", 422, {
errorCode: "coach.fenced.invalid",
});
return apiValidationError(
"Invalid fenced chat request",
sanitiseZodIssues(parsed.error.issues),
422,
{
errorCode: "coach.fenced.invalid",
},
);
}
const {
conversationId,
@@ -34,8 +34,10 @@ import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -91,7 +93,17 @@ async function handlePost(request: NextRequest, ctx: RouteContext) {
action: { name: "insights.coach.message.feedback" },
meta: { outcome: "validation_failed" },
});
throw new HttpError(422, "feedback.body.invalid");
// The dotted token keeps its place in `error`: this refusal keeps its 422,
// so moving the string would be a wire change with nothing forcing it —
// the `invalid_json` siblings moved only because their status moved to 400
// anyway. `meta.errorCode` publishes the same token in the field a machine
// code belongs in, and `details.issues` names the fields that were refused.
return apiValidationError(
"feedback.body.invalid",
sanitiseZodIssues(parsed.error.issues),
422,
{ errorCode: "feedback.body.invalid" },
);
}
const body = parsed.data;
+17 -5
View File
@@ -27,10 +27,15 @@
* 7. Persist user message + assistant message (encrypted) and bump
* the day's CoachUsage token ledger.
*/
import { NextResponse, type NextRequest } from "next/server";
import { type NextRequest } from "next/server";
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import {
apiError,
apiSuccess,
apiValidationError,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate, getEvent } from "@/lib/logging/context";
import { redactOptional, redactSecrets } from "@/lib/logging/redact";
import { auditLog } from "@/lib/auth/audit";
@@ -209,9 +214,16 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
action: { name: "insights.coach.invalid" },
meta: { issues: parsed.error.issues.length },
});
return NextResponse.json(
{ data: null, error: "coach.request.invalid" },
{ status: 422 },
// The dotted token keeps its place in `error`: this refusal keeps its 422,
// so moving the string would be a wire change with nothing forcing it —
// the `invalid_json` siblings moved only because their status moved to 400
// anyway. `meta.errorCode` publishes the same token in the field a machine
// code belongs in, and `details.issues` names the fields that were refused.
return apiValidationError(
"coach.request.invalid",
sanitiseZodIssues(parsed.error.issues),
422,
{ errorCode: "coach.request.invalid" },
);
}
const {
+7 -1
View File
@@ -4,8 +4,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -66,7 +68,11 @@ async function handlePost(request: NextRequest) {
action: { name: "insights.recommendation.feedback" },
meta: { outcome: "validation_failed" },
});
return apiError("Invalid feedback payload", 422);
return apiValidationError(
"Invalid feedback payload",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
const body = parsed.data;
+11 -2
View File
@@ -2,7 +2,12 @@ import type { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, HttpError, requireAuth } from "@/lib/api-handler";
import { apiSuccess, safeJson } from "@/lib/api-response";
import {
apiSuccess,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { resolveProviderChain } from "@/lib/ai/provider";
import { getLastWorkingProvider } from "@/lib/ai/provider-runner";
@@ -111,7 +116,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const parsed = chainBodySchema.safeParse(body);
if (!parsed.success) {
throw new HttpError(422, "Invalid provider chain payload");
return apiValidationError(
"Invalid provider chain payload",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
// Defence-in-depth: reject duplicate provider types so a stale-tab
@@ -1,6 +1,17 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/rate-limit", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/rate-limit")>()),
// The shared single-record write ceiling. Allowed here: this file is about
// what the handler does with a body it accepted, not about the bucket.
checkRecordWriteRateLimit: vi.fn(async () => ({
allowed: true,
limit: 300,
remaining: 299,
resetAt: Date.now() + 60_000,
})),
}));
vi.mock("@/lib/db", () => ({
prisma: {
labResult: {
+15 -4
View File
@@ -28,7 +28,13 @@
import { Buffer } from "node:buffer";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
import {
apiError,
apiSuccess,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { assertConsentForChain } from "@/lib/ai/consent-guard";
import {
@@ -154,9 +160,14 @@ async function handleTextExtract(
const parsed = ocrTextExtractSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid OCR text payload", 422, {
errorCode: "labs.ocr.extractFailed",
});
return apiValidationError(
"Invalid OCR text payload",
sanitiseZodIssues(parsed.error.issues),
422,
{
errorCode: "labs.ocr.extractFailed",
},
);
}
// Budget — text mode is a plain text→JSON structuring pass, far cheaper than
+12 -1
View File
@@ -10,6 +10,7 @@ import {
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
@@ -148,7 +149,17 @@ async function postLabResult(request: NextRequest) {
// it mints it under `user.id`: the marker joins the record's catalogue, not
// the caller's, which is the only reading of "add a result to your record"
// that leaves the owner with a usable catalogue afterwards.
const { user } = await requireRecordAuth("write", "labs");
const { user, actor } = await requireRecordAuth("write", "labs");
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
// batch siblings have always been capped; the per-record creates a looping
// client hits were not.
const writeRl = await checkRecordWriteRateLimit(actor.id);
if (!writeRl.allowed) {
return apiError("Too many writes, try again later", 429, {
errorCode: "record_write.rate_limited",
});
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
@@ -12,6 +12,17 @@ const SELF = {
timezone: "Europe/Berlin",
};
vi.mock("@/lib/rate-limit", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/rate-limit")>()),
// The shared single-record write ceiling. Allowed here: this file is about
// what the handler does with a body it accepted, not about the bucket.
checkRecordWriteRateLimit: vi.fn(async () => ({
allowed: true,
limit: 300,
remaining: 299,
resetAt: Date.now() + 60_000,
})),
}));
vi.mock("@/lib/api-handler", () => ({
apiHandler: (fn: unknown) => fn,
requireRecordAuth: vi.fn(async () => ({ user: SELF, actor: SELF })),
@@ -9,6 +9,17 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/rate-limit", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/rate-limit")>()),
// The shared single-record write ceiling. Allowed here: this file is about
// what the handler does with a body it accepted, not about the bucket.
checkRecordWriteRateLimit: vi.fn(async () => ({
allowed: true,
limit: 300,
remaining: 299,
resetAt: Date.now() + 60_000,
})),
}));
vi.mock("@/lib/db", () => ({
prisma: {
measurement: {
+7 -1
View File
@@ -45,8 +45,10 @@ import { auditLog } from "@/lib/auth/audit";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { withIdempotency } from "@/lib/idempotency";
import {
@@ -274,7 +276,11 @@ async function postBatch(request: NextRequest): Promise<Response> {
const parsed = batchPayloadSchema.safeParse(rawBody);
if (!parsed.success) {
return apiError(parsed.error.issues[0]?.message ?? "Invalid batch", 422);
return apiValidationError(
parsed.error.issues[0]?.message ?? "Invalid batch",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
// A scoped credential may attribute MANUAL and nothing else. Refused loudly
@@ -38,8 +38,10 @@ import { auditLog } from "@/lib/auth/audit";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
@@ -83,7 +85,11 @@ async function deleteByExternalIds(request: NextRequest): Promise<Response> {
const parsed = payloadSchema.safeParse(rawBody);
if (!parsed.success) {
return apiError(parsed.error.issues[0]?.message ?? "Invalid batch", 422);
return apiValidationError(
parsed.error.issues[0]?.message ?? "Invalid batch",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
const { externalIds } = parsed.data;
+11
View File
@@ -16,6 +16,7 @@ import {
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
import {
createMeasurementSchema,
createBatchMeasurementSchema,
@@ -801,6 +802,16 @@ 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,
});
+113 -101
View File
@@ -28,6 +28,7 @@ import {
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { glp1PostBodySchema } from "@/lib/validations/medication";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { withIdempotency } from "@/lib/idempotency";
import {
estimateDailyDoseCount,
estimateRunwayDays,
@@ -236,103 +237,71 @@ export const GET = apiHandler(
* Rate-limit: 30/min/user (same as sibling POST routes).
* Audit: `medication.glp1.update` for every successful mutation.
*/
/**
* Wrapped in `withIdempotency`: a titration step is replayed out of the
* client's offline outbox under the same `Idempotency-Key`, and a replay after
* a lost success response would otherwise write a second dose change or a
* second supply delta. Neither row carries a natural key, so the wrapper is the
* only thing that can collapse the retry.
*/
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
// v1.37.0 — MANAGE. A titration step is record data and the dose is what
// the server acts on; administering one is the case the level names.
const { user, actor } = await requireRecordAuth("manage", "medications");
const { id } = await params;
withIdempotency<[NextRequest, RouteParams]>(postGlp1Update),
);
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
async function postGlp1Update(
request: NextRequest,
{ params }: RouteParams,
): Promise<Response> {
// v1.37.0 — MANAGE. A titration step is record data and the dose is what
// the server acts on; administering one is the case the level names.
const { user, actor } = await requireRecordAuth("manage", "medications");
const { id } = await params;
// Per-caller POST rate-limit — matches the 30/min sibling routes
// (inventory, side-effects). Generous for a hand-driven session,
// tight enough to cut off the spam case.
//
// v1.37.0 — C1: keyed on the ACTOR, so a manager burns their own
// allowance and cannot collect a fresh one by switching records.
const rl = await checkRateLimit(
`medication-glp1:post:${actor.id}`,
POST_RATE_LIMIT,
POST_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many requests", 429, {
headers: rateLimitHeaders(rl),
});
}
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
// Per-caller POST rate-limit — matches the 30/min sibling routes
// (inventory, side-effects). Generous for a hand-driven session,
// tight enough to cut off the spam case.
//
// v1.37.0 — C1: keyed on the ACTOR, so a manager burns their own
// allowance and cannot collect a fresh one by switching records.
const rl = await checkRateLimit(
`medication-glp1:post:${actor.id}`,
POST_RATE_LIMIT,
POST_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many requests", 429, {
headers: rateLimitHeaders(rl),
});
if (jsonError) return jsonError;
}
const parsed = glp1PostBodySchema.safeParse(body);
if (!parsed.success) {
// v1.4.43 W6 — multi-issue 422.
return returnAllZodIssues(parsed.error, 422);
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const ip = getClientIp(request);
const parsed = glp1PostBodySchema.safeParse(body);
if (!parsed.success) {
// v1.4.43 W6 — multi-issue 422.
return returnAllZodIssues(parsed.error, 422);
}
if (parsed.data.doseChange) {
const { effectiveFrom, doseValue, doseUnit, note } =
parsed.data.doseChange;
const created = await prisma.medicationDoseChange.create({
data: {
medicationId: id,
effectiveFrom,
doseValue,
doseUnit,
// Encrypt the titration note at rest; the plaintext column stays null.
noteEncrypted: encryptNote(note),
note: null,
},
});
const ip = getClientIp(request);
await auditLog("medication.glp1.update", {
userId: user.id,
ipAddress: ip,
details: {
medicationId: id,
kind: "doseChange",
doseChangeId: created.id,
doseValue,
doseUnit,
},
});
annotate({
action: {
name: "medication.glp1.doseChange.create",
entity_type: "medication_dose_change",
entity_id: created.id,
},
meta: { medication_id: id, doseValue, doseUnit },
});
// A dose change shifts the daily-dose estimate the list payload's
// runway derivation uses. Hard-evict the medications + compliance
// buckets so the card reflects the new runway on the next read.
invalidateUserMedications(user.id, { evict: true });
return apiSuccess({ doseChange: shapeDoseChangeNote(created) }, 201);
}
// The schema's XOR refinement guarantees `inventory` is present
// when `doseChange` is not, so the non-null assertion is safe.
//
// DEPRECATED branch (v1.16.10): this writes the legacy
// MedicationInventoryEvent running-sum ledger. The per-item
// endpoints (`POST /api/medications/[id]/inventory`,
// `PATCH /api/medications/[id]/inventory/[itemId]`) replaced it;
// GET above and the Coach snapshot read the ledger only while the
// medication has zero inventory items. New callers must register
// containers instead of posting deltas.
const { delta, reason } = parsed.data.inventory!;
const created = await prisma.medicationInventoryEvent.create({
data: { medicationId: id, delta, reason },
if (parsed.data.doseChange) {
const { effectiveFrom, doseValue, doseUnit, note } = parsed.data.doseChange;
const created = await prisma.medicationDoseChange.create({
data: {
medicationId: id,
effectiveFrom,
doseValue,
doseUnit,
// Encrypt the titration note at rest; the plaintext column stays null.
noteEncrypted: encryptNote(note),
note: null,
},
});
await auditLog("medication.glp1.update", {
@@ -340,27 +309,70 @@ export const POST = apiHandler(
ipAddress: ip,
details: {
medicationId: id,
kind: "inventory",
inventoryEventId: created.id,
delta,
reason,
kind: "doseChange",
doseChangeId: created.id,
doseValue,
doseUnit,
},
});
annotate({
action: {
name: "medication.glp1.inventory.create",
entity_type: "medication_inventory_event",
name: "medication.glp1.doseChange.create",
entity_type: "medication_dose_change",
entity_id: created.id,
},
meta: { medication_id: id, delta },
meta: { medication_id: id, doseValue, doseUnit },
});
// A legacy ledger delta changes the stock the list payload reports
// for a medication with no per-item containers. Hard-evict so the
// card reflects it on the next read.
// A dose change shifts the daily-dose estimate the list payload's
// runway derivation uses. Hard-evict the medications + compliance
// buckets so the card reflects the new runway on the next read.
invalidateUserMedications(user.id, { evict: true });
return apiSuccess({ inventory: created }, 201);
},
);
return apiSuccess({ doseChange: shapeDoseChangeNote(created) }, 201);
}
// The schema's XOR refinement guarantees `inventory` is present
// when `doseChange` is not, so the non-null assertion is safe.
//
// DEPRECATED branch (v1.16.10): this writes the legacy
// MedicationInventoryEvent running-sum ledger. The per-item
// endpoints (`POST /api/medications/[id]/inventory`,
// `PATCH /api/medications/[id]/inventory/[itemId]`) replaced it;
// GET above and the Coach snapshot read the ledger only while the
// medication has zero inventory items. New callers must register
// containers instead of posting deltas.
const { delta, reason } = parsed.data.inventory!;
const created = await prisma.medicationInventoryEvent.create({
data: { medicationId: id, delta, reason },
});
await auditLog("medication.glp1.update", {
userId: user.id,
ipAddress: ip,
details: {
medicationId: id,
kind: "inventory",
inventoryEventId: created.id,
delta,
reason,
},
});
annotate({
action: {
name: "medication.glp1.inventory.create",
entity_type: "medication_inventory_event",
entity_id: created.id,
},
meta: { medication_id: id, delta },
});
// A legacy ledger delta changes the stock the list payload reports
// for a medication with no per-item containers. Hard-evict so the
// card reflects it on the next read.
invalidateUserMedications(user.id, { evict: true });
return apiSuccess({ inventory: created }, 201);
}
@@ -22,6 +22,17 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/rate-limit", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/rate-limit")>()),
// The shared single-record write ceiling. Allowed here: this file is about
// what the handler does with a body it accepted, not about the bucket.
checkRecordWriteRateLimit: vi.fn(async () => ({
allowed: true,
limit: 300,
remaining: 299,
resetAt: Date.now() + 60_000,
})),
}));
vi.mock("@/lib/db", () => ({
prisma: {
medication: {
@@ -18,6 +18,17 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/rate-limit", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/rate-limit")>()),
// The shared single-record write ceiling. Allowed here: this file is about
// what the handler does with a body it accepted, not about the bucket.
checkRecordWriteRateLimit: vi.fn(async () => ({
allowed: true,
limit: 300,
remaining: 299,
resetAt: Date.now() + 60_000,
})),
}));
vi.mock("@/lib/db", () => ({
prisma: {
medication: {
@@ -10,6 +10,7 @@ import {
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
import {
intakeSchema,
listIntakeEventsSchema,
@@ -58,6 +59,16 @@ 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 follow-up — the sibling route `POST /api/medications/intake`
// refuses a delegate changing a dose the owner already recorded. This route
// reaches the same rows through the slot upsert, so without the same rule it
@@ -1,6 +1,17 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/rate-limit", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/rate-limit")>()),
// The shared single-record write ceiling. Allowed here: this file is about
// what the handler does with a body it accepted, not about the bucket.
checkRecordWriteRateLimit: vi.fn(async () => ({
allowed: true,
limit: 300,
remaining: 299,
resetAt: Date.now() + 60_000,
})),
}));
vi.mock("@/lib/db", () => ({
prisma: {
medicationIntakeEvent: {
+11
View File
@@ -21,6 +21,7 @@ import {
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
@@ -203,6 +204,16 @@ 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,
});
@@ -159,6 +159,17 @@ async function postAssessment(request: NextRequest): Promise<Response> {
// into the record: the ordinary way this instrument is used when the person
// it is about cannot run their own record. The row records what the person
// in the room reported; the audit row says who typed it.
//
// It writes ONE row outside its declared section, and the crossing is
// deliberate rather than an oversight: the `*_SCORE` Measurement below is a
// projection of this administration, minted `COMPUTED`, keyed on
// `assessment:<id>`, and it exists only so the trend reads through the chart
// and rollup tier without ever touching the encrypted item content. Fencing
// it on `measurements` the way the booster mint is fenced would file a
// screener whose own total is missing from the record — an inconsistency
// inside the section the grant DOES open, to avoid a derived point in one it
// does not. The consent copy for this section says so, which is where a
// widening belongs.
const { user, actor } = await requireRecordAuth("manage", "mind");
// Opt-in module (default OFF) — same gate as GET.
+13 -2
View File
@@ -1,6 +1,12 @@
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
sanitiseZodIssues,
} from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { getGlitchtipSettings } from "@/lib/monitoring-settings";
import { sendGlitchtipEvent } from "@/lib/monitoring/glitchtip";
@@ -42,7 +48,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
const parsed = clientErrorSchema.safeParse(body);
if (!parsed.success) return apiError("Invalid error data", 422);
if (!parsed.success)
return apiValidationError(
"Invalid error data",
sanitiseZodIssues(parsed.error.issues),
422,
);
const delivery = await sendGlitchtipEvent({
dsn: settings.glitchtipDsn,
@@ -31,6 +31,17 @@ const txClient = {
},
};
vi.mock("@/lib/rate-limit", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/rate-limit")>()),
// The shared single-record write ceiling. Allowed here: this file is about
// what the handler does with a body it accepted, not about the bucket.
checkRecordWriteRateLimit: vi.fn(async () => ({
allowed: true,
limit: 300,
remaining: 299,
resetAt: Date.now() + 60_000,
})),
}));
vi.mock("@/lib/db", () => ({
prisma: {
moodEntry: {
+11
View File
@@ -8,6 +8,7 @@ import {
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
import {
createMoodEntrySchema,
listMoodEntriesSchema,
@@ -105,6 +106,16 @@ 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,
});
@@ -42,6 +42,28 @@ vi.mock("@/lib/rate-limit", () => ({
}));
vi.mock("@/lib/api-response", () => ({
// The parse-refusal pair the routes now answer with: the same envelope
// plus the per-field issue list.
sanitiseZodIssues: (
issues: { path: (string | number)[]; code: string; message: string }[],
) =>
issues.map((issue) => ({
path: issue.path.join("."),
code: issue.code,
message: issue.message,
})),
apiValidationError: (
error: string,
issues: unknown[],
status: number,
options?: { errorCode?: string },
) => ({
data: null,
error,
status,
details: { issues },
meta: options?.errorCode ? { errorCode: options.errorCode } : undefined,
}),
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
apiError: (
error: string,
+12 -2
View File
@@ -3,7 +3,13 @@ import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import {
apiError,
apiSuccess,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate, getEvent } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { encrypt } from "@/lib/crypto";
@@ -53,7 +59,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
const result = z.safeParse(nightscoutConnectSchema, body);
if (!result.success) {
return apiError("A valid Nightscout URL is required", 422);
return apiValidationError(
"A valid Nightscout URL is required",
sanitiseZodIssues(result.error.issues),
422,
);
}
const { url, token } = result.data;
+12 -2
View File
@@ -2,7 +2,12 @@ import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { apiSuccess, apiError } from "@/lib/api-response";
import {
apiError,
apiSuccess,
apiValidationError,
sanitiseZodIssues,
} from "@/lib/api-response";
import { notificationPreferenceSchema } from "@/lib/validations/notifications";
import { EVENT_TYPES, CHANNEL_TYPE_LABELS } from "@/lib/notifications/types";
import type { ChannelType } from "@/lib/notifications/types";
@@ -106,7 +111,12 @@ export const PUT = apiHandler(async (request: NextRequest) => {
}
const parsed = notificationPreferenceSchema.safeParse(body);
if (!parsed.success) return apiError("Invalid data", 422);
if (!parsed.success)
return apiValidationError(
"Invalid data",
sanitiseZodIssues(parsed.error.issues),
422,
);
const { channelId, eventType, enabled } = parsed.data;
+12 -2
View File
@@ -2,7 +2,12 @@ import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, apiError } from "@/lib/api-response";
import {
apiError,
apiSuccess,
apiValidationError,
sanitiseZodIssues,
} from "@/lib/api-response";
import { CHANNEL_TYPE_LABELS, EVENT_TYPES } from "@/lib/notifications/types";
import type { ChannelType, EventType } from "@/lib/notifications/types";
import { reEnableChannel } from "@/lib/notifications/channel-state";
@@ -156,7 +161,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
const parsed = reEnableChannelSchema.safeParse(body);
if (!parsed.success) return apiError("Invalid data", 422);
if (!parsed.success)
return apiValidationError(
"Invalid data",
sanitiseZodIssues(parsed.error.issues),
422,
);
const channel = await prisma.notificationChannel.findFirst({
where: { id: parsed.data.channelId, userId: user.id },
+186
View File
@@ -0,0 +1,186 @@
/**
* v1.39 (C1) — `PATCH /api/onboarding/answers`.
*
* One step at a time, saved the moment it is answered, so leaving the flow and
* coming back resumes where it stopped (design spec §Principles 4). The body
* names the step and either its answer or a deliberate skip; the union in
* `src/lib/validations/onboarding-needs.ts` is what makes "skipped, and here is
* the answer anyway" unrepresentable.
*
* Idempotent: the state is computed by a pure function from the stored row and
* the body, so sending the same step twice writes the same value twice and the
* second write changes nothing a reader can see. The one instant that could
* drift — the first-result completion stamp — is kept from the existing row
* rather than re-taken.
*
* `requireAuth()` rather than `requireRecordAuth`: this route writes the
* CALLER's own record and refuses outright while the session is acting on
* somebody else's (403 `sharing.not_permitted`), at every grant level. The
* storage is keyed by record so a guardian running a managed profile's setup
* has a row to write, but the route into it belongs with the rest of the
* managed-record configuration (#939 / C2), not here — a delegable declaration
* is a reviewed entry in the sharing surface guard, not a default.
*/
import type { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma, toJson } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { applyOnboardingAnswer } from "@/lib/onboarding/needs-apply";
import { readHeldUnitPreferences } from "@/lib/onboarding/needs";
import {
ONBOARDING_RECORD_SELECT,
readOnboardingRecordState,
toOnboardingStateDto,
} from "@/lib/onboarding/needs-store";
import { checkRateLimit } from "@/lib/rate-limit";
import { onboardingAnswerSchema } from "@/lib/validations/onboarding-needs";
export const dynamic = "force-dynamic";
const RATE_LIMIT = 120;
const RATE_WINDOW_MS = 10 * 60 * 1000;
export const PATCH = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "onboarding.needs.save" } });
const rl = await checkRateLimit(
`onboarding-answers:${user.id}`,
RATE_LIMIT,
RATE_WINDOW_MS,
);
if (!rl.allowed) {
annotate({
action: { name: "onboarding.needs.save" },
meta: { outcome: "rate_limited" },
});
return apiError("Too many onboarding writes, try again later", 429, {
errorCode: "onboarding.answers.rateLimited",
});
}
const { data: body, error: jsonError } = await safeJson<unknown>(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = onboardingAnswerSchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "onboarding.needs.save" },
meta: { outcome: "validation_failed" },
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "onboarding.answers.invalid",
});
}
const answer = parsed.data;
const existing = await prisma.onboardingRecord.findUnique({
where: { userId: user.id },
select: ONBOARDING_RECORD_SELECT,
});
const next = applyOnboardingAnswer(
readOnboardingRecordState(existing),
answer,
new Date(),
);
// The unit answers are the one step whose consequence lives outside this
// table: `User.glucoseUnit` and `User.unitPreference` are what every display
// surface already reads, so the flow writes them rather than inventing a
// second source of truth. What is kept beside them in `needs.units` is the
// answer as given, so a restart can show it back without re-deriving it from
// a column Settings may have changed since.
const unitData: { glucoseUnit?: string; unitPreference?: string } = {};
if (answer.step === "units" && answer.status !== "skipped") {
if (answer.units.glucoseUnit) {
unitData.glucoseUnit = answer.units.glucoseUnit;
}
if (answer.units.unitPreference) {
unitData.unitPreference = answer.units.unitPreference;
}
}
const upsert = prisma.onboardingRecord.upsert({
where: { userId: user.id },
// Field-by-field, never a spread of the parsed body: the three columns are
// the pure function's output, and nothing the client sent reaches Prisma
// without passing through it.
create: {
userId: user.id,
needsJson: toJson(next.needs),
stepsJson: toJson(next.steps),
firstResultJson: next.firstResult ? toJson(next.firstResult) : undefined,
},
update: {
needsJson: toJson(next.needs),
stepsJson: toJson(next.steps),
...(next.firstResult
? { firstResultJson: toJson(next.firstResult) }
: {}),
},
select: ONBOARDING_RECORD_SELECT,
});
// One transaction, because the two writes are one answer: a failure between
// them would leave the display columns changed with no record of the answer
// that changed them.
const [written] =
Object.keys(unitData).length > 0
? await prisma.$transaction([
upsert,
prisma.user.update({ where: { id: user.id }, data: unitData }),
])
: await prisma.$transaction([upsert]);
const held = readHeldUnitPreferences({
glucoseUnit: unitData.glucoseUnit ?? user.glucoseUnit,
unitPreference: unitData.unitPreference ?? user.unitPreference,
});
// Both columns have a dedicated settings route that writes an audit row, and
// the trail should not depend on which surface the person changed them from.
// Same event names, so the activity panel groups them.
if (unitData.glucoseUnit !== undefined) {
await auditLog("user.glucose-unit.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
previous: user.glucoseUnit,
next: unitData.glucoseUnit,
source: "onboarding",
},
});
}
if (unitData.unitPreference !== undefined) {
await auditLog("user.unit-preference.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
previous: user.unitPreference,
next: unitData.unitPreference,
source: "onboarding",
},
});
}
annotate({
action: { name: "onboarding.needs.save" },
meta: {
step: answer.step,
status: answer.status === "skipped" ? "skipped" : "done",
},
});
return apiSuccess({ onboarding: toOnboardingStateDto(written, held) });
});
Loaded 100 of 255 files, more files were not shown because too many files have changed in this diff. Show more