Merge branch 'release/v1.46.0' into feat/v146-integrations

This commit is contained in:
Marc-André Bombeck committed 2026-10-23 14:13:32 +02:00
commit 870c8e053a
532 files changed
+83264 -16479

No files matched your search

+89 -1
View File
@@ -48217,6 +48217,32 @@ components:
description: Time asleep across those naps, in minutes.
type: number
minimum: 0
sleepStages:
description: The main night's minutes per stage (`IN_BED`, `ASLEEP`, `AWAKE`, `CORE`, `DEEP`, `REM`), each the union of
that stage's spans; empty on a day of naps alone.
type: object
propertyNames:
type: string
additionalProperties:
type: number
minimum: 0
inBedMinutes:
description: The main night's time in bed in minutes, or null when no in-bed span was recorded.
anyOf:
- type: number
minimum: 0
- type: "null"
awakeMinutes:
description: The main night's time awake in bed in minutes, or null when no awake span was recorded.
anyOf:
- type: number
minimum: 0
- type: "null"
awakenings:
description: Awake spans between the main night's first and last time asleep.
type: integer
minimum: 0
maximum: 9007199254740991
required:
- id
- type
@@ -48987,6 +49013,32 @@ components:
description: Time asleep across those naps, in minutes.
type: number
minimum: 0
sleepStages:
description: The main night's minutes per stage (`IN_BED`, `ASLEEP`, `AWAKE`, `CORE`, `DEEP`, `REM`), each the union of
that stage's spans; empty on a day of naps alone.
type: object
propertyNames:
type: string
additionalProperties:
type: number
minimum: 0
inBedMinutes:
description: The main night's time in bed in minutes, or null when no in-bed span was recorded.
anyOf:
- type: number
minimum: 0
- type: "null"
awakeMinutes:
description: The main night's time awake in bed in minutes, or null when no awake span was recorded.
anyOf:
- type: number
minimum: 0
- type: "null"
awakenings:
description: Awake spans between the main night's first and last time asleep.
type: integer
minimum: 0
maximum: 9007199254740991
required:
- id
- type
@@ -49015,6 +49067,16 @@ components:
type: integer
minimum: 0
maximum: 9007199254740991
minValue:
description: The lowest reading in the bucket; absent on a summed (cumulative) type.
type:
- number
- "null"
maxValue:
description: The highest reading in the bucket; absent on a summed (cumulative) type.
type:
- number
- "null"
unit:
type: string
required:
@@ -49192,6 +49254,32 @@ components:
description: Time asleep across those naps, in minutes.
type: number
minimum: 0
sleepStages:
description: The main night's minutes per stage (`IN_BED`, `ASLEEP`, `AWAKE`, `CORE`, `DEEP`, `REM`), each the union of
that stage's spans; empty on a day of naps alone.
type: object
propertyNames:
type: string
additionalProperties:
type: number
minimum: 0
inBedMinutes:
description: The main night's time in bed in minutes, or null when no in-bed span was recorded.
anyOf:
- type: number
minimum: 0
- type: "null"
awakeMinutes:
description: The main night's time awake in bed in minutes, or null when no awake span was recorded.
anyOf:
- type: number
minimum: 0
- type: "null"
awakenings:
description: Awake spans between the main night's first and last time asleep.
type: integer
minimum: 0
maximum: 9007199254740991
status:
type: string
enum:
@@ -49216,11 +49304,11 @@ components:
properties:
data:
anyOf:
- $ref: "#/components/schemas/NamedSleepStageMeasurement"
- $ref: "#/components/schemas/MeasurementResource"
- type: array
items:
$ref: "#/components/schemas/MeasurementResource"
- $ref: "#/components/schemas/NamedSleepStageMeasurement"
error:
type: "null"
meta:
@@ -16,6 +16,11 @@
import { describe, it, expect } from "vitest";
import { join } from "node:path";
import {
API_HANDLER,
authGuardModules,
guardSourceOf,
} from "./helpers/auth-guards";
import {
readSource,
readSourceText,
@@ -80,8 +85,16 @@ describe("the acting-account carrier is read in one place", () => {
// Reads it. The one statement of "what does this request say it is acting
// as", for both transports and for every caller that has to ask.
"lib/auth/acting-carrier.ts",
// The resolver. The only thing that acts on the value.
"lib/api-handler.ts",
// The resolver. The only thing that acts on the value: the guard module
// that reads the carrier off the auth context.
"lib/auth/guards/acting.ts",
// Declares the field on the auth context the resolvers pass around, and
// explains it in prose. Holds no logic that could read it.
"lib/auth/guards/context.ts",
// Names the column in prose only: the record resolver's note on why the
// fence and the carrier read one session row. It reads the carrier
// through `acting.ts`.
"lib/auth/guards/record.ts",
// v1.36.0 — the only thing that WRITES it: the switch endpoint sets it
// through here, ending a grant clears it through here. The routes
// themselves call these two functions and never name the column, which is
@@ -180,7 +193,7 @@ describe("the cookie-only helpers cannot see the acting account", () => {
const COOKIE_ONLY = ["requireAdmin", "requireCookieAuth", "requireFreshMfa"];
it.each(COOKIE_ONLY)("%s resolves the actor and nothing else", (name) => {
const body = functionBody(readSource("src/lib/api-handler.ts", name), name);
const body = functionBody(guardSourceOf(name), name);
// Non-zero proof, twice over: the slice found a function, and the slice is
// the function it claims to be. A renamed helper must fail here rather
@@ -194,7 +207,7 @@ describe("the cookie-only helpers cannot see the acting account", () => {
});
it("substitutes a data-scope user in exactly two functions", () => {
const src = readSource("src/lib/api-handler.ts", "requireRecordAuth");
const src = guardSourceOf("requireRecordAuth", ["requireGuardianAuth"]);
const record = functionBody(src, "requireRecordAuth");
expect(record).toContain("readActingCarrier");
@@ -210,8 +223,22 @@ describe("the cookie-only helpers cannot see the acting account", () => {
// `readActingCarrier` is what turns a request into "acting as somebody
// else". Three callers: the bare path, which refuses on it, and the two
// record paths, which act on it. A fourth would be a mode nobody declared.
const callers = (src.match(/await readActingCarrier\(/g) ?? []).length;
// Counted over the whole auth layer, the wrapper included, because the
// bare path lives in another guard module than the record paths.
const callers = (
[API_HANDLER, ...authGuardModules().map(([rel]) => rel)]
.map((rel) => read(rel))
.join("\n")
.match(/await readActingCarrier\(/g) ?? []
).length;
expect(callers).toBe(3);
expect(
(
functionBody(guardSourceOf("requireAuth"), "requireAuth").match(
/await readActingCarrier\(/g,
) ?? []
).length,
).toBe(1);
});
});
@@ -314,8 +314,8 @@ describe("workout route geometry never reaches an AI path", () => {
const ROUTE_READERS: Record<string, string> = {
"app/api/workouts/[id]/route.ts":
"The workout detail read: serves the track to the person's own map.",
"app/api/workouts/batch/route.ts":
"Writes a kept track (opt-in) and derives its climb before storage.",
"lib/workouts/batch-ingest/write.ts":
"The workout batch ingest's write: stores a kept track (opt-in), sealed.",
"lib/apple-health/route-import.ts":
"Writes a kept track from the Apple Health export (opt-in).",
"lib/crypto/encrypted-columns.ts":
@@ -48,7 +48,7 @@ import {
convertHkValue,
isConvertibleHkUnit,
} from "@/lib/measurements/hk-units";
import { readSource } from "@/__tests__/helpers/source-files";
import { readSource, readSourceFolder } from "@/__tests__/helpers/source-files";
const ENTRIES = Object.values(APPLE_HEALTH_TYPE_MAP);
@@ -194,10 +194,17 @@ describe("Apple Health mapping — the record's own unit (issue #944)", () => {
"utf8",
);
expect(archive).toContain(optIn);
const batch = readSource("src/app/api/measurements/batch/route.ts", [
"export const POST",
"hkIdentifier",
]);
// The route parses; its ingest maps the entries in the service folder.
const batch = [
readSource("src/app/api/measurements/batch/route.ts", [
"export const POST",
"hkIdentifier",
]),
...readSourceFolder("src/lib/measurements/batch-ingest", {
floor: 5,
}).map(([, text]) => text),
].join("\n");
expect(batch).toContain("mapAppleHealthEntry(");
expect(
batch,
"the batch route opted into record-unit conversion",
@@ -0,0 +1,80 @@
/**
* The request guards have one public entry.
*
* `requireAuth`, the record modes, the cookie-only helpers and the step-up
* gates live in `src/lib/auth/guards/`, one module per family, and
* `src/lib/api-handler.ts` re-exports them beside `apiHandler`. Routes, jobs
* and libraries import them from there. The guard modules import each other.
* Nothing else reaches into the folder.
*
* One entry keeps the structural guards that freeze the auth surface honest:
* they match an import of `@/lib/api-handler` and follow the local name to its
* calls (`step-up-elevation-guard`, `recent-proof-surface-guard`,
* `sharing-surface-guard`). A route that imported `requireMfaManagementAuth`
* from its guard module directly would be invisible to all of them.
*/
import { dirname, join, relative, sep } from "node:path";
import { describe, expect, it } from "vitest";
import { AUTH_GUARDS_DIR, authGuardModules } from "./helpers/auth-guards";
import {
productionSources,
readSrc,
testSources,
} from "./helpers/source-metrics";
const SRC = join(process.cwd(), "src");
const GUARDS = "lib/auth/guards/";
/** The first-party module a specifier names, relative to `src/`, or null. */
function target(specifier: string, from: string): string | null {
if (specifier.startsWith("@/")) return specifier.slice(2);
if (specifier.startsWith(".")) {
return relative(SRC, join(SRC, dirname(from), specifier))
.split(sep)
.join("/");
}
return null;
}
/**
* Every module specifier in a file: static imports and re-exports, type-only
* ones included (a type import is still a reach into the folder), and
* dynamic `import("…")` / `vi.mock("…")` with a literal.
*/
const SPECIFIER =
/\bfrom\s*["']([^"']+)["']|\b(?:import|mock|doMock)\(\s*["']([^"']+)["']/g;
/** Files that import, re-export or mock anything in the guard folder. */
function importersOfGuards(files: readonly string[]): string[] {
return files.filter((rel) => {
const text = readSrc(rel);
if (!text.includes("guards")) return false;
return [...text.matchAll(SPECIFIER)].some((m) =>
(target(m[1] ?? m[2], rel) ?? "").startsWith(GUARDS),
);
});
}
describe("the auth guards' public entry", () => {
it("is the only module outside the folder that imports a guard module", () => {
const outside = importersOfGuards(productionSources()).filter(
(rel) => !rel.startsWith(GUARDS),
);
// Non-zero proof: the entry itself must be found, or the matcher is blind.
expect(outside.length).toBeGreaterThan(0);
expect(outside).toEqual(["lib/api-handler.ts"]);
});
it("is not bypassed by tests either", () => {
// Tests import the guards the way routes do, so a mock of
// `@/lib/api-handler` stays the one seam they replace.
expect(importersOfGuards(testSources())).toEqual([]);
});
it("covers a folder that is still there", () => {
expect(authGuardModules().length).toBeGreaterThanOrEqual(10);
expect(AUTH_GUARDS_DIR).toBe(`src/${GUARDS.slice(0, -1)}`);
});
});
@@ -55,6 +55,7 @@ const ADMITTED: Record<string, { jobs: string[]; why: string }> = {
const NEVER = [
"lib/insights/status-batch.ts",
"lib/insights/status-card-generation.ts",
"lib/insights/status-pipeline.ts",
"lib/insights/metric-status.ts",
"lib/insights/biomarker-status.ts",
"lib/insights/derived/derived-assessment-ai.ts",
+1 -36
View File
@@ -1,47 +1,12 @@
{
"$comment": "Runtime import cycles under src/: the back-edge count and the strongly connected components. Maintained by src/__tests__/import-cycle-guard.test.ts.",
"cycles": 13,
"cycles": 2,
"components": [
[
"components/command-palette/command-palette.tsx",
"components/command-palette/palette-store.ts",
"components/layout/global-shortcuts.tsx"
],
[
"lib/analytics/compliance.ts",
"lib/analytics/compliance/display.ts",
"lib/analytics/compliance/ledger.ts",
"lib/analytics/compliance/occurrences.ts",
"lib/analytics/compliance/windows.ts",
"lib/medications/scheduling/band-minter.ts",
"lib/medications/scheduling/cadence.ts",
"lib/medications/scheduling/compliance.ts",
"lib/medications/scheduling/schedule-eras.ts"
],
[
"lib/auth/audit.ts",
"lib/geo.ts",
"lib/integrations/oauth-kit/connect-target.ts",
"lib/jobs/offhost-purge.ts",
"lib/managed-profiles/lifecycle.ts",
"lib/notifications/channel-state.ts",
"lib/notifications/delivery-identity.ts",
"lib/notifications/dispatch-localised.ts",
"lib/notifications/dispatcher.ts",
"lib/sharing/grants.ts"
],
[
"lib/cycle/gate.ts",
"lib/modules/gate.ts"
],
[
"lib/insights/features.ts",
"lib/insights/signals-of-day.ts"
],
[
"lib/insights/mood-aggregates.ts",
"lib/insights/mood-patterns.ts"
],
[
"lib/jobs/boss-instance.ts",
"lib/jobs/boss-ready.ts"
@@ -0,0 +1,68 @@
{
"$comment": "safeJson calls per route file under src/app; new body-reading routes use parseJsonBody. Maintained by src/__tests__/route-body-parse-ratchet.test.ts.",
"count": 61,
"total": 61,
"entries": {
"app/api/admin/ai-settings/route.ts": 1,
"app/api/admin/drain-per-sample-cumulative/route.ts": 1,
"app/api/admin/encryption/key-backup/verify/route.ts": 1,
"app/api/admin/notifications/reminder-check/route.ts": 1,
"app/api/admin/rollups/recompute/route.ts": 1,
"app/api/admin/users/[id]/reset-password/route.ts": 1,
"app/api/ai/test/route.ts": 1,
"app/api/auth/login/route.ts": 1,
"app/api/auth/me/locale/route.ts": 1,
"app/api/auth/me/mfa/disable/route.ts": 1,
"app/api/auth/me/mfa/totp/confirm/route.ts": 1,
"app/api/auth/me/timezone/route.ts": 1,
"app/api/auth/mfa/verify/route.ts": 1,
"app/api/auth/mfa/webauthn/verify/route.ts": 1,
"app/api/auth/passkey/login-options/route.ts": 1,
"app/api/auth/profile/route.ts": 1,
"app/api/auth/refresh/route.ts": 1,
"app/api/coach/about-me/route.ts": 1,
"app/api/cycle/day-logs/bulk/route.ts": 1,
"app/api/dashboard/widgets/route.ts": 1,
"app/api/dawarich/connect/route.ts": 1,
"app/api/dawarich/disconnect/route.ts": 1,
"app/api/dawarich/settings/route.ts": 1,
"app/api/dawarich/sync/route.ts": 1,
"app/api/documents/inbound/[id]/extract/route.ts": 1,
"app/api/documents/inbound/[id]/index/route.ts": 1,
"app/api/documents/inbound/[id]/suggest/route.ts": 1,
"app/api/documents/inbound/[id]/summary/route.ts": 1,
"app/api/fitbit/credentials/route.ts": 1,
"app/api/google-health/credentials/route.ts": 1,
"app/api/import/route.ts": 1,
"app/api/insights/generate/route.ts": 1,
"app/api/insights/layout/route.ts": 1,
"app/api/integrations/healthkit/route.ts": 1,
"app/api/internal/deploy-webhook/route.ts": 1,
"app/api/labs/ocr/extract/route.ts": 1,
"app/api/measurements/batch/route.ts": 1,
"app/api/measurements/by-external-ids/route.ts": 1,
"app/api/measurements/route.ts": 1,
"app/api/medications/[id]/intake/[eventId]/route.ts": 1,
"app/api/medications/[id]/intake/route.ts": 1,
"app/api/medications/intake/bulk/route.ts": 1,
"app/api/medications/intake/route.ts": 1,
"app/api/medications/layout/route.ts": 1,
"app/api/medications/route.ts": 1,
"app/api/mood-entries/bulk/route.ts": 1,
"app/api/mood/tags/layout/route.ts": 1,
"app/api/nightscout/connect/route.ts": 1,
"app/api/nutrients/batch/route.ts": 1,
"app/api/oura/credentials/route.ts": 1,
"app/api/polar/credentials/route.ts": 1,
"app/api/record-settings/[family]/route.ts": 1,
"app/api/settings/email/route.ts": 1,
"app/api/settings/ntfy/route.ts": 1,
"app/api/settings/telegram/route.ts": 1,
"app/api/settings/webhook/route.ts": 1,
"app/api/strava/credentials/route.ts": 1,
"app/api/user/profile/route.ts": 1,
"app/api/whoop/credentials/route.ts": 1,
"app/api/withings/credentials/route.ts": 1,
"app/api/workouts/batch/route.ts": 1
}
}
+6 -13
View File
@@ -1,16 +1,13 @@
{
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 113,
"total": 132997,
"count": 106,
"total": 122271,
"entries": {
"app/api/dashboard/summary/route.ts": 1032,
"app/api/documents/inbound/route.ts": 811,
"app/api/insights/generate/route.ts": 1214,
"app/api/measurements/batch/route.ts": 1562,
"app/api/measurements/route.ts": 938,
"app/api/medications/[id]/route.ts": 1281,
"app/api/medications/[id]/route.ts": 1270,
"app/api/medications/intake/bulk/route.ts": 1044,
"app/api/workouts/batch/route.ts": 958,
"app/medications/page-client.tsx": 839,
"app/page-client.tsx": 2324,
"components/admin/backups-section.tsx": 1462,
@@ -46,7 +43,6 @@
"lib/ai/coach/results/metric-table-tool.ts": 1102,
"lib/ai/coach/snapshot.ts": 1347,
"lib/ai/coach/stream-events.ts": 817,
"lib/ai/coach/tools/executor.ts": 1522,
"lib/ai/coach/turn/model.ts": 820,
"lib/ai/coach/types.ts": 1011,
"lib/ai/codex-client.ts": 1094,
@@ -56,7 +52,6 @@
"lib/analytics/score/reader.ts": 998,
"lib/analytics/sleep-night.ts": 1345,
"lib/analytics/summaries-slice.ts": 1248,
"lib/api-handler.ts": 1978,
"lib/auth/session.ts": 811,
"lib/cache/server-cache.ts": 851,
"lib/cycle/prediction.ts": 883,
@@ -84,9 +79,8 @@
"lib/insights/correlation-channel-series.ts": 1339,
"lib/insights/correlation-discovery.ts": 1240,
"lib/insights/correlations.ts": 1026,
"lib/insights/features.ts": 1923,
"lib/insights/mood-aggregates.ts": 1055,
"lib/insights/mood-status.ts": 814,
"lib/insights/features.ts": 1875,
"lib/insights/mood-aggregates.ts": 1053,
"lib/insights/status-cache.ts": 812,
"lib/integrations/status.ts": 1445,
"lib/jobs/backup-restore.ts": 887,
@@ -99,7 +93,6 @@
"lib/managed-profiles/handover.ts": 986,
"lib/mcp/record-search.ts": 1467,
"lib/mcp/rich-reads.ts": 1467,
"lib/mcp/tools.ts": 2654,
"lib/measurements/dense-intraday-retention.ts": 1057,
"lib/measurements/import-apple-health-export.ts": 1722,
"lib/medications/scheduling/cadence.ts": 978,
@@ -109,7 +102,7 @@
"lib/oura/sync.ts": 900,
"lib/rollups/measurement-rollups.ts": 1496,
"lib/search/search-records.ts": 996,
"lib/sharing/grants.ts": 1046,
"lib/sharing/grants.ts": 807,
"lib/support/report.ts": 903,
"lib/telegram-webhook-handlers.ts": 1341,
"lib/validations/inbound-documents.ts": 1130,
@@ -1,71 +1,64 @@
{
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 402,
"total": 144428,
"count": 384,
"total": 135706,
"entries": {
"app/achievements/page.tsx :: AchievementsPage": 206,
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314,
"app/api/admin/notifications/reminder-check/route.ts :: apiHandler(arg)": 208,
"app/api/admin/settings/route.ts :: apiHandler(arg) #2": 217,
"app/api/admin/settings/route.ts :: apiHandler(arg) #2": 211,
"app/api/analytics/route.ts :: buildAnalyticsResponse": 446,
"app/api/auth/login/route.ts :: apiHandler(arg)": 262,
"app/api/auth/me/route.ts :: apiHandler(arg)": 386,
"app/api/auth/oidc/callback/route.ts :: apiHandler(arg)": 499,
"app/api/auth/register/route.ts :: apiHandler(arg)": 275,
"app/api/auth/register/route.ts :: apiHandler(arg)": 268,
"app/api/coach/about-me/route.ts :: apiHandler(arg) #2": 309,
"app/api/cycle/calendar/route.ts :: apiHandler(arg)": 230,
"app/api/cycle/day-logs/bulk/route.ts :: postBulk": 213,
"app/api/cycle/insights/route.ts :: apiHandler(arg)": 226,
"app/api/dashboard/summary/route.ts :: buildDashboardSummary": 606,
"app/api/devices/route.ts :: apiHandler(arg)": 237,
"app/api/documents/inbound/bulk/route.ts :: apiHandler(arg)": 212,
"app/api/devices/route.ts :: apiHandler(arg)": 231,
"app/api/documents/inbound/bulk/route.ts :: apiHandler(arg)": 207,
"app/api/documents/inbound/route.ts :: processUpload": 309,
"app/api/encounters/[id]/route.ts :: apiHandler(arg) #2": 275,
"app/api/encounters/[id]/route.ts :: apiHandler(arg) #2": 269,
"app/api/environment/route.ts :: apiHandler(arg)": 201,
"app/api/export/health-record/route.ts :: apiHandler(arg)": 246,
"app/api/export/health-record/route.ts :: apiHandler(arg)": 241,
"app/api/import/csv/route.ts :: apiHandler(arg)": 407,
"app/api/import/route.ts :: apiHandler(arg)": 364,
"app/api/ingest/medication/route.ts :: apiHandler(arg)": 269,
"app/api/ingest/medication/route.ts :: apiHandler(arg)": 267,
"app/api/insights/cards/route.ts :: apiHandler(arg)": 211,
"app/api/insights/comprehensive/route.ts :: buildComprehensiveResponse": 505,
"app/api/insights/generate/route.ts :: apiHandler(arg) #2": 866,
"app/api/insights/generate/route.ts :: apiHandler(arg) > withFeatureCacheScope(arg)": 862,
"app/api/integrations/status/route.ts :: apiHandler(arg)": 451,
"app/api/labs/[id]/route.ts :: apiHandler(arg) #2": 244,
"app/api/labs/ocr/commit/route.ts :: commitOcrRows": 290,
"app/api/labs/route.ts :: postLabResult": 248,
"app/api/labs/[id]/route.ts :: apiHandler(arg) #2": 239,
"app/api/labs/ocr/commit/route.ts :: commitOcrRows": 285,
"app/api/labs/route.ts :: postLabResult": 242,
"app/api/mcp/oauth/token/route.ts :: POST": 250,
"app/api/mcp/oauth/token/route.ts :: POST > withBackgroundEvent(arg)": 248,
"app/api/measurement-reminders/[id]/route.ts :: apiHandler(arg) #2": 240,
"app/api/measurements/[id]/route.ts :: apiHandler(arg) #2": 203,
"app/api/measurements/batch/route.ts :: postBatch": 1255,
"app/api/measurements/route.ts :: apiHandler(arg)": 286,
"app/api/measurements/route.ts :: postMeasurement": 377,
"app/api/measurement-reminders/[id]/route.ts :: apiHandler(arg) #2": 239,
"app/api/measurements/series/route.ts :: apiHandler(arg)": 549,
"app/api/medications/[id]/dose-history/route.ts :: apiHandler(arg)": 256,
"app/api/medications/[id]/intake/[eventId]/route.ts :: apiHandler(arg)": 390,
"app/api/medications/[id]/intake/route.ts :: postIntake": 607,
"app/api/medications/[id]/inventory/[itemId]/route.ts :: apiHandler(arg)": 210,
"app/api/medications/[id]/inventory/[itemId]/route.ts :: apiHandler(arg)": 204,
"app/api/medications/[id]/route.ts :: apiHandler(arg)": 214,
"app/api/medications/[id]/route.ts :: apiHandler(arg) #2": 892,
"app/api/medications/[id]/route.ts :: apiHandler(arg) #2": 886,
"app/api/medications/[id]/route.ts :: apiHandler(arg) > prisma.$transaction(arg)": 293,
"app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts :: apiHandler(arg)": 254,
"app/api/medications/extract/route.ts :: handleExtract": 203,
"app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts :: apiHandler(arg)": 250,
"app/api/medications/extract/route.ts :: handleExtract": 201,
"app/api/medications/intake/bulk/route.ts :: postBulk": 846,
"app/api/medications/intake/route.ts :: apiHandler(arg) #2": 432,
"app/api/medications/route.ts :: postMedication": 403,
"app/api/mental-health/assessments/route.ts :: postAssessment": 254,
"app/api/mood-entries/[id]/route.ts :: apiHandler(arg) #2": 302,
"app/api/mental-health/assessments/route.ts :: postAssessment": 249,
"app/api/mood-entries/[id]/route.ts :: apiHandler(arg) #2": 296,
"app/api/mood-entries/bulk/route.ts :: postBulk": 479,
"app/api/mood-entries/route.ts :: postMoodEntry": 329,
"app/api/mood-entries/route.ts :: postMoodEntry": 324,
"app/api/nutrients/batch/route.ts :: postBatch": 304,
"app/api/onboarding/complete/route.ts :: completeNeedsFlow": 218,
"app/api/record-settings/[family]/route.ts :: apiHandler(arg) #2": 262,
"app/api/sync/changes/route.ts :: apiHandler(arg)": 427,
"app/api/user/ai-provider/route.ts :: apiHandler(arg) #2": 252,
"app/api/user/ai-provider/route.ts :: apiHandler(arg) #2": 248,
"app/api/withings/callback/route.ts :: apiHandler(arg)": 247,
"app/api/workouts/[id]/route.ts :: apiHandler(arg)": 297,
"app/api/workouts/batch/route.ts :: postBatch": 709,
"app/auth/login/page.tsx :: LoginPage": 576,
"app/auth/register/page.tsx :: RegisterPage": 285,
"app/coach/conversations/page.tsx :: CoachConversationsBody": 249,
@@ -332,20 +325,11 @@
"lib/illness/correlation.ts :: computeIllnessCorrelation": 233,
"lib/import/labs-csv.ts :: importLabsCsv": 309,
"lib/import/labs-csv.ts :: importLabsCsv > flush": 231,
"lib/insights/biomarker-status.ts :: generateBiomarkerStatus": 290,
"lib/insights/blood-pressure-status.ts :: prepareBloodPressureStatusForUser": 635,
"lib/insights/bmi-status.ts :: prepareBmiStatusForUser": 387,
"lib/insights/comprehensive-aggregator.ts :: buildFromRollups": 220,
"lib/insights/comprehensive-generate.ts :: generateComprehensiveInsight": 876,
"lib/insights/features.ts :: extractFeatures": 796,
"lib/insights/general-status.ts :: prepareGeneralStatusForUser": 449,
"lib/insights/medication-compliance-status.ts :: prepareMedicationComplianceStatusForUser": 485,
"lib/insights/metric-status.ts :: generateMetricStatus": 477,
"lib/insights/mood-aggregates.ts :: fetchMoodAggregates": 246,
"lib/insights/mood-status.ts :: prepareMoodStatusForUser": 646,
"lib/insights/pulse-status.ts :: preparePulseStatusForUser": 414,
"lib/insights/status-provider.ts :: runStatusCompletion": 241,
"lib/insights/weight-status.ts :: prepareWeightStatusForUser": 598,
"lib/integrations/connection-health-load.ts :: loadConnectionFacts": 227,
"lib/integrations/status.ts :: recordSyncFailure": 292,
"lib/jobs/apple-health-import-worker.ts :: handleAppleHealthImport": 300,
@@ -371,8 +355,6 @@
"lib/labs/biomarker-merge.ts :: runMerge": 254,
"lib/labs/ocr-extract.ts :: runOcrExtraction": 207,
"lib/labs/ocr-run.ts :: executeOcrExtraction": 206,
"lib/mcp/tools.ts :: searchAndFetchTools": 421,
"lib/mcp/tools.ts :: searchAndFetchTools > run": 205,
"lib/measurements/consolidate-daily-mean.ts :: consolidateDailyMean": 278,
"lib/measurements/consolidate-legacy-steps.ts :: consolidateLegacySteps": 263,
"lib/measurements/dense-intraday-retention.ts :: runDenseIntradayRetention": 537,
@@ -1,7 +1,7 @@
{
"$comment": "tests that read a source file by path, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.",
"count": 349,
"total": 349,
"count": 348,
"total": 348,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 1,
"__tests__/admin-data-wipe-completeness.test.ts": 1,
@@ -284,7 +284,6 @@
"lib/ai/coach/__tests__/snapshot.test.ts": 1,
"lib/ai/coach/memory/__tests__/contract.test.ts": 1,
"lib/ai/coach/tools/__tests__/turn-budget.test.ts": 1,
"lib/ai/coach/turn/__tests__/steps.test.ts": 1,
"lib/analytics/__tests__/glucose-panel-window.test.ts": 1,
"lib/analytics/compliance/__tests__/as-needed-compliance-exclusion.test.ts": 1,
"lib/analytics/mood-prognosis/__tests__/gates.test.ts": 1,
@@ -303,6 +302,7 @@
"lib/export/__tests__/intraday-profile-backup.test.ts": 1,
"lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts": 1,
"lib/fitbit/__tests__/sync-module-graph.test.ts": 1,
"lib/fitbit/jobs/__tests__/queues.test.ts": 1,
"lib/format/__tests__/bytes.test.ts": 1,
"lib/google-health/__tests__/sync-module-graph.test.ts": 1,
"lib/http/__tests__/deprecation.test.ts": 1,
@@ -320,7 +320,6 @@
"lib/jobs/__tests__/data-arrival-provider-isolation.test.ts": 1,
"lib/jobs/__tests__/data-arrival-queue.test.ts": 1,
"lib/jobs/__tests__/data-backup-send-policy.test.ts": 1,
"lib/fitbit/jobs/__tests__/queues.test.ts": 1,
"lib/jobs/__tests__/geo-backfill.test.ts": 1,
"lib/jobs/__tests__/geolite2-fetch-queue.test.ts": 1,
"lib/jobs/__tests__/google-health-queues.test.ts": 1,
@@ -1,7 +1,7 @@
{
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
"count": 2919,
"total": 10165,
"count": 2915,
"total": 9910,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 2,
"__tests__/acting-account-boundary-guard.test.ts": 7,
@@ -36,7 +36,7 @@
"__tests__/manage-activity-inventory.test.ts": 1,
"__tests__/managed-record-navigation.test.tsx": 1,
"__tests__/measurement-backup-completeness.test.ts": 1,
"__tests__/measurement-write-plausibility-guard.test.ts": 2,
"__tests__/measurement-write-plausibility-guard.test.ts": 1,
"__tests__/medication-ingredient-backup-completeness.test.ts": 1,
"__tests__/medication-intake-tracking-guard.test.ts": 2,
"__tests__/medications-efficacy-verb-guard.test.ts": 1,
@@ -113,13 +113,13 @@
"app/api/admin/provider-health/__tests__/route.test.ts": 6,
"app/api/admin/provider-health/route.ts": 6,
"app/api/admin/settings/__tests__/route.test.ts": 1,
"app/api/admin/settings/assistant-flags/route.ts": 3,
"app/api/admin/settings/assistant-flags/route.ts": 2,
"app/api/admin/settings/module-availability/route.ts": 1,
"app/api/admin/settings/route.ts": 3,
"app/api/admin/settings/route.ts": 2,
"app/api/admin/status/__tests__/route.test.ts": 1,
"app/api/admin/status/route.ts": 1,
"app/api/admin/users/[id]/reset-password/route.ts": 2,
"app/api/admin/users/[id]/route.ts": 2,
"app/api/admin/users/[id]/route.ts": 1,
"app/api/admin/users/route.ts": 1,
"app/api/ai-runs/[id]/route.ts": 1,
"app/api/ai/test/__tests__/route.test.ts": 2,
@@ -170,11 +170,11 @@
"app/api/auth/passkey/login-verify/route.ts": 5,
"app/api/auth/passkeys/__tests__/route.test.ts": 1,
"app/api/auth/password/__tests__/route.test.ts": 3,
"app/api/auth/password/route.ts": 5,
"app/api/auth/password/route.ts": 4,
"app/api/auth/refresh/__tests__/error-code.test.ts": 1,
"app/api/auth/refresh/route.ts": 2,
"app/api/auth/register/__tests__/route.test.ts": 5,
"app/api/auth/register/route.ts": 11,
"app/api/auth/register/route.ts": 10,
"app/api/biomarkers/__tests__/error-contract.test.ts": 1,
"app/api/biomarkers/[id]/__tests__/route.test.ts": 2,
"app/api/biomarkers/[id]/route.ts": 6,
@@ -222,7 +222,7 @@
"app/api/dashboard/summary/__tests__/route.test.ts": 16,
"app/api/dashboard/summary/route.ts": 31,
"app/api/dashboard/widgets/__tests__/route.test.ts": 4,
"app/api/dashboard/widgets/route.ts": 20,
"app/api/dashboard/widgets/route.ts": 14,
"app/api/devices/__tests__/route.test.ts": 1,
"app/api/devices/[id]/__tests__/route.test.ts": 1,
"app/api/devices/[id]/route.ts": 1,
@@ -403,13 +403,13 @@
"app/api/measurements/batch/__tests__/hr-bucket-observability.test.ts": 1,
"app/api/measurements/batch/__tests__/pr-detection-hook.test.ts": 1,
"app/api/measurements/batch/__tests__/sync-trigger.test.ts": 1,
"app/api/measurements/batch/route.ts": 49,
"app/api/measurements/batch/route.ts": 12,
"app/api/measurements/bulk-delete/__tests__/route.test.ts": 2,
"app/api/measurements/bulk-delete/route.ts": 4,
"app/api/measurements/by-external-ids/route.ts": 4,
"app/api/measurements/restore/__tests__/route.test.ts": 2,
"app/api/measurements/restore/route.ts": 3,
"app/api/measurements/route.ts": 38,
"app/api/measurements/route.ts": 13,
"app/api/measurements/series-batch/__tests__/route.test.ts": 1,
"app/api/measurements/series-batch/route.ts": 1,
"app/api/measurements/series/__tests__/route.test.ts": 5,
@@ -425,25 +425,25 @@
"app/api/medications/[id]/dose-history/__tests__/route.test.ts": 1,
"app/api/medications/[id]/dose-history/route.ts": 14,
"app/api/medications/[id]/glp1/__tests__/route.test.ts": 3,
"app/api/medications/[id]/glp1/route.ts": 9,
"app/api/medications/[id]/glp1/route.ts": 8,
"app/api/medications/[id]/intake/__tests__/route.test.ts": 11,
"app/api/medications/[id]/intake/__tests__/source-provenance.test.ts": 1,
"app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts": 9,
"app/api/medications/[id]/intake/[eventId]/route.ts": 24,
"app/api/medications/[id]/intake/[eventId]/route.ts": 4,
"app/api/medications/[id]/intake/bulk-delete/route.ts": 7,
"app/api/medications/[id]/intake/import/__tests__/route.test.ts": 2,
"app/api/medications/[id]/intake/import/route.ts": 6,
"app/api/medications/[id]/intake/purge/route.ts": 4,
"app/api/medications/[id]/intake/route.ts": 30,
"app/api/medications/[id]/inventory/[itemId]/route.ts": 9,
"app/api/medications/[id]/inventory/route.ts": 5,
"app/api/medications/[id]/phase-config/route.ts": 5,
"app/api/medications/[id]/route.ts": 59,
"app/api/medications/[id]/inventory/[itemId]/route.ts": 8,
"app/api/medications/[id]/inventory/route.ts": 4,
"app/api/medications/[id]/phase-config/route.ts": 4,
"app/api/medications/[id]/route.ts": 58,
"app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts": 1,
"app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts": 9,
"app/api/medications/[id]/schedule-revisions/route.ts": 3,
"app/api/medications/[id]/side-effects/[logId]/route.ts": 2,
"app/api/medications/[id]/side-effects/route.ts": 6,
"app/api/medications/[id]/side-effects/route.ts": 5,
"app/api/medications/categories/[key]/route.ts": 1,
"app/api/medications/categories/route.ts": 1,
"app/api/medications/compliance/__tests__/route.test.ts": 2,
@@ -458,7 +458,7 @@
"app/api/medications/intake/route.ts": 34,
"app/api/medications/layout/__tests__/route.test.ts": 3,
"app/api/medications/layout/route.ts": 3,
"app/api/medications/route.ts": 32,
"app/api/medications/route.ts": 2,
"app/api/mental-health/assessments/__tests__/route.test.ts": 2,
"app/api/mental-health/assessments/route.ts": 6,
"app/api/mood-entries/__tests__/route.test.ts": 7,
@@ -544,7 +544,6 @@
"app/api/user/profile/__tests__/route.test.ts": 3,
"app/api/user/profile/route.ts": 3,
"app/api/user/thresholds/__tests__/route.test.ts": 1,
"app/api/user/thresholds/route.ts": 1,
"app/api/vaccinations/route.ts": 1,
"app/api/version/route.ts": 3,
"app/api/whoop/callback/route.ts": 2,
@@ -561,7 +560,7 @@
"app/api/workouts/__tests__/batch-create.test.ts": 3,
"app/api/workouts/__tests__/canonical-dedup.test.ts": 3,
"app/api/workouts/[id]/route.ts": 5,
"app/api/workouts/batch/route.ts": 9,
"app/api/workouts/batch/route.ts": 3,
"app/api/workouts/route.ts": 2,
"app/auth/claim/page.tsx": 1,
"app/auth/login/page.tsx": 5,
@@ -1721,7 +1720,7 @@
"lib/ai/coach/tools/definitions.ts": 11,
"lib/ai/coach/tools/dialog-tools.ts": 1,
"lib/ai/coach/tools/environment-read.ts": 2,
"lib/ai/coach/tools/executor.ts": 17,
"lib/ai/coach/tools/executor.ts": 4,
"lib/ai/coach/tools/index.ts": 1,
"lib/ai/coach/tools/inventory.ts": 10,
"lib/ai/coach/tools/loop.ts": 22,
@@ -1887,7 +1886,7 @@
"lib/analytics/window-confidence.ts": 1,
"lib/analytics/window-trend.ts": 1,
"lib/api-errors.ts": 2,
"lib/api-handler.ts": 22,
"lib/api-handler.ts": 5,
"lib/api-response.ts": 8,
"lib/api/__tests__/api-fetch.test.ts": 1,
"lib/api/__tests__/optimistic-token.test.ts": 1,
@@ -1948,7 +1947,6 @@
"lib/cycle/custom-symptoms-shared.ts": 1,
"lib/cycle/custom-symptoms.ts": 1,
"lib/cycle/day-log-write.ts": 1,
"lib/cycle/gate.ts": 1,
"lib/cycle/import-accumulator.ts": 1,
"lib/cycle/phase-copy.ts": 1,
"lib/cycle/phase-crosstab.ts": 1,
@@ -2064,6 +2062,10 @@
"lib/fitbit/__tests__/poll-cohort.test.ts": 1,
"lib/fitbit/__tests__/sync-user-fitbit.test.ts": 1,
"lib/fitbit/client.ts": 1,
"lib/fitbit/jobs/__tests__/backfill.test.ts": 1,
"lib/fitbit/jobs/backfill.ts": 1,
"lib/fitbit/jobs/oauth-state-cleanup.ts": 1,
"lib/fitbit/jobs/sync-handlers.ts": 2,
"lib/fitbit/sync.ts": 1,
"lib/format-locale.ts": 1,
"lib/format.ts": 4,
@@ -2163,9 +2165,8 @@
"lib/insights/__tests__/values-back-link.test.ts": 1,
"lib/insights/__tests__/weight-status.test.ts": 5,
"lib/insights/assessment-context.ts": 1,
"lib/insights/biomarker-status.ts": 1,
"lib/insights/blood-pressure-status.ts": 18,
"lib/insights/bmi-status.ts": 14,
"lib/insights/blood-pressure-status.ts": 11,
"lib/insights/bmi-status.ts": 4,
"lib/insights/breathing-screening.ts": 1,
"lib/insights/briefing-failure-marker.ts": 2,
"lib/insights/briefing-payload.ts": 1,
@@ -2228,26 +2229,26 @@
"lib/insights/feature-blocks.ts": 5,
"lib/insights/feature-cache.ts": 2,
"lib/insights/features.ts": 34,
"lib/insights/general-status.ts": 14,
"lib/insights/general-status.ts": 5,
"lib/insights/glp1-plateau.ts": 2,
"lib/insights/graded-series.ts": 3,
"lib/insights/health-status.ts": 3,
"lib/insights/illness-cycle-briefing.ts": 1,
"lib/insights/labs-changes.ts": 1,
"lib/insights/measurement-diversity.ts": 1,
"lib/insights/medication-compliance-status.ts": 20,
"lib/insights/medication-compliance-status.ts": 11,
"lib/insights/memory.ts": 2,
"lib/insights/metric-availability.ts": 11,
"lib/insights/metric-catalog.ts": 2,
"lib/insights/metric-correlation-context.ts": 1,
"lib/insights/metric-signal.ts": 1,
"lib/insights/metric-status-registry.ts": 5,
"lib/insights/metric-status.ts": 11,
"lib/insights/metric-status.ts": 8,
"lib/insights/mood-aggregates.ts": 37,
"lib/insights/mood-crosstab.ts": 2,
"lib/insights/mood-dimension-series.ts": 1,
"lib/insights/mood-narratives.ts": 3,
"lib/insights/mood-status.ts": 25,
"lib/insights/mood-status.ts": 16,
"lib/insights/narrative/__tests__/period-narrative-db.test.ts": 2,
"lib/insights/narrative/__tests__/period-narrative-deterministic.test.ts": 1,
"lib/insights/narrative/__tests__/period-narrative-generate.test.ts": 1,
@@ -2256,7 +2257,7 @@
"lib/insights/narrative/period-narrative-generate.ts": 5,
"lib/insights/narrative/period-narrative.ts": 2,
"lib/insights/no-key-fallbacks.ts": 3,
"lib/insights/pulse-status.ts": 13,
"lib/insights/pulse-status.ts": 3,
"lib/insights/recovery-score.ts": 2,
"lib/insights/sanitize.ts": 1,
"lib/insights/score-row.ts": 2,
@@ -2278,7 +2279,7 @@
"lib/insights/trend-descriptor.ts": 1,
"lib/insights/trend-sentiment.ts": 1,
"lib/insights/values-back-link.ts": 1,
"lib/insights/weight-status.ts": 15,
"lib/insights/weight-status.ts": 5,
"lib/insights/with-timeout.ts": 1,
"lib/integrations/__tests__/admin-alert.test.ts": 4,
"lib/integrations/__tests__/status.test.ts": 11,
@@ -2292,7 +2293,6 @@
"lib/jobs/__tests__/coach-reminder-sweep.test.ts": 1,
"lib/jobs/__tests__/cycle-reminder.test.ts": 2,
"lib/jobs/__tests__/feedback-aggregator.test.ts": 2,
"lib/fitbit/jobs/__tests__/backfill.test.ts": 1,
"lib/jobs/__tests__/geo-backfill.test.ts": 1,
"lib/jobs/__tests__/google-health-sleep-repair.test.ts": 1,
"lib/jobs/__tests__/hero-line-json-envelope.test.ts": 1,
@@ -2336,8 +2336,6 @@
"lib/jobs/environment-air-quality-history.ts": 1,
"lib/jobs/environment-fetch.ts": 2,
"lib/jobs/feedback-aggregator.ts": 6,
"lib/fitbit/jobs/backfill.ts": 1,
"lib/fitbit/jobs/oauth-state-cleanup.ts": 1,
"lib/jobs/free-text-encryption-backfill.ts": 8,
"lib/jobs/geo-backfill.ts": 4,
"lib/jobs/google-health-backfill.ts": 1,
@@ -2374,7 +2372,6 @@
"lib/jobs/reminder/__tests__/cleanup-handlers-visibility.test.ts": 1,
"lib/jobs/reminder/__tests__/queue-retention.test.ts": 1,
"lib/jobs/reminder/cleanup-handlers.ts": 7,
"lib/fitbit/jobs/sync-handlers.ts": 2,
"lib/jobs/reminder/google-health-sync.ts": 1,
"lib/jobs/reminder/insights-handlers.ts": 3,
"lib/jobs/reminder/medication-maintenance.ts": 3,
@@ -2442,7 +2439,6 @@
"lib/mcp/record-search.ts": 1,
"lib/mcp/resources.ts": 6,
"lib/mcp/rich-reads.ts": 8,
"lib/mcp/tools.ts": 18,
"lib/mcp/writes.ts": 2,
"lib/measurement-reminders/__tests__/holds-open.test.ts": 1,
"lib/measurement-reminders/__tests__/resolve.test.ts": 2,
@@ -2786,7 +2782,7 @@
"lib/sharing/audit-details.ts": 1,
"lib/sharing/delegated-generation.ts": 1,
"lib/sharing/grant-view.ts": 2,
"lib/sharing/grants.ts": 4,
"lib/sharing/grants.ts": 3,
"lib/sharing/module-disclosure.ts": 1,
"lib/sharing/record-session-fence-contract.ts": 1,
"lib/sharing/record-session-fence.ts": 1,
@@ -15,6 +15,7 @@
import { describe, it, expect } from "vitest";
import { join } from "node:path";
import { guardSourceOf } from "./helpers/auth-guards";
import {
readSource,
readSourceText,
@@ -226,8 +227,9 @@ describe("T1 — the Bearer resolution set is frozen", () => {
const RESOLUTION_ALLOWLIST = [
// The resolver. Owns the one authorisation arm.
"lib/auth/bearer.ts",
// The REST edge — maps `requireAuth`'s optional scope onto the union.
"lib/api-handler.ts",
// The REST edge — maps `requireAuth`'s optional scope onto the union. The
// guard module behind `requireAuth`, re-exported by `lib/api-handler.ts`.
"lib/auth/guards/caller.ts",
// The MCP edge — the one deliberate `any-valid-token` posture.
"lib/mcp/auth.ts",
// The `/mcp` transport, via `resolveMcpAuthContext`.
@@ -543,7 +545,7 @@ describe("T5 — a delegable route naming a Bearer scope is frozen", () => {
// it and every other leg in this file stays green while a single-purpose
// token gains delegated write — so the refusal is asserted where it lives,
// not only through the behaviour that depends on it.
const src = readSource("src/lib/api-handler.ts", "narrow_scope_selector");
const src = guardSourceOf("requireRecordAuth", ["narrow_scope_selector"]);
expect(src).toMatch(/narrow_scope_selector/);
expect(src).toMatch(/isScopedCredential\(auth\)/);
});
@@ -64,6 +64,11 @@ const SCOPED_DIRS = [
"lib/export",
"lib/targets/",
"lib/doctor-report",
// Request-handler logic moved out of its route into a service keeps the
// route's scope.
"lib/measurements/batch-ingest/",
"lib/measurements/create/",
"lib/measurements/list/measurement-list.ts",
];
/** Types a device writes at sampling rate rather than a few times a day. */
@@ -103,9 +108,9 @@ const ALLOWED: Record<string, string> = {
"Health-score inputs: steps (drained to one row per day nightly), sleep, waist, weight, blood pressure, fasting glucose only.",
'app/api/analytics/route.ts::"BLOOD_GLUCOSE"':
"The 30-day per-context glucose summary, the same window and bound as the dashboard and coach glucose panels: at a sensor's fixed rate, below 45 000 rows.",
"app/api/measurements/batch/route.ts::p.row.type as MeasurementType":
"lib/measurements/batch-ingest/write-measurements.ts::p.row.type as MeasurementType":
"Cross-source merge probe: one ±tolerance window around each posted reading's instant, so the rows found are bounded by the batch size.",
"app/api/measurements/route.ts::(shorthand)":
"lib/measurements/create/create-sleep-stage.ts::(shorthand)":
"Cross-source merge probe for one posted sleep segment: SLEEP_DURATION in a ±tolerance window around its end.",
"lib/targets/build-response.ts::{ in: recentTypes":
"Thirty days of weight, blood pressure, resting heart rate, body fat and steps (drained to one row per day). PULSE is filtered out of `recentTypes` and folds in SQL.",
@@ -67,7 +67,7 @@ const VORSORGE_READ_SITES: ReadonlyArray<{ file: string; surface: string }> = [
surface: "the insights preventive-care block",
},
{
file: "src/lib/mcp/tools.ts",
file: "src/lib/mcp/tools/get-preventive-care.ts",
surface: "the MCP get_preventive_care tool",
},
];
@@ -394,17 +394,31 @@ describe("the batch surfaces refuse per entry, never whole-batch", () => {
// Each of these drains a client sync queue. A schema-level refusal here
// would stop a whole batch of good rows because of one bad one, which is
// worse than the duplicate it prevents.
const BATCH_ROUTES = [
"app/api/measurements/batch/route.ts",
"app/api/mood-entries/bulk/route.ts",
"app/api/medications/intake/bulk/route.ts",
"app/api/workouts/batch/route.ts",
"app/api/cycle/day-logs/bulk/route.ts",
"app/api/import/route.ts",
];
//
// A surface whose ingest moved into a service names the service files
// beside the route: the route parses, the service refuses per entry.
const BATCH_SURFACES: Record<string, readonly string[]> = {
"app/api/measurements/batch/route.ts": [
"lib/measurements/batch-ingest/prepare.ts",
"lib/measurements/batch-ingest/ingest.ts",
],
"app/api/mood-entries/bulk/route.ts": [],
"app/api/medications/intake/bulk/route.ts": [],
"app/api/workouts/batch/route.ts": [
"lib/workouts/batch-ingest/entries.ts",
"lib/workouts/batch-ingest/ingest.ts",
],
"app/api/cycle/day-logs/bulk/route.ts": [],
"app/api/import/route.ts": [],
};
const BATCH_ROUTES = Object.keys(BATCH_SURFACES);
/** The route and the service files that carry its per-entry ingest. */
const surface = (rel: string): string =>
[rel, ...BATCH_SURFACES[rel]].map((file) => body(file)).join("\n");
it.each(BATCH_ROUTES)("%s classifies each entry itself", (rel) => {
const source = body(rel);
const source = surface(rel);
expect(source).toContain("classifyExternalId");
expect(source).toContain("unstableExternalIdMeta");
});
@@ -414,7 +428,7 @@ describe("the batch surfaces refuse per entry, never whole-batch", () => {
(rel) => {
// The JSON importer has no per-entry status envelope — it reports a
// skipped COUNT — so it is the one batch surface without the code.
expect(body(rel)).toContain("UNSTABLE_EXTERNAL_ID_REASON");
expect(surface(rel)).toContain("UNSTABLE_EXTERNAL_ID_REASON");
},
);
});
+78
View File
@@ -0,0 +1,78 @@
/**
* Where the request guards live, for the structural tests that read them.
*
* `apiHandler` and the guards it fronts (`requireAuth`, the record modes, the
* cookie-only helpers, the step-up gates) were one file. The guards now live
* in `src/lib/auth/guards/`, one module per family, and `src/lib/api-handler.ts`
* keeps the wrapper and re-exports them as the one public entry.
*
* A test that pins a guard reads the module that DECLARES it, found by name
* here, rather than a hard-coded path. Two properties follow: moving a guard
* between the modules does not need every test edited, and a guard that is
* renamed or deleted throws here instead of leaving a negative assertion green
* over a file that no longer holds it.
*/
import { readSource, readSourceFolder, requireFloor } from "./source-files";
/** The guard folder, relative to the repository root. */
export const AUTH_GUARDS_DIR = "src/lib/auth/guards";
/** The route wrapper and public entry, relative to `src/`. */
export const API_HANDLER = "lib/api-handler.ts";
/** How many guard modules there are at least; a smaller folder is a broken read. */
const GUARD_MODULE_FLOOR = 10;
/** Every guard module as `[path relative to src/, text]`, sorted. */
export function authGuardModules(): Array<[string, string]> {
const modules = readSourceFolder(AUTH_GUARDS_DIR, {
floor: GUARD_MODULE_FLOOR,
}).map(([rel, text]): [string, string] => [`lib/auth/guards/${rel}`, text]);
return requireFloor(
"auth guard modules",
modules,
GUARD_MODULE_FLOOR,
) as Array<[string, string]>;
}
function declares(text: string, name: string): boolean {
return new RegExp(`(^|\\n)(export )?(async )?function ${name}\\s*[(<]`).test(
text,
);
}
/**
* The guard module (relative to `src/`) that declares `function name(`.
* Throws unless exactly one does.
*/
export function guardModuleOf(name: string): string {
const found = authGuardModules()
.filter(([, text]) => declares(text, name))
.map(([rel]) => rel);
if (found.length !== 1) {
throw new Error(
`guardModuleOf(${name}): expected exactly one module under ` +
`${AUTH_GUARDS_DIR} to declare it, found ${found.length} ` +
`(${found.join(", ") || "none"}).`,
);
}
return found[0];
}
/**
* The text of the module that declares `name`, read with `readSource` so the
* name and every extra anchor must be present in it.
*/
export function guardSourceOf(
name: string,
extraAnchors: readonly (string | RegExp)[] = [],
): string {
return readSource(`src/${guardModuleOf(name)}`, [name, ...extraAnchors]);
}
/** All guard modules joined, for counts that span the folder. */
export function authGuardsText(): string {
return authGuardModules()
.map(([, text]) => text)
.join("\n");
}
@@ -71,8 +71,9 @@ const RECORD_RESOLVER = "requireRecordAuth";
/**
* Rows the feed cannot show, and the reason each one cannot.
*
* `src/lib/api-handler.ts` is a direct import of every route, so the walk
* below reaches the three actions the auth kit files itself. None of them can
* `src/lib/api-handler.ts` is a direct import of every route, and it
* re-exports the guards in `src/lib/auth/guards/`, so the walk below reaches
* the three actions the auth kit files itself. None of them can
* appear in a record owner's activity view, and the reason is the same for all
* three: they are filed under `auth.user.id` — the CALLER — at a point where
* no record has been resolved. The feed selects rows where `userId` is the
@@ -148,6 +149,13 @@ interface ModuleFacts {
auditActions: string[];
/** First-party modules this one imports, resolved to files. */
imports: string[];
/**
* First-party modules this one re-exports with `export … from`, resolved to
* files. A re-export makes the named module's bindings this module's own,
* so its actions count as this module's (`lib/api-handler.ts` re-exports
* the guards in `lib/auth/guards/`).
*/
reexports: string[];
}
/**
@@ -173,7 +181,19 @@ export function moduleFacts(source: string, fileName: string): ModuleFacts {
const aliases = new Map<string, string>();
const namespaces = new Set<string>();
const importPaths: string[] = [];
const reexportPaths: string[] = [];
parsed.forEachChild((node) => {
if (
ts.isExportDeclaration(node) &&
node.moduleSpecifier &&
ts.isStringLiteral(node.moduleSpecifier)
) {
const resolved = resolveFirstParty(node.moduleSpecifier.text, fileName);
if (resolved !== null && !resolved.includes("__tests__")) {
reexportPaths.push(resolved);
}
return;
}
if (!ts.isImportDeclaration(node)) return;
if (ts.isStringLiteral(node.moduleSpecifier)) {
const resolved = resolveFirstParty(node.moduleSpecifier.text, fileName);
@@ -232,7 +252,13 @@ export function moduleFacts(source: string, fileName: string): ModuleFacts {
};
parsed.forEachChild(visit);
return { delegatesRecord, recordLevels, auditActions, imports: importPaths };
return {
delegatesRecord,
recordLevels,
auditActions,
imports: importPaths,
reexports: reexportPaths,
};
}
/**
@@ -267,7 +293,8 @@ export function resolveFirstParty(
* to this route can file; one level is where a route's own collaborators live
* — the import-admission module and the medication lifecycle module are both
* direct imports of the routes that use them, and both were invisible to a
* walk that read only `route.ts`.
* walk that read only `route.ts`. A module an import re-exports is part of
* that import, not a level below it, so its actions count at the same level.
*/
function actionsReachableFrom(
file: string,
@@ -276,7 +303,41 @@ function actionsReachableFrom(
const own = read(file);
const out = [...own.auditActions];
for (const imported of own.imports) {
out.push(...read(imported).auditActions);
const facts = read(imported);
out.push(...facts.auditActions);
for (const reexported of facts.reexports) {
out.push(...packageActions(reexported, read));
}
}
return out;
}
/**
* The actions of a re-exported module together with the sibling modules it
* imports from its own folder. The re-exported module is a package's public
* face; the siblings are its private parts, which run inside the same call
* (`lib/auth/guards/record.ts` files its refusals through `./acting.ts`).
* Bounded to the one folder, so this does not become the transitive closure
* the walk above deliberately avoids.
*/
function packageActions(
file: string,
read: (path: string) => ModuleFacts,
): string[] {
const folder = dirname(file);
const seen = new Set<string>([file]);
const queue = [file];
const out: string[] = [];
while (queue.length > 0) {
const current = queue.shift() as string;
const facts = read(current);
out.push(...facts.auditActions);
for (const next of [...facts.imports, ...facts.reexports]) {
if (dirname(next) === folder && !seen.has(next)) {
seen.add(next);
queue.push(next);
}
}
}
return out;
}
@@ -68,15 +68,19 @@ const WRITERS: Record<string, Disposition> = {
"lib/measurements/reconcile-external-measurement.ts": { kind: "gate" },
// ── Person-driven write paths, validated ──────────────────────────────
"app/api/measurements/route.ts": {
// The hand-entry writes behind `POST /api/measurements`.
"lib/measurements/create/create-readings.ts": {
kind: "upstream",
validatedIn: "lib/validations/measurement.ts",
},
"app/api/import/route.ts": { kind: "validator" },
// v1.44 — the set-based insert writes rows that already passed the
// route's `validateMeasurementRange` per entry; everything else goes
// through the reconciler, which gates itself.
"app/api/measurements/batch/route.ts": { kind: "validator" },
// The batch ingest's set-based insert writes rows that already passed
// `validateMeasurementRange` per entry in its first pass; everything else
// goes through the reconciler, which gates itself.
"lib/measurements/batch-ingest/write-measurements.ts": {
kind: "upstream",
validatedIn: "lib/measurements/batch-ingest/prepare.ts",
},
"lib/mcp/writes.ts": { kind: "validator" },
"lib/measurements/create-from-telegram.ts": { kind: "validator" },
"lib/measurements/import-apple-health-export.ts": { kind: "validator" },
@@ -32,6 +32,13 @@ const WRITE_RE =
/** Runs `afterMeasurementMutation` on the rows it wrote. */
const TAIL = "runs afterMeasurementMutation";
/**
* Writes here; the module named runs `afterMeasurementMutation` on the same
* rows (a service split into stages, whose write and tail are siblings).
*/
const TAIL_VIA_PREFIX = "runs afterMeasurementMutation via ";
const TAIL_VIA = (file: string) => `${TAIL_VIA_PREFIX}${file}`;
/**
* Runs the provider write step (`integrations/sync-kit/after-write.ts`),
* which is `afterMeasurementMutation` plus the arrivals and, where asked, the
@@ -51,8 +58,10 @@ const WRITE_SITES: Record<string, string> = {
"app/api/measurements/bulk-delete/route.ts": TAIL,
"app/api/measurements/by-external-ids/route.ts": TAIL,
"app/api/measurements/restore/route.ts": TAIL,
"app/api/measurements/batch/route.ts": TAIL,
"app/api/measurements/route.ts": TAIL,
"lib/measurements/batch-ingest/write-measurements.ts": TAIL_VIA(
"lib/measurements/batch-ingest/tail.ts",
),
"lib/measurements/create/create-readings.ts": TAIL,
"app/api/mental-health/assessments/route.ts": TAIL,
"lib/insights/score-row.ts": TAIL,
"lib/mcp/writes.ts": TAIL,
@@ -159,9 +168,20 @@ describe("measurement write tail", () => {
});
it("the files that claim the shared tail call it", () => {
const missing = Object.entries(WRITE_SITES)
.filter(([, reason]) => reason === TAIL)
.map(([file]) => file)
const claims = Object.entries(WRITE_SITES).filter(
([, reason]) => reason === TAIL || reason.startsWith(TAIL_VIA_PREFIX),
);
// Both shapes are in use; a set that lost either would check nothing.
expect(claims.filter(([, r]) => r === TAIL).length).toBeGreaterThan(5);
expect(
claims.filter(([, r]) => r.startsWith(TAIL_VIA_PREFIX)).length,
).toBeGreaterThan(0);
const missing = claims
.map(([file, reason]) =>
reason.startsWith(TAIL_VIA_PREFIX)
? reason.slice(TAIL_VIA_PREFIX.length)
: file,
)
.filter(
(file) =>
!/afterMeasurementMutation\s*\(/.test(
@@ -135,7 +135,7 @@ type HttpMethod = (typeof HTTP_METHODS)[number];
*/
const REASONS = {
/**
* `requireAdmin()` is cookie-only by construction (`src/lib/api-handler.ts`),
* `requireAdmin()` is cookie-only by construction (`src/lib/auth/guards/cookie.ts`),
* so a Bearer token cannot reach these however wide its scope. The only
* caller that can is the operator's own browser on the admin console, which
* ships with the server and does not read a spec to find out what it talks
@@ -128,7 +128,7 @@ const TS_READERS: Record<string, Entry> = {
/applyPayloadBudget\(\s*pulsePoints\s*,\s*\{[^}]*type\s*:\s*"PULSE"/,
},
"lib/insights/signals-of-day.ts": {
via: "lib/insights/features.ts",
via: "lib/insights/feature-window.ts",
passes: /byType\(\s*"PULSE"\s*\)[\s\S]{0,80}type\s*:\s*"PULSE"/,
},
"lib/ai/coach/snapshot-blocks/core-metrics-block.ts": {
@@ -4,8 +4,8 @@
* Exporting the whole record, making a clinician share link, minting a token,
* and the admin backup, restore, wipe and reset actions ask for a fresh proof,
* not only a live session (`requireRecentProof` / `assertRecentCookieProof` in
* `src/lib/api-handler.ts`). This file pins which routes those are, so a route
* cannot drop the gate, and a new one cannot quietly join the list of routes
* `src/lib/auth/guards/recent-proof.ts`, imported through `src/lib/api-handler.ts`).
* This file pins which routes those are, so a route cannot drop the gate, and a new one cannot quietly join the list of routes
* whose Bearer arm still takes the token alone.
*
* Matching is on the IMPORT and then on calls of the local name, the way
@@ -16,11 +16,8 @@
import { describe, it, expect } from "vitest";
import { join } from "node:path";
import {
readSource,
readSourceText,
walkSourceFiles,
} from "./helpers/source-files";
import { guardSourceOf } from "./helpers/auth-guards";
import { readSourceText, walkSourceFiles } from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
@@ -185,10 +182,9 @@ describe("the recent-proof gate", () => {
});
it("the gate reads the session's own stamps, and only a second factor on an enrolled account", () => {
const src = readSource(
"src/lib/api-handler.ts",
const src = guardSourceOf("assertRecentCookieProof", [
"export async function assertRecentCookieProof(",
);
]);
const start = src.indexOf("export async function assertRecentCookieProof(");
expect(start).toBeGreaterThan(-1);
const body = src.slice(start, src.indexOf("\nexport ", start + 1));
@@ -41,6 +41,7 @@
import { describe, it, expect } from "vitest";
import { join } from "node:path";
import { authGuardsText, guardSourceOf } from "./helpers/auth-guards";
import {
readSource,
readSourceText,
@@ -84,10 +85,18 @@ function functionBody(src: string, name: string): string {
return "";
}
const API_HANDLER = readSource("src/lib/api-handler.ts", [
"requireRecordAuth",
"requireGuardianAuth",
]);
/**
* The auth layer: the route wrapper and every guard module it re-exports,
* joined, with the two record resolvers as anchors so a move that loses them
* throws instead of leaving the negative legs below green.
*/
const AUTH_LAYER = (() => {
guardSourceOf("requireRecordAuth", ["requireGuardianAuth"]);
return [
readSource("src/lib/api-handler.ts", "apiHandler"),
authGuardsText(),
].join("\n");
})();
/** The two helpers that resolve a record and therefore must consult the fence. */
const FENCED_RESOLVERS = ["requireRecordAuth", "requireGuardianAuth"] as const;
@@ -124,13 +133,18 @@ const UNFENCED_HELPERS = [
] as const;
describe("the fence runs in exactly two auth helpers", () => {
it("is called by both record resolvers and by nothing else in api-handler", () => {
const calls = [...API_HANDLER.matchAll(/assertRecordSessionFence\s*\(/g)];
it("is called by both record resolvers and by nothing else in the auth layer", () => {
const calls = [
...authGuardsText().matchAll(/assertRecordSessionFence\s*\(/g),
];
expect(readSource("src/lib/api-handler.ts", "apiHandler")).not.toMatch(
/assertRecordSessionFence\s*\(/,
);
// Non-zero proof: an emptied file would agree with an emptied expectation.
expect(calls.length).toBeGreaterThan(0);
const callers = FENCED_RESOLVERS.filter((name) =>
functionBody(API_HANDLER, name).includes("assertRecordSessionFence("),
functionBody(AUTH_LAYER, name).includes("assertRecordSessionFence("),
);
expect(callers).toEqual([...FENCED_RESOLVERS]);
// Two call sites, one import. Anything else means a third caller appeared.
@@ -140,7 +154,7 @@ describe("the fence runs in exactly two auth helpers", () => {
it.each(FENCED_RESOLVERS)(
"%s fences after authentication and before the carrier and the grant",
(name) => {
const body = functionBody(API_HANDLER, name);
const body = functionBody(AUTH_LAYER, name);
expect(body.length).toBeGreaterThan(100);
const fence = body.indexOf("assertRecordSessionFence(");
@@ -163,11 +177,11 @@ describe("the fence runs in exactly two auth helpers", () => {
it("fences before any grant lookup in the shared resolver path", () => {
// `findActiveGrant` lives in `resolveSwitchedRecord`, which both resolvers
// tail-call. Assert it appears nowhere ABOVE the fence in either body.
const grantCalls = [...API_HANDLER.matchAll(/findActiveGrant\s*\(/g)];
const grantCalls = [...authGuardsText().matchAll(/findActiveGrant\s*\(/g)];
expect(grantCalls.length).toBeGreaterThan(0);
for (const name of FENCED_RESOLVERS) {
const body = functionBody(API_HANDLER, name);
const body = functionBody(AUTH_LAYER, name);
const fence = body.indexOf("assertRecordSessionFence(");
const grant = body.indexOf("findActiveGrant(");
// Either the body never names it (it is in the shared tail) or it names
@@ -177,7 +191,7 @@ describe("the fence runs in exactly two auth helpers", () => {
});
it.each(UNFENCED_HELPERS)("%s deliberately does not fence", (name) => {
const body = functionBody(API_HANDLER, name);
const body = functionBody(AUTH_LAYER, name);
expect(body.length).toBeGreaterThan(50);
expect(body).not.toContain("assertRecordSessionFence");
});
@@ -226,7 +240,7 @@ describe("the epoch has one writer and a closed reader set", () => {
// Projects it off the session row it already loaded.
"lib/auth/session.ts",
// Carries it on the auth context the fence is handed.
"lib/api-handler.ts",
"lib/auth/guards/context.ts",
// Compares it. The one place that decides anything from it.
"lib/sharing/record-session-fence.ts",
// Reads it off the same session lookup the idempotency wrapper already
@@ -0,0 +1,87 @@
/**
* Route handlers read their JSON body through `parseJsonBody`.
*
* `parseJsonBody` (`src/lib/api/body.ts`) is `safeJson`, the Zod
* `safeParse`, the validation-failed wide-event action and the multi-issue
* 422 envelope in one call. Before it, every body-accepting route spelled
* those steps out by hand, and the copies drifted: some annotated the
* refusal and some did not, some answered a flat 400 where the rest answered
* the multi-issue 422.
*
* The routes that still call `safeJson` themselves are counted per file in
* `baselines/route-safe-json.json`. Most of them do something between
* reading and validating (an optimistic-concurrency token split off the body,
* a count cap answered before the schema runs, a refund of a spent quota on a
* malformed body), which is a reason to keep the read separate, not a reason
* to copy it into a new route. A new route file that calls `safeJson`, or a
* listed one that gains a call, fails. A file that sheds one fails until the
* lower count is written back with
* `RATCHET_WRITE=1 pnpm vitest run src/__tests__/route-body-parse-ratchet.test.ts`.
*/
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { runRatchet, writeBackHint } from "./helpers/ratchet";
import {
readSourceText,
requireFloor,
stripComments,
walkSourceFiles,
} from "./helpers/source-files";
const GUARD = "src/__tests__/route-body-parse-ratchet.test.ts";
const APP = join(process.cwd(), "src/app");
const SAFE_JSON_CALL = /\bsafeJson\s*(?:<[^>()]*>)?\s*\(/g;
const PARSE_JSON_BODY_CALL = /\bparseJsonBody\s*\(/g;
function count(text: string, pattern: RegExp): number {
return [...text.matchAll(pattern)].length;
}
describe("route body parsing", () => {
const routes = walkSourceFiles(APP, { floor: 400 }).filter((rel) =>
rel.endsWith("/route.ts"),
);
const safeJson: Record<string, number> = {};
const helper: string[] = [];
for (const rel of routes) {
const code = stripComments(readSourceText(join(APP, rel)));
const n = count(code, SAFE_JSON_CALL);
if (n > 0) safeJson[`app/${rel}`] = n;
if (count(code, PARSE_JSON_BODY_CALL) > 0) helper.push(rel);
}
it("finds the routes on both sides", () => {
requireFloor("route files", routes, 400);
requireFloor(
"routes reading their body through parseJsonBody",
helper,
100,
);
requireFloor("routes still calling safeJson", Object.keys(safeJson), 1);
});
it("adds no route that calls safeJson itself", () => {
const r = runRatchet("route-safe-json.json", safeJson, {
comment: `safeJson calls per route file under src/app; new body-reading routes use parseJsonBody. Maintained by ${GUARD}.`,
});
expect(
r.grew,
"A route that reads its body with safeJson. Use parseJsonBody " +
"(src/lib/api/body.ts); its options cover the size cap, the " +
"validation-failed action, the error code and the message.",
).toEqual([]);
expect(r.stale, writeBackHint(GUARD)).toEqual([]);
});
it("counts a call however it is spelled", () => {
expect(count("await safeJson(request)", SAFE_JSON_CALL)).toBe(1);
expect(
count("await safeJson<Body>(req, { maxBytes: 1 })", SAFE_JSON_CALL),
).toBe(1);
expect(count("await safeJson (\n request,\n)", SAFE_JSON_CALL)).toBe(1);
expect(count("import { safeJson } from", SAFE_JSON_CALL)).toBe(0);
});
});
@@ -3,7 +3,7 @@
*
* Every authenticated request resolves its caller through the session lookup
* (`lib/auth/session.ts`), the Bearer resolver (`lib/auth/bearer.ts`) or, for a
* switched record, the owner load in `lib/api-handler.ts`. They used to
* switched record, the owner load in `lib/auth/guards/record.ts`. They used to
* `include: { user: true }` / `findUnique({ where })`, which copied the profile
* picture (megabytes), the cached briefing text and every stored credential
* into memory on each request. They now name `SESSION_USER_SELECT`.
@@ -19,6 +19,7 @@ import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { guardModuleOf } from "./helpers/auth-guards";
import { walkSourceFiles } from "./helpers/source-files";
import { SESSION_USER_SELECT } from "@/lib/auth/session-user";
@@ -70,7 +71,10 @@ describe("caller lookups load the session projection", () => {
});
it("the Bearer resolver and the switched-owner load select SESSION_USER_SELECT", () => {
for (const rel of ["lib/auth/bearer.ts", "lib/api-handler.ts"]) {
for (const rel of [
"lib/auth/bearer.ts",
guardModuleOf("resolveSwitchedRecord"),
]) {
const calls = callObjects(read(rel), USER_LOOKUP);
expect(calls.length, rel).toBeGreaterThan(0);
for (const body of calls) {
+1 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { grantCoversDomain, normaliseScope } from "@/lib/sharing/grants";
import { grantCoversDomain, normaliseScope } from "@/lib/sharing/grant-access";
import { SHARE_DOMAINS } from "@/lib/sharing/scope";
import { inviteGrantSchema } from "@/lib/validations/account-sharing";
+154 -41
View File
@@ -75,6 +75,7 @@ import { ENTIRE_RECORD, SHARE_DOMAINS } from "@/lib/sharing/scope";
import type { ShareScope } from "@/lib/sharing/scope";
import { ADMITTED_MUTATING_HANDLERS } from "../../tests/fixtures/v137/sharing-matrix";
import { API_HANDLER, guardModuleOf } from "./helpers/auth-guards";
import { readSourceText } from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
@@ -115,7 +116,7 @@ const VALID_SCOPES: ReadonlySet<string> = new Set<string>([
* "is this caller an admin", "has this caller just re-proven a second factor",
* "is this caller here over a cookie". All three resolve through `getSession()`
* and see the actor, never the owner — that is what
* `acting-account-boundary-guard.test.ts` pins inside `api-handler.ts`.
* `acting-account-boundary-guard.test.ts` pins inside `lib/auth/guards/`.
*
* This file pins the other end. A handler that resolves a RECORD and then runs
* one of these has put a role check and a substituted data scope in the same
@@ -420,6 +421,51 @@ function callsSymbol(rel: string, symbol: string): boolean {
return callSites(rel).some((c) => c.name === symbol);
}
/**
* Route modules whose handler bodies live in service modules under `lib/`.
*
* The route keeps the resolver call, so the verb and membership legs read the
* route alone. The legs about what a handler writes (the audit trail, the
* tombstone, the overwrite and destruction details, the provenance a moved
* dose keeps) ask the route AND the modules it hands the body to, because
* that is where the rows are written now. A leg reading only the route would
* go on passing for a reason that has nothing to do with the rule. Each
* listed module has to be imported by its route (leg below), so an entry
* cannot outlive the delegation it describes.
*/
const HANDLER_BODY_MODULES: Record<string, readonly string[]> = {
"app/api/medications/route.ts": ["lib/medications/create-medication.ts"],
"app/api/medications/[id]/intake/[eventId]/route.ts": [
"lib/medications/update-intake-event.ts",
"lib/medications/delete-intake-event.ts",
],
};
/** The route and the service modules carrying its handler bodies. */
function handlerModules(rel: string): string[] {
return [rel, ...(HANDLER_BODY_MODULES[rel] ?? [])];
}
function handlerCallsSymbol(rel: string, symbol: string): boolean {
return handlerModules(rel).some((m) => callsSymbol(m, symbol));
}
function handlerIdentifiers(rel: string): Set<string> {
return new Set(handlerModules(rel).flatMap((m) => [...identifiers(m)]));
}
function handlerAuditDetailKeys(rel: string): string[][] {
return handlerModules(rel).flatMap((m) => auditDetailKeysIn(m));
}
function handlerDirectAuditWrites(rel: string): string[] {
return handlerModules(rel).flatMap((m) => directAuditWritesIn(read(m), m));
}
function handlerText(rel: string): string {
return handlerModules(rel).map(read).join("\n");
}
/**
* v1.37.0 — the exported HTTP verbs whose bodies reach the record resolver at
* a given level.
@@ -721,6 +767,25 @@ function functionSource(rel: string, name: string): string {
return found;
}
/** Names a module re-exports with `export { … } from "…"`. */
function reexportedNames(rel: string): string[] {
const parsed = ts.createSourceFile(
rel,
read(rel),
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TS,
);
const names: string[] = [];
parsed.forEachChild((node) => {
if (!ts.isExportDeclaration(node) || !node.moduleSpecifier) return;
const clause = node.exportClause;
if (!clause || !ts.isNamedExports(clause)) return;
for (const spec of clause.elements) names.push(spec.name.text);
});
return names;
}
/** Names of the top-level functions a module exports. */
function exportedFunctionNames(rel: string): string[] {
const parsed = ts.createSourceFile(
@@ -2557,11 +2622,17 @@ describe("the resolvers exist and live in one place", () => {
// between "no route administers a managed profile" and "the guard is
// looking for a function that does not exist": its frozen list is empty,
// so its leg's negative result proves nothing on its own.
const exported = exportedFunctionNames("lib/api-handler.ts");
const declaring = guardModuleOf(RECORD_RESOLVER);
const exported = exportedFunctionNames(declaring);
expect(exported.length).toBeGreaterThan(0);
expect(exported).toContain(RECORD_RESOLVER);
expect(exported).toContain(ACTOR_RESOLVER);
expect(exported).toContain(GUARDIAN_RESOLVER);
// And the public entry routes import them from re-exports all three.
const reexported = reexportedNames(API_HANDLER);
expect(reexported).toContain(RECORD_RESOLVER);
expect(reexported).toContain(ACTOR_RESOLVER);
expect(reexported).toContain(GUARDIAN_RESOLVER);
});
it("nothing outside the frozen route lists reaches any resolver", () => {
@@ -2582,7 +2653,9 @@ describe("the resolvers exist and live in one place", () => {
expect(naming).toEqual(
[
// Declares all three. Not a caller.
"lib/api-handler.ts",
guardModuleOf(RECORD_RESOLVER),
// Re-exports all three as the public entry. Not a caller.
API_HANDLER,
...Object.keys(DELEGABLE_ROUTES),
...Object.keys(GUARDIAN_ROUTES),
...Object.keys(ACTOR_ROUTES),
@@ -2617,10 +2690,11 @@ describe("(a) the delegable route set is frozen", () => {
// names nothing" were the same sentence; the list is populated now, so the
// assertion below would catch a neutered matcher on its own. Kept anyway,
// because a future removal could empty the list again and the day that
// happens is not the day to rediscover this. `lib/api-handler.ts` is the
// guaranteed positive — it declares the symbol.
expect(modulesNaming(["lib/api-handler.ts"], RECORD_RESOLVER)).toEqual([
"lib/api-handler.ts",
// happens is not the day to rediscover this. The guard module that
// declares the symbol is the guaranteed positive.
const recordModule = guardModuleOf(RECORD_RESOLVER);
expect(modulesNaming([recordModule], RECORD_RESOLVER)).toEqual([
recordModule,
]);
expect(modulesNaming(scanned, RECORD_RESOLVER)).toEqual(
@@ -2640,9 +2714,8 @@ describe("(b) the actor-surface route set is frozen", () => {
expect(scanned.length).toBeGreaterThan(ROUTE_MODULE_FLOOR);
// Positive control, same reason as leg (a).
expect(modulesNaming(["lib/api-handler.ts"], ACTOR_RESOLVER)).toEqual([
"lib/api-handler.ts",
]);
const actorModule = guardModuleOf(ACTOR_RESOLVER);
expect(modulesNaming([actorModule], ACTOR_RESOLVER)).toEqual([actorModule]);
expect(modulesNaming(scanned, ACTOR_RESOLVER)).toEqual(
Object.keys(ACTOR_ROUTES).sort(),
@@ -2733,14 +2806,22 @@ describe("(c) a record resolver never shares a module with a role or step-up che
});
it("the declaration site is out of scope by construction", () => {
// `lib/api-handler.ts` declares the record resolver AND all three cookie-
// only helpers, so a scan of every source file would need it exempted, and
// an exemption is a hole. Scoping the leg to handler modules removes the
// need for one. This asserts the scoping still holds.
const src = exportedFunctionNames("lib/api-handler.ts");
expect(src).toContain(RECORD_RESOLVER);
for (const helper of COOKIE_ONLY_HELPERS) expect(src).toContain(helper);
expect(routeModules()).not.toContain("lib/api-handler.ts");
// The auth layer declares the record resolver AND all three cookie-only
// helpers (in `lib/auth/guards/`, re-exported by `lib/api-handler.ts`), so
// a scan of every source file would need those modules exempted, and an
// exemption is a hole. Scoping the leg to handler modules removes the need
// for one. This asserts the scoping still holds.
const declaring = [RECORD_RESOLVER, ...COOKIE_ONLY_HELPERS].map((name) =>
guardModuleOf(name),
);
expect(exportedFunctionNames(declaring[0])).toContain(RECORD_RESOLVER);
COOKIE_ONLY_HELPERS.forEach((helper, i) =>
expect(exportedFunctionNames(declaring[i + 1])).toContain(helper),
);
const routes = routeModules();
for (const rel of [API_HANDLER, ...declaring]) {
expect(routes).not.toContain(rel);
}
});
});
@@ -2840,9 +2921,10 @@ describe("(e) the delegable write set is frozen", () => {
expect(modules.length).toBeGreaterThan(0);
for (const rel of modules) {
expect(callsSymbol(rel, "auditLog"), `${rel} never calls auditLog`).toBe(
true,
);
expect(
handlerCallsSymbol(rel, "auditLog"),
`${rel} never calls auditLog`,
).toBe(true);
}
});
@@ -2862,7 +2944,7 @@ describe("(e) the delegable write set is frozen", () => {
for (const rel of modules) {
expect(
directAuditWritesIn(read(rel), rel),
handlerDirectAuditWrites(rel),
`${rel} writes an audit row without the helper`,
).toEqual([]);
}
@@ -2979,6 +3061,28 @@ describe("(g) the MANAGE route set is frozen", () => {
expect(discovered).toEqual(expected);
});
it("reads each moved handler body where its route imports it from", () => {
// The map above widens what the audit and tombstone legs read. An entry
// naming a module the route no longer imports would widen it to code the
// handler does not run, so every entry is held to the route's imports,
// and every listed module has to carry an audit call of its own.
const entries = Object.entries(HANDLER_BODY_MODULES);
expect(entries.length).toBeGreaterThan(0);
for (const [route, modules] of entries) {
expect(DELEGABLE_ROUTES, route).toHaveProperty([route]);
for (const body of modules) {
const specifier = `@/${body.replace(/\.ts$/, "")}`;
expect(read(route), `${route} does not import ${specifier}`).toContain(
`"${specifier}"`,
);
expect(
callsSymbol(body, "auditLog"),
`${body} writes no audit row`,
).toBe(true);
}
}
});
it("is a subset of the delegable set", () => {
// A module cannot manage a record it may not read. The read list is where
// each file's argument lives, so a manage entry with no read entry would
@@ -3007,16 +3111,17 @@ describe("(g) the MANAGE route set is frozen", () => {
expect(mutating.length).toBeGreaterThan(30);
for (const rel of mutating) {
expect(callsSymbol(rel, "auditLog"), `${rel} never calls auditLog`).toBe(
true,
);
expect(
handlerCallsSymbol(rel, "auditLog"),
`${rel} never calls auditLog`,
).toBe(true);
}
});
it("no manage module reaches the audit table without the helper", () => {
for (const rel of Object.keys(DELEGABLE_MANAGE_ROUTES)) {
expect(
directAuditWritesIn(read(rel), rel),
handlerDirectAuditWrites(rel),
`${rel} writes an audit row without the helper`,
).toEqual([]);
}
@@ -3035,7 +3140,7 @@ describe("(g) the MANAGE route set is frozen", () => {
for (const rel of destructive) {
const entry = DELEGABLE_MANAGE_ROUTES[rel];
const tombstones = identifiers(rel).has("deletedAt");
const tombstones = handlerIdentifiers(rel).has("deletedAt");
expect(
tombstones ||
entry.conditions.includes("C3") ||
@@ -3056,7 +3161,7 @@ describe("(g) the MANAGE route set is frozen", () => {
for (const [rel, { keys, why }] of members) {
expect(why.trim().length, `${rel} has no reason`).toBeGreaterThan(0);
expect(DELEGABLE_MANAGE_ROUTES, rel).toHaveProperty([rel]);
const carried = auditDetailKeysIn(rel);
const carried = handlerAuditDetailKeys(rel);
expect(carried.length, `${rel} files no audit details`).toBeGreaterThan(
0,
);
@@ -3079,10 +3184,10 @@ describe("(g) the MANAGE route set is frozen", () => {
for (const rel of members) {
if (rel in MANAGE_RECONSTRUCTION_BY_HAND) continue;
expect(
callsSymbol(rel, "destroyedDetails"),
handlerCallsSymbol(rel, "destroyedDetails"),
`${rel} carries C3 and never calls destroyedDetails`,
).toBe(true);
const named = auditDetailKeysIn(rel).some((keys) =>
const named = handlerAuditDetailKeys(rel).some((keys) =>
keys.some((key) => /(^id$|Id$)/.test(key)),
);
expect(named, `${rel} files no id on any audit details literal`).toBe(
@@ -3097,7 +3202,7 @@ describe("(g) the MANAGE route set is frozen", () => {
for (const rel of members) {
expect(
callsSymbol(rel, "overwriteDetails"),
handlerCallsSymbol(rel, "overwriteDetails"),
`${rel} carries C4 and never calls overwriteDetails`,
).toBe(true);
}
@@ -3152,7 +3257,7 @@ describe("(g) the MANAGE route set is frozen", () => {
// …and the fact it reads has to be stamped. The resolver is where the
// owner row is in hand, so it is the only place that can decide it.
const resolver = functionSource(
"lib/api-handler.ts",
guardModuleOf("resolveSwitchedRecord"),
"resolveSwitchedRecord",
);
expect(resolver.length).toBeGreaterThan(0);
@@ -3278,7 +3383,7 @@ describe("(g) the MANAGE route set is frozen", () => {
const medication = "app/api/medications/[id]/route.ts";
expect(manageModulesWith("C7")).toEqual([medication]);
const cadence = auditDetailKeysIn(medication).some(
const cadence = handlerAuditDetailKeys(medication).some(
(keys) =>
keys.includes("replacedCadence") &&
keys.includes("tombstonedSlotCount"),
@@ -3289,14 +3394,15 @@ describe("(g) the MANAGE route set is frozen", () => {
expect(manageModulesWith("C8")).toEqual([dose]);
// The hardcoded provenance this condition removed. A re-created row that
// stamps `WEB` says a human typed a dose that arrived from a device.
expect(read(dose)).not.toContain('createSource: "WEB"');
expect(handlerText(dose)).not.toContain('createSource: "WEB"');
expect(handlerText(dose)).toContain("createSource: event.source");
expect(manageModulesWith("C9")).toEqual([
"app/api/medications/[id]/intake/import/route.ts",
"app/api/medications/intake/dose-history-import/route.ts",
]);
for (const rel of manageModulesWith("C9")) {
const joined = auditDetailKeysIn(rel).some((keys) =>
const joined = handlerAuditDetailKeys(rel).some((keys) =>
keys.includes("jobId"),
);
expect(joined, `${rel} enqueues work no audit row names`).toBe(true);
@@ -3343,10 +3449,11 @@ describe("(h) the guardian route set is frozen", () => {
// Positive control for this leg's own matcher, the same one leg (a) uses
// and for the same reason twice over: the literal is empty, so "no route
// names the resolver" and "the matcher names nothing" are one sentence
// until something the matcher CAN find is named. `lib/api-handler.ts`
// declares the symbol and is the guaranteed positive.
expect(modulesNaming(["lib/api-handler.ts"], GUARDIAN_RESOLVER)).toEqual([
"lib/api-handler.ts",
// until something the matcher CAN find is named. The guard module that
// declares the symbol is the guaranteed positive.
const guardianModule = guardModuleOf(GUARDIAN_RESOLVER);
expect(modulesNaming([guardianModule], GUARDIAN_RESOLVER)).toEqual([
guardianModule,
]);
expect(modulesNaming(scanned, GUARDIAN_RESOLVER)).toEqual(
@@ -3361,7 +3468,10 @@ describe("(h) the guardian route set is frozen", () => {
// marker. A resolver that stopped reading it would leave every leg here
// passing and the identity fence gone — an invited adult's MANAGE grant
// would reach the settings of a record its owner still runs.
const guardian = functionSource("lib/api-handler.ts", GUARDIAN_RESOLVER);
const guardian = functionSource(
guardModuleOf(GUARDIAN_RESOLVER),
GUARDIAN_RESOLVER,
);
// Non-zero proof, twice: the slice found a function, and it is the one it
// claims to be. A renamed resolver must fail here rather than assert
// against an empty string.
@@ -3372,7 +3482,10 @@ describe("(h) the guardian route set is frozen", () => {
// …and the record resolver must NOT read it. The two resolvers differ in
// exactly this fact, so a marker check that drifted into the delegable
// path would make the fence meaningless while every list still agreed.
const record = functionSource("lib/api-handler.ts", RECORD_RESOLVER);
const record = functionSource(
guardModuleOf(RECORD_RESOLVER),
RECORD_RESOLVER,
);
expect(record.length).toBeGreaterThan(0);
expect(record).not.toContain("managedProfileAt");
});
+33 -22
View File
@@ -25,6 +25,7 @@
import { describe, it, expect } from "vitest";
import { join } from "node:path";
import { guardModuleOf, guardSourceOf } from "./helpers/auth-guards";
import {
readSource,
readSourceText,
@@ -77,6 +78,28 @@ function importedAs(
const API_HANDLER = /api-handler$/;
const STEP_UP_MODULE = /auth\/step-up$/;
/**
* The guard module that reads the elevation header, validates and claims:
* the one redemption path. Found by the function that does it, so a move
* between guard modules repoints every leg below at once.
*/
const GATE_MODULE = guardModuleOf("resolveBearerElevation");
/**
* An exported declaration's source, from its signature to the next top-level
* export (or the end of its module), read from the guard module that declares
* `name`. Throws when the signature is gone, so a negative assertion on the
* slice cannot pass over an empty string.
*/
function exportedBlock(name: string, signature: string): string {
const src = guardSourceOf(name, [signature]);
const start = src.indexOf(signature);
const next = src.indexOf("\nexport ", start + 1);
const body = src.slice(start, next === -1 ? undefined : next);
expect(body.length).toBeGreaterThan(signature.length);
return body;
}
/** Files that import `exportName` from the module AND call it. */
function callers(exportName: string, moduleRe: RegExp): string[] {
return sourceFiles()
@@ -260,15 +283,10 @@ describe("S6 — the erasure routes accept a fresh-factor elevation", () => {
// A password-proved elevation must not erase an account, and the gate
// must not grow a second, inline factor check beside the elevation: one
// mint surface, one redemption path.
const src = readSource(
"src/lib/api-handler.ts",
const body = exportedBlock(
"requireFreshMfaOrElevationIfEnrolled",
"export async function requireFreshMfaOrElevationIfEnrolled(",
);
const start = src.indexOf(
"export async function requireFreshMfaOrElevationIfEnrolled(",
);
expect(start).toBeGreaterThan(-1);
const body = src.slice(start, src.indexOf("\nexport ", start + 1));
expect(body).toMatch(/freshFactor:\s*true/);
expect(body).not.toMatch(/verifyMfaFactor|verifyTotp|recoveryCode/);
});
@@ -279,14 +297,14 @@ describe("S2 — mint and redeem happen in exactly one place each", () => {
const found = callers("claimStepUpElevation", STEP_UP_MODULE).filter(
(rel) => rel !== "lib/auth/step-up.ts",
);
expect(found).toEqual(["lib/api-handler.ts"]);
expect(found).toEqual([GATE_MODULE]);
});
it("only the gate validates one", () => {
const found = callers("validateStepUpElevation", STEP_UP_MODULE).filter(
(rel) => rel !== "lib/auth/step-up.ts",
);
expect(found).toEqual(["lib/api-handler.ts"]);
expect(found).toEqual([GATE_MODULE]);
});
it("only the mint route mints one", () => {
@@ -300,7 +318,7 @@ describe("S2 — mint and redeem happen in exactly one place each", () => {
// Both spellings — the literal string AND the exported constant — across
// every source file, not only `route.ts`. A helper could read it too.
const offenders = sourceFiles()
.filter((rel) => rel !== "lib/api-handler.ts")
.filter((rel) => rel !== GATE_MODULE)
.filter((rel) => {
const src = read(rel);
if (/["']x-step-up["']/i.test(src)) return true;
@@ -314,13 +332,9 @@ describe("S2 — mint and redeem happen in exactly one place each", () => {
describe("S3 — requireAdmin stays cookie-only", () => {
it("resolves through getSession and nothing else", () => {
const src = readSource("src/lib/api-handler.ts", [
const body = exportedBlock(
"requireAdmin",
"export async function requireAdmin(",
"export async function requireCookieAuth(",
]);
const body = src.slice(
src.indexOf("export async function requireAdmin("),
src.indexOf("export async function requireCookieAuth("),
);
expect(body).toContain("await getSession()");
expect(body).not.toMatch(/authenticateBearer|resolveBearerToken/);
@@ -345,14 +359,11 @@ describe("S4 — the mint surface refuses a cookie", () => {
});
it("requireBearerAuth never consults the session cookie", () => {
const src = readSource("src/lib/api-handler.ts", [
const body = exportedBlock(
"requireBearerAuth",
"export async function requireBearerAuth(",
"export const STEP_UP_ELEVATION_HEADER",
]);
const body = src.slice(
src.indexOf("export async function requireBearerAuth("),
src.indexOf("export const STEP_UP_ELEVATION_HEADER"),
);
expect(body).toContain("authenticateBearer(");
expect(body).not.toContain("getSession(");
});
});
@@ -38,6 +38,9 @@ const SCOPES = [
"app/api/insights/",
"app/api/analytics/",
"app/api/dashboard/",
// Request-handler logic moved out of its route into a service keeps the
// route's scope.
"lib/dashboard/widgets/",
];
/** `model@file` → why this read includes deleted rows. */
@@ -189,9 +189,10 @@ describe("T2 — the set of files writing Workout rows is frozen", () => {
* already resolved, so the provenance rule does not reach them.
*/
const NON_MAPPING_WRITERS: Record<string, string> = {
// The iOS / client batch endpoint. The client sends an enum value and MAY
// The iOS / client batch endpoint (its write, behind
// `/api/workouts/batch`). The client sends an enum value and MAY
// send its own `metadata`; no server-side label mapping happens here.
"app/api/workouts/batch/route.ts": "client-supplied enum",
"lib/workouts/batch-ingest/write.ts": "client-supplied enum",
// Backup restore replays rows this app previously wrote, provenance and
// all.
"lib/export/workouts-backup.ts": "restore replay",
+17 -14
View File
@@ -26,11 +26,15 @@ import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { guardSourceOf } from "./helpers/auth-guards";
import { walkSourceFiles } from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
/** The resolvers in `api-handler.ts` that charge the shared write bucket. */
/**
* The resolvers that charge the shared write bucket: the guards in
* `lib/auth/guards/`, imported by routes through `lib/api-handler.ts`.
*/
const METERING_RESOLVERS = [
"requireAuth",
"requireRecordAuth",
@@ -194,7 +198,16 @@ describe("every mutating route meets a rate limit", () => {
});
it("charges the bucket in each metering resolver", () => {
const handler = read("lib/api-handler.ts");
/** The resolver's source, from its signature to the next export. */
const resolverBody = (name: string): string => {
const signature = new RegExp(
`export\\s+async\\s+function\\s+${name}\\s*\\(`,
);
const handler = guardSourceOf(name, [signature]);
const start = handler.search(signature);
const next = handler.indexOf("\nexport ", start + 1);
return handler.slice(start, next === -1 ? undefined : next);
};
for (const name of [
"requireAuth",
"requireActorAuth",
@@ -205,13 +218,7 @@ describe("every mutating route meets a rate limit", () => {
"requireFreshMfa",
"requireBearerAuth",
]) {
const start = handler.search(
new RegExp(`export\\s+async\\s+function\\s+${name}\\s*\\(`),
);
expect(start, name).toBeGreaterThanOrEqual(0);
const next = handler.indexOf("\nexport ", start + 1);
const body = handler.slice(start, next === -1 ? undefined : next);
expect(body, name).toMatch(/\bmeterAccountWrite\s*\(/);
expect(resolverBody(name), name).toMatch(/\bmeterAccountWrite\s*\(/);
}
// The three that only delegate reach the meter through `requireAuth`.
for (const name of [
@@ -219,11 +226,7 @@ describe("every mutating route meets a rate limit", () => {
"requireFreshMfaOrElevationIfEnrolled",
"requireRecentProof",
]) {
const start = handler.search(
new RegExp(`export\\s+async\\s+function\\s+${name}\\s*\\(`),
);
const next = handler.indexOf("\nexport ", start + 1);
expect(handler.slice(start, next), name).toMatch(/\brequireAuth\s*\(/);
expect(resolverBody(name), name).toMatch(/\brequireAuth\s*\(/);
}
});
});
@@ -16,7 +16,10 @@
* 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.
* refusal and is left alone. A route that reads its body through
* `parseJsonBody` has no such block: each call is a site of its own, carrying
* the issue list by construction, and its `message` option is read as the
* refused string by the token sweep below.
*
* 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
@@ -94,11 +97,58 @@ interface RefusalSite {
carriesIssues: boolean;
}
/**
* Every `parseJsonBody(…)` call in a file, as its paren-balanced source text
* with the line it starts on. The helper reads, validates and refuses in one
* call (`src/lib/api/body.ts`), so a route that uses it has no
* `if (!x.success)` block for the matcher above to find; its refusal is the
* helper's envelope, which always carries `details.issues`.
*/
function parseJsonBodyCalls(src: string): { text: string; line: number }[] {
const calls: { text: string; line: number }[] = [];
for (const m of src.matchAll(/\bparseJsonBody\s*\(/g)) {
const start = m.index ?? 0;
let depth = 0;
let end = src.length;
for (let j = start + m[0].length - 1; j < src.length; j++) {
if (src[j] === "(") depth++;
else if (src[j] === ")") {
depth--;
if (depth === 0) {
end = j + 1;
break;
}
}
}
calls.push({
text: src.slice(start, end),
line: src.slice(0, start).split("\n").length,
});
}
return calls;
}
/** The status a `parseJsonBody` call refuses with: its `status` option, else 422. */
function parseJsonBodyStatus(call: string): number {
const m = /\bstatus:\s*(\d{3})\b/.exec(call);
return m ? Number(m[1]) : 422;
}
/** 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");
// A `parseJsonBody` refusal is a 422 with the issue list unless the call
// names another status, which is then a different refusal, as below.
for (const call of parseJsonBodyCalls(src)) {
if (parseJsonBodyStatus(call.text) !== 422) continue;
sites.push({
route: relative(process.cwd(), file).split(sep).join("/"),
line: call.line,
carriesIssues: true,
});
}
// 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>();
@@ -228,12 +278,20 @@ function tokenRefusals(): string[] {
/apiValidationError\(\s*"([^"]+)"/g,
/new HttpError\(\s*422\s*,\s*"([^"]+)"/g,
];
const refused: string[] = [];
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);
}
for (const m of src.matchAll(shape)) refused.push(m[1]);
}
// `parseJsonBody`'s `message` option is the 422's `error` string.
for (const call of parseJsonBodyCalls(src)) {
if (parseJsonBodyStatus(call.text) !== 422) continue;
const m = /\bmessage:\s*"([^"]+)"/.exec(call.text);
if (m) refused.push(m[1]);
}
for (const text of refused) {
if (!DOTTED_TOKEN.test(text)) continue;
const key = `${route} ${text}`;
if (!found.includes(key)) found.push(key);
}
}
return found;
@@ -1,7 +1,7 @@
/**
* Structural guard: the three read routes that used to write must stay reads.
*
* `src/lib/api-handler.ts` states the invariant next to `READ_HTTP_METHODS` —
* `src/lib/auth/guards/context.ts` states the invariant next to `READ_HTTP_METHODS` —
* an MCP-audience token is admitted on GET/HEAD/OPTIONS on the REST surface
* because those methods are assumed side-effect-free, and adding a
* side-effecting GET silently widens that token's reach. These three routes
+5 -16
View File
@@ -54,13 +54,8 @@ import {
requireRecentProof,
MFA_STEP_UP_MAX_AGE_SECONDS,
} from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { getAuditLogRetentionDays } from "@/lib/jobs/audit-log-cleanup";
@@ -163,17 +158,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("Too many invitations, try again later", 429);
}
const { data: rawBody, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, inviteGrantSchema, {
maxBytes: 8 * 1024,
errorCode: "sharing.invite.invalid",
});
if (jsonError) return jsonError;
const parsed = inviteGrantSchema.safeParse(rawBody);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "sharing.invite.invalid",
});
}
if (!parsed.ok) return parsed.error;
const { identifier, expiresAt, access, scope } = parsed.data;
+4 -10
View File
@@ -19,12 +19,8 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import {
decideHandover,
@@ -55,12 +51,10 @@ export const GET = apiHandler(async (_request: NextRequest) => {
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, handoverDecisionSchema, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = handoverDecisionSchema.safeParse(body);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
if (!parsed.ok) return parsed.error;
let result;
try {
+6 -17
View File
@@ -55,13 +55,8 @@ import {
requireActorAuth,
SharingAccessDeniedError,
} from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
@@ -69,7 +64,7 @@ import {
pointSessionAt,
pointSessionAtIfUnchanged,
} from "@/lib/sharing/acting-session";
import { findActiveGrant } from "@/lib/sharing/grants";
import { findActiveGrant } from "@/lib/sharing/grant-access";
import { switchAccountSchema } from "@/lib/validations/account-sharing";
export const POST = apiHandler(async (request: NextRequest) => {
@@ -83,17 +78,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
);
}
const { data: rawBody, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, switchAccountSchema, {
maxBytes: 4 * 1024,
errorCode: "sharing.switch.invalid",
});
if (jsonError) return jsonError;
const parsed = switchAccountSchema.safeParse(rawBody);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "sharing.switch.invalid",
});
}
if (!parsed.ok) return parsed.error;
const { accountId, expectedEpoch } = parsed.data;
const ip = getClientIp(request);
+5 -14
View File
@@ -8,12 +8,8 @@
* (`activity_goals_json`) and the activity calendar reads them.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { auditLog } from "@/lib/auth/audit";
import { Prisma } from "@/generated/prisma/client";
import { prisma, toJson } from "@/lib/db";
@@ -56,16 +52,11 @@ export const GET = apiHandler(async () => {
export const PATCH = apiHandler(async (request: Request) => {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, activityGoalsSchema, {
maxBytes: 1024,
action: "activity.goals.validation-failed",
});
if (jsonError) return jsonError;
const parsed = activityGoalsSchema.safeParse(body);
if (!parsed.success) {
annotate({ action: { name: "activity.goals.validation-failed" } });
return returnAllZodIssues(parsed.error, 422);
}
if (!parsed.ok) return parsed.error;
const next = mergeActivityGoals(await readGoals(user.id), parsed.data);
await prisma.user.update({
@@ -11,13 +11,8 @@ import type { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import {
@@ -38,10 +33,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAdmin();
annotate({ action: { name: "admin.encryption.keyBackup.confirm" } });
const body = await safeJson(request, { maxBytes: 1024 });
if (body.error) return body.error;
const parsed = confirmSchema.safeParse(body.data);
if (!parsed.success) return returnAllZodIssues(parsed.error);
const parsed = await parseJsonBody(request, confirmSchema, {
maxBytes: 1024,
});
if (!parsed.ok) return parsed.error;
const result = await confirmKeyBackup({
keyId: parsed.data.keyId,
+5 -13
View File
@@ -13,12 +13,8 @@
*/
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import {
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { hashToken } from "@/lib/auth/hmac";
@@ -62,15 +58,11 @@ export const GET = apiHandler(async () => {
export const POST = apiHandler(async (req: Request) => {
const { user } = await requireAdmin();
const { data: body, error: jsonError } = await safeJson(req, {
const parsed = await parseJsonBody(req, inviteCreateSchema, {
maxBytes: 16 * 1024,
input: (body) => body ?? {},
});
if (jsonError) return jsonError;
const parsed = inviteCreateSchema.safeParse(body ?? {});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422);
}
if (!parsed.ok) return parsed.error;
const { expiresInDays, maxUses } = parsed.data;
const rawToken = generateInviteToken();
@@ -15,13 +15,8 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
@@ -99,12 +94,10 @@ export const GET = apiHandler(async () => {
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAdmin();
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, pulseDayMeanActionSchema, {
maxBytes: 1024,
});
if (jsonError) return jsonError;
const parsed = pulseDayMeanActionSchema.safeParse(body);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
if (!parsed.ok) return parsed.error;
const action = parsed.data.action;
annotate({
action: { name: "admin.pulse_day_mean.requested" },
@@ -52,13 +52,8 @@ import type { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { resolveAssistantFlags } from "@/lib/feature-flags";
@@ -180,16 +175,10 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAdmin();
annotate({ action: { name: "admin.settings.assistant-flags.update" } });
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, assistantFlagsSchema, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = assistantFlagsSchema.safeParse(body);
if (!parsed.success) {
// v1.4.43 — multi-issue 422.
return returnAllZodIssues(parsed.error, 422);
}
if (!parsed.ok) return parsed.error;
// Field by field from the parsed body; the schema is strict, so every key
// here is one the route owns.
@@ -33,13 +33,8 @@ import type { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
@@ -95,15 +90,10 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
const { user } = await requireAdmin();
annotate({ action: { name: "admin.settings.module-availability.update" } });
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, moduleAvailabilitySchema, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = moduleAvailabilitySchema.safeParse(body);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422);
}
if (!parsed.ok) return parsed.error;
const patch: Record<string, boolean> = {};
for (const [key, value] of Object.entries(parsed.data)) {
+4 -15
View File
@@ -1,13 +1,8 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { encrypt } from "@/lib/crypto";
import { adminSettingsSchema } from "@/lib/validations/admin";
@@ -109,16 +104,10 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAdmin();
annotate({ action: { name: "admin.settings.update" } });
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, adminSettingsSchema, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = adminSettingsSchema.safeParse(body);
if (!parsed.success) {
// v1.4.43 — multi-issue 422.
return returnAllZodIssues(parsed.error, 422);
}
if (!parsed.ok) return parsed.error;
const data = parsed.data;
const updates: Record<string, unknown> = {};
@@ -1,14 +1,8 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { encrypt } from "@/lib/crypto";
import { z } from "zod/v4";
@@ -53,19 +47,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAdmin();
annotate({ action: { name: "admin.webPushVapid.generate" } });
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, generateSchema, {
maxBytes: 4 * 1024,
input: (body) => body ?? {},
message: "Invalid request",
});
if (jsonError) return jsonError;
const parsed = generateSchema.safeParse(body ?? {});
if (!parsed.success) {
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
if (!parsed.ok) return parsed.error;
const existing = await prisma.appSettings.findUnique({
where: { id: "singleton" },
+4 -15
View File
@@ -1,13 +1,8 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
@@ -49,16 +44,10 @@ export const PUT = apiHandler(
},
});
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, updateUserSchema, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = updateUserSchema.safeParse(body);
if (!parsed.success) {
// v1.4.43 — multi-issue 422.
return returnAllZodIssues(parsed.error, 422);
}
if (!parsed.ok) return parsed.error;
const target = await prisma.user.findUnique({
where: { id },
+5 -16
View File
@@ -19,13 +19,8 @@ import {
destroyedDetails,
overwriteDetails,
} from "@/lib/sharing/audit-details";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { allergyUpdateSchema } from "@/lib/validations/allergy";
import { toAllergyDTO } from "@/lib/records/dto";
@@ -82,17 +77,11 @@ export const PATCH = apiHandler(
return apiError("Allergy not found", 404);
}
const { data: rawBody, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, allergyUpdateSchema, {
maxBytes: 16 * 1024,
errorCode: "allergy.invalid",
});
if (jsonError) return jsonError;
const parsed = allergyUpdateSchema.safeParse(rawBody);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "allergy.invalid",
});
}
if (!parsed.ok) return parsed.error;
const entry = parsed.data;
// Field-by-field — only fields actually present in the body are written.
+6 -14
View File
@@ -18,8 +18,8 @@ import {
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { withIdempotency } from "@/lib/idempotency";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import {
@@ -92,21 +92,13 @@ async function postAllergy(request: NextRequest): Promise<Response> {
// regardless.
const { user } = await requireRecordAuth("manage", "profile");
const { data: rawBody, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, allergyCreateSchema, {
maxBytes: 16 * 1024,
action: "allergy.validation-failed",
countIssues: true,
errorCode: "allergy.invalid",
});
if (jsonError) return jsonError;
const parsed = allergyCreateSchema.safeParse(rawBody);
if (!parsed.success) {
annotate({
action: { name: "allergy.validation-failed" },
meta: { issue_count: parsed.error.issues.length },
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "allergy.invalid",
});
}
if (!parsed.ok) return parsed.error;
const entry = parsed.data;
+5 -15
View File
@@ -16,13 +16,8 @@
import { NextRequest } from "next/server";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { auditLog } from "@/lib/auth/audit";
import { withIdempotency } from "@/lib/idempotency";
import { annotate } from "@/lib/logging/context";
@@ -46,16 +41,11 @@ async function postAuditC(request: NextRequest): Promise<Response> {
});
}
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, auditCRequestSchema, {
maxBytes: 4 * 1024,
errorCode: "anamnesis.auditC.invalid",
});
if (jsonError) return jsonError;
const parsed = auditCRequestSchema.safeParse(body);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "anamnesis.auditC.invalid",
});
}
if (!parsed.ok) return parsed.error;
const input = parsed.data;
+4 -11
View File
@@ -1,12 +1,8 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import {
@@ -31,13 +27,10 @@ export const GET = apiHandler(async () => {
export const PATCH = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { data: rawBody, error } = await safeJson(request, {
const parsed = await parseJsonBody(request, emergencyProfileUpdateSchema, {
maxBytes: 16 * 1024,
});
if (error) return error;
const parsed = emergencyProfileUpdateSchema.safeParse(rawBody);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
if (!parsed.ok) return parsed.error;
const payload = await writeEmergencyProfile(user.id, parsed.data);
await auditLog("anamnesis.emergency.update", {
+4 -12
View File
@@ -1,13 +1,8 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
@@ -39,13 +34,10 @@ export const PATCH = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const { data: rawBody, error } = await safeJson(request, {
const parsed = await parseJsonBody(request, factCorrectionSchema, {
maxBytes: 8 * 1024,
});
if (error) return error;
const parsed = factCorrectionSchema.safeParse(rawBody);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
if (!parsed.ok) return parsed.error;
const current = await prisma.healthProfileFactRevision.findFirst({
where: {
+4 -12
View File
@@ -1,13 +1,8 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { auditLog } from "@/lib/auth/audit";
import { withIdempotency } from "@/lib/idempotency";
import { annotate } from "@/lib/logging/context";
@@ -41,13 +36,10 @@ export const GET = apiHandler(async () => {
async function postFact(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
const { data: rawBody, error } = await safeJson(request, {
const parsed = await parseJsonBody(request, factWriteSchema, {
maxBytes: 8 * 1024,
});
if (error) return error;
const parsed = factWriteSchema.safeParse(rawBody);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
if (!parsed.ok) return parsed.error;
const now = new Date();
const validFrom = parsed.data.validFrom
@@ -12,13 +12,8 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import { updateLifeAiSections } from "@/lib/profile/life-circumstances";
@@ -27,16 +22,11 @@ import { lifeAiSectionsPatchSchema } from "@/lib/validations/life-circumstances"
export const PATCH = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, lifeAiSectionsPatchSchema, {
maxBytes: 4 * 1024,
errorCode: "anamnesis.lifeSections.invalid",
});
if (jsonError) return jsonError;
const parsed = lifeAiSectionsPatchSchema.safeParse(body);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "anamnesis.lifeSections.invalid",
});
}
if (!parsed.ok) return parsed.error;
const outcome = await updateLifeAiSections(
user.id,
@@ -8,7 +8,8 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
import { apiSuccess } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { confirmLifeSection } from "@/lib/profile/life-circumstances";
import { lifeConfirmSchema } from "@/lib/validations/life-circumstances";
@@ -16,16 +17,11 @@ import { lifeConfirmSchema } from "@/lib/validations/life-circumstances";
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, lifeConfirmSchema, {
maxBytes: 1024,
errorCode: "anamnesis.lifeConfirm.invalid",
});
if (jsonError) return jsonError;
const parsed = lifeConfirmSchema.safeParse(body);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "anamnesis.lifeConfirm.invalid",
});
}
if (!parsed.ok) return parsed.error;
const lastConfirmedAt = await confirmLifeSection(
user.id,
+4 -5
View File
@@ -14,7 +14,8 @@
import { NextRequest } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
import { apiSuccess } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { getSession } from "@/lib/auth/session";
import { annotate } from "@/lib/logging/context";
import {
@@ -35,12 +36,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
);
if (gate.refusal) return gate.refusal;
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, claimPreviewSchema, {
maxBytes: 4 * 1024,
});
if (jsonError) return jsonError;
const parsed = claimPreviewSchema.safeParse(body);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
if (!parsed.ok) return parsed.error;
const preview = await previewHandover(parsed.data.token);
annotate({
+4 -10
View File
@@ -24,12 +24,8 @@
import { NextRequest } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { auditLog } from "@/lib/auth/audit";
import { recordSignInDevice } from "@/lib/auth/login-alert";
import { checkPasswordStrength, hashPassword } from "@/lib/auth/password";
@@ -59,12 +55,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (gate.refusal) return gate.refusal;
const ip = gate.ip ?? "unknown";
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, claimSchema, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = claimSchema.safeParse(body);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
if (!parsed.ok) return parsed.error;
const { token, username, email, password } = parsed.data;
// The link first. Without this the collision probe below would answer 409
+4 -10
View File
@@ -20,12 +20,8 @@
import { NextRequest } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { auditLog } from "@/lib/auth/audit";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
@@ -44,12 +40,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
const gate = await claimGate(request, "sso", (await getSession()) !== null);
if (gate.refusal) return gate.refusal;
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, claimSsoSchema, {
maxBytes: 4 * 1024,
});
if (jsonError) return jsonError;
const parsed = claimSsoSchema.safeParse(body);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
if (!parsed.ok) return parsed.error;
const { token, username } = parsed.data;
// The link first, so the username probe below is no oracle for anybody
+5 -15
View File
@@ -10,13 +10,8 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
@@ -40,16 +35,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
});
}
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, aiUsagePutSchema, {
maxBytes: 1024,
errorCode: "ai.usage.invalid",
});
if (jsonError) return jsonError;
const parsed = aiUsagePutSchema.safeParse(body);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "ai.usage.invalid",
});
}
if (!parsed.ok) return parsed.error;
const { level } = parsed.data;
const settings = await loadAiUsageSettings();
+1 -1
View File
@@ -27,7 +27,7 @@ import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { getOrCreateCycleProfile } from "@/lib/cycle/profile";
import { isCycleEnabled } from "@/lib/cycle/gate";
import { isCycleEnabled } from "@/lib/cycle/enabled";
import { invalidateUserHealthContext } from "@/lib/cache/invalidate";
import { toCycleProfileDTO } from "@/lib/cycle/dto";
import { cyclePrefsSchema } from "@/lib/validations/cycle";
+4 -12
View File
@@ -25,12 +25,8 @@
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -56,14 +52,10 @@ export const PATCH = apiHandler(
const { user } = await requireAuth();
const { id } = await context.params;
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, devicePatchSchema, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = devicePatchSchema.safeParse(body);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422);
}
if (!parsed.ok) return parsed.error;
// Ownership boundary — scope the update by userId so a cross-user id
// can't be patched, and 404 rather than leak existence.
+5 -15
View File
@@ -20,13 +20,8 @@
import { z } from "zod";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -79,16 +74,11 @@ export const PATCH = apiHandler(async (req: Request) => {
// The body is a single boolean — bound the parse so a malformed or
// oversized payload is rejected before it is materialised.
const { data: body, error: jsonError } = await safeJson(req, {
const parsed = await parseJsonBody(req, patchBodySchema, {
maxBytes: 1024,
action: "auth.me.diabetes.patch.invalid_shape",
});
if (jsonError) return jsonError;
const parsed = patchBodySchema.safeParse(body);
if (!parsed.success) {
annotate({ action: { name: "auth.me.diabetes.patch.invalid_shape" } });
return returnAllZodIssues(parsed.error, 422);
}
if (!parsed.ok) return parsed.error;
const next = parsed.data.hasDiabetes;
@@ -26,13 +26,8 @@
* resolved next state so the client can hard-set the optimistic update.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
@@ -134,18 +129,11 @@ export const PATCH = apiHandler(async (req: Request) => {
// The body is a single boolean — bound the parse so a malformed or oversized
// payload is rejected before it is materialised.
const { data: body, error: jsonError } = await safeJson(req, {
const parsed = await parseJsonBody(req, documentsAutoAiReadPatchSchema, {
maxBytes: 1024,
action: "auth.me.documentsAutoAiRead.patch.invalid_shape",
});
if (jsonError) return jsonError;
const parsed = documentsAutoAiReadPatchSchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "auth.me.documentsAutoAiRead.patch.invalid_shape" },
});
return returnAllZodIssues(parsed.error, 422);
}
if (!parsed.ok) return parsed.error;
const next = parsed.data.documentsAutoAiRead;
+5 -15
View File
@@ -25,13 +25,8 @@
* update without an extra round-trip.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -81,16 +76,11 @@ export const PATCH = apiHandler(async (req: Request) => {
// The body is a single enum value — bound the parse so a malformed or
// oversized payload is rejected before it is materialised.
const { data: body, error: jsonError } = await safeJson(req, {
const parsed = await parseJsonBody(req, glucoseUnitPatchSchema, {
maxBytes: 1024,
action: "auth.me.glucose-unit.patch.invalid_shape",
});
if (jsonError) return jsonError;
const parsed = glucoseUnitPatchSchema.safeParse(body);
if (!parsed.success) {
annotate({ action: { name: "auth.me.glucose-unit.patch.invalid_shape" } });
return returnAllZodIssues(parsed.error, 422);
}
if (!parsed.ok) return parsed.error;
const next = parsed.data.glucoseUnit;
@@ -19,13 +19,8 @@
* field-by-field write (no mass assignment).
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -77,18 +72,11 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
return response;
}
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, injectionSitePrefsPatchSchema, {
maxBytes: 64 * 1024,
action: "auth.me.injection-site-prefs.patch.invalid_shape",
});
if (jsonError) return jsonError;
const parsed = injectionSitePrefsPatchSchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "auth.me.injection-site-prefs.patch.invalid_shape" },
});
return returnAllZodIssues(parsed.error, 422);
}
if (!parsed.ok) return parsed.error;
// Dedup while preserving canonical order from the enum tuple.
const submitted = new Set(parsed.data.globalExcludedInjectionSites);
+5 -15
View File
@@ -17,13 +17,8 @@
* resolved next state so the client can hard-set the optimistic update.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -71,16 +66,11 @@ export const PATCH = apiHandler(async (req: Request) => {
// The body is a single boolean — bound the parse so a malformed or oversized
// payload is rejected before it is materialised.
const { data: body, error: jsonError } = await safeJson(req, {
const parsed = await parseJsonBody(req, labsLocalOcrPatchSchema, {
maxBytes: 1024,
action: "auth.me.labsLocalOcr.patch.invalid_shape",
});
if (jsonError) return jsonError;
const parsed = labsLocalOcrPatchSchema.safeParse(body);
if (!parsed.success) {
annotate({ action: { name: "auth.me.labsLocalOcr.patch.invalid_shape" } });
return returnAllZodIssues(parsed.error, 422);
}
if (!parsed.ok) return parsed.error;
const next = parsed.data.labsLocalOcrEnabled;
+5 -19
View File
@@ -10,14 +10,8 @@
*/
import { NextRequest } from "next/server";
import { apiHandler, requireMfaManagementAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -34,19 +28,11 @@ export const PATCH = apiHandler(
const { user } = auth;
const { id } = await params;
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, mfaWebauthnRenameSchema, {
maxBytes: 4 * 1024,
message: "Invalid request",
});
if (jsonError) return jsonError;
const parsed = mfaWebauthnRenameSchema.safeParse(body);
if (!parsed.success) {
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
if (!parsed.ok) return parsed.error;
const existing = await prisma.webauthnMfaCredential.findUnique({
where: { id },
@@ -8,14 +8,8 @@
*/
import { NextRequest } from "next/server";
import { apiHandler, requireMfaManagementAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -30,19 +24,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
const auth = await requireMfaManagementAuth();
const { user } = auth;
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, mfaWebauthnRegisterVerifySchema, {
maxBytes: 64 * 1024,
message: "Invalid request",
});
if (jsonError) return jsonError;
const parsed = mfaWebauthnRegisterVerifySchema.safeParse(body);
if (!parsed.success) {
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
if (!parsed.ok) return parsed.error;
const { challengeId, credential, name } = parsed.data;
const verification = await verifyMfaRegistration(
+1 -1
View File
@@ -50,7 +50,7 @@ import { buildAvatarUrl } from "@/lib/avatar";
import { decrypt } from "@/lib/crypto";
import { prisma } from "@/lib/db";
import { resolveStoredTimezone } from "@/lib/tz/resolver";
import { isCycleEnabled } from "@/lib/cycle/gate";
import { isCycleEnabled } from "@/lib/cycle/enabled";
import {
resolveModuleMap,
getOperatorModuleAvailability,
+1 -1
View File
@@ -38,7 +38,7 @@ import {
} from "@/lib/auth/session";
import { lookupIpLocation } from "@/lib/geo";
import { GRANT_PARTY_SELECT } from "@/lib/sharing/grant-view";
import { isGrantActive } from "@/lib/sharing/grants";
import { isGrantActive } from "@/lib/sharing/grant-access";
import { coarseDeviceLabel, maskIp } from "@/lib/auth/device-fingerprint";
export const dynamic = "force-dynamic";
+5 -17
View File
@@ -25,13 +25,8 @@
* `res.ok` and never the status.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -85,18 +80,11 @@ export const PATCH = apiHandler(async (req: Request) => {
// The body is a single enum value — bound the parse so a malformed or
// oversized payload is rejected before it is materialised.
const { data: body, error: jsonError } = await safeJson(req, {
const parsed = await parseJsonBody(req, unitPreferencePatchSchema, {
maxBytes: 1024,
action: "auth.me.unit-preference.patch.invalid_shape",
});
if (jsonError) return jsonError;
const parsed = unitPreferencePatchSchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "auth.me.unit-preference.patch.invalid_shape" },
});
return returnAllZodIssues(parsed.error, 422);
}
if (!parsed.ok) return parsed.error;
const next = parsed.data.unitPreference;
+5 -17
View File
@@ -17,13 +17,8 @@
* assignment). Idempotent — always returns the resolved next state.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -69,18 +64,11 @@ export const PATCH = apiHandler(async (req: Request) => {
return response;
}
const { data: body, error: jsonError } = await safeJson(req, {
const parsed = await parseJsonBody(req, useCentralCodexPatchSchema, {
maxBytes: 1024,
action: "auth.me.useCentralCodex.patch.invalid_shape",
});
if (jsonError) return jsonError;
const parsed = useCentralCodexPatchSchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "auth.me.useCentralCodex.patch.invalid_shape" },
});
return returnAllZodIssues(parsed.error, 422);
}
if (!parsed.ok) return parsed.error;
const next = parsed.data.useCentralCodex;
+5 -15
View File
@@ -15,13 +15,8 @@
* the resolved next state in the response.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -67,16 +62,11 @@ export const PATCH = apiHandler(async (req: Request) => {
return response;
}
const { data: body, error: jsonError } = await safeJson(req, {
const parsed = await parseJsonBody(req, workoutRoutesPatchSchema, {
maxBytes: 1024,
action: "auth.me.workoutRoutes.patch.invalid_shape",
});
if (jsonError) return jsonError;
const parsed = workoutRoutesPatchSchema.safeParse(body);
if (!parsed.success) {
annotate({ action: { name: "auth.me.workoutRoutes.patch.invalid_shape" } });
return returnAllZodIssues(parsed.error, 422);
}
if (!parsed.ok) return parsed.error;
const next = parsed.data.storeWorkoutRoutes;
@@ -13,13 +13,8 @@
*/
import { NextRequest } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { apiError, apiSuccess } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
@@ -44,19 +39,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
await ensureDbCompatibility();
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, mfaWebauthnLoginOptionsSchema, {
maxBytes: 16 * 1024,
message: "Invalid request",
});
if (jsonError) return jsonError;
const parsed = mfaWebauthnLoginOptionsSchema.safeParse(body);
if (!parsed.success) {
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
if (!parsed.ok) return parsed.error;
const challenge = await loadActiveChallenge(parsed.data.mfaTicket);
if (!challenge) {
@@ -11,6 +11,8 @@ import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { guardSourceOf } from "@/__tests__/helpers/auth-guards";
const SRC = join(process.cwd(), "src");
const read = (rel: string) => readFileSync(join(SRC, rel), "utf8");
@@ -94,18 +96,15 @@ describe("T11/T13 — cookie-only boundaries and no bundle-in-browser", () => {
});
it("requireAdmin and requireFreshMfa remain cookie-only (getSession)", () => {
const handler = read("lib/api-handler.ts");
// Both resolve via getSession() (cookie-only) — restated as a tripwire so a
// future edit that threads a Bearer into either trips this test.
const requireAdmin = handler.slice(
handler.indexOf("export async function requireAdmin"),
handler.indexOf("export async function requireAdmin") + 400,
);
expect(requireAdmin).toMatch(/getSession\(\)/);
const requireFresh = handler.slice(
handler.indexOf("export async function requireFreshMfa"),
handler.indexOf("export async function requireFreshMfa") + 400,
);
expect(requireFresh).toMatch(/getSession\(\)/);
// future edit that threads a Bearer into either trips this test. Read from
// the guard module that declares each, which throws if it is gone.
const opening = (name: string) => {
const src = guardSourceOf(name, [`export async function ${name}(`]);
const start = src.indexOf(`export async function ${name}(`);
return src.slice(start, start + 600);
};
expect(opening("requireAdmin")).toMatch(/getSession\(\)/);
expect(opening("requireFreshMfa")).toMatch(/getSession\(\)/);
});
});
+5 -17
View File
@@ -20,12 +20,8 @@
* `requireFreshMfa` step-up.
*/
import { NextRequest } from "next/server";
import {
apiError,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { apiError } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
@@ -81,19 +77,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
await ensureDbCompatibility();
// (3) Body — bounded, then the exact wire contract.
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, nativeHandoffTokenSchema, {
maxBytes: 16 * 1024,
message: "Invalid request",
});
if (jsonError) return jsonError;
const parsed = nativeHandoffTokenSchema.safeParse(body);
if (!parsed.success) {
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
if (!parsed.ok) return parsed.error;
const { code, codeVerifier } = parsed.data;
// (4) Lookup + flow check + replay/expiry/PKCE + atomic single-use consume.
+5 -17
View File
@@ -23,12 +23,8 @@
* `requireFreshMfa` step-up, so an SSO login can never satisfy step-up.
*/
import { NextRequest } from "next/server";
import {
apiError,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { apiError } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
@@ -87,19 +83,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
await ensureDbCompatibility();
// (3) Body — bounded, then the exact wire contract.
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, oidcNativeTokenSchema, {
maxBytes: 16 * 1024,
message: "Invalid request",
});
if (jsonError) return jsonError;
const parsed = oidcNativeTokenSchema.safeParse(body);
if (!parsed.success) {
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
if (!parsed.ok) return parsed.error;
const { code, codeVerifier } = parsed.data;
// (4–8) Lookup + replay/expiry/PKCE + atomic single-use consume.
+4 -19
View File
@@ -1,12 +1,7 @@
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { NextRequest } from "next/server";
import {
apiHandler,
@@ -24,20 +19,10 @@ export const PATCH = apiHandler(
const { user } = await requireAuth();
const { id } = await params;
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, passkeyRenameSchema, {
maxBytes: 4 * 1024,
});
if (jsonError) return jsonError;
const parsed = passkeyRenameSchema.safeParse(body);
if (!parsed.success) {
// Multi-issue, like every sibling that parses a body. The flat
// `apiError("Invalid request", 422)` this replaces discarded the issues,
// so a person renaming a passkey could not tell an empty name from one
// over the 64-character limit — the form had nothing to put beside the
// field.
return returnAllZodIssues(parsed.error, 422);
}
if (!parsed.ok) return parsed.error;
const passkey = await prisma.passkey.findUnique({
where: { id },
+4 -15
View File
@@ -7,13 +7,8 @@ import {
} from "@/lib/auth/password";
import { changePasswordSchema } from "@/lib/validations/auth";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import {
recordReproofFailure,
refundReproof,
@@ -46,16 +41,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
const limited = await throttleReproof(user.id, ip, "password_change");
if (limited) return limited;
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, changePasswordSchema, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = changePasswordSchema.safeParse(body);
if (!parsed.success) {
// v1.4.43 — multi-issue 422.
return returnAllZodIssues(parsed.error, 422);
}
if (!parsed.ok) return parsed.error;
const { currentPassword, newPassword } = parsed.data;
const credential = await prisma.user.findUnique({
+4 -15
View File
@@ -9,12 +9,8 @@ import { hashPassword, checkPasswordStrength } from "@/lib/auth/password";
import { createSession, getSession } from "@/lib/auth/session";
import { recordSignInDevice } from "@/lib/auth/login-alert";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
apiError,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiSuccess, apiError } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { NextRequest, NextResponse } from "next/server";
@@ -100,17 +96,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
} catch {
// Table may not exist yet; allow registration
}
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, registerSchema, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = registerSchema.safeParse(body);
if (!parsed.success) {
// v1.4.43 — multi-issue 422.
return returnAllZodIssues(parsed.error, 422);
}
if (!parsed.ok) return parsed.error;
const {
email,
+5 -19
View File
@@ -42,14 +42,8 @@ import {
recentProofMethods,
requireCookieAuth,
} from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
@@ -68,19 +62,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
const { user, session } = await requireCookieAuth();
const ip = getClientIp(request);
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, stepUpMintSchema, {
maxBytes: 64 * 1024,
message: "Invalid request",
});
if (jsonError) return jsonError;
const parsed = stepUpMintSchema.safeParse(body);
if (!parsed.success) {
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
if (!parsed.ok) return parsed.error;
const proof = parsed.data;
// Refuse a proof that could not satisfy the gate BEFORE spending a guess on
+5 -18
View File
@@ -18,13 +18,8 @@
*/
import { NextRequest } from "next/server";
import { apiHandler, requireBearerAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { apiError, apiSuccess } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
@@ -49,19 +44,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
}
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, stepUpOptionsSchema, {
maxBytes: 4 * 1024,
message: "Invalid request",
});
if (jsonError) return jsonError;
const parsed = stepUpOptionsSchema.safeParse(body);
if (!parsed.success) {
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
if (!parsed.ok) return parsed.error;
if (parsed.data.method === "webauthn") {
const result = await createMfaAuthenticationOptions(user.id);
+5 -19
View File
@@ -30,14 +30,8 @@
*/
import { NextRequest } from "next/server";
import { apiHandler, requireBearerAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
@@ -110,19 +104,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
}
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, stepUpMintSchema, {
maxBytes: 64 * 1024,
message: "Invalid request",
});
if (jsonError) return jsonError;
const parsed = stepUpMintSchema.safeParse(body);
if (!parsed.success) {
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
if (!parsed.ok) return parsed.error;
const method: StepUpMethod = parsed.data.method;
// The shared verifier: the same challenge ownership and ceremony checks, and
+5 -15
View File
@@ -19,13 +19,8 @@
import { NextRequest } from "next/server";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { auditLog } from "@/lib/auth/audit";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import { withIdempotency } from "@/lib/idempotency";
@@ -47,16 +42,11 @@ async function mergeBiomarkers(
const { user } = await requireRecordAuth("manage", "labs");
const { id } = await params;
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, biomarkerMergeSchema, {
maxBytes: 4 * 1024,
errorCode: "labs.biomarker.merge.invalid",
});
if (jsonError) return jsonError;
const parsed = biomarkerMergeSchema.safeParse(body);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "labs.biomarker.merge.invalid",
});
}
if (!parsed.ok) return parsed.error;
if (parsed.data.sourceIds.includes(id)) {
return apiError("The target cannot be one of the sources", 422, {
errorCode: "labs.biomarker.merge.invalid",
+22 -28
View File
@@ -5,10 +5,9 @@ import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { auditLog } from "@/lib/auth/audit";
import {
destroyedDetails,
@@ -122,34 +121,29 @@ export const PUT = apiHandler(
});
}
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, updateBiomarkerSchema, {
maxBytes: 16 * 1024,
action: "labs.biomarker.update.validation-failed",
countIssues: true,
meta: { biomarkerId: id },
errorCode: "labs.biomarker.update.invalid",
onInvalid: (error) => {
const auditIssues = sanitiseZodIssues(error.issues, {
stripValuesFromMessage: true,
});
// v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog
// .create`, because that helper is the only writer that stamps
// `actorUserId`. Filed under the resolved record either way; without
// the stamp a manager's malformed payload would read as the owner's own.
void auditLog("labs.biomarker.update.validation-failed", {
userId: user.id,
details: { issues: auditIssues, biomarkerId: id },
}).catch(() => {
/* swallow — the 422 response is the contract */
});
},
});
if (jsonError) return jsonError;
const parsed = updateBiomarkerSchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "labs.biomarker.update.validation-failed" },
meta: { issue_count: parsed.error.issues.length, biomarkerId: id },
});
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
// v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog
// .create`, because that helper is the only writer that stamps
// `actorUserId`. Filed under the resolved record either way; without the
// stamp a manager's malformed payload would read as the owner's own.
void auditLog("labs.biomarker.update.validation-failed", {
userId: user.id,
details: { issues: auditIssues, biomarkerId: id },
}).catch(() => {
/* swallow — the 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "labs.biomarker.update.invalid",
});
}
if (!parsed.ok) return parsed.error;
const d = parsed.data;
+18 -25
View File
@@ -5,10 +5,9 @@ import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { withIdempotency } from "@/lib/idempotency";
@@ -77,31 +76,25 @@ async function postBiomarker(request: NextRequest) {
// tracks gets the ordinary 409 rather than a second copy.
const { user } = await requireRecordAuth("write", "labs");
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, createBiomarkerSchema, {
maxBytes: 16 * 1024,
action: "labs.biomarker.create.validation-failed",
countIssues: true,
errorCode: "labs.biomarker.create.invalid",
onInvalid: (error) => {
const auditIssues = sanitiseZodIssues(error.issues, {
stripValuesFromMessage: true,
});
// v1.36.x — through `auditLog()`, the only writer that stamps the actor.
void auditLog("labs.biomarker.create.validation-failed", {
userId: user.id,
details: { issues: auditIssues },
}).catch(() => {
/* swallow — the 422 response is the contract */
});
},
});
if (jsonError) return jsonError;
const parsed = createBiomarkerSchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "labs.biomarker.create.validation-failed" },
meta: { issue_count: parsed.error.issues.length },
});
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
// v1.36.x — through `auditLog()`, the only writer that stamps the actor.
void auditLog("labs.biomarker.create.validation-failed", {
userId: user.id,
details: { issues: auditIssues },
}).catch(() => {
/* swallow — the 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "labs.biomarker.create.invalid",
});
}
if (!parsed.ok) return parsed.error;
const { name, unit, lowerBound, upperBound, context, panel } = parsed.data;
+4 -11
View File
@@ -23,12 +23,8 @@ import { NextRequest } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
import { resolveShareGateState } from "@/lib/clinician-share/resolve-share-token";
@@ -69,13 +65,10 @@ export const POST = apiHandler(
return apiError("Too many attempts", 429);
}
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, unlockShareLinkSchema, {
maxBytes: 4 * 1024,
});
if (jsonError) return jsonError;
const parsed = unlockShareLinkSchema.safeParse(body);
if (!parsed.success) return returnAllZodIssues(parsed.error);
if (!parsed.ok) return parsed.error;
// A token with no live gate, or one carrying no passphrase, cannot be
// unlocked. `verifyPassphrase` is constant-time and returns false for a
+4 -14
View File
@@ -28,13 +28,8 @@
* prose and must not land in plaintext audit details.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -111,15 +106,10 @@ export const POST = apiHandler(async (req: Request) => {
return response;
}
const { data: body, error: jsonError } = await safeJson(req, {
const parsed = await parseJsonBody(req, aboutMeAdoptSchema, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = aboutMeAdoptSchema.safeParse(body);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422);
}
if (!parsed.ok) return parsed.error;
const { question, answer } = parsed.data;
const normAnswer = normalise(answer);
@@ -13,7 +13,8 @@
import { z } from "zod/v4";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
import { apiSuccess } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import {
getPendingQuestionsForUser,
@@ -48,15 +49,11 @@ export const DELETE = apiHandler(async (req: Request) => {
const gate = await requireModuleEnabled(user.id, "coach");
if (!gate.enabled) return gate.response;
const { data: body, error: jsonError } = await safeJson(req, {
const parsed = await parseJsonBody(req, dismissSchema, {
maxBytes: 4 * 1024,
input: (body) => body ?? {},
});
if (jsonError) return jsonError;
const parsed = dismissSchema.safeParse(body ?? {});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422);
}
if (!parsed.ok) return parsed.error;
const current = await getPendingQuestionsForUser(user.id);
const remaining =
+4 -13
View File
@@ -25,12 +25,8 @@
import type { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { requireModuleEnabled } from "@/lib/modules/gate";
@@ -77,15 +73,10 @@ export const PATCH = apiHandler(async (req: NextRequest, ctx: RouteCtx) => {
const { id } = await ctx.params;
const { data: body, error: jsonError } = await safeJson(req, {
const parsed = await parseJsonBody(req, coachPlanPatchSchema, {
maxBytes: 4 * 1024,
});
if (jsonError) return jsonError;
const parsed = coachPlanPatchSchema.safeParse(body);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422);
}
if (!parsed.ok) return parsed.error;
// Field-by-field data assembly (no mass assignment): only the lifecycle
// fields the body carried are written; the metric + encrypted text are never
@@ -24,13 +24,8 @@ import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit } from "@/lib/rate-limit";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import {
@@ -65,13 +60,12 @@ async function postAction(request: NextRequest): Promise<Response> {
return apiError("coach.suggestion.rateLimited", 429);
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 4 * 1024,
});
if (jsonError) return jsonError;
const parsed = coachReminderSuggestionActionSchema.safeParse(body);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
const parsed = await parseJsonBody(
request,
coachReminderSuggestionActionSchema,
{ maxBytes: 4 * 1024 },
);
if (!parsed.ok) return parsed.error;
const { cadenceId, action } = parsed.data;
if (!isCadenceId(cadenceId)) {
+4 -11
View File
@@ -23,12 +23,8 @@
import type { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { requireModuleEnabled } from "@/lib/modules/gate";
@@ -71,13 +67,10 @@ export const PATCH = apiHandler(async (req: NextRequest, ctx: RouteCtx) => {
const { id } = await ctx.params;
const { data: body, error: jsonError } = await safeJson(req, {
const parsed = await parseJsonBody(req, coachReminderPatchSchema, {
maxBytes: 4 * 1024,
});
if (jsonError) return jsonError;
const parsed = coachReminderPatchSchema.safeParse(body);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
if (!parsed.ok) return parsed.error;
// Field-by-field (no mass assignment): only the lifecycle / timing fields the
// body carried are written; the note + encrypted text are never touched here.
+4 -11
View File
@@ -22,12 +22,8 @@
* / due / surfaced), soonest-due first.
*/
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { requireModuleEnabled } from "@/lib/modules/gate";
@@ -151,13 +147,10 @@ export const POST = apiHandler(async (req: Request) => {
);
if (!rate.allowed) return apiError("Too many requests", 429);
const { data: body, error: jsonError } = await safeJson(req, {
const parsed = await parseJsonBody(req, coachReminderCreateSchema, {
maxBytes: 4 * 1024,
});
if (jsonError) return jsonError;
const parsed = coachReminderCreateSchema.safeParse(body);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
if (!parsed.ok) return parsed.error;
// Resolve the optional `when` against the closed grammar; an invalid token
// 422s rather than silently dropping the timing the user asked for.
+4 -12
View File
@@ -26,13 +26,8 @@ import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit } from "@/lib/rate-limit";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
@@ -63,13 +58,10 @@ async function postAction(request: NextRequest): Promise<Response> {
);
if (!rate.allowed) return apiError("coach.action.rateLimited", 429);
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, coachSuggestedActionSchema, {
maxBytes: 4 * 1024,
});
if (jsonError) return jsonError;
const parsed = coachSuggestedActionSchema.safeParse(body);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
if (!parsed.ok) return parsed.error;
const action = parsed.data;
// ── checkup.create → a preventive-care Vorsorge reminder ─────
+6 -14
View File
@@ -18,12 +18,8 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { fireAndForget } from "@/lib/logging/fire-and-forget";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { checkConsentRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
@@ -50,16 +46,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
}
const { data: body, error } = await safeJson(request, {
// v1.4.43 — multi-issue 400 (consent routes use 400 not 422).
const parsed = await parseJsonBody(request, consentPostBody, {
maxBytes: 128 * 1024,
status: 400,
});
if (error) return error;
const parsed = consentPostBody.safeParse(body);
if (!parsed.success) {
// v1.4.43 — multi-issue 400 (consent routes use 400 not 422).
return returnAllZodIssues(parsed.error, 400);
}
if (!parsed.ok) return parsed.error;
const { kind, artefact, signedAt, confirm } = parsed.data;
if (confirm !== true) {
+5 -13
View File
@@ -17,12 +17,8 @@
* grants.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { checkConsentRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
@@ -52,15 +48,11 @@ export const POST = apiHandler(async (req: Request) => {
let confirmed = false;
const contentType = req.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
const { data: body, error: jsonError } = await safeJson(req, {
const parsed = await parseJsonBody(req, webConsentGrantBody, {
maxBytes: 1024,
action: "consent.ai.web-grant.invalid_shape",
});
if (jsonError) return jsonError;
const parsed = webConsentGrantBody.safeParse(body);
if (!parsed.success) {
annotate({ action: { name: "consent.ai.web-grant.invalid_shape" } });
return returnAllZodIssues(parsed.error, 422);
}
if (!parsed.ok) return parsed.error;
confirmed = parsed.data.confirm === true;
}
if (!confirmed) {
@@ -5,10 +5,9 @@ import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { auditLog } from "@/lib/auth/audit";
import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate";
import { serialiseCustomMetricEntry } from "@/lib/custom-metrics/custom-metric-store";
@@ -49,33 +48,29 @@ export const PATCH = apiHandler(
return apiError("Custom metric entry not found", 404);
}
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, updateCustomMetricEntrySchema, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = updateCustomMetricEntrySchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "custom-metric.entry.update.validation-failed" },
meta: { issue_count: parsed.error.issues.length, entryId },
});
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "custom-metric.entry.update.validation-failed",
details: JSON.stringify({ issues: auditIssues, entryId }),
},
})
.catch(() => {
/* swallow — the 422 response is the contract */
action: "custom-metric.entry.update.validation-failed",
countIssues: true,
meta: { entryId },
onInvalid: (error) => {
const auditIssues = sanitiseZodIssues(error.issues, {
stripValuesFromMessage: true,
});
return returnAllZodIssues(parsed.error, 422);
}
prisma.auditLog
.create({
data: {
userId: user.id,
action: "custom-metric.entry.update.validation-failed",
details: JSON.stringify({ issues: auditIssues, entryId }),
},
})
.catch(() => {
/* swallow — the 422 response is the contract */
});
},
});
if (!parsed.ok) return parsed.error;
const d = parsed.data;
@@ -18,12 +18,8 @@ import { z } from "zod/v4";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
@@ -43,15 +39,10 @@ export const POST = apiHandler(
const { user } = await requireAuth();
const { id } = await params;
const { data: rawBody, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, restoreSchema, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = restoreSchema.safeParse(rawBody);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422);
}
if (!parsed.ok) return parsed.error;
const { count } = await prisma.customMetricEntry.updateMany({
where: {
@@ -6,9 +6,9 @@ import {
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { auditLog } from "@/lib/auth/audit";
import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate";
import { serialiseCustomMetricEntry } from "@/lib/custom-metrics/custom-metric-store";
@@ -117,29 +117,25 @@ async function postCustomMetricEntry(
return apiError("Custom metric not found", 404);
}
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, createCustomMetricEntrySchema, {
maxBytes: 16 * 1024,
action: "custom-metric.entry.create.validation-failed",
countIssues: true,
meta: { customMetricId: id },
onInvalid: (error) => {
const auditIssues = sanitiseZodIssues(error.issues, {
stripValuesFromMessage: true,
});
// v1.36.x — through `auditLog()`, the only writer that stamps the actor.
void auditLog("custom-metric.entry.create.validation-failed", {
userId: user.id,
details: { issues: auditIssues, customMetricId: id },
}).catch(() => {
/* swallow — the 422 response is the contract */
});
},
});
if (jsonError) return jsonError;
const parsed = createCustomMetricEntrySchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "custom-metric.entry.create.validation-failed" },
meta: { issue_count: parsed.error.issues.length, customMetricId: id },
});
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
// v1.36.x — through `auditLog()`, the only writer that stamps the actor.
void auditLog("custom-metric.entry.create.validation-failed", {
userId: user.id,
details: { issues: auditIssues, customMetricId: id },
}).catch(() => {
/* swallow — the 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
if (!parsed.ok) return parsed.error;
const { value, measuredAt, note } = parsed.data;
+25 -30
View File
@@ -5,10 +5,9 @@ import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { auditLog } from "@/lib/auth/audit";
import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate";
import {
@@ -81,36 +80,32 @@ export const PATCH = apiHandler(
return apiError("Custom metric not found", 404);
}
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, updateCustomMetricSchema, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = updateCustomMetricSchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "custom-metric.metric.update.validation-failed" },
meta: { issue_count: parsed.error.issues.length, customMetricId: id },
});
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "custom-metric.metric.update.validation-failed",
details: JSON.stringify({
issues: auditIssues,
customMetricId: id,
}),
},
})
.catch(() => {
/* swallow — the 422 response is the contract */
action: "custom-metric.metric.update.validation-failed",
countIssues: true,
meta: { customMetricId: id },
onInvalid: (error) => {
const auditIssues = sanitiseZodIssues(error.issues, {
stripValuesFromMessage: true,
});
return returnAllZodIssues(parsed.error, 422);
}
prisma.auditLog
.create({
data: {
userId: user.id,
action: "custom-metric.metric.update.validation-failed",
details: JSON.stringify({
issues: auditIssues,
customMetricId: id,
}),
},
})
.catch(() => {
/* swallow — the 422 response is the contract */
});
},
});
if (!parsed.ok) return parsed.error;
const d = parsed.data;
+21 -27
View File
@@ -5,10 +5,9 @@ import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { auditLog } from "@/lib/auth/audit";
import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate";
import {
@@ -63,33 +62,28 @@ export const POST = apiHandler(
async function postCustomMetric(request: NextRequest) {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, createCustomMetricSchema, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = createCustomMetricSchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "custom-metric.metric.create.validation-failed" },
meta: { issue_count: parsed.error.issues.length },
});
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "custom-metric.metric.create.validation-failed",
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
/* swallow — the 422 response is the contract */
action: "custom-metric.metric.create.validation-failed",
countIssues: true,
onInvalid: (error) => {
const auditIssues = sanitiseZodIssues(error.issues, {
stripValuesFromMessage: true,
});
return returnAllZodIssues(parsed.error, 422);
}
prisma.auditLog
.create({
data: {
userId: user.id,
action: "custom-metric.metric.create.validation-failed",
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
/* swallow — the 422 response is the contract */
});
},
});
if (!parsed.ok) return parsed.error;
const {
name,
Loaded 100 of 532 files, more files were not shown because too many files have changed in this diff. Show more