mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
Merge branch 'release/v1.46.0' into feat/v146-integrations
This commit is contained in:
commit
870c8e053a
532 files changed
+83264
-16479
No files matched your search
+89
-1
@@ -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,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
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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,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";
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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\(\)/);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ─────
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
Reference in new issue
Block a user