mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Merge the issue list on every shape refusal, the shared limiter on single-record writes and the error-code catalogue
This commit is contained in:
commit
0c14cbd489
113 files changed
+3107
-663
No files matched your search
+504
-378
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,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([]);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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() },
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import {
|
||||
@@ -154,7 +155,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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { setOnboardingPendingCookie } from "@/lib/auth/session";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
@@ -22,9 +27,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
if (jsonError) return jsonError;
|
||||
const result = z.safeParse(onboardingCompleteSchema, body);
|
||||
if (!result.success) {
|
||||
return apiError("Invalid input", 422, {
|
||||
errorCode: "onboarding.complete.invalid",
|
||||
});
|
||||
return apiValidationError(
|
||||
"Invalid input",
|
||||
sanitiseZodIssues(result.error.issues),
|
||||
422,
|
||||
{
|
||||
errorCode: "onboarding.complete.invalid",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {
|
||||
|
||||
@@ -3,7 +3,12 @@ import { z } from "zod/v4";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { DISCLAIMER_VERSION } from "@/lib/onboarding/disclaimer";
|
||||
|
||||
/**
|
||||
@@ -38,9 +43,14 @@ export const POST = apiHandler(async (request) => {
|
||||
|
||||
const parsed = z.safeParse(bodySchema, rawBody);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid input", 422, {
|
||||
errorCode: "onboarding.disclaimer.invalid",
|
||||
});
|
||||
return apiValidationError(
|
||||
"Invalid input",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
{
|
||||
errorCode: "onboarding.disclaimer.invalid",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// The server pins the canonical version it stamps — the body version is
|
||||
|
||||
@@ -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 { setOnboardingPendingCookie } from "@/lib/auth/session";
|
||||
@@ -80,9 +82,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
action: { name: "onboarding.step" },
|
||||
meta: { outcome: "validation_failed" },
|
||||
});
|
||||
return apiError("Invalid step payload", 422, {
|
||||
errorCode: "onboarding.step.invalid",
|
||||
});
|
||||
return apiValidationError(
|
||||
"Invalid step payload",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
{
|
||||
errorCode: "onboarding.step.invalid",
|
||||
},
|
||||
);
|
||||
}
|
||||
const { step, goals } = parsed.data;
|
||||
|
||||
|
||||
@@ -4,7 +4,12 @@ import { z } from "zod/v4";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { tourProgressSchema } from "@/lib/onboarding/tour-progress";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
@@ -56,9 +61,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const parsed = z.safeParse(tourBodySchema, body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid input", 422, {
|
||||
errorCode: "onboarding.tour.invalid",
|
||||
});
|
||||
return apiValidationError(
|
||||
"Invalid input",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
{
|
||||
errorCode: "onboarding.tour.invalid",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Build the update field-by-field (no mass assignment). `completed`
|
||||
|
||||
@@ -23,6 +23,28 @@ vi.mock("@/lib/oura/credentials", () => ({
|
||||
}));
|
||||
|
||||
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: vi.fn(),
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { markDisconnected } from "@/lib/integrations/status";
|
||||
@@ -51,7 +56,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const result = z.safeParse(ouraCredentialsSchema, 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 storeOuraClientCredentials(
|
||||
|
||||
@@ -26,6 +26,28 @@ vi.mock("@/lib/polar/credentials", () => ({
|
||||
clearPolarClientCredentials: clearMock,
|
||||
}));
|
||||
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: vi.fn(async (req: { _body: unknown }) => ({
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
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 { markDisconnected } from "@/lib/integrations/status";
|
||||
import {
|
||||
@@ -51,7 +56,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const result = z.safeParse(polarCredentialsSchema, 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 storePolarClientCredentials(
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import {
|
||||
emailSettingsSchema,
|
||||
notificationChannelEnabledSchema,
|
||||
@@ -102,7 +108,12 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
const parsed = emailSettingsSchema.safeParse(body);
|
||||
if (!parsed.success) return apiError("Invalid data", 422);
|
||||
if (!parsed.success)
|
||||
return apiValidationError(
|
||||
"Invalid data",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
);
|
||||
|
||||
const { recipient, enabled } = parsed.data;
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import {
|
||||
notificationChannelEnabledSchema,
|
||||
ntfySettingsSchema,
|
||||
@@ -119,7 +125,12 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
const parsed = ntfySettingsSchema.safeParse(body);
|
||||
if (!parsed.success) return apiError("Invalid data", 422);
|
||||
if (!parsed.success)
|
||||
return apiValidationError(
|
||||
"Invalid data",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
);
|
||||
|
||||
const { serverUrl, topic, authToken, enabled } = parsed.data;
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { decrypt, encrypt } from "@/lib/crypto";
|
||||
import { deleteTelegramWebhook, setTelegramWebhook } from "@/lib/telegram";
|
||||
import { telegramSettingsSchema } from "@/lib/validations/telegram";
|
||||
@@ -171,7 +177,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const result = z.safeParse(telegramSettingsSchema, body);
|
||||
if (!result.success) {
|
||||
return apiError("Invalid input", 422);
|
||||
return apiValidationError(
|
||||
"Invalid input",
|
||||
sanitiseZodIssues(result.error.issues),
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
const { botToken, chatId, enabled } = result.data;
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import {
|
||||
notificationChannelEnabledSchema,
|
||||
webhookSettingsSchema,
|
||||
@@ -108,7 +114,12 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
const parsed = webhookSettingsSchema.safeParse(body);
|
||||
if (!parsed.success) return apiError("Invalid data", 422);
|
||||
if (!parsed.success)
|
||||
return apiValidationError(
|
||||
"Invalid data",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
);
|
||||
|
||||
const { url, headerName, headerValue, enabled } = parsed.data;
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { markDisconnected } from "@/lib/integrations/status";
|
||||
@@ -52,7 +57,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const result = z.safeParse(stravaCredentialsSchema, 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 storeStravaClientCredentials(
|
||||
|
||||
@@ -50,7 +50,12 @@ import { z } from "zod/v4";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { readNote } from "@/lib/crypto/note-cipher";
|
||||
@@ -211,7 +216,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
limit: url.searchParams.get("limit") ?? undefined,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid sync query", 422);
|
||||
return apiValidationError(
|
||||
"Invalid sync query",
|
||||
sanitiseZodIssues(parsed.error.issues),
|
||||
422,
|
||||
);
|
||||
}
|
||||
const limit = parsed.data.limit ?? DEFAULT_LIMIT;
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import {
|
||||
@@ -112,7 +113,17 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postVaccination));
|
||||
|
||||
async function postVaccination(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireRecordAuth("write", "profile");
|
||||
const { user, actor } = await requireRecordAuth("write", "profile");
|
||||
|
||||
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
|
||||
// batch siblings have always been capped; the per-record creates a looping
|
||||
// client hits were not.
|
||||
const writeRl = await checkRecordWriteRateLimit(actor.id);
|
||||
if (!writeRl.allowed) {
|
||||
return apiError("Too many writes, try again later", 429, {
|
||||
errorCode: "record_write.rate_limited",
|
||||
});
|
||||
}
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 32 * 1024,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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";
|
||||
@@ -45,7 +50,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const result = z.safeParse(whoopCredentialsSchema, 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({
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { isP2025 } from "@/lib/prisma-errors";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -46,7 +51,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
if (jsonError) return jsonError;
|
||||
const result = z.safeParse(withingsCredentialsSchema, 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({
|
||||
|
||||
@@ -92,8 +92,14 @@ export interface AutoExportRefusal {
|
||||
reason: AutoExportRefusalReason;
|
||||
}
|
||||
|
||||
/** A file-level problem: nothing can be read at all. */
|
||||
export const AUTO_EXPORT_FATAL_REASONS = [
|
||||
/**
|
||||
* A file-level problem: nothing can be read at all.
|
||||
*
|
||||
* These values ship as `meta.errorCode` on the import route's 422, which is
|
||||
* why the constant is named for what they are on the wire rather than for the
|
||||
* field that carries them internally.
|
||||
*/
|
||||
export const AUTO_EXPORT_FATAL_ERROR_CODES = [
|
||||
"empty_file",
|
||||
"missing_required_columns",
|
||||
"unreadable_json",
|
||||
@@ -102,10 +108,11 @@ export const AUTO_EXPORT_FATAL_REASONS = [
|
||||
"json_carries_no_intake_time",
|
||||
] as const;
|
||||
|
||||
export type AutoExportFatalReason = (typeof AUTO_EXPORT_FATAL_REASONS)[number];
|
||||
export type AutoExportFatalErrorCode =
|
||||
(typeof AUTO_EXPORT_FATAL_ERROR_CODES)[number];
|
||||
|
||||
export interface AutoExportParseOutcome {
|
||||
fatal?: { reason: AutoExportFatalReason; detail?: string };
|
||||
fatal?: { reason: AutoExportFatalErrorCode; detail?: string };
|
||||
doses: AutoExportDose[];
|
||||
refusals: AutoExportRefusal[];
|
||||
/** Data rows read, refusals included. `doses + refusals` must equal it. */
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* Every `meta.errorCode` the API emits, grouped by the surface that emits it.
|
||||
*
|
||||
* `meta.errorCode` is the mechanism this API chose for machine-readable errors
|
||||
* — the envelope's own description tells a client to branch on it rather than
|
||||
* on the prose — and until this file existed a client could not enumerate it.
|
||||
* Sixteen of the two hundred and forty-two codes appeared somewhere in
|
||||
* `docs/api/openapi.yaml`; the rest existed only in server source, so the only
|
||||
* way to learn one was to trigger the error in testing and the only way to
|
||||
* learn a new one had appeared was to trigger that too.
|
||||
*
|
||||
* The list is generated from the code and then committed as a literal, so it is
|
||||
* reviewable in a diff and stable across a release. `error-code-catalogue.test.ts`
|
||||
* re-derives it from the tree on every run and fails in both directions: a code
|
||||
* emitted and not listed, and a code listed and no longer emitted.
|
||||
*
|
||||
* Naming: four conventions coexist here — dot.case (the majority and the one
|
||||
* to use), bare snake_case, a camelCase segment, and a hyphen. Nothing is
|
||||
* renamed: a code is a wire value and a shipped client branches on it. New
|
||||
* codes are dot.case.
|
||||
*
|
||||
* Not in this list, and deliberately:
|
||||
*
|
||||
* - `assistant.disabled.<surface>` is built from a template at runtime
|
||||
* (`src/lib/feature-flags/index.ts`), so the surface segment is open.
|
||||
* `assistant.disabled.coach` is the one the iOS contract names.
|
||||
* - The integration-probe classes — `credentials_rejected`, `rate_limited`,
|
||||
* `upstream_error`, `timeout`, `connection_failed`, and the per-provider
|
||||
* additions beside them — are chosen by each `/test` route's own
|
||||
* categoriser and are already enumerated in those operations'
|
||||
* descriptions, where the per-provider differences can be stated.
|
||||
* - `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 envelope.
|
||||
*/
|
||||
export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
{
|
||||
ai_provider: ["ai_provider.invalid", "ai_provider.no_fields"],
|
||||
allergy: ["allergy.invalid"],
|
||||
analytics: ["analytics.invalid_query"],
|
||||
anamnesis: [
|
||||
"anamnesis.fact.conflict",
|
||||
"anamnesis.fact.currentExists",
|
||||
"anamnesis.fact.invalidValue",
|
||||
],
|
||||
auth: [
|
||||
"auth.admin.required",
|
||||
"auth.mfa.code_invalid",
|
||||
"auth.missing",
|
||||
"auth.refresh.invalid",
|
||||
"auth.refresh.reuse",
|
||||
"auth.refresh.revoked",
|
||||
"auth.scope.insufficient",
|
||||
"auth.stepup.required",
|
||||
"auth.token.expired",
|
||||
"auth.token.invalid",
|
||||
],
|
||||
backup: ["backup.payload.undecryptable", "backup.section.missing"],
|
||||
coach: [
|
||||
"coach.conversation.invalidTitle",
|
||||
"coach.conversation.notFound",
|
||||
"coach.fenced.attachmentConflict",
|
||||
"coach.fenced.attachmentLimit",
|
||||
"coach.fenced.attachmentNotFound",
|
||||
"coach.fenced.attachmentNotIndexed",
|
||||
"coach.fenced.attachmentRequired",
|
||||
"coach.fenced.attachmentUnavailable",
|
||||
"coach.fenced.invalid",
|
||||
"coach.request.invalid",
|
||||
],
|
||||
"coach-prefs": [
|
||||
"coach-prefs.body.invalid_json",
|
||||
"coach-prefs.body.invalid_shape",
|
||||
],
|
||||
consent: ["consent.ai.required"],
|
||||
cycle: [
|
||||
"cycle.bulk.invalid",
|
||||
"cycle.bulk.too_large",
|
||||
"cycle.calendar.invalid",
|
||||
"cycle.calendar.range",
|
||||
"cycle.cycles.invalid",
|
||||
"cycle.day-log.conflict",
|
||||
"cycle.day-log.invalid",
|
||||
"cycle.disabled",
|
||||
"cycle.period.invalid",
|
||||
"cycle.symptom.custom.invalid",
|
||||
"cycle.symptom.custom.limit",
|
||||
],
|
||||
"cycle-prefs": ["cycle-prefs.body.invalid_json", "cycle-prefs.invalid"],
|
||||
"disable-coach": ["disable-coach.body.invalid_json"],
|
||||
documents: [
|
||||
"documents.chat.conversationNotFound",
|
||||
"documents.chat.invalid",
|
||||
"documents.inbound.alreadyConfirmed",
|
||||
"documents.inbound.alreadyPartlyConfirmed",
|
||||
"documents.inbound.budgetExceeded",
|
||||
"documents.inbound.decryptFailed",
|
||||
"documents.inbound.encounterNotFound",
|
||||
"documents.inbound.episodeNotFound",
|
||||
"documents.inbound.extractFailed",
|
||||
"documents.inbound.factNotFound",
|
||||
"documents.inbound.factNotPending",
|
||||
"documents.inbound.factTypeMismatch",
|
||||
"documents.inbound.fileTooLarge",
|
||||
"documents.inbound.fileType",
|
||||
"documents.inbound.invalidMetadata",
|
||||
"documents.inbound.invalidQuery",
|
||||
"documents.inbound.invalidUpdate",
|
||||
"documents.inbound.localOcrDisabled",
|
||||
"documents.inbound.notFound",
|
||||
"documents.inbound.notIndexed",
|
||||
"documents.inbound.pdfNeedsAnthropic",
|
||||
"documents.inbound.providerUnsupported",
|
||||
"documents.inbound.quotaExceeded",
|
||||
"documents.inbound.rateLimited",
|
||||
"documents.inbound.restoreDuplicate",
|
||||
"documents.inbound.restoreGone",
|
||||
"documents.inbound.uploadBusy",
|
||||
"documents.inbound.uploadTimeout",
|
||||
],
|
||||
encounter: [
|
||||
"encounter.invalid",
|
||||
"encounter.link.invalid",
|
||||
"encounter.practitioner-not-found",
|
||||
"encounter.reminder-conflict",
|
||||
"encounter.reminder-not-found",
|
||||
],
|
||||
environment: [
|
||||
"environment.invalid",
|
||||
"environment.no_home",
|
||||
"environment.range_too_large",
|
||||
"environment.travel.not_found",
|
||||
],
|
||||
export: ["export.selection.empty", "export.selection.unknown_leaf"],
|
||||
familyHistory: ["familyHistory.invalid"],
|
||||
feedback: ["feedback.body.invalid"],
|
||||
health_score_config: [
|
||||
"health_score_config.invalid",
|
||||
"health_score_config.too_narrow",
|
||||
],
|
||||
illness: [
|
||||
"illness.day-log.invalid",
|
||||
"illness.disabled",
|
||||
"illness.episode.chronic-no-resolve",
|
||||
"illness.episode.invalid",
|
||||
"illness.episode.invalid-window",
|
||||
"illness.episode.parent-not-found",
|
||||
"illness.insights.invalid",
|
||||
],
|
||||
import: ["import.write_failed"],
|
||||
ingest: ["ingest.rate_limited"],
|
||||
insights: [
|
||||
"insights.generate.budgetExceeded",
|
||||
"insights.generate.outboundScreened",
|
||||
"insights.generate.profileScopeChanged",
|
||||
"insights.generate.scopeChanged",
|
||||
],
|
||||
labs: [
|
||||
"labs.biomarker.create.invalid",
|
||||
"labs.biomarker.duplicate",
|
||||
"labs.biomarker.notFound",
|
||||
"labs.biomarker.update.invalid",
|
||||
"labs.biomarker.update.referenceRangeInvalid",
|
||||
"labs.create.invalid",
|
||||
"labs.ocr.budgetExceeded",
|
||||
"labs.ocr.extractFailed",
|
||||
"labs.ocr.fileTooLarge",
|
||||
"labs.ocr.fileType",
|
||||
"labs.ocr.localOcrDisabled",
|
||||
"labs.ocr.pdfNeedsAnthropic",
|
||||
"labs.ocr.providerUnsupported",
|
||||
"labs.ocr.rateLimited",
|
||||
"labs.restore.invalid",
|
||||
"labs.result.notFound",
|
||||
"labs.update.invalid",
|
||||
"labs.update.linkedFieldsImmutable",
|
||||
"labs.update.numericExpected",
|
||||
"labs.update.qualitativeExpected",
|
||||
"labs.update.referenceRangeInvalid",
|
||||
"labs.update.sourceReferenceRangeInvalid",
|
||||
],
|
||||
managed_profile: [
|
||||
"managed_profile.guardian.duplicate",
|
||||
"managed_profile.guardian.managed_invitee",
|
||||
"managed_profile.guardian.required",
|
||||
"managed_profile.not_found",
|
||||
],
|
||||
measurement: [
|
||||
"measurement.batch.source_not_permitted",
|
||||
"measurement.batch.too_large",
|
||||
"measurement.bulk-delete.invalid",
|
||||
"measurement.create.invalid",
|
||||
"measurement.create.source_not_permitted",
|
||||
"measurement.delete.too_large",
|
||||
"measurement.duplicate_timestamp",
|
||||
"measurement.restore.invalid",
|
||||
"measurement.update.server_owned_source",
|
||||
],
|
||||
medication: ["medication.intake.import.invalid_format"],
|
||||
medications: [
|
||||
"medications.intake.bulk.apple_health_not_mirrored",
|
||||
"medications.intake.bulk.invalid",
|
||||
"medications.intake.bulk.too_large",
|
||||
"medications.intake.force_slot.invalid",
|
||||
"medications.intake.force_slot.occupied",
|
||||
"medications.intake.injection_site.disallowed",
|
||||
"medications.intake.taken_at.before_start",
|
||||
"medications.mirror.limit_exceeded",
|
||||
],
|
||||
mentalHealth: ["mentalHealth.rateLimited"],
|
||||
module: ["module.disabled"],
|
||||
modules: ["modules.body.invalid_json", "modules.invalid"],
|
||||
mood: [
|
||||
"mood.bulk-delete.invalid",
|
||||
"mood.bulk.invalid",
|
||||
"mood.bulk.too_large",
|
||||
"mood.create.external_id_not_delegable",
|
||||
"mood.create.invalid",
|
||||
"mood.duplicate_timestamp",
|
||||
"mood.linkedContext.invalid",
|
||||
"mood.list.invalid",
|
||||
"mood.not_found",
|
||||
"mood.ratedFactor.out_of_range",
|
||||
"mood.restore.invalid",
|
||||
"mood.update.invalid",
|
||||
],
|
||||
"notification-prefs": ["notification-prefs.body.invalid_json"],
|
||||
nutrient: [
|
||||
"nutrient.batch.invalid",
|
||||
"nutrient.batch.too_large",
|
||||
"nutrient.daily.invalid",
|
||||
"nutrient.read.invalid",
|
||||
"nutrient.water.invalid",
|
||||
"nutrient.water.invalid_day",
|
||||
],
|
||||
onboarding: [
|
||||
"onboarding.complete.invalid",
|
||||
"onboarding.disclaimer.invalid",
|
||||
"onboarding.step.completed",
|
||||
"onboarding.step.concurrent",
|
||||
"onboarding.step.invalid",
|
||||
"onboarding.step.outOfOrder",
|
||||
"onboarding.step.rateLimited",
|
||||
"onboarding.tour.invalid",
|
||||
"onboarding.user.notFound",
|
||||
],
|
||||
personal_records: ["personal_records.invalid_query"],
|
||||
practitioner: ["practitioner.invalid"],
|
||||
profile: [
|
||||
"profile.update.emailInUse",
|
||||
"profile.update.invalidBody",
|
||||
"profile.update.nothingSaved",
|
||||
],
|
||||
record_write: ["record_write.rate_limited"],
|
||||
"report-selection": [
|
||||
"report-selection.body.invalid_json",
|
||||
"report-selection.body.invalid_shape",
|
||||
"report-selection.leaves.unknown",
|
||||
],
|
||||
route: ["route.retired"],
|
||||
"share-link": [
|
||||
"share-link.document.decryptFailed",
|
||||
"share-link.document.notFound",
|
||||
"share-link.document.rateLimited",
|
||||
"share-link.documents.invalid",
|
||||
"share-link.report.notFound",
|
||||
"share-link.report.rateLimited",
|
||||
"share-link.selection.forbidden_leaf",
|
||||
"share-link.selection.unknown_leaf",
|
||||
],
|
||||
sharing: [
|
||||
"sharing.accept.expired",
|
||||
"sharing.accept.not_found",
|
||||
"sharing.accept.not_pending",
|
||||
"sharing.accept.revoked",
|
||||
"sharing.access.denied",
|
||||
"sharing.invite.duplicate",
|
||||
"sharing.invite.invalid",
|
||||
"sharing.invite.invalid_scope",
|
||||
"sharing.invite.manage_browser_only",
|
||||
"sharing.invite.refused",
|
||||
"sharing.invite.self",
|
||||
"sharing.not_permitted",
|
||||
"sharing.renounce.already_ended",
|
||||
"sharing.renounce.not_found",
|
||||
"sharing.revoke.already_ended",
|
||||
"sharing.revoke.not_found",
|
||||
"sharing.session.changed",
|
||||
"sharing.switch.invalid",
|
||||
"sharing.switch.wrong_transport",
|
||||
],
|
||||
"source-priority": [
|
||||
"source-priority.body.invalid_json",
|
||||
"source-priority.body.invalid_shape",
|
||||
],
|
||||
tokens: ["tokens.measurements.ceiling_reached"],
|
||||
vaccination: [
|
||||
"vaccination.booster-invalid",
|
||||
"vaccination.booster-no-antigen",
|
||||
"vaccination.encounter-not-found",
|
||||
"vaccination.identity-required",
|
||||
"vaccination.invalid",
|
||||
"vaccination.link.invalid",
|
||||
"vaccination.not-found",
|
||||
"vaccination.practitioner-not-found",
|
||||
],
|
||||
workout: [
|
||||
"workout.batch.invalid",
|
||||
"workout.batch.payload_too_large",
|
||||
"workout.batch.too_large",
|
||||
],
|
||||
"(unprefixed)": [
|
||||
"about_me_conflict",
|
||||
"ai_response_truncated",
|
||||
"coach_prefs_conflict",
|
||||
"dashboard_layout_conflict",
|
||||
"empty_file",
|
||||
"health_score_config_conflict",
|
||||
"insights_layout_conflict",
|
||||
"invalid_base_updated_at",
|
||||
"json_carries_no_intake_time",
|
||||
"json_not_an_array",
|
||||
"medication_layout_conflict",
|
||||
"missing_required_columns",
|
||||
"modules_conflict",
|
||||
"mood_tag_layout_conflict",
|
||||
"not_configured",
|
||||
"notification_prefs_conflict",
|
||||
"oidc_only",
|
||||
"private_origin_not_approved",
|
||||
"rate_limited_self",
|
||||
"too_many_rows",
|
||||
"unreadable_json",
|
||||
"upstream_invalid_json",
|
||||
"vapid_not_configured",
|
||||
],
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The catalogue as one sentence for the published `meta.errorCode` description.
|
||||
*
|
||||
* Rendered rather than hand-written so the document cannot drift from the list
|
||||
* the guard checks: there is one source, and it is the object above.
|
||||
*/
|
||||
export function renderErrorCodeCatalogue(): string {
|
||||
return Object.entries(ERROR_CODE_CATALOGUE)
|
||||
.map(([surface, codes]) => `${surface} — ${codes.join(", ")}`)
|
||||
.join("; ");
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
idempotencyKeyParameter,
|
||||
idempotentWrite,
|
||||
recordRefusal,
|
||||
recordWriteRateLimitResponse,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
|
||||
@@ -125,6 +126,7 @@ export const allergyPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
...recordWriteRateLimitResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Loaded 100 of 113 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user