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

This commit is contained in:
Marc-André Bombeck committed 2026-10-23 14:33:21 +02:00
commit 6555acefe1
968 files changed
+116775 -29965

No files matched your search

+89 -1
View File
@@ -48217,6 +48217,32 @@ components:
description: Time asleep across those naps, in minutes.
type: number
minimum: 0
sleepStages:
description: The main night's minutes per stage (`IN_BED`, `ASLEEP`, `AWAKE`, `CORE`, `DEEP`, `REM`), each the union of
that stage's spans; empty on a day of naps alone.
type: object
propertyNames:
type: string
additionalProperties:
type: number
minimum: 0
inBedMinutes:
description: The main night's time in bed in minutes, or null when no in-bed span was recorded.
anyOf:
- type: number
minimum: 0
- type: "null"
awakeMinutes:
description: The main night's time awake in bed in minutes, or null when no awake span was recorded.
anyOf:
- type: number
minimum: 0
- type: "null"
awakenings:
description: Awake spans between the main night's first and last time asleep.
type: integer
minimum: 0
maximum: 9007199254740991
required:
- id
- type
@@ -48987,6 +49013,32 @@ components:
description: Time asleep across those naps, in minutes.
type: number
minimum: 0
sleepStages:
description: The main night's minutes per stage (`IN_BED`, `ASLEEP`, `AWAKE`, `CORE`, `DEEP`, `REM`), each the union of
that stage's spans; empty on a day of naps alone.
type: object
propertyNames:
type: string
additionalProperties:
type: number
minimum: 0
inBedMinutes:
description: The main night's time in bed in minutes, or null when no in-bed span was recorded.
anyOf:
- type: number
minimum: 0
- type: "null"
awakeMinutes:
description: The main night's time awake in bed in minutes, or null when no awake span was recorded.
anyOf:
- type: number
minimum: 0
- type: "null"
awakenings:
description: Awake spans between the main night's first and last time asleep.
type: integer
minimum: 0
maximum: 9007199254740991
required:
- id
- type
@@ -49015,6 +49067,16 @@ components:
type: integer
minimum: 0
maximum: 9007199254740991
minValue:
description: The lowest reading in the bucket; absent on a summed (cumulative) type.
type:
- number
- "null"
maxValue:
description: The highest reading in the bucket; absent on a summed (cumulative) type.
type:
- number
- "null"
unit:
type: string
required:
@@ -49192,6 +49254,32 @@ components:
description: Time asleep across those naps, in minutes.
type: number
minimum: 0
sleepStages:
description: The main night's minutes per stage (`IN_BED`, `ASLEEP`, `AWAKE`, `CORE`, `DEEP`, `REM`), each the union of
that stage's spans; empty on a day of naps alone.
type: object
propertyNames:
type: string
additionalProperties:
type: number
minimum: 0
inBedMinutes:
description: The main night's time in bed in minutes, or null when no in-bed span was recorded.
anyOf:
- type: number
minimum: 0
- type: "null"
awakeMinutes:
description: The main night's time awake in bed in minutes, or null when no awake span was recorded.
anyOf:
- type: number
minimum: 0
- type: "null"
awakenings:
description: Awake spans between the main night's first and last time asleep.
type: integer
minimum: 0
maximum: 9007199254740991
status:
type: string
enum:
@@ -49216,11 +49304,11 @@ components:
properties:
data:
anyOf:
- $ref: "#/components/schemas/NamedSleepStageMeasurement"
- $ref: "#/components/schemas/MeasurementResource"
- type: array
items:
$ref: "#/components/schemas/MeasurementResource"
- $ref: "#/components/schemas/NamedSleepStageMeasurement"
error:
type: "null"
meta:
+3 -3
View File
@@ -54,8 +54,8 @@
"mcp:stdio": "npx tsx src/cli/mcp-stdio.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1152.0",
"@aws-sdk/lib-storage": "3.1152.0",
"@aws-sdk/client-s3": "^3.1153.0",
"@aws-sdk/lib-storage": "3.1153.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@@ -67,7 +67,7 @@
"@prisma/client": "^7.10.1",
"@simplewebauthn/browser": "^14.0.0",
"@simplewebauthn/server": "^14.0.3",
"@tanstack/react-query": "5.104.4",
"@tanstack/react-query": "5.104.5",
"@tanstack/react-virtual": "^3.14.21",
"@types/sax": "^1.2.7",
"class-variance-authority": "^0.7.1",
+43 -52
View File
@@ -46,11 +46,11 @@ importers:
.:
dependencies:
'@aws-sdk/client-s3':
specifier: ^3.1152.0
version: 3.1152.0
specifier: ^3.1153.0
version: 3.1153.0
'@aws-sdk/lib-storage':
specifier: 3.1152.0
version: 3.1152.0(@aws-sdk/client-s3@3.1152.0)
specifier: 3.1153.0
version: 3.1153.0(@aws-sdk/client-s3@3.1153.0)
'@dnd-kit/core':
specifier: ^6.3.1
version: 6.3.1(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
@@ -85,8 +85,8 @@ importers:
specifier: ^14.0.3
version: 14.0.3
'@tanstack/react-query':
specifier: 5.104.4
version: 5.104.4(@types/react@19.3.0)(react@19.3.1)
specifier: 5.104.5
version: 5.104.5(@types/react@19.3.0)(react@19.3.1)
'@tanstack/react-virtual':
specifier: ^3.14.21
version: 3.14.21(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
@@ -309,8 +309,8 @@ packages:
resolution: {integrity: sha512-H8BiG/2DnfwydVxD1uDPLa1s1sfhRnVTbD2OwzfTr3WlJzgbIzICDXxBTlTMjbWNNCe4VS9X55fco+JI34L9kw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-s3@3.1152.0':
resolution: {integrity: sha512-oI1+ZaM6pSOwZazu4nAb7rL8ug7LSjg31tDoJqRB9MooRqqWCf/qvkIZqne1mRA3xK3slg6K0RNWXaJYHbWLvg==}
'@aws-sdk/client-s3@3.1153.0':
resolution: {integrity: sha512-h0VScQZPfBApg1Qi4AedsOUSMZ7m758kZIg0JO0B5HtwSzQhB7Tgh6h1JoGEXELgrWADPusYMpMBDmrRAEx2yw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/core@3.978.4':
@@ -349,11 +349,11 @@ packages:
resolution: {integrity: sha512-nHkgkMIC6NHgqfI6UAQq5cCS4bUkNxkT+r9fXCq/X4IKfX/DHGZ8UM3T4b78OXpWK5OlzEzN4kxwyax0W/MUCA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/lib-storage@3.1152.0':
resolution: {integrity: sha512-UU1fDLKBd8az+4AmNx30ggnA4D4HolUd1MxT/J6ka76qKcckI0IxjAy6FpX/vwiUf2KDNxxN2YX0cfwjMqzWoQ==}
'@aws-sdk/lib-storage@3.1153.0':
resolution: {integrity: sha512-zshL4tYVwTXfEOQ0iqNsXUPq/0aoZwOHrAsqlBmGcAij1+8qc79rm9LpzBuEV7KWiSMetZ+89n7mSYgcJKFgyA==}
engines: {node: '>=20.0.0'}
peerDependencies:
'@aws-sdk/client-s3': ^3.1152.0
'@aws-sdk/client-s3': ^3.1153.0
'@aws-sdk/middleware-sdk-s3@3.972.80':
resolution: {integrity: sha512-zX3lXN/9XOUAuGpxgt3wXwZrXhTjQYEgRsqa856PbZWpOtleUWmCMbhlkLfusPuhW24cOC/8JYR8hnRalrrIkQ==}
@@ -2865,12 +2865,8 @@ packages:
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
engines: {node: '>=18'}
'@smithy/core@3.35.2':
resolution: {integrity: sha512-ejTukBuP/Pg3t9N8Ux+0zKsXeHWm7p6W3HbXFOyltKasSxZwCFmvTut7kBdZSCF3m9y1bXXyVu6p2xuMFcsTyQ==}
engines: {node: '>=18.0.0'}
'@smithy/core@3.36.1':
resolution: {integrity: sha512-WC5W1Jrsfxu1y6LeqXMie7N14qmcdafij/Haye4feVlyMMXLGpYxBeOX6Ee1iRJbDzGG5LVman8U1rrt9MYFHA==}
'@smithy/core@3.36.0':
resolution: {integrity: sha512-3hJiwG1F5t1SoXqp/yOCmYXziyx7ZVqF63S5W6YU201e/qwVaHPex+E/3vvyY9a3ORq/b+BydeNZpP2saQ9X2g==}
engines: {node: '>=18.0.0'}
'@smithy/credential-provider-imds@4.5.2':
@@ -3017,11 +3013,11 @@ packages:
'@tailwindcss/postcss@4.3.3':
resolution: {integrity: sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==}
'@tanstack/query-core@5.104.4':
resolution: {integrity: sha512-nU4Yrb8Sjn0DeVb7PKG9uiepNavys3yoxy1FX0hViY4/P6Ng/mBje2SMl7i9812rtyHKtHyqiaC/td6GkI8StA==}
'@tanstack/query-core@5.104.5':
resolution: {integrity: sha512-Q94gI8Opd+CbdVNOdj8ZCyV8WqLYitrDq1t96Xc8WZT6lY0r5iRnJLXYDxMex7EEf2v+mXvn4FcjaBKSxXZQ9A==}
'@tanstack/react-query@5.104.4':
resolution: {integrity: sha512-YD5QkvUNy8Ey3LbOy2vwL97JjoYycwDv++cWAibTe+9BLYvl3c09XpYp5dZ2VcxcguwoDhGF+utYJYb79RB36w==}
'@tanstack/react-query@5.104.5':
resolution: {integrity: sha512-RORgFe052U+93weaNuqg7qHq7vNVNzXN/VrXqD4bbAuQCWUqOl2lV5Ui9flWk5CVvMyL/Bh27QWhzwH0aaOx9g==}
peerDependencies:
'@types/react': ^18 || ^19
react: ^18 || ^19
@@ -7358,11 +7354,11 @@ snapshots:
dependencies:
'@aws-sdk/core': 3.978.4
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.36.1
'@smithy/core': 3.36.0
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/client-s3@3.1152.0':
'@aws-sdk/client-s3@3.1153.0':
dependencies:
'@aws-sdk/checksums': 3.1001.4
'@aws-sdk/core': 3.978.4
@@ -7370,7 +7366,7 @@ snapshots:
'@aws-sdk/middleware-sdk-s3': 3.972.80
'@aws-sdk/signature-v4-multi-region': 3.996.48
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.36.1
'@smithy/core': 3.36.0
'@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.19.0
@@ -7381,7 +7377,7 @@ snapshots:
'@aws-sdk/types': 3.974.6
'@aws-sdk/xml-builder': 3.972.41
'@aws/lambda-invoke-store': 0.3.0
'@smithy/core': 3.36.1
'@smithy/core': 3.36.0
'@smithy/signature-v4': 5.7.4
'@smithy/types': 4.19.0
bowser: 2.14.1
@@ -7391,7 +7387,7 @@ snapshots:
dependencies:
'@aws-sdk/core': 3.978.4
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.36.1
'@smithy/core': 3.36.0
'@smithy/types': 4.19.0
tslib: 2.8.1
@@ -7399,7 +7395,7 @@ snapshots:
dependencies:
'@aws-sdk/core': 3.978.4
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.36.1
'@smithy/core': 3.36.0
'@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.19.0
@@ -7416,7 +7412,7 @@ snapshots:
'@aws-sdk/credential-provider-web-identity': 3.972.81
'@aws-sdk/nested-clients': 3.997.49
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.36.1
'@smithy/core': 3.36.0
'@smithy/credential-provider-imds': 4.5.2
'@smithy/types': 4.19.0
tslib: 2.8.1
@@ -7426,7 +7422,7 @@ snapshots:
'@aws-sdk/core': 3.978.4
'@aws-sdk/nested-clients': 3.997.49
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.36.1
'@smithy/core': 3.36.0
'@smithy/types': 4.19.0
tslib: 2.8.1
@@ -7439,7 +7435,7 @@ snapshots:
'@aws-sdk/credential-provider-sso': 3.973.19
'@aws-sdk/credential-provider-web-identity': 3.972.81
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.36.1
'@smithy/core': 3.36.0
'@smithy/credential-provider-imds': 4.5.2
'@smithy/types': 4.19.0
tslib: 2.8.1
@@ -7448,7 +7444,7 @@ snapshots:
dependencies:
'@aws-sdk/core': 3.978.4
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.36.1
'@smithy/core': 3.36.0
'@smithy/types': 4.19.0
tslib: 2.8.1
@@ -7458,7 +7454,7 @@ snapshots:
'@aws-sdk/nested-clients': 3.997.49
'@aws-sdk/token-providers': 3.1153.0
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.36.1
'@smithy/core': 3.36.0
'@smithy/types': 4.19.0
tslib: 2.8.1
@@ -7467,14 +7463,14 @@ snapshots:
'@aws-sdk/core': 3.978.4
'@aws-sdk/nested-clients': 3.997.49
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.36.1
'@smithy/core': 3.36.0
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/lib-storage@3.1152.0(@aws-sdk/client-s3@3.1152.0)':
'@aws-sdk/lib-storage@3.1153.0(@aws-sdk/client-s3@3.1153.0)':
dependencies:
'@aws-sdk/client-s3': 3.1152.0
'@smithy/core': 3.35.2
'@aws-sdk/client-s3': 3.1153.0
'@smithy/core': 3.36.0
'@smithy/types': 4.19.0
buffer: 5.6.0
events: 3.3.0
@@ -7486,7 +7482,7 @@ snapshots:
'@aws-sdk/core': 3.978.4
'@aws-sdk/signature-v4-multi-region': 3.996.48
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.36.1
'@smithy/core': 3.36.0
'@smithy/types': 4.19.0
tslib: 2.8.1
@@ -7495,7 +7491,7 @@ snapshots:
'@aws-sdk/core': 3.978.4
'@aws-sdk/signature-v4-multi-region': 3.996.48
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.36.1
'@smithy/core': 3.36.0
'@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.19.0
@@ -7513,7 +7509,7 @@ snapshots:
'@aws-sdk/core': 3.978.4
'@aws-sdk/nested-clients': 3.997.49
'@aws-sdk/types': 3.974.6
'@smithy/core': 3.36.1
'@smithy/core': 3.36.0
'@smithy/types': 4.19.0
tslib: 2.8.1
@@ -10016,37 +10012,32 @@ snapshots:
'@sindresorhus/merge-streams@4.0.0': {}
'@smithy/core@3.35.2':
dependencies:
'@smithy/types': 4.19.0
tslib: 2.8.1
'@smithy/core@3.36.1':
'@smithy/core@3.36.0':
dependencies:
'@smithy/types': 4.19.0
tslib: 2.8.1
'@smithy/credential-provider-imds@4.5.2':
dependencies:
'@smithy/core': 3.36.1
'@smithy/core': 3.36.0
'@smithy/types': 4.19.0
tslib: 2.8.1
'@smithy/fetch-http-handler@5.8.0':
dependencies:
'@smithy/core': 3.36.1
'@smithy/core': 3.36.0
'@smithy/types': 4.19.0
tslib: 2.8.1
'@smithy/node-http-handler@4.12.1':
dependencies:
'@smithy/core': 3.36.1
'@smithy/core': 3.36.0
'@smithy/types': 4.19.0
tslib: 2.8.1
'@smithy/signature-v4@5.7.4':
dependencies:
'@smithy/core': 3.36.1
'@smithy/core': 3.36.0
'@smithy/types': 4.19.0
tslib: 2.8.1
@@ -10197,11 +10188,11 @@ snapshots:
postcss: 8.5.26
tailwindcss: 4.3.3
'@tanstack/query-core@5.104.4': {}
'@tanstack/query-core@5.104.5': {}
'@tanstack/react-query@5.104.4(@types/react@19.3.0)(react@19.3.1)':
'@tanstack/react-query@5.104.5(@types/react@19.3.0)(react@19.3.1)':
dependencies:
'@tanstack/query-core': 5.104.4
'@tanstack/query-core': 5.104.5
react: 19.3.1
optionalDependencies:
'@types/react': 19.3.0
@@ -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)}`);
});
});
@@ -25,7 +25,7 @@
* true, only that the set did not change without someone editing this file.
*/
import { describe, it, expect } from "vitest";
import { readFileSync, readdirSync } from "node:fs";
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { walkSourceFiles } from "./helpers/source-files";
@@ -84,17 +84,25 @@ function backfillFiles(): string[] {
const jobs = readdirSync(join(SRC, "lib/jobs"))
.filter((f) => /-backfill\.ts$/.test(f))
.map((f) => `src/lib/jobs/${f}`);
// A provider that carries its own jobs folder (`lib/<provider>/jobs/`).
const providerJobs = readdirSync(join(SRC, "lib")).flatMap((dir) => {
const jobsDir = join(SRC, "lib", dir, "jobs");
if (!existsSync(jobsDir)) return [];
return readdirSync(jobsDir)
.filter((f) => /(?:^|-)backfill\.ts$/.test(f))
.map((f) => `src/lib/${dir}/jobs/${f}`);
});
const scripts = readdirSync(join(ROOT, "scripts"))
.filter((f) => /^backfill-.*\.ts$/.test(f))
.map((f) => `scripts/${f}`);
return [...jobs, ...scripts].sort();
return [...jobs, ...providerJobs, ...scripts].sort();
}
/**
* Backfill writes that stamp `updatedAt` on purpose, keyed `file > model`.
*/
const BACKFILL_STAMPS: Record<string, string> = {
"src/lib/jobs/fitbit-backfill.ts > fitbitConnection":
"src/lib/fitbit/jobs/backfill.ts > fitbitConnection":
"the job's own progress on the connection row; not a listed record",
"src/lib/jobs/google-health-backfill.ts > googleHealthConnection":
"the job's own progress on the connection row; not a listed record",
@@ -55,6 +55,7 @@ const ADMITTED: Record<string, { jobs: string[]; why: string }> = {
const NEVER = [
"lib/insights/status-batch.ts",
"lib/insights/status-card-generation.ts",
"lib/insights/status-pipeline.ts",
"lib/insights/metric-status.ts",
"lib/insights/biomarker-status.ts",
"lib/insights/derived/derived-assessment-ai.ts",
+1 -36
View File
@@ -1,47 +1,12 @@
{
"$comment": "Runtime import cycles under src/: the back-edge count and the strongly connected components. Maintained by src/__tests__/import-cycle-guard.test.ts.",
"cycles": 13,
"cycles": 2,
"components": [
[
"components/command-palette/command-palette.tsx",
"components/command-palette/palette-store.ts",
"components/layout/global-shortcuts.tsx"
],
[
"lib/analytics/compliance.ts",
"lib/analytics/compliance/display.ts",
"lib/analytics/compliance/ledger.ts",
"lib/analytics/compliance/occurrences.ts",
"lib/analytics/compliance/windows.ts",
"lib/medications/scheduling/band-minter.ts",
"lib/medications/scheduling/cadence.ts",
"lib/medications/scheduling/compliance.ts",
"lib/medications/scheduling/schedule-eras.ts"
],
[
"lib/auth/audit.ts",
"lib/geo.ts",
"lib/integrations/oauth-kit/connect-target.ts",
"lib/jobs/offhost-purge.ts",
"lib/managed-profiles/lifecycle.ts",
"lib/notifications/channel-state.ts",
"lib/notifications/delivery-identity.ts",
"lib/notifications/dispatch-localised.ts",
"lib/notifications/dispatcher.ts",
"lib/sharing/grants.ts"
],
[
"lib/cycle/gate.ts",
"lib/modules/gate.ts"
],
[
"lib/insights/features.ts",
"lib/insights/signals-of-day.ts"
],
[
"lib/insights/mood-aggregates.ts",
"lib/insights/mood-patterns.ts"
],
[
"lib/jobs/boss-instance.ts",
"lib/jobs/boss-ready.ts"
@@ -0,0 +1,68 @@
{
"$comment": "safeJson calls per route file under src/app; new body-reading routes use parseJsonBody. Maintained by src/__tests__/route-body-parse-ratchet.test.ts.",
"count": 61,
"total": 61,
"entries": {
"app/api/admin/ai-settings/route.ts": 1,
"app/api/admin/drain-per-sample-cumulative/route.ts": 1,
"app/api/admin/encryption/key-backup/verify/route.ts": 1,
"app/api/admin/notifications/reminder-check/route.ts": 1,
"app/api/admin/rollups/recompute/route.ts": 1,
"app/api/admin/users/[id]/reset-password/route.ts": 1,
"app/api/ai/test/route.ts": 1,
"app/api/auth/login/route.ts": 1,
"app/api/auth/me/locale/route.ts": 1,
"app/api/auth/me/mfa/disable/route.ts": 1,
"app/api/auth/me/mfa/totp/confirm/route.ts": 1,
"app/api/auth/me/timezone/route.ts": 1,
"app/api/auth/mfa/verify/route.ts": 1,
"app/api/auth/mfa/webauthn/verify/route.ts": 1,
"app/api/auth/passkey/login-options/route.ts": 1,
"app/api/auth/profile/route.ts": 1,
"app/api/auth/refresh/route.ts": 1,
"app/api/coach/about-me/route.ts": 1,
"app/api/cycle/day-logs/bulk/route.ts": 1,
"app/api/dashboard/widgets/route.ts": 1,
"app/api/dawarich/connect/route.ts": 1,
"app/api/dawarich/disconnect/route.ts": 1,
"app/api/dawarich/settings/route.ts": 1,
"app/api/dawarich/sync/route.ts": 1,
"app/api/documents/inbound/[id]/extract/route.ts": 1,
"app/api/documents/inbound/[id]/index/route.ts": 1,
"app/api/documents/inbound/[id]/suggest/route.ts": 1,
"app/api/documents/inbound/[id]/summary/route.ts": 1,
"app/api/fitbit/credentials/route.ts": 1,
"app/api/google-health/credentials/route.ts": 1,
"app/api/import/route.ts": 1,
"app/api/insights/generate/route.ts": 1,
"app/api/insights/layout/route.ts": 1,
"app/api/integrations/healthkit/route.ts": 1,
"app/api/internal/deploy-webhook/route.ts": 1,
"app/api/labs/ocr/extract/route.ts": 1,
"app/api/measurements/batch/route.ts": 1,
"app/api/measurements/by-external-ids/route.ts": 1,
"app/api/measurements/route.ts": 1,
"app/api/medications/[id]/intake/[eventId]/route.ts": 1,
"app/api/medications/[id]/intake/route.ts": 1,
"app/api/medications/intake/bulk/route.ts": 1,
"app/api/medications/intake/route.ts": 1,
"app/api/medications/layout/route.ts": 1,
"app/api/medications/route.ts": 1,
"app/api/mood-entries/bulk/route.ts": 1,
"app/api/mood/tags/layout/route.ts": 1,
"app/api/nightscout/connect/route.ts": 1,
"app/api/nutrients/batch/route.ts": 1,
"app/api/oura/credentials/route.ts": 1,
"app/api/polar/credentials/route.ts": 1,
"app/api/record-settings/[family]/route.ts": 1,
"app/api/settings/email/route.ts": 1,
"app/api/settings/ntfy/route.ts": 1,
"app/api/settings/telegram/route.ts": 1,
"app/api/settings/webhook/route.ts": 1,
"app/api/strava/credentials/route.ts": 1,
"app/api/user/profile/route.ts": 1,
"app/api/whoop/credentials/route.ts": 1,
"app/api/withings/credentials/route.ts": 1,
"app/api/workouts/batch/route.ts": 1
}
}
+17 -33
View File
@@ -1,16 +1,13 @@
{
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 112,
"total": 129084,
"count": 96,
"total": 106730,
"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,
"components/charts/mood-chart.tsx": 1166,
"components/cycle/log-day-sheet.tsx": 1639,
@@ -36,7 +33,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,
@@ -46,7 +42,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,
@@ -59,13 +54,12 @@
"lib/export/backup-plan.ts": 853,
"lib/export/coach-backup.ts": 1070,
"lib/export/profile-backup.ts": 1019,
"lib/fitbit/client.ts": 1529,
"lib/fitbit/sync-core.ts": 968,
"lib/fitbit/client.ts": 1262,
"lib/fitbit/sync-core.ts": 951,
"lib/gamification/achievements-result.ts": 1094,
"lib/gamification/achievements.ts": 913,
"lib/google-health/client.ts": 1875,
"lib/google-health/mappers.ts": 1295,
"lib/google-health/sync-core.ts": 1634,
"lib/google-health/sync-core.ts": 1621,
"lib/idempotency.ts": 879,
"lib/illness/correlation.ts": 1059,
"lib/import/health-connect/import.ts": 1689,
@@ -75,45 +69,35 @@
"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/apple-health-import-worker.ts": 801,
"lib/jobs/backup-restore.ts": 888,
"lib/jobs/coach-nudge.ts": 1089,
"lib/jobs/insight-pregenerate.ts": 1266,
"lib/jobs/medication-intake-import.ts": 822,
"lib/jobs/offhost-backup.ts": 1168,
"lib/jobs/backup-restore.ts": 887,
"lib/jobs/coach-nudge.ts": 1084,
"lib/jobs/insight-pregenerate.ts": 1259,
"lib/jobs/medication-intake-import.ts": 819,
"lib/jobs/offhost-backup.ts": 1147,
"lib/jobs/reaction-line.ts": 824,
"lib/jobs/reminder/medication-reminder-check.ts": 818,
"lib/jobs/reminder/register-integration-sync.ts": 1051,
"lib/jobs/reminder/register-maintenance.ts": 2197,
"lib/jobs/reminder/register-rollup.ts": 1156,
"lib/jobs/reminder/register-status.ts": 878,
"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,
"lib/medications/scheduling/recurrence.ts": 1108,
"lib/medications/scheduling/slot-upsert.ts": 926,
"lib/notifications/senders/apns.ts": 1100,
"lib/oura/client.ts": 1360,
"lib/oura/sync.ts": 902,
"lib/polar/client.ts": 1010,
"lib/oura/sync.ts": 900,
"lib/rollups/measurement-rollups.ts": 1496,
"lib/search/search-records.ts": 996,
"lib/sharing/grants.ts": 1046,
"lib/support/report.ts": 905,
"lib/sharing/grants.ts": 807,
"lib/support/report.ts": 903,
"lib/telegram-webhook-handlers.ts": 1341,
"lib/validations/inbound-documents.ts": 1130,
"lib/whoop/client.ts": 917,
"lib/withings/sync-sleep.ts": 884,
"lib/withings/sync.ts": 978,
"lib/withings/sync.ts": 933,
"proxy.ts": 870
}
}
@@ -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": 399,
"total": 136612,
"count": 373,
"total": 124458,
"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,
@@ -313,29 +306,19 @@
"lib/export/vaccinations-backup.ts :: restoreVaccinationsData": 216,
"lib/export/visits-backup.ts :: restoreVisitsData": 261,
"lib/fhir/build-bundle.ts :: buildFhirDocumentBundle": 363,
"lib/fitbit/sync-core.ts :: upsertFitbitMeasurements": 370,
"lib/fitbit/sync-core.ts :: upsertFitbitMeasurements": 356,
"lib/gamification/achievements-result.ts :: buildAchievementsResult": 586,
"lib/google-health/client.ts :: forEachDataPointPage": 288,
"lib/google-health/client.ts :: forEachDataPointPage > walk": 223,
"lib/google-health/sync-core.ts :: upsertGoogleHealthMeasurements": 463,
"lib/google-health/sync.ts :: runGoogleHealthSyncCycle": 203,
"lib/google-health/data-points.ts :: forEachDataPointPage": 288,
"lib/google-health/data-points.ts :: forEachDataPointPage > walk": 223,
"lib/google-health/sync-core.ts :: upsertGoogleHealthMeasurements": 453,
"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,
@@ -357,19 +340,10 @@
"lib/jobs/reaction-line.ts :: runReactionLine": 337,
"lib/jobs/reminder/medication-reminder-check.ts :: handleReminderCheck": 619,
"lib/jobs/reminder/medication-reminder-check.ts :: handleReminderCheck > withBackgroundEvent(arg)": 614,
"lib/jobs/reminder/register-integration-sync.ts :: enqueueIntegrationSyncBootDiscovery": 224,
"lib/jobs/reminder/register-integration-sync.ts :: registerIntegrationSyncQueues": 320,
"lib/jobs/reminder/register-maintenance.ts :: enqueueMaintenanceBootDiscovery": 232,
"lib/jobs/reminder/register-maintenance.ts :: registerMaintenanceQueues": 997,
"lib/jobs/reminder/register-rollup.ts :: enqueueRollupBootDiscovery": 238,
"lib/jobs/reminder/register-rollup.ts :: registerRollupQueues": 616,
"lib/jobs/reminder/register-status.ts :: registerStatusQueues": 542,
"lib/jobs/workout-insight-generate.ts :: runWorkoutInsightGenerate": 324,
"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,
@@ -400,7 +374,7 @@
"lib/withings/sync-activity.ts :: syncUserActivity": 377,
"lib/withings/sync-ecg.ts :: syncUserEcg": 202,
"lib/withings/sync-sleep.ts :: syncUserSleep": 414,
"lib/withings/sync.ts :: syncUserMeasurements": 373,
"lib/withings/sync.ts :: syncUserMeasurements": 337,
"proxy.ts :: proxy": 499
}
}
+3 -22
View File
@@ -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": 356,
"total": 356,
"count": 337,
"total": 337,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 1,
"__tests__/admin-data-wipe-completeness.test.ts": 1,
@@ -273,7 +273,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,
@@ -292,6 +291,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,
@@ -303,44 +303,25 @@
"lib/jobs/__tests__/ai-job-capability-inventory.test.ts": 1,
"lib/jobs/__tests__/apple-health-import-ecg.test.ts": 1,
"lib/jobs/__tests__/apple-health-import-export-date.test.ts": 1,
"lib/jobs/__tests__/apple-health-import-reconcile.test.ts": 1,
"lib/jobs/__tests__/apple-health-import-send-policy.test.ts": 1,
"lib/jobs/__tests__/coach-memory-refresh-queue.test.ts": 1,
"lib/jobs/__tests__/coach-message-cleanup-queue.test.ts": 1,
"lib/jobs/__tests__/cumulative-pr-rederive-queue.test.ts": 1,
"lib/jobs/__tests__/cycle-reminder-queue.test.ts": 1,
"lib/jobs/__tests__/daily-briefing.test.ts": 1,
"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/jobs/__tests__/drain-cumulative-queue.test.ts": 1,
"lib/jobs/__tests__/fitbit-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,
"lib/jobs/__tests__/insight-pregenerate.test.ts": 1,
"lib/jobs/__tests__/insight-status-generate.test.ts": 1,
"lib/jobs/__tests__/intake-slot-dedup-queue.test.ts": 1,
"lib/jobs/__tests__/integration-backfill-admission.test.ts": 1,
"lib/jobs/__tests__/job-failures-retention.test.ts": 1,
"lib/jobs/__tests__/job-outcome-source-inventory.test.ts": 1,
"lib/jobs/__tests__/mcp-token-cleanup-queue.test.ts": 1,
"lib/jobs/__tests__/mean-consolidation-queue.test.ts": 1,
"lib/jobs/__tests__/measurement-reminder-queue.test.ts": 1,
"lib/jobs/__tests__/measurement-tombstone-cleanup.test.ts": 1,
"lib/jobs/__tests__/nightscout-queues.test.ts": 1,
"lib/jobs/__tests__/period-narrative-warm.test.ts": 1,
"lib/jobs/__tests__/queue-policy-tables.test.ts": 1,
"lib/jobs/__tests__/read-side-effect-queues.test.ts": 1,
"lib/jobs/__tests__/recovery-score-queue.test.ts": 1,
"lib/jobs/__tests__/sleep-timeline-queues.test.ts": 1,
"lib/jobs/__tests__/status-cron-candidates.test.ts": 1,
"lib/jobs/__tests__/step-consolidation-repair-queue.test.ts": 1,
"lib/jobs/__tests__/stress-strain-retention-queue.test.ts": 1,
"lib/jobs/__tests__/subsystem-surface.test.ts": 1,
"lib/jobs/__tests__/tls-pin-monitor-queue.test.ts": 1,
"lib/jobs/__tests__/whoop-queues.test.ts": 1,
"lib/jobs/__tests__/withings-ecg-worker.test.ts": 1,
"lib/jobs/__tests__/withings-queues.test.ts": 1,
"lib/jobs/__tests__/workout-insight-queue.test.ts": 1,
"lib/jobs/reminder/__tests__/integration-cohort-outcomes.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": 2943,
"total": 10219,
"count": 2904,
"total": 9630,
"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,
@@ -1710,7 +1709,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,
@@ -1876,7 +1875,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,
@@ -1937,7 +1936,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,
@@ -2053,6 +2051,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,
@@ -2068,7 +2070,6 @@
"lib/google-health/__tests__/sync-intraday-daily-wide.test.ts": 1,
"lib/google-health/__tests__/sync-intraday-overlap.test.ts": 1,
"lib/google-health/__tests__/sync-sleep-morning-refresh.test.ts": 1,
"lib/google-health/client.ts": 1,
"lib/google-health/sync-core.ts": 2,
"lib/google-health/sync-metrics.ts": 1,
"lib/i18n/__tests__/relative-time-updated.test.ts": 2,
@@ -2153,9 +2154,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,
@@ -2218,26 +2218,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,
@@ -2246,7 +2246,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,
@@ -2268,7 +2268,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,
@@ -2277,59 +2277,33 @@
"lib/integrations/metric-freshness.ts": 1,
"lib/integrations/status.ts": 19,
"lib/intl/__tests__/formatter-cache.test.ts": 1,
"lib/jobs/__tests__/apple-health-import-reconcile.test.ts": 2,
"lib/jobs/__tests__/apple-health-import-send-policy.test.ts": 1,
"lib/jobs/__tests__/coach-memory-refresh-queue.test.ts": 2,
"lib/jobs/__tests__/coach-message-cleanup-queue.test.ts": 1,
"lib/jobs/__tests__/apple-health-import-reconcile.test.ts": 1,
"lib/jobs/__tests__/coach-nudge.test.ts": 3,
"lib/jobs/__tests__/coach-reminder-sweep.test.ts": 1,
"lib/jobs/__tests__/cumulative-pr-rederive-queue.test.ts": 1,
"lib/jobs/__tests__/cycle-reminder-queue.test.ts": 2,
"lib/jobs/__tests__/cycle-reminder.test.ts": 2,
"lib/jobs/__tests__/data-arrival-queue.test.ts": 1,
"lib/jobs/__tests__/drain-cumulative-queue.test.ts": 4,
"lib/jobs/__tests__/feedback-aggregator.test.ts": 2,
"lib/jobs/__tests__/fitbit-backfill.test.ts": 1,
"lib/jobs/__tests__/fitbit-queues.test.ts": 2,
"lib/jobs/__tests__/geo-backfill.test.ts": 3,
"lib/jobs/__tests__/google-health-queues.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,
"lib/jobs/__tests__/insight-pregenerate-retry-enqueue.test.ts": 1,
"lib/jobs/__tests__/insight-pregenerate.test.ts": 16,
"lib/jobs/__tests__/insight-status-generate.test.ts": 2,
"lib/jobs/__tests__/insight-pregenerate.test.ts": 14,
"lib/jobs/__tests__/intake-auto-skip.test.ts": 3,
"lib/jobs/__tests__/intake-auto-take.test.ts": 1,
"lib/jobs/__tests__/intake-slot-dedup-queue.test.ts": 6,
"lib/jobs/__tests__/job-run-report.test.ts": 1,
"lib/jobs/__tests__/mean-consolidation-queue.test.ts": 2,
"lib/jobs/__tests__/measurement-reminder-queue.test.ts": 2,
"lib/jobs/__tests__/measurement-reminder.test.ts": 6,
"lib/jobs/__tests__/measurement-tombstone-cleanup.test.ts": 2,
"lib/jobs/__tests__/medication-inventory-expire.test.ts": 1,
"lib/jobs/__tests__/medication-low-stock.test.ts": 2,
"lib/jobs/__tests__/medication-reminder-check.test.ts": 2,
"lib/jobs/__tests__/mood-reminder.test.ts": 2,
"lib/jobs/__tests__/nightscout-queues.test.ts": 3,
"lib/jobs/__tests__/period-narrative-warm.test.ts": 1,
"lib/jobs/__tests__/reaction-line-degradation.test.ts": 2,
"lib/jobs/__tests__/recovery-score-queue.test.ts": 2,
"lib/jobs/__tests__/recovery-score.test.ts": 1,
"lib/jobs/__tests__/reminder-phases.test.ts": 1,
"lib/jobs/__tests__/reminder-satisfy.test.ts": 3,
"lib/jobs/__tests__/report-worker-error.test.ts": 1,
"lib/jobs/__tests__/run-job.test.ts": 1,
"lib/jobs/__tests__/sleep-timeline-backfill.test.ts": 1,
"lib/jobs/__tests__/sleep-timeline-queues.test.ts": 2,
"lib/jobs/__tests__/status-cron-candidates.test.ts": 2,
"lib/jobs/__tests__/step-consolidation-repair-queue.test.ts": 1,
"lib/jobs/__tests__/stress-strain-retention-queue.test.ts": 2,
"lib/jobs/__tests__/tls-pin-monitor-queue.test.ts": 2,
"lib/jobs/__tests__/whoop-backfill.test.ts": 1,
"lib/jobs/__tests__/whoop-queues.test.ts": 2,
"lib/jobs/__tests__/whoop-resource-sync.test.ts": 1,
"lib/jobs/__tests__/withings-queues.test.ts": 2,
"lib/jobs/__tests__/workout-insight-queue.test.ts": 1,
"lib/jobs/apple-health-import-worker.ts": 4,
"lib/jobs/coach-message-cleanup.ts": 1,
"lib/jobs/coach-nudge-ai.ts": 3,
@@ -2351,8 +2325,6 @@
"lib/jobs/environment-air-quality-history.ts": 1,
"lib/jobs/environment-fetch.ts": 2,
"lib/jobs/feedback-aggregator.ts": 6,
"lib/jobs/fitbit-backfill.ts": 1,
"lib/jobs/fitbit-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,
@@ -2385,11 +2357,10 @@
"lib/jobs/recovery-score.ts": 1,
"lib/jobs/reminder-phases.ts": 1,
"lib/jobs/reminder-satisfy.ts": 1,
"lib/jobs/reminder-worker.ts": 4,
"lib/jobs/reminder-worker.ts": 3,
"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/jobs/reminder/fitbit-sync.ts": 2,
"lib/jobs/reminder/google-health-sync.ts": 1,
"lib/jobs/reminder/insights-handlers.ts": 3,
"lib/jobs/reminder/medication-maintenance.ts": 3,
@@ -2399,11 +2370,6 @@
"lib/jobs/reminder/oura-sync.ts": 1,
"lib/jobs/reminder/polar-sync.ts": 2,
"lib/jobs/reminder/poll-cohort.ts": 2,
"lib/jobs/reminder/register-integration-sync.ts": 63,
"lib/jobs/reminder/register-maintenance.ts": 114,
"lib/jobs/reminder/register-reminders.ts": 15,
"lib/jobs/reminder/register-rollup.ts": 37,
"lib/jobs/reminder/register-status.ts": 38,
"lib/jobs/reminder/registrar-shared.ts": 2,
"lib/jobs/reminder/strava-sync.ts": 1,
"lib/jobs/reminder/whoop-sync.ts": 3,
@@ -2462,7 +2428,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,
@@ -2705,7 +2670,6 @@
"lib/openapi/routes/workouts.ts": 4,
"lib/optimistic-lock.ts": 1,
"lib/oura/__tests__/sync.test.ts": 1,
"lib/oura/client.ts": 1,
"lib/oura/credentials.ts": 3,
"lib/oura/cycle-sync.ts": 1,
"lib/oura/response-classifier.ts": 1,
@@ -2717,7 +2681,6 @@
"lib/personal-records/pr-detection-worker.ts": 6,
"lib/personal-records/pr-direction.ts": 26,
"lib/polar/__tests__/dedup.test.ts": 1,
"lib/polar/client.ts": 1,
"lib/polar/credentials.ts": 3,
"lib/polar/response-classifier.ts": 1,
"lib/polar/sync-workouts.ts": 1,
@@ -2808,7 +2771,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,
@@ -2819,7 +2782,6 @@
"lib/sse/__tests__/create-stream.test.ts": 1,
"lib/sse/create-stream.ts": 3,
"lib/strava/__tests__/dedup.test.ts": 1,
"lib/strava/client.ts": 1,
"lib/strava/credentials.ts": 1,
"lib/strava/response-classifier.ts": 1,
"lib/strava/sync.ts": 1,
@@ -2921,7 +2883,6 @@
"lib/whoop/__tests__/sync-workout-overlap.test.ts": 1,
"lib/whoop/__tests__/sync.test.ts": 1,
"lib/whoop/__tests__/webhook-notification.test.ts": 1,
"lib/whoop/client.ts": 3,
"lib/whoop/connect-ticket.ts": 1,
"lib/whoop/oauth-state.ts": 1,
"lib/whoop/return-scheme.ts": 1,
@@ -2934,13 +2895,13 @@
"lib/withings/__tests__/sync-sleep.test.ts": 4,
"lib/withings/__tests__/sync-typed-classification.test.ts": 1,
"lib/withings/__tests__/sync.test.ts": 1,
"lib/withings/client.ts": 5,
"lib/withings/client.ts": 3,
"lib/withings/oauth-state.ts": 1,
"lib/withings/response-classifier.ts": 1,
"lib/withings/sync-activity.ts": 7,
"lib/withings/sync-ecg.ts": 4,
"lib/withings/sync-sleep.ts": 9,
"lib/withings/sync.ts": 9,
"lib/withings/sync.ts": 6,
"lib/withings/webhook-handler.ts": 3,
"lib/workouts/list-read.ts": 1,
"proxy.ts": 29,
@@ -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",
},
];
@@ -88,15 +88,18 @@ describe("an empty environment variable counts as unset", () => {
it("T3 — the sweep read the tree and the matcher fires on real source", () => {
// An empty offender list proves nothing if the narrowed walk found no
// files, or if the matcher no longer fires on what the tree actually
// holds. Floor the narrowed set, then put the pre-fix read back into a
// real client and require the matcher to find it there.
// holds. Floor the narrowed set, then put the pre-fix read back into the
// redirect-URI helper every client uses and require the matcher to find it there.
const files = sourceFiles();
expect(files.length).toBeGreaterThan(2400);
expect(files).toContain("lib/withings/client.ts");
const real = readFileSync(join(SRC, "lib/withings/client.ts"), "utf8");
expect(files).toContain("lib/integrations/oauth-kit/redirect-uri.ts");
const real = readFileSync(
join(SRC, "lib/integrations/oauth-kit/redirect-uri.ts"),
"utf8",
);
expect([...real.matchAll(NULLISH_ENV_FALLBACK)]).toEqual([]);
const reverted = real.replace(
/envOr\(\s*"WITHINGS_REDIRECT_URI",/,
/envOr\(envVar,/,
"process.env.WITHINGS_REDIRECT_URI ?? (",
);
expect(reverted).not.toBe(real);
@@ -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");
},
);
});
-2
View File
@@ -63,8 +63,6 @@ const NO_FLOOR_NEEDED: Readonly<Record<string, string>> = {
"named walks, each must positively match the keyset cursor",
"components/charts/__tests__/touch-action-guard.test.ts":
"named chart wrappers, each must positively declare touch-pan-y",
"__tests__/moodlog-removal-guard.test.ts":
"absence checks over named paths, columns and keys; no matcher to drift",
// Unit tests of runtime code whose name ends in "guard". They read no
// source and sweep nothing.
+78
View File
@@ -0,0 +1,78 @@
/**
* Where the request guards live, for the structural tests that read them.
*
* `apiHandler` and the guards it fronts (`requireAuth`, the record modes, the
* cookie-only helpers, the step-up gates) were one file. The guards now live
* in `src/lib/auth/guards/`, one module per family, and `src/lib/api-handler.ts`
* keeps the wrapper and re-exports them as the one public entry.
*
* A test that pins a guard reads the module that DECLARES it, found by name
* here, rather than a hard-coded path. Two properties follow: moving a guard
* between the modules does not need every test edited, and a guard that is
* renamed or deleted throws here instead of leaving a negative assertion green
* over a file that no longer holds it.
*/
import { readSource, readSourceFolder, requireFloor } from "./source-files";
/** The guard folder, relative to the repository root. */
export const AUTH_GUARDS_DIR = "src/lib/auth/guards";
/** The route wrapper and public entry, relative to `src/`. */
export const API_HANDLER = "lib/api-handler.ts";
/** How many guard modules there are at least; a smaller folder is a broken read. */
const GUARD_MODULE_FLOOR = 10;
/** Every guard module as `[path relative to src/, text]`, sorted. */
export function authGuardModules(): Array<[string, string]> {
const modules = readSourceFolder(AUTH_GUARDS_DIR, {
floor: GUARD_MODULE_FLOOR,
}).map(([rel, text]): [string, string] => [`lib/auth/guards/${rel}`, text]);
return requireFloor(
"auth guard modules",
modules,
GUARD_MODULE_FLOOR,
) as Array<[string, string]>;
}
function declares(text: string, name: string): boolean {
return new RegExp(`(^|\\n)(export )?(async )?function ${name}\\s*[(<]`).test(
text,
);
}
/**
* The guard module (relative to `src/`) that declares `function name(`.
* Throws unless exactly one does.
*/
export function guardModuleOf(name: string): string {
const found = authGuardModules()
.filter(([, text]) => declares(text, name))
.map(([rel]) => rel);
if (found.length !== 1) {
throw new Error(
`guardModuleOf(${name}): expected exactly one module under ` +
`${AUTH_GUARDS_DIR} to declare it, found ${found.length} ` +
`(${found.join(", ") || "none"}).`,
);
}
return found[0];
}
/**
* The text of the module that declares `name`, read with `readSource` so the
* name and every extra anchor must be present in it.
*/
export function guardSourceOf(
name: string,
extraAnchors: readonly (string | RegExp)[] = [],
): string {
return readSource(`src/${guardModuleOf(name)}`, [name, ...extraAnchors]);
}
/** All guard modules joined, for counts that span the folder. */
export function authGuardsText(): string {
return authGuardModules()
.map(([, text]) => text)
.join("\n");
}
+41 -1
View File
@@ -197,6 +197,8 @@ export interface ModuleImports {
value: string[];
/** True when every statement is an `export … from` (or an import). */
reexportOnly: boolean;
/** The value imports evaluated when the module loads: `value` without `import("…")`. */
staticValue: string[];
}
/**
@@ -240,6 +242,7 @@ export function moduleImports(sf: ts.SourceFile): ModuleImports {
}
other += 1;
}
const staticValue = [...value];
const visit = (node: ts.Node): void => {
if (
ts.isCallExpression(node) &&
@@ -252,7 +255,7 @@ export function moduleImports(sf: ts.SourceFile): ModuleImports {
ts.forEachChild(node, visit);
};
visit(sf);
return { value, reexportOnly: reexports > 0 && other === 0 };
return { value, staticValue, reexportOnly: reexports > 0 && other === 0 };
}
export interface ImportGraph {
@@ -374,3 +377,40 @@ export function backEdgeCycles(edges: Map<string, string[]>): string[][] {
for (const v of [...edges.keys()].sort()) walk(v);
return cycles;
}
/**
* Every module under `src/` that loading `entries` evaluates: static value
* imports and re-exports, transitively. A dynamic `import("…")` is not
* followed, since it loads only when it runs. Paths are relative to `src/`.
*/
export function staticImportClosure(entries: readonly string[]): Set<string> {
const cache = ts.createModuleResolutionCache(
process.cwd(),
(x) => x,
COMPILER_OPTIONS,
);
const seen = new Set<string>();
const stack = [...entries];
while (stack.length > 0) {
const rel = stack.pop()!;
if (seen.has(rel)) continue;
seen.add(rel);
const abs = join(SRC, rel);
for (const spec of moduleImports(parse(rel, readFileSync(abs, "utf8")))
.staticValue) {
const hit = ts.resolveModuleName(
spec,
abs,
COMPILER_OPTIONS,
ts.sys,
cache,
).resolvedModule;
if (!hit || hit.isExternalLibraryImport) continue;
if (!hit.resolvedFileName.startsWith(`${SRC}/`)) continue;
const target = hit.resolvedFileName.slice(SRC.length + 1);
if (target.startsWith("generated/") || target.endsWith(".d.ts")) continue;
stack.push(target);
}
}
return seen;
}
+117 -188
View File
@@ -11,23 +11,23 @@
* `src/lib/jobs/queue-runtime.ts` declares every queue `short`, `budgeted` or
* `long`. This guard holds the tree to it:
*
* - every queue bound with `createAndWork` is in the table, and every table
* entry is bound;
* - every `boss.send` and every schedule tuple names a queue in the table;
* - every queue the worker registry declares (`jobs/registry/`) is in the
* table, and every table entry is declared;
* - every `boss.send` and every scheduled queue names a queue in the table;
* - a `long` queue has an expiry between the default and pg-boss's 24-hour
* ceiling, every send and schedule for it carries `expireInSeconds` (or an
* identifier the table names as its carrier), it stops on the job's budget
* where the table says (`jobBudget` / `jobDeadline`, or a declared
* one-shot), and it is exclusive per identity where the table says
* (`lockedPass` at the binding, or the named marker);
* ceiling, every send for it carries `expireInSeconds` (or an identifier
* the table names as its carrier) and its schedule's send options do, it
* stops on the job's budget where the table says (`jobBudget` /
* `jobDeadline`, or a declared one-shot), and it is exclusive per identity
* where the table says (the definition's `lock`, or the named marker);
* - a `budgeted` queue stops on the budget and relies on the default expiry;
* - a `short` queue's sends set no expiry of their own, which would mean the
* job is longer than the table claims.
*
* Floors fail the guard if a matcher stops matching, and the "plants" cases
* run the checks over a planted long queue that lacks each protection in turn
* (the mutation proof kept in the suite). Removing `lockedPass` from the
* pr-detection binding, or the expiry from its cron tuple, fails the real
* (the mutation proof kept in the suite). Removing the `lock` from the
* pr-detection definition, or the expiry from its schedule, fails the real
* checks below.
*/
import { describe, expect, it } from "vitest";
@@ -39,19 +39,14 @@ import {
DEFAULT_JOB_EXPIRE_SECONDS,
QUEUE_RUNTIME,
type QueueRuntime,
type SourceSite,
} from "@/lib/jobs/queue-runtime";
import { SCHEDULED_AND_SENT_QUEUES } from "@/lib/jobs/reminder/registrar-shared";
import { HEAVY_QUEUES } from "@/lib/jobs/heavy-lane";
import type { JobDefinition } from "@/lib/jobs/registry/define-job";
import { allJobDefinitions } from "@/lib/jobs/registry/index";
const SRC = join(process.cwd(), "src");
const REGISTRARS = [
"lib/jobs/reminder/register-integration-sync.ts",
"lib/jobs/reminder/register-maintenance.ts",
"lib/jobs/reminder/register-reminders.ts",
"lib/jobs/reminder/register-rollup.ts",
"lib/jobs/reminder/register-status.ts",
];
const DEFINITIONS = allJobDefinitions();
/** pg-boss refuses an expiry of 24 hours or more. */
const MAX_EXPIRE_SECONDS = 24 * 60 * 60;
@@ -120,27 +115,6 @@ interface Site {
text: string;
}
/** Every `createAndWork(boss, QUEUE, …)` in the registrars. */
function bindings(
texts: ReadonlyMap<string, string>,
definitions: Definitions,
): Site[] {
const out: Site[] = [];
for (const file of REGISTRARS) {
const text = code(texts.get(file) ?? "");
for (const m of text.matchAll(
/createAndWork\s*(?:<[^(]*?>)?\(\s*boss\s*,\s*([A-Z][A-Z0-9_]*)/g,
)) {
const open = text.indexOf("(", m.index);
const queue = resolve(definitions, file, m[1]);
if (queue) {
out.push({ file, queue, text: text.slice(open, closeOf(text, open)) });
}
}
}
return out;
}
/** Every `boss.send(QUEUE, …)` / `getGlobalBoss()?.send(QUEUE, …)`. */
function sends(
texts: ReadonlyMap<string, string>,
@@ -162,31 +136,6 @@ function sends(
return out;
}
/** Every `[QUEUE, CRON, options?]` tuple in a registrar's `schedules`. */
function schedules(
texts: ReadonlyMap<string, string>,
definitions: Definitions,
): Site[] {
const out: Site[] = [];
for (const file of REGISTRARS) {
const text = code(texts.get(file) ?? "");
const m = /const\s+schedules\s*:\s*ScheduleEntry\[\]\s*=\s*\[/.exec(text);
if (!m) continue;
const open = m.index + m[0].length - 1;
const body = text.slice(open + 1, closeOf(text, open) - 1);
for (let i = 0; i < body.length; i += 1) {
if (body[i] !== "[") continue;
const end = closeOf(body, i);
const tuple = body.slice(i, end);
const ident = /^\[\s*([A-Z][A-Z0-9_]*)/.exec(tuple)?.[1];
const queue = ident ? resolve(definitions, file, ident) : null;
if (queue) out.push({ file, queue, text: tuple });
i = end - 1;
}
}
return out;
}
/** The text of `fn` (a function or a const) in `file`. */
function bodyOf(
texts: ReadonlyMap<string, string>,
@@ -201,14 +150,6 @@ function bodyOf(
return text.slice(m.index, end < 0 ? undefined : end + 2);
}
function siteText(
texts: ReadonlyMap<string, string>,
site: SourceSite,
binding: Site | undefined,
): string {
return site === "binding" ? (binding?.text ?? "") : bodyOf(texts, site);
}
const STOPS = /\bjob(?:Budget|Deadline)\s*\(/;
/** Every finding for one queue's declaration against the tree. */
@@ -216,13 +157,12 @@ function check(
queue: string,
entry: QueueRuntime,
texts: ReadonlyMap<string, string>,
all: { bindings: Site[]; sends: Site[]; schedules: Site[] },
all: { definitions: readonly JobDefinition[]; sends: Site[] },
): string[] {
const problems: string[] = [];
const binding = all.bindings.find((b) => b.queue === queue);
const carriers = [...all.sends, ...all.schedules].filter(
(s) => s.queue === queue,
);
const definition = all.definitions.find((d) => d.queue === queue);
const sends = all.sends.filter((s) => s.queue === queue);
const scheduledExpiry = definition?.scheduleOptions?.expireInSeconds;
if (entry.runtime === "long") {
if (
!(entry.expireInSeconds > DEFAULT_JOB_EXPIRE_SECONDS) ||
@@ -230,85 +170,86 @@ function check(
) {
problems.push(`${queue}: expiry ${entry.expireInSeconds}s out of range`);
}
for (const site of carriers) {
for (const site of sends) {
const carried =
/\bexpireInSeconds\b/.test(site.text) ||
entry.expiryVia.some((v) => new RegExp(`\\b${v}\\b`).test(site.text));
if (!carried) {
problems.push(
`${queue}: ${site.file} sends or schedules it without an expiry`,
);
problems.push(`${queue}: ${site.file} sends it without an expiry`);
}
}
if (definition?.cron && typeof scheduledExpiry !== "number") {
problems.push(`${queue}: its schedule carries no expiry`);
}
if (typeof entry.stop === "object" && "oneShot" in entry.stop) {
if (entry.stop.oneShot.length < 20) {
problems.push(`${queue}: a one-shot needs its reason`);
}
} else if (!STOPS.test(siteText(texts, entry.stop, binding))) {
} else if (!STOPS.test(bodyOf(texts, entry.stop))) {
problems.push(`${queue}: does not stop on the job's budget`);
}
if (entry.exclusive === "lockedPass") {
if (!/\blockedPass\s*\(/.test(binding?.text ?? "")) {
problems.push(`${queue}: binding holds no lockedPass`);
if (!definition?.lock) {
problems.push(`${queue}: definition holds no lock`);
}
} else if (
!siteText(texts, entry.exclusive.at, binding).includes(
entry.exclusive.marker,
)
!bodyOf(texts, entry.exclusive.at).includes(entry.exclusive.marker)
) {
problems.push(`${queue}: no "${entry.exclusive.marker}" where declared`);
}
} else {
if (entry.runtime === "budgeted") {
if (!STOPS.test(siteText(texts, entry.budget, binding))) {
if (!STOPS.test(bodyOf(texts, entry.budget))) {
problems.push(`${queue}: does not stop on the job's budget`);
}
}
for (const site of carriers) {
for (const site of sends) {
if (/\bexpireInSeconds\b/.test(site.text)) {
problems.push(
`${queue}: ${site.file} sets an expiry on a queue declared ${entry.runtime}`,
);
}
}
if (scheduledExpiry !== undefined) {
problems.push(
`${queue}: its schedule sets an expiry on a queue declared ${entry.runtime}`,
);
}
}
return problems;
}
function scan(texts: ReadonlyMap<string, string>) {
const definitions = definitionsOf(texts);
return {
bindings: bindings(texts, definitions),
sends: sends(texts, definitions),
schedules: schedules(texts, definitions),
};
return { sends: sends(texts, definitionsOf(texts)) };
}
describe("every queue declares its runtime", () => {
const all = scan(source);
const bound = new Set(all.bindings.map((b) => b.queue));
const all = { definitions: DEFINITIONS, ...scan(source) };
const declared = new Set(DEFINITIONS.map((d) => d.queue));
it("finds the bindings, sends and schedules it is meant to police", () => {
// Pinned below the counts on 2026-10-01 (112 / 64 / 74).
expect(all.bindings.length).toBeGreaterThanOrEqual(105);
it("finds the declarations, sends and schedules it is meant to police", () => {
// Pinned below the counts on 2026-10-23 (122 / 64 / 78).
expect(DEFINITIONS.length).toBeGreaterThanOrEqual(115);
expect(all.sends.length).toBeGreaterThanOrEqual(50);
expect(all.schedules.length).toBeGreaterThanOrEqual(70);
expect(DEFINITIONS.filter((d) => d.cron).length).toBeGreaterThanOrEqual(70);
expect(
Object.values(QUEUE_RUNTIME).filter((e) => e.runtime === "long").length,
).toBeGreaterThanOrEqual(15);
});
it("every bound queue is in the table, and every entry is bound", () => {
expect([...bound].filter((q) => !(q in QUEUE_RUNTIME)).sort()).toEqual([]);
it("every declared queue is in the table, and every entry is declared", () => {
expect([...declared].filter((q) => !(q in QUEUE_RUNTIME)).sort()).toEqual(
[],
);
expect(
Object.keys(QUEUE_RUNTIME)
.filter((q) => !bound.has(q))
.filter((q) => !declared.has(q))
.sort(),
).toEqual([]);
});
it("every send and schedule names a queue in the table", () => {
const unknown = [...all.sends, ...all.schedules]
it("every send names a queue in the table", () => {
const unknown = all.sends
.filter((s) => !(s.queue in QUEUE_RUNTIME))
.map((s) => `${s.file} → ${s.queue}`);
expect(unknown).toEqual([]);
@@ -322,51 +263,56 @@ describe("every queue declares its runtime", () => {
});
it("plants: a long queue missing any protection is caught", () => {
const registrar = REGISTRARS[1];
const planted = (binding: string, schedule: string) =>
const file = "lib/jobs/planted.ts";
const texts = (handler: string) =>
new Map([
...source,
[
registrar,
`const PLANT_QUEUE = "planted-long";\n` +
`const schedules: ScheduleEntry[] = [\n ${schedule},\n];\n` +
`async function register(boss) {\n await createAndWork(boss, PLANT_QUEUE, { localConcurrency: 1 }, ${binding});\n}\n`,
],
[file, `export async function plant(jobs) {\n ${handler}\n}\n`],
]);
const entry: QueueRuntime = {
runtime: "long",
expireInSeconds: 3600,
expiryVia: [],
stop: "binding",
stop: { file, fn: "plant" },
exclusive: "lockedPass",
why: "planted",
};
const findings = (binding: string, schedule: string) => {
const texts = planted(binding, schedule);
return check("planted-long", entry, texts, scan(texts));
const planted = (overrides: Partial<JobDefinition>): JobDefinition => ({
queue: "planted-long",
cron: "0 3 * * *",
scheduleOptions: { expireInSeconds: 3600 },
work: { localConcurrency: 1 },
lock: () => "pass",
load: async () => async () => ({ ok: true, did: {} }),
...overrides,
});
const findings = (
handler: string,
overrides: Partial<JobDefinition> = {},
) => {
const t = texts(handler);
return check("planted-long", entry, t, {
definitions: [planted(overrides)],
...scan(t),
});
};
const safe = [
"lockedPass(PLANT_QUEUE, () => WHOLE_PASS, async (jobs) => run(jobBudget(jobs)))",
"[PLANT_QUEUE, CRON, { expireInSeconds: 3600 }]",
] as const;
expect(findings(...safe)).toEqual([]);
expect(findings(safe[0], "[PLANT_QUEUE, CRON]")).toEqual([
`planted-long: ${registrar} sends or schedules it without an expiry`,
const budgeted = "return run(jobBudget(jobs));";
expect(findings(budgeted)).toEqual([]);
expect(findings(budgeted, { scheduleOptions: { retryLimit: 0 } })).toEqual([
"planted-long: its schedule carries no expiry",
]);
expect(
findings("lockedPass(PLANT_QUEUE, () => WHOLE_PASS, run)", safe[1]),
).toEqual(["planted-long: does not stop on the job's budget"]);
expect(findings("async (jobs) => run(jobBudget(jobs))", safe[1])).toEqual([
"planted-long: binding holds no lockedPass",
expect(findings("return run(jobs);")).toEqual([
"planted-long: does not stop on the job's budget",
]);
expect(findings(budgeted, { lock: undefined })).toEqual([
"planted-long: definition holds no lock",
]);
expect(
check(
"planted-long",
{ ...entry, expireInSeconds: 900 },
...((): [ReadonlyMap<string, string>, ReturnType<typeof scan>] => {
const texts = planted(...safe);
return [texts, scan(texts)];
})(),
texts(budgeted),
{ definitions: [planted({})], ...scan(texts(budgeted)) },
),
).toEqual(["planted-long: expiry 900s out of range"]);
});
@@ -374,12 +320,13 @@ describe("every queue declares its runtime", () => {
/**
* Queues a file sends to through a variable (`boss.send(queueName, …)`): every
* known queue name the file spells out, as a constant or a map value, counts
* as sent, because the matcher above cannot tell which one the variable holds.
* known queue the file names, as a literal, a map value or an imported
* `*_QUEUE` constant, counts as sent, because the matcher above cannot tell which one the variable holds.
*/
function dynamicallySent(texts: ReadonlyMap<string, string>): Set<string> {
const definitions = definitionsOf(texts);
const out = new Set<string>();
for (const [, raw] of texts) {
for (const [file, raw] of texts) {
const text = code(raw);
if (
!/\b(?:boss|getGlobalBoss\(\)\??)\s*\.\s*send\s*(?:<[^(]*?>)?\(\s*[a-z]/.test(
@@ -391,13 +338,20 @@ function dynamicallySent(texts: ReadonlyMap<string, string>): Set<string> {
for (const m of text.matchAll(/"([a-z0-9-]+)"/g)) {
if (m[1] in QUEUE_RUNTIME) out.add(m[1]);
}
// A queue named through an imported constant counts the same.
for (const m of text.matchAll(/\b([A-Z][A-Z0-9_]*_QUEUE)\b/g)) {
const queue = resolve(definitions, file, m[1]);
if (queue && queue in QUEUE_RUNTIME) out.add(queue);
}
}
return out;
}
describe("a cron-fed queue polls slowly, a sent one does not", () => {
const all = scan(source);
const scheduled = new Set(all.schedules.map((s) => s.queue));
const scheduled = new Set(
DEFINITIONS.filter((d) => d.cron).map((d) => d.queue),
);
const sent = new Set([
...all.sends.map((s) => s.queue),
...dynamicallySent(source),
@@ -418,26 +372,14 @@ describe("a cron-fed queue polls slowly, a sent one does not", () => {
});
describe("heavy jobs share the lane", () => {
const all = scan(source);
const laned = all.bindings.filter((b) => /\bheavyLane\s*\(/.test(b.text));
const laned = DEFINITIONS.filter((d) => d.lane);
it("binds every heavy queue in the lane, and nothing else", () => {
expect(laned.map((b) => b.queue).sort()).toEqual(
it("puts every heavy queue in the lane, and nothing else", () => {
expect(laned.map((d) => d.queue).sort()).toEqual(
Object.keys(HEAVY_QUEUES).sort(),
);
});
it("holds a lockedPass outside the lane, so an expiry retry finds the run going", () => {
// Outside, a retry pg-boss starts while the expired run still holds the
// lane finds the lock and settles; inside, the lane would defer it and
// run the job a second time once the first is done.
const inverted = laned
.filter((b) => /\blockedPass\s*\(/.test(b.text))
.filter((b) => b.text.indexOf("lockedPass") > b.text.indexOf("heavyLane"))
.map((b) => b.queue);
expect(inverted).toEqual([]);
});
it("declares every heavy queue long, so its expiry, stop and exclusivity are proven", () => {
// A heavy job is the one most likely to run past pg-boss's default
// expiry, and the retry that follows an expiry runs it a second time.
@@ -464,41 +406,28 @@ describe("heavy jobs share the lane", () => {
// A deferred job returns from active to created. Under `short` (one
// queued job per key) and `stately` (one per state) a twin already
// queued would refuse it, and the job would fail instead of waiting.
const policies = policiesOf(source);
expect(policies.size).toBeGreaterThanOrEqual(40);
const refused = Object.keys(HEAVY_QUEUES)
.filter((queue) => /^(?:short|stately)$/.test(policies.get(queue) ?? ""))
.sort();
expect(refused).toEqual([]);
// The planted case: the same check over a table that declares one.
const planted = new Map(source);
const file = REGISTRARS[2];
planted.set(
file,
`const DATA_BACKUP_QUEUE = "data-backup";\nconst queuePolicies: QueuePolicyTable = {\n [DATA_BACKUP_QUEUE]: { policy: "short", reason: "x" },\n};\n`,
const refusing = (definitions: readonly JobDefinition[]) =>
definitions
.filter(
(d) => d.lane && /^(?:short|stately)$/.test(d.policy?.policy ?? ""),
)
.map((d) => d.queue)
.sort();
expect(DEFINITIONS.filter((d) => d.policy).length).toBeGreaterThanOrEqual(
40,
);
expect(policiesOf(planted).get("data-backup")).toBe("short");
expect(refusing(DEFINITIONS)).toEqual([]);
// The planted case: the same check over a heavy queue declared `short`.
expect(
refusing([
{
queue: "data-backup",
policy: { policy: "short", reason: "x" },
work: { localConcurrency: 1 },
lane: true,
load: async () => async () => ({ ok: true, did: {} }),
},
]),
).toEqual(["data-backup"]);
});
});
/** Every `[QUEUE]: { policy: "…" }` in a registrar's `queuePolicies`. */
function policiesOf(texts: ReadonlyMap<string, string>): Map<string, string> {
const definitions = definitionsOf(texts);
const out = new Map<string, string>();
for (const file of REGISTRARS) {
const text = code(texts.get(file) ?? "");
const m = /const\s+queuePolicies\s*:\s*QueuePolicyTable\s*=\s*\{/.exec(
text,
);
if (!m) continue;
const open = m.index + m[0].length - 1;
const body = text.slice(open, closeOf(text, open));
for (const entry of body.matchAll(
/\[\s*([A-Z][A-Z0-9_]*)\s*\]\s*:\s*\{\s*policy\s*:\s*"([a-z]+)"/g,
)) {
const queue = resolve(definitions, file, entry[1]);
if (queue) out.set(queue, entry[2]);
}
}
return out;
}
@@ -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,20 @@ 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
* dashboard snapshot.
*/
const PROVIDER_TAIL = "runs afterProviderWrite";
/**
* Every file with a measurement write, and how it keeps the two readers
* current. A file listed here must still contain a write (a stale entry
@@ -44,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,
@@ -57,17 +73,13 @@ const WRITE_SITES: Record<string, string> = {
// Retiring the equivalent-distance rows earlier Oura syncs wrote.
"lib/oura/retire-distance.ts": TAIL,
// Integration syncs: each collects the (type, day) pairs it touched and
// runs both legs itself at the end of the sync.
"lib/fitbit/sync-core.ts":
"refolds the touched days and re-warms the touched types at the end of the upsert",
"lib/google-health/sync-core.ts":
"refolds the touched days and re-warms the touched types at the end of the upsert",
"lib/nightscout/sync.ts":
"refolds the touched days and re-warms the touched types at the end of the sync",
// runs the provider write step over them.
"lib/fitbit/sync-core.ts": PROVIDER_TAIL,
"lib/google-health/sync-core.ts": PROVIDER_TAIL,
"lib/nightscout/sync.ts": PROVIDER_TAIL,
"lib/withings/sync.ts": PROVIDER_TAIL,
"lib/oura/sync-heart-rate.ts":
"collects the days each heart-rate page touched; refolds them and re-warms the type once per window",
"lib/withings/sync.ts":
"refolds the touched days and re-warms the touched types at the end of the sync",
"lib/withings/sync-activity.ts":
"refolds the touched days (and the day a moved row left) and re-warms the touched types",
"lib/withings/sync-ecg.ts":
@@ -156,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(
@@ -168,6 +191,18 @@ describe("measurement write tail", () => {
expect(missing).toEqual([]);
});
it("the files that claim the provider write step call it", () => {
const claimed = Object.entries(WRITE_SITES)
.filter(([, reason]) => reason === PROVIDER_TAIL)
.map(([file]) => file);
expect(claimed.length).toBeGreaterThan(0);
const missing = claimed.filter(
(file) =>
!/afterProviderWrite\s*\(/.test(readFileSync(join(SRC, file), "utf8")),
);
expect(missing).toEqual([]);
});
it("the syncs that run both legs by hand call both", () => {
const handRolled = Object.entries(WRITE_SITES)
.filter(([, reason]) => reason.includes("re-warms"))
+5 -7
View File
@@ -18,11 +18,12 @@
*/
import { readFileSync, existsSync } from "node:fs";
import path from "node:path";
import { allJobDefinitions } from "@/lib/jobs/registry/index";
import { describe, expect, it } from "vitest";
import { INTEGRATION_CADENCE } from "@/lib/integrations/sync-verdict";
import { INTEGRATION_DISPLAY_NAMES } from "@/components/settings/integrations/integration-fallback-row";
import { readSource } from "@/__tests__/helpers/source-files";
const ROOT = path.resolve(__dirname, "../..");
@@ -76,12 +77,9 @@ describe("moodLog integration stays removed", () => {
});
it("registers no moodlog queue, cron or handler binding", () => {
const registrar = readSource(
"src/lib/jobs/reminder/register-integration-sync.ts",
"export async function registerIntegrationSyncQueues",
);
expect(registrar).not.toContain("moodlog");
expect(registrar).not.toContain("MoodLog");
const queues = allJobDefinitions().map((job) => job.queue);
expect(queues.length).toBeGreaterThan(100);
expect(queues.filter((queue) => /mood-?log/i.test(queue))).toEqual([]);
});
it("carries no moodlog integration key on the status envelope", () => {
+31 -14
View File
@@ -10,17 +10,23 @@
*
* A. Each kit provider has `src/lib/<provider>/oauth-descriptor.ts`, and the
* descriptor names that provider.
* B. A provider client's `refreshAccessToken` is imported by that
* provider's descriptor and by nothing else outside tests.
* B. A provider's refresh (`<provider>OAuth.refresh(…)`, from the shared
* OAuth client factory) is called by that provider's descriptor and by
* nothing else outside tests.
* C. The refresh lock is taken by the kit and by the AI provider store
* (`src/lib/ai/provider.ts`, which is not an integration) only.
* D. A token is sealed into a connection row only by a callback route
* (the connect-time write). The kit writes ciphertext the descriptor is
* handed and never encrypts on its own.
* E. A provider integration speaks to a token endpoint only through the
* factory (`oauth-kit/client.ts`): no other integration module builds a
* `grant_type` form. Sign-in (OIDC), the MCP authorization server and
* the Codex device login are not provider integrations.
*
* Fitbit is the one listed exception: the classic Fitbit Web API is retired
* on 2026-10-30 and its integration rides out on its own refresh until then,
* rather than being migrated onto a lifecycle it will never use.
* Fitbit is the one listed exception to the lifecycle: the classic Fitbit Web
* API is retired on 2026-10-30 and its integration rides out on its own
* refresh until then, rather than being migrated onto a lifecycle it will
* never use. Its wire goes through the factory like every other provider's.
*
* Every leg asserts a non-empty match set before it compares, so a matcher
* that stops matching fails instead of passing.
@@ -68,21 +74,18 @@ describe("OAuth token lifecycle guard", () => {
}
});
it("B: a provider's refreshAccessToken is imported only by its descriptor", () => {
const importers = files.filter((f) => {
const src = read(f);
return /import\s*\{[^}]*\brefreshAccessToken\b[^}]*\}\s*from\s*["'](?:\.\/client|@\/lib\/[a-z-]+\/client)["']/.test(
src,
);
});
expect(importers.length).toBeGreaterThan(0);
it("B: a provider's refresh is called only by its descriptor", () => {
const callers = files.filter((f) =>
/\b[a-z][A-Za-z]*OAuth\.refresh\s*\(/.test(read(f)),
);
expect(callers.length).toBeGreaterThan(0);
const allowed = new Set([
...KIT_PROVIDERS.filter((p) => p !== "polar").map(
(p) => `src/lib/${p}/oauth-descriptor.ts`,
),
...RETIRED,
]);
expect(importers.filter((f) => !allowed.has(f))).toEqual([]);
expect(callers.filter((f) => !allowed.has(f))).toEqual([]);
});
it("C: only the kit and the AI provider store take the refresh lock", () => {
@@ -111,4 +114,18 @@ describe("OAuth token lifecycle guard", () => {
);
expect(offenders).toEqual([]);
});
it("E: provider integrations reach a token endpoint only through the factory", () => {
const forms = files.filter((f) => /\bgrant_type\b/.test(read(f)));
expect(forms.length).toBeGreaterThan(0);
const NOT_INTEGRATIONS = new Set([
"src/lib/integrations/oauth-kit/client.ts",
"src/lib/auth/oidc.ts",
"src/lib/ai/codex-oauth.ts",
"src/lib/mcp/oauth/metadata.ts",
"src/app/api/mcp/oauth/token/route.ts",
"src/app/api/mcp/oauth/register/route.ts",
]);
expect(forms.filter((f) => !NOT_INTEGRATIONS.has(f)).sort()).toEqual([]);
});
});
@@ -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
+1 -1
View File
@@ -35,7 +35,7 @@ const LIB = join(process.cwd(), "src", "lib");
* three outcomes cannot carry.
*/
const EXCEPTIONS: Readonly<Record<string, { matches: number; why: string }>> = {
"google-health/client.ts": {
"google-health/data-points.ts": {
matches: 1,
why: "progress-bounded streaming list walk with slice budget and resume (forEachDataPointPage)",
},
@@ -0,0 +1,70 @@
/**
* A provider sync loads none of the AI layer.
*
* Thirteen sync modules once imported `insights/comprehensive-generate` to
* reach one invalidation function it re-exported, so every sync worker
* evaluated the whole insight generator. The write tail is
* `integrations/sync-kit/after-write.ts` now, and it reaches the status cards
* through `insights/status-invalidation`. This guard holds that:
*
* - no production module in a provider folder (`PROVIDER_DIRS`) imports
* anything under `lib/ai/` or the comprehensive generator;
* - and loading any of them does not evaluate the comprehensive generator
* transitively either.
*
* The planted case runs the transitive check over a module that does load
* it, so a walk that resolves nothing cannot pass.
*/
import { describe, expect, it } from "vitest";
import { PROVIDER_DIRS } from "./helpers/provider-dirs";
import { requireFloor, walkSourceFiles } from "./helpers/source-files";
import {
SRC,
moduleImports,
parse,
readSrc,
staticImportClosure,
} from "./helpers/source-metrics";
const GENERATOR = "lib/insights/comprehensive-generate.ts";
const FORBIDDEN_SPEC = /^@\/lib\/(?:ai\/|insights\/comprehensive-generate$)/;
function providerModules(): string[] {
return PROVIDER_DIRS.flatMap((dir) =>
walkSourceFiles(`${SRC}/lib/${dir}`, { floor: 1 })
.filter((f) => !f.includes("__tests__") && !/\.test\.tsx?$/.test(f))
.map((f) => `lib/${dir}/${f}`),
);
}
describe("provider folders and the AI layer", () => {
const modules = providerModules();
it("reads every provider folder", () => {
requireFloor("provider modules", modules, 60);
});
it("imports nothing from lib/ai or the comprehensive generator", () => {
const offenders = modules.flatMap((rel) =>
moduleImports(parse(rel, readSrc(rel)))
.value.filter((spec) => FORBIDDEN_SPEC.test(spec))
.map((spec) => `${rel} → ${spec}`),
);
expect(offenders).toEqual([]);
});
it("does not load the comprehensive generator transitively", () => {
const closure = staticImportClosure(modules);
expect(closure.size).toBeGreaterThan(modules.length);
expect(closure.has(GENERATOR)).toBe(false);
});
it("catches a module that loads it (planted)", () => {
expect(
staticImportClosure(["lib/jobs/morning-digest-refresh.ts"]).has(
GENERATOR,
),
).toBe(true);
});
});
@@ -128,7 +128,7 @@ const TS_READERS: Record<string, Entry> = {
/applyPayloadBudget\(\s*pulsePoints\s*,\s*\{[^}]*type\s*:\s*"PULSE"/,
},
"lib/insights/signals-of-day.ts": {
via: "lib/insights/features.ts",
via: "lib/insights/feature-window.ts",
passes: /byType\(\s*"PULSE"\s*\)[\s\S]{0,80}type\s*:\s*"PULSE"/,
},
"lib/ai/coach/snapshot-blocks/core-metrics-block.ts": {
@@ -4,8 +4,8 @@
* Exporting the whole record, making a clinician share link, minting a token,
* and the admin backup, restore, wipe and reset actions ask for a fresh proof,
* not only a live session (`requireRecentProof` / `assertRecentCookieProof` in
* `src/lib/api-handler.ts`). This file pins which routes those are, so a route
* cannot drop the gate, and a new one cannot quietly join the list of routes
* `src/lib/auth/guards/recent-proof.ts`, imported through `src/lib/api-handler.ts`).
* This file pins which routes those are, so a route cannot drop the gate, and a new one cannot quietly join the list of routes
* whose Bearer arm still takes the token alone.
*
* Matching is on the IMPORT and then on calls of the local name, the way
@@ -16,11 +16,8 @@
import { describe, it, expect } from "vitest";
import { join } from "node:path";
import {
readSource,
readSourceText,
walkSourceFiles,
} from "./helpers/source-files";
import { guardSourceOf } from "./helpers/auth-guards";
import { readSourceText, walkSourceFiles } from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
@@ -185,10 +182,9 @@ describe("the recent-proof gate", () => {
});
it("the gate reads the session's own stamps, and only a second factor on an enrolled account", () => {
const src = readSource(
"src/lib/api-handler.ts",
const src = guardSourceOf("assertRecentCookieProof", [
"export async function assertRecentCookieProof(",
);
]);
const start = src.indexOf("export async function assertRecentCookieProof(");
expect(start).toBeGreaterThan(-1);
const body = src.slice(start, src.indexOf("\nexport ", start + 1));
@@ -41,6 +41,7 @@
import { describe, it, expect } from "vitest";
import { join } from "node:path";
import { authGuardsText, guardSourceOf } from "./helpers/auth-guards";
import {
readSource,
readSourceText,
@@ -84,10 +85,18 @@ function functionBody(src: string, name: string): string {
return "";
}
const API_HANDLER = readSource("src/lib/api-handler.ts", [
"requireRecordAuth",
"requireGuardianAuth",
]);
/**
* The auth layer: the route wrapper and every guard module it re-exports,
* joined, with the two record resolvers as anchors so a move that loses them
* throws instead of leaving the negative legs below green.
*/
const AUTH_LAYER = (() => {
guardSourceOf("requireRecordAuth", ["requireGuardianAuth"]);
return [
readSource("src/lib/api-handler.ts", "apiHandler"),
authGuardsText(),
].join("\n");
})();
/** The two helpers that resolve a record and therefore must consult the fence. */
const FENCED_RESOLVERS = ["requireRecordAuth", "requireGuardianAuth"] as const;
@@ -124,13 +133,18 @@ const UNFENCED_HELPERS = [
] as const;
describe("the fence runs in exactly two auth helpers", () => {
it("is called by both record resolvers and by nothing else in api-handler", () => {
const calls = [...API_HANDLER.matchAll(/assertRecordSessionFence\s*\(/g)];
it("is called by both record resolvers and by nothing else in the auth layer", () => {
const calls = [
...authGuardsText().matchAll(/assertRecordSessionFence\s*\(/g),
];
expect(readSource("src/lib/api-handler.ts", "apiHandler")).not.toMatch(
/assertRecordSessionFence\s*\(/,
);
// Non-zero proof: an emptied file would agree with an emptied expectation.
expect(calls.length).toBeGreaterThan(0);
const callers = FENCED_RESOLVERS.filter((name) =>
functionBody(API_HANDLER, name).includes("assertRecordSessionFence("),
functionBody(AUTH_LAYER, name).includes("assertRecordSessionFence("),
);
expect(callers).toEqual([...FENCED_RESOLVERS]);
// Two call sites, one import. Anything else means a third caller appeared.
@@ -140,7 +154,7 @@ describe("the fence runs in exactly two auth helpers", () => {
it.each(FENCED_RESOLVERS)(
"%s fences after authentication and before the carrier and the grant",
(name) => {
const body = functionBody(API_HANDLER, name);
const body = functionBody(AUTH_LAYER, name);
expect(body.length).toBeGreaterThan(100);
const fence = body.indexOf("assertRecordSessionFence(");
@@ -163,11 +177,11 @@ describe("the fence runs in exactly two auth helpers", () => {
it("fences before any grant lookup in the shared resolver path", () => {
// `findActiveGrant` lives in `resolveSwitchedRecord`, which both resolvers
// tail-call. Assert it appears nowhere ABOVE the fence in either body.
const grantCalls = [...API_HANDLER.matchAll(/findActiveGrant\s*\(/g)];
const grantCalls = [...authGuardsText().matchAll(/findActiveGrant\s*\(/g)];
expect(grantCalls.length).toBeGreaterThan(0);
for (const name of FENCED_RESOLVERS) {
const body = functionBody(API_HANDLER, name);
const body = functionBody(AUTH_LAYER, name);
const fence = body.indexOf("assertRecordSessionFence(");
const grant = body.indexOf("findActiveGrant(");
// Either the body never names it (it is in the shared tail) or it names
@@ -177,7 +191,7 @@ describe("the fence runs in exactly two auth helpers", () => {
});
it.each(UNFENCED_HELPERS)("%s deliberately does not fence", (name) => {
const body = functionBody(API_HANDLER, name);
const body = functionBody(AUTH_LAYER, name);
expect(body.length).toBeGreaterThan(50);
expect(body).not.toContain("assertRecordSessionFence");
});
@@ -226,7 +240,7 @@ describe("the epoch has one writer and a closed reader set", () => {
// Projects it off the session row it already loaded.
"lib/auth/session.ts",
// Carries it on the auth context the fence is handed.
"lib/api-handler.ts",
"lib/auth/guards/context.ts",
// Compares it. The one place that decides anything from it.
"lib/sharing/record-session-fence.ts",
// Reads it off the same session lookup the idempotency wrapper already
@@ -0,0 +1,87 @@
/**
* Route handlers read their JSON body through `parseJsonBody`.
*
* `parseJsonBody` (`src/lib/api/body.ts`) is `safeJson`, the Zod
* `safeParse`, the validation-failed wide-event action and the multi-issue
* 422 envelope in one call. Before it, every body-accepting route spelled
* those steps out by hand, and the copies drifted: some annotated the
* refusal and some did not, some answered a flat 400 where the rest answered
* the multi-issue 422.
*
* The routes that still call `safeJson` themselves are counted per file in
* `baselines/route-safe-json.json`. Most of them do something between
* reading and validating (an optimistic-concurrency token split off the body,
* a count cap answered before the schema runs, a refund of a spent quota on a
* malformed body), which is a reason to keep the read separate, not a reason
* to copy it into a new route. A new route file that calls `safeJson`, or a
* listed one that gains a call, fails. A file that sheds one fails until the
* lower count is written back with
* `RATCHET_WRITE=1 pnpm vitest run src/__tests__/route-body-parse-ratchet.test.ts`.
*/
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { runRatchet, writeBackHint } from "./helpers/ratchet";
import {
readSourceText,
requireFloor,
stripComments,
walkSourceFiles,
} from "./helpers/source-files";
const GUARD = "src/__tests__/route-body-parse-ratchet.test.ts";
const APP = join(process.cwd(), "src/app");
const SAFE_JSON_CALL = /\bsafeJson\s*(?:<[^>()]*>)?\s*\(/g;
const PARSE_JSON_BODY_CALL = /\bparseJsonBody\s*\(/g;
function count(text: string, pattern: RegExp): number {
return [...text.matchAll(pattern)].length;
}
describe("route body parsing", () => {
const routes = walkSourceFiles(APP, { floor: 400 }).filter((rel) =>
rel.endsWith("/route.ts"),
);
const safeJson: Record<string, number> = {};
const helper: string[] = [];
for (const rel of routes) {
const code = stripComments(readSourceText(join(APP, rel)));
const n = count(code, SAFE_JSON_CALL);
if (n > 0) safeJson[`app/${rel}`] = n;
if (count(code, PARSE_JSON_BODY_CALL) > 0) helper.push(rel);
}
it("finds the routes on both sides", () => {
requireFloor("route files", routes, 400);
requireFloor(
"routes reading their body through parseJsonBody",
helper,
100,
);
requireFloor("routes still calling safeJson", Object.keys(safeJson), 1);
});
it("adds no route that calls safeJson itself", () => {
const r = runRatchet("route-safe-json.json", safeJson, {
comment: `safeJson calls per route file under src/app; new body-reading routes use parseJsonBody. Maintained by ${GUARD}.`,
});
expect(
r.grew,
"A route that reads its body with safeJson. Use parseJsonBody " +
"(src/lib/api/body.ts); its options cover the size cap, the " +
"validation-failed action, the error code and the message.",
).toEqual([]);
expect(r.stale, writeBackHint(GUARD)).toEqual([]);
});
it("counts a call however it is spelled", () => {
expect(count("await safeJson(request)", SAFE_JSON_CALL)).toBe(1);
expect(
count("await safeJson<Body>(req, { maxBytes: 1 })", SAFE_JSON_CALL),
).toBe(1);
expect(count("await safeJson (\n request,\n)", SAFE_JSON_CALL)).toBe(1);
expect(count("import { safeJson } from", SAFE_JSON_CALL)).toBe(0);
});
});
@@ -3,7 +3,7 @@
*
* Every authenticated request resolves its caller through the session lookup
* (`lib/auth/session.ts`), the Bearer resolver (`lib/auth/bearer.ts`) or, for a
* switched record, the owner load in `lib/api-handler.ts`. They used to
* switched record, the owner load in `lib/auth/guards/record.ts`. They used to
* `include: { user: true }` / `findUnique({ where })`, which copied the profile
* picture (megabytes), the cached briefing text and every stored credential
* into memory on each request. They now name `SESSION_USER_SELECT`.
@@ -19,6 +19,7 @@ import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { guardModuleOf } from "./helpers/auth-guards";
import { walkSourceFiles } from "./helpers/source-files";
import { SESSION_USER_SELECT } from "@/lib/auth/session-user";
@@ -70,7 +71,10 @@ describe("caller lookups load the session projection", () => {
});
it("the Bearer resolver and the switched-owner load select SESSION_USER_SELECT", () => {
for (const rel of ["lib/auth/bearer.ts", "lib/api-handler.ts"]) {
for (const rel of [
"lib/auth/bearer.ts",
guardModuleOf("resolveSwitchedRecord"),
]) {
const calls = callObjects(read(rel), USER_LOOKUP);
expect(calls.length, rel).toBeGreaterThan(0);
for (const body of calls) {
+1 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { grantCoversDomain, normaliseScope } from "@/lib/sharing/grants";
import { grantCoversDomain, normaliseScope } from "@/lib/sharing/grant-access";
import { SHARE_DOMAINS } from "@/lib/sharing/scope";
import { inviteGrantSchema } from "@/lib/validations/account-sharing";
+154 -41
View File
@@ -75,6 +75,7 @@ import { ENTIRE_RECORD, SHARE_DOMAINS } from "@/lib/sharing/scope";
import type { ShareScope } from "@/lib/sharing/scope";
import { ADMITTED_MUTATING_HANDLERS } from "../../tests/fixtures/v137/sharing-matrix";
import { API_HANDLER, guardModuleOf } from "./helpers/auth-guards";
import { readSourceText } from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
@@ -115,7 +116,7 @@ const VALID_SCOPES: ReadonlySet<string> = new Set<string>([
* "is this caller an admin", "has this caller just re-proven a second factor",
* "is this caller here over a cookie". All three resolve through `getSession()`
* and see the actor, never the owner — that is what
* `acting-account-boundary-guard.test.ts` pins inside `api-handler.ts`.
* `acting-account-boundary-guard.test.ts` pins inside `lib/auth/guards/`.
*
* This file pins the other end. A handler that resolves a RECORD and then runs
* one of these has put a role check and a substituted data scope in the same
@@ -420,6 +421,51 @@ function callsSymbol(rel: string, symbol: string): boolean {
return callSites(rel).some((c) => c.name === symbol);
}
/**
* Route modules whose handler bodies live in service modules under `lib/`.
*
* The route keeps the resolver call, so the verb and membership legs read the
* route alone. The legs about what a handler writes (the audit trail, the
* tombstone, the overwrite and destruction details, the provenance a moved
* dose keeps) ask the route AND the modules it hands the body to, because
* that is where the rows are written now. A leg reading only the route would
* go on passing for a reason that has nothing to do with the rule. Each
* listed module has to be imported by its route (leg below), so an entry
* cannot outlive the delegation it describes.
*/
const HANDLER_BODY_MODULES: Record<string, readonly string[]> = {
"app/api/medications/route.ts": ["lib/medications/create-medication.ts"],
"app/api/medications/[id]/intake/[eventId]/route.ts": [
"lib/medications/update-intake-event.ts",
"lib/medications/delete-intake-event.ts",
],
};
/** The route and the service modules carrying its handler bodies. */
function handlerModules(rel: string): string[] {
return [rel, ...(HANDLER_BODY_MODULES[rel] ?? [])];
}
function handlerCallsSymbol(rel: string, symbol: string): boolean {
return handlerModules(rel).some((m) => callsSymbol(m, symbol));
}
function handlerIdentifiers(rel: string): Set<string> {
return new Set(handlerModules(rel).flatMap((m) => [...identifiers(m)]));
}
function handlerAuditDetailKeys(rel: string): string[][] {
return handlerModules(rel).flatMap((m) => auditDetailKeysIn(m));
}
function handlerDirectAuditWrites(rel: string): string[] {
return handlerModules(rel).flatMap((m) => directAuditWritesIn(read(m), m));
}
function handlerText(rel: string): string {
return handlerModules(rel).map(read).join("\n");
}
/**
* v1.37.0 — the exported HTTP verbs whose bodies reach the record resolver at
* a given level.
@@ -721,6 +767,25 @@ function functionSource(rel: string, name: string): string {
return found;
}
/** Names a module re-exports with `export { … } from "…"`. */
function reexportedNames(rel: string): string[] {
const parsed = ts.createSourceFile(
rel,
read(rel),
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TS,
);
const names: string[] = [];
parsed.forEachChild((node) => {
if (!ts.isExportDeclaration(node) || !node.moduleSpecifier) return;
const clause = node.exportClause;
if (!clause || !ts.isNamedExports(clause)) return;
for (const spec of clause.elements) names.push(spec.name.text);
});
return names;
}
/** Names of the top-level functions a module exports. */
function exportedFunctionNames(rel: string): string[] {
const parsed = ts.createSourceFile(
@@ -2557,11 +2622,17 @@ describe("the resolvers exist and live in one place", () => {
// between "no route administers a managed profile" and "the guard is
// looking for a function that does not exist": its frozen list is empty,
// so its leg's negative result proves nothing on its own.
const exported = exportedFunctionNames("lib/api-handler.ts");
const declaring = guardModuleOf(RECORD_RESOLVER);
const exported = exportedFunctionNames(declaring);
expect(exported.length).toBeGreaterThan(0);
expect(exported).toContain(RECORD_RESOLVER);
expect(exported).toContain(ACTOR_RESOLVER);
expect(exported).toContain(GUARDIAN_RESOLVER);
// And the public entry routes import them from re-exports all three.
const reexported = reexportedNames(API_HANDLER);
expect(reexported).toContain(RECORD_RESOLVER);
expect(reexported).toContain(ACTOR_RESOLVER);
expect(reexported).toContain(GUARDIAN_RESOLVER);
});
it("nothing outside the frozen route lists reaches any resolver", () => {
@@ -2582,7 +2653,9 @@ describe("the resolvers exist and live in one place", () => {
expect(naming).toEqual(
[
// Declares all three. Not a caller.
"lib/api-handler.ts",
guardModuleOf(RECORD_RESOLVER),
// Re-exports all three as the public entry. Not a caller.
API_HANDLER,
...Object.keys(DELEGABLE_ROUTES),
...Object.keys(GUARDIAN_ROUTES),
...Object.keys(ACTOR_ROUTES),
@@ -2617,10 +2690,11 @@ describe("(a) the delegable route set is frozen", () => {
// names nothing" were the same sentence; the list is populated now, so the
// assertion below would catch a neutered matcher on its own. Kept anyway,
// because a future removal could empty the list again and the day that
// happens is not the day to rediscover this. `lib/api-handler.ts` is the
// guaranteed positive — it declares the symbol.
expect(modulesNaming(["lib/api-handler.ts"], RECORD_RESOLVER)).toEqual([
"lib/api-handler.ts",
// happens is not the day to rediscover this. The guard module that
// declares the symbol is the guaranteed positive.
const recordModule = guardModuleOf(RECORD_RESOLVER);
expect(modulesNaming([recordModule], RECORD_RESOLVER)).toEqual([
recordModule,
]);
expect(modulesNaming(scanned, RECORD_RESOLVER)).toEqual(
@@ -2640,9 +2714,8 @@ describe("(b) the actor-surface route set is frozen", () => {
expect(scanned.length).toBeGreaterThan(ROUTE_MODULE_FLOOR);
// Positive control, same reason as leg (a).
expect(modulesNaming(["lib/api-handler.ts"], ACTOR_RESOLVER)).toEqual([
"lib/api-handler.ts",
]);
const actorModule = guardModuleOf(ACTOR_RESOLVER);
expect(modulesNaming([actorModule], ACTOR_RESOLVER)).toEqual([actorModule]);
expect(modulesNaming(scanned, ACTOR_RESOLVER)).toEqual(
Object.keys(ACTOR_ROUTES).sort(),
@@ -2733,14 +2806,22 @@ describe("(c) a record resolver never shares a module with a role or step-up che
});
it("the declaration site is out of scope by construction", () => {
// `lib/api-handler.ts` declares the record resolver AND all three cookie-
// only helpers, so a scan of every source file would need it exempted, and
// an exemption is a hole. Scoping the leg to handler modules removes the
// need for one. This asserts the scoping still holds.
const src = exportedFunctionNames("lib/api-handler.ts");
expect(src).toContain(RECORD_RESOLVER);
for (const helper of COOKIE_ONLY_HELPERS) expect(src).toContain(helper);
expect(routeModules()).not.toContain("lib/api-handler.ts");
// The auth layer declares the record resolver AND all three cookie-only
// helpers (in `lib/auth/guards/`, re-exported by `lib/api-handler.ts`), so
// a scan of every source file would need those modules exempted, and an
// exemption is a hole. Scoping the leg to handler modules removes the need
// for one. This asserts the scoping still holds.
const declaring = [RECORD_RESOLVER, ...COOKIE_ONLY_HELPERS].map((name) =>
guardModuleOf(name),
);
expect(exportedFunctionNames(declaring[0])).toContain(RECORD_RESOLVER);
COOKIE_ONLY_HELPERS.forEach((helper, i) =>
expect(exportedFunctionNames(declaring[i + 1])).toContain(helper),
);
const routes = routeModules();
for (const rel of [API_HANDLER, ...declaring]) {
expect(routes).not.toContain(rel);
}
});
});
@@ -2840,9 +2921,10 @@ describe("(e) the delegable write set is frozen", () => {
expect(modules.length).toBeGreaterThan(0);
for (const rel of modules) {
expect(callsSymbol(rel, "auditLog"), `${rel} never calls auditLog`).toBe(
true,
);
expect(
handlerCallsSymbol(rel, "auditLog"),
`${rel} never calls auditLog`,
).toBe(true);
}
});
@@ -2862,7 +2944,7 @@ describe("(e) the delegable write set is frozen", () => {
for (const rel of modules) {
expect(
directAuditWritesIn(read(rel), rel),
handlerDirectAuditWrites(rel),
`${rel} writes an audit row without the helper`,
).toEqual([]);
}
@@ -2979,6 +3061,28 @@ describe("(g) the MANAGE route set is frozen", () => {
expect(discovered).toEqual(expected);
});
it("reads each moved handler body where its route imports it from", () => {
// The map above widens what the audit and tombstone legs read. An entry
// naming a module the route no longer imports would widen it to code the
// handler does not run, so every entry is held to the route's imports,
// and every listed module has to carry an audit call of its own.
const entries = Object.entries(HANDLER_BODY_MODULES);
expect(entries.length).toBeGreaterThan(0);
for (const [route, modules] of entries) {
expect(DELEGABLE_ROUTES, route).toHaveProperty([route]);
for (const body of modules) {
const specifier = `@/${body.replace(/\.ts$/, "")}`;
expect(read(route), `${route} does not import ${specifier}`).toContain(
`"${specifier}"`,
);
expect(
callsSymbol(body, "auditLog"),
`${body} writes no audit row`,
).toBe(true);
}
}
});
it("is a subset of the delegable set", () => {
// A module cannot manage a record it may not read. The read list is where
// each file's argument lives, so a manage entry with no read entry would
@@ -3007,16 +3111,17 @@ describe("(g) the MANAGE route set is frozen", () => {
expect(mutating.length).toBeGreaterThan(30);
for (const rel of mutating) {
expect(callsSymbol(rel, "auditLog"), `${rel} never calls auditLog`).toBe(
true,
);
expect(
handlerCallsSymbol(rel, "auditLog"),
`${rel} never calls auditLog`,
).toBe(true);
}
});
it("no manage module reaches the audit table without the helper", () => {
for (const rel of Object.keys(DELEGABLE_MANAGE_ROUTES)) {
expect(
directAuditWritesIn(read(rel), rel),
handlerDirectAuditWrites(rel),
`${rel} writes an audit row without the helper`,
).toEqual([]);
}
@@ -3035,7 +3140,7 @@ describe("(g) the MANAGE route set is frozen", () => {
for (const rel of destructive) {
const entry = DELEGABLE_MANAGE_ROUTES[rel];
const tombstones = identifiers(rel).has("deletedAt");
const tombstones = handlerIdentifiers(rel).has("deletedAt");
expect(
tombstones ||
entry.conditions.includes("C3") ||
@@ -3056,7 +3161,7 @@ describe("(g) the MANAGE route set is frozen", () => {
for (const [rel, { keys, why }] of members) {
expect(why.trim().length, `${rel} has no reason`).toBeGreaterThan(0);
expect(DELEGABLE_MANAGE_ROUTES, rel).toHaveProperty([rel]);
const carried = auditDetailKeysIn(rel);
const carried = handlerAuditDetailKeys(rel);
expect(carried.length, `${rel} files no audit details`).toBeGreaterThan(
0,
);
@@ -3079,10 +3184,10 @@ describe("(g) the MANAGE route set is frozen", () => {
for (const rel of members) {
if (rel in MANAGE_RECONSTRUCTION_BY_HAND) continue;
expect(
callsSymbol(rel, "destroyedDetails"),
handlerCallsSymbol(rel, "destroyedDetails"),
`${rel} carries C3 and never calls destroyedDetails`,
).toBe(true);
const named = auditDetailKeysIn(rel).some((keys) =>
const named = handlerAuditDetailKeys(rel).some((keys) =>
keys.some((key) => /(^id$|Id$)/.test(key)),
);
expect(named, `${rel} files no id on any audit details literal`).toBe(
@@ -3097,7 +3202,7 @@ describe("(g) the MANAGE route set is frozen", () => {
for (const rel of members) {
expect(
callsSymbol(rel, "overwriteDetails"),
handlerCallsSymbol(rel, "overwriteDetails"),
`${rel} carries C4 and never calls overwriteDetails`,
).toBe(true);
}
@@ -3152,7 +3257,7 @@ describe("(g) the MANAGE route set is frozen", () => {
// …and the fact it reads has to be stamped. The resolver is where the
// owner row is in hand, so it is the only place that can decide it.
const resolver = functionSource(
"lib/api-handler.ts",
guardModuleOf("resolveSwitchedRecord"),
"resolveSwitchedRecord",
);
expect(resolver.length).toBeGreaterThan(0);
@@ -3278,7 +3383,7 @@ describe("(g) the MANAGE route set is frozen", () => {
const medication = "app/api/medications/[id]/route.ts";
expect(manageModulesWith("C7")).toEqual([medication]);
const cadence = auditDetailKeysIn(medication).some(
const cadence = handlerAuditDetailKeys(medication).some(
(keys) =>
keys.includes("replacedCadence") &&
keys.includes("tombstonedSlotCount"),
@@ -3289,14 +3394,15 @@ describe("(g) the MANAGE route set is frozen", () => {
expect(manageModulesWith("C8")).toEqual([dose]);
// The hardcoded provenance this condition removed. A re-created row that
// stamps `WEB` says a human typed a dose that arrived from a device.
expect(read(dose)).not.toContain('createSource: "WEB"');
expect(handlerText(dose)).not.toContain('createSource: "WEB"');
expect(handlerText(dose)).toContain("createSource: event.source");
expect(manageModulesWith("C9")).toEqual([
"app/api/medications/[id]/intake/import/route.ts",
"app/api/medications/intake/dose-history-import/route.ts",
]);
for (const rel of manageModulesWith("C9")) {
const joined = auditDetailKeysIn(rel).some((keys) =>
const joined = handlerAuditDetailKeys(rel).some((keys) =>
keys.includes("jobId"),
);
expect(joined, `${rel} enqueues work no audit row names`).toBe(true);
@@ -3343,10 +3449,11 @@ describe("(h) the guardian route set is frozen", () => {
// Positive control for this leg's own matcher, the same one leg (a) uses
// and for the same reason twice over: the literal is empty, so "no route
// names the resolver" and "the matcher names nothing" are one sentence
// until something the matcher CAN find is named. `lib/api-handler.ts`
// declares the symbol and is the guaranteed positive.
expect(modulesNaming(["lib/api-handler.ts"], GUARDIAN_RESOLVER)).toEqual([
"lib/api-handler.ts",
// until something the matcher CAN find is named. The guard module that
// declares the symbol is the guaranteed positive.
const guardianModule = guardModuleOf(GUARDIAN_RESOLVER);
expect(modulesNaming([guardianModule], GUARDIAN_RESOLVER)).toEqual([
guardianModule,
]);
expect(modulesNaming(scanned, GUARDIAN_RESOLVER)).toEqual(
@@ -3361,7 +3468,10 @@ describe("(h) the guardian route set is frozen", () => {
// marker. A resolver that stopped reading it would leave every leg here
// passing and the identity fence gone — an invited adult's MANAGE grant
// would reach the settings of a record its owner still runs.
const guardian = functionSource("lib/api-handler.ts", GUARDIAN_RESOLVER);
const guardian = functionSource(
guardModuleOf(GUARDIAN_RESOLVER),
GUARDIAN_RESOLVER,
);
// Non-zero proof, twice: the slice found a function, and it is the one it
// claims to be. A renamed resolver must fail here rather than assert
// against an empty string.
@@ -3372,7 +3482,10 @@ describe("(h) the guardian route set is frozen", () => {
// …and the record resolver must NOT read it. The two resolvers differ in
// exactly this fact, so a marker check that drifted into the delegable
// path would make the fence meaningless while every list still agreed.
const record = functionSource("lib/api-handler.ts", RECORD_RESOLVER);
const record = functionSource(
guardModuleOf(RECORD_RESOLVER),
RECORD_RESOLVER,
);
expect(record.length).toBeGreaterThan(0);
expect(record).not.toContain("managedProfileAt");
});
+33 -22
View File
@@ -25,6 +25,7 @@
import { describe, it, expect } from "vitest";
import { join } from "node:path";
import { guardModuleOf, guardSourceOf } from "./helpers/auth-guards";
import {
readSource,
readSourceText,
@@ -77,6 +78,28 @@ function importedAs(
const API_HANDLER = /api-handler$/;
const STEP_UP_MODULE = /auth\/step-up$/;
/**
* The guard module that reads the elevation header, validates and claims:
* the one redemption path. Found by the function that does it, so a move
* between guard modules repoints every leg below at once.
*/
const GATE_MODULE = guardModuleOf("resolveBearerElevation");
/**
* An exported declaration's source, from its signature to the next top-level
* export (or the end of its module), read from the guard module that declares
* `name`. Throws when the signature is gone, so a negative assertion on the
* slice cannot pass over an empty string.
*/
function exportedBlock(name: string, signature: string): string {
const src = guardSourceOf(name, [signature]);
const start = src.indexOf(signature);
const next = src.indexOf("\nexport ", start + 1);
const body = src.slice(start, next === -1 ? undefined : next);
expect(body.length).toBeGreaterThan(signature.length);
return body;
}
/** Files that import `exportName` from the module AND call it. */
function callers(exportName: string, moduleRe: RegExp): string[] {
return sourceFiles()
@@ -260,15 +283,10 @@ describe("S6 — the erasure routes accept a fresh-factor elevation", () => {
// A password-proved elevation must not erase an account, and the gate
// must not grow a second, inline factor check beside the elevation: one
// mint surface, one redemption path.
const src = readSource(
"src/lib/api-handler.ts",
const body = exportedBlock(
"requireFreshMfaOrElevationIfEnrolled",
"export async function requireFreshMfaOrElevationIfEnrolled(",
);
const start = src.indexOf(
"export async function requireFreshMfaOrElevationIfEnrolled(",
);
expect(start).toBeGreaterThan(-1);
const body = src.slice(start, src.indexOf("\nexport ", start + 1));
expect(body).toMatch(/freshFactor:\s*true/);
expect(body).not.toMatch(/verifyMfaFactor|verifyTotp|recoveryCode/);
});
@@ -279,14 +297,14 @@ describe("S2 — mint and redeem happen in exactly one place each", () => {
const found = callers("claimStepUpElevation", STEP_UP_MODULE).filter(
(rel) => rel !== "lib/auth/step-up.ts",
);
expect(found).toEqual(["lib/api-handler.ts"]);
expect(found).toEqual([GATE_MODULE]);
});
it("only the gate validates one", () => {
const found = callers("validateStepUpElevation", STEP_UP_MODULE).filter(
(rel) => rel !== "lib/auth/step-up.ts",
);
expect(found).toEqual(["lib/api-handler.ts"]);
expect(found).toEqual([GATE_MODULE]);
});
it("only the mint route mints one", () => {
@@ -300,7 +318,7 @@ describe("S2 — mint and redeem happen in exactly one place each", () => {
// Both spellings — the literal string AND the exported constant — across
// every source file, not only `route.ts`. A helper could read it too.
const offenders = sourceFiles()
.filter((rel) => rel !== "lib/api-handler.ts")
.filter((rel) => rel !== GATE_MODULE)
.filter((rel) => {
const src = read(rel);
if (/["']x-step-up["']/i.test(src)) return true;
@@ -314,13 +332,9 @@ describe("S2 — mint and redeem happen in exactly one place each", () => {
describe("S3 — requireAdmin stays cookie-only", () => {
it("resolves through getSession and nothing else", () => {
const src = readSource("src/lib/api-handler.ts", [
const body = exportedBlock(
"requireAdmin",
"export async function requireAdmin(",
"export async function requireCookieAuth(",
]);
const body = src.slice(
src.indexOf("export async function requireAdmin("),
src.indexOf("export async function requireCookieAuth("),
);
expect(body).toContain("await getSession()");
expect(body).not.toMatch(/authenticateBearer|resolveBearerToken/);
@@ -345,14 +359,11 @@ describe("S4 — the mint surface refuses a cookie", () => {
});
it("requireBearerAuth never consults the session cookie", () => {
const src = readSource("src/lib/api-handler.ts", [
const body = exportedBlock(
"requireBearerAuth",
"export async function requireBearerAuth(",
"export const STEP_UP_ELEVATION_HEADER",
]);
const body = src.slice(
src.indexOf("export async function requireBearerAuth("),
src.indexOf("export const STEP_UP_ELEVATION_HEADER"),
);
expect(body).toContain("authenticateBearer(");
expect(body).not.toContain("getSession(");
});
});
@@ -38,6 +38,9 @@ const SCOPES = [
"app/api/insights/",
"app/api/analytics/",
"app/api/dashboard/",
// Request-handler logic moved out of its route into a service keeps the
// route's scope.
"lib/dashboard/widgets/",
];
/** `model@file` → why this read includes deleted rows. */
@@ -83,7 +83,7 @@ const PROVIDER_INGESTS: ProviderIngest[] = [
provider: "Polar",
mapper: "mapPolarSportType",
mapperFile: "lib/polar/sport-map.ts",
captureFile: "lib/polar/client.ts",
captureFile: "lib/polar/map.ts",
captureField: "detailed_sport_info",
writeFile: "lib/polar/sync-workouts.ts",
metadataKey: "polarSport",
@@ -92,7 +92,7 @@ const PROVIDER_INGESTS: ProviderIngest[] = [
provider: "Strava",
mapper: "mapStravaSportType",
mapperFile: "lib/strava/sport-map.ts",
captureFile: "lib/strava/client.ts",
captureFile: "lib/strava/map.ts",
captureField: "rawSportType",
writeFile: "lib/strava/sync.ts",
metadataKey: "stravaType",
@@ -189,9 +189,10 @@ describe("T2 — the set of files writing Workout rows is frozen", () => {
* already resolved, so the provenance rule does not reach them.
*/
const NON_MAPPING_WRITERS: Record<string, string> = {
// The iOS / client batch endpoint. The client sends an enum value and MAY
// The iOS / client batch endpoint (its write, behind
// `/api/workouts/batch`). The client sends an enum value and MAY
// send its own `metadata`; no server-side label mapping happens here.
"app/api/workouts/batch/route.ts": "client-supplied enum",
"lib/workouts/batch-ingest/write.ts": "client-supplied enum",
// Backup restore replays rows this app previously wrote, provenance and
// all.
"lib/export/workouts-backup.ts": "restore replay",
+17 -14
View File
@@ -26,11 +26,15 @@ import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { guardSourceOf } from "./helpers/auth-guards";
import { walkSourceFiles } from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
/** The resolvers in `api-handler.ts` that charge the shared write bucket. */
/**
* The resolvers that charge the shared write bucket: the guards in
* `lib/auth/guards/`, imported by routes through `lib/api-handler.ts`.
*/
const METERING_RESOLVERS = [
"requireAuth",
"requireRecordAuth",
@@ -194,7 +198,16 @@ describe("every mutating route meets a rate limit", () => {
});
it("charges the bucket in each metering resolver", () => {
const handler = read("lib/api-handler.ts");
/** The resolver's source, from its signature to the next export. */
const resolverBody = (name: string): string => {
const signature = new RegExp(
`export\\s+async\\s+function\\s+${name}\\s*\\(`,
);
const handler = guardSourceOf(name, [signature]);
const start = handler.search(signature);
const next = handler.indexOf("\nexport ", start + 1);
return handler.slice(start, next === -1 ? undefined : next);
};
for (const name of [
"requireAuth",
"requireActorAuth",
@@ -205,13 +218,7 @@ describe("every mutating route meets a rate limit", () => {
"requireFreshMfa",
"requireBearerAuth",
]) {
const start = handler.search(
new RegExp(`export\\s+async\\s+function\\s+${name}\\s*\\(`),
);
expect(start, name).toBeGreaterThanOrEqual(0);
const next = handler.indexOf("\nexport ", start + 1);
const body = handler.slice(start, next === -1 ? undefined : next);
expect(body, name).toMatch(/\bmeterAccountWrite\s*\(/);
expect(resolverBody(name), name).toMatch(/\bmeterAccountWrite\s*\(/);
}
// The three that only delegate reach the meter through `requireAuth`.
for (const name of [
@@ -219,11 +226,7 @@ describe("every mutating route meets a rate limit", () => {
"requireFreshMfaOrElevationIfEnrolled",
"requireRecentProof",
]) {
const start = handler.search(
new RegExp(`export\\s+async\\s+function\\s+${name}\\s*\\(`),
);
const next = handler.indexOf("\nexport ", start + 1);
expect(handler.slice(start, next), name).toMatch(/\brequireAuth\s*\(/);
expect(resolverBody(name), name).toMatch(/\brequireAuth\s*\(/);
}
});
});
@@ -16,7 +16,10 @@
* every `if (!<result>.success)` block whose identifier came from a `safeParse`
* in that file is read on its own, and only the blocks that answer 422 are
* judged — a 400, a 401 or a 403 raised off a parse result is a different
* refusal and is left alone.
* refusal and is left alone. A route that reads its body through
* `parseJsonBody` has no such block: each call is a site of its own, carrying
* the issue list by construction, and its `message` option is read as the
* refused string by the token sweep below.
*
* The second half of the same audit item is the dotted token in `error`, and
* it is checked by its own sweep at the bottom of this file rather than by the
@@ -94,11 +97,58 @@ interface RefusalSite {
carriesIssues: boolean;
}
/**
* Every `parseJsonBody(…)` call in a file, as its paren-balanced source text
* with the line it starts on. The helper reads, validates and refuses in one
* call (`src/lib/api/body.ts`), so a route that uses it has no
* `if (!x.success)` block for the matcher above to find; its refusal is the
* helper's envelope, which always carries `details.issues`.
*/
function parseJsonBodyCalls(src: string): { text: string; line: number }[] {
const calls: { text: string; line: number }[] = [];
for (const m of src.matchAll(/\bparseJsonBody\s*\(/g)) {
const start = m.index ?? 0;
let depth = 0;
let end = src.length;
for (let j = start + m[0].length - 1; j < src.length; j++) {
if (src[j] === "(") depth++;
else if (src[j] === ")") {
depth--;
if (depth === 0) {
end = j + 1;
break;
}
}
}
calls.push({
text: src.slice(start, end),
line: src.slice(0, start).split("\n").length,
});
}
return calls;
}
/** The status a `parseJsonBody` call refuses with: its `status` option, else 422. */
function parseJsonBodyStatus(call: string): number {
const m = /\bstatus:\s*(\d{3})\b/.exec(call);
return m ? Number(m[1]) : 422;
}
/** Every 422 refusal decided by a `safeParse` result, across the route tree. */
function scanRefusalSites(): RefusalSite[] {
const sites: RefusalSite[] = [];
for (const file of routeFiles(API_ROOT)) {
const src = readFileSync(file, "utf8");
// A `parseJsonBody` refusal is a 422 with the issue list unless the call
// names another status, which is then a different refusal, as below.
for (const call of parseJsonBodyCalls(src)) {
if (parseJsonBodyStatus(call.text) !== 422) continue;
sites.push({
route: relative(process.cwd(), file).split(sep).join("/"),
line: call.line,
carriesIssues: true,
});
}
// Identifiers bound to a parse result. Both call styles are in the tree:
// `schema.safeParse(body)` and the standalone `z.safeParse(schema, body)`.
const parsed = new Set<string>();
@@ -228,12 +278,20 @@ function tokenRefusals(): string[] {
/apiValidationError\(\s*"([^"]+)"/g,
/new HttpError\(\s*422\s*,\s*"([^"]+)"/g,
];
const refused: string[] = [];
for (const shape of shapes) {
for (const m of src.matchAll(shape)) {
if (!DOTTED_TOKEN.test(m[1])) continue;
const key = `${route} ${m[1]}`;
if (!found.includes(key)) found.push(key);
}
for (const m of src.matchAll(shape)) refused.push(m[1]);
}
// `parseJsonBody`'s `message` option is the 422's `error` string.
for (const call of parseJsonBodyCalls(src)) {
if (parseJsonBodyStatus(call.text) !== 422) continue;
const m = /\bmessage:\s*"([^"]+)"/.exec(call.text);
if (m) refused.push(m[1]);
}
for (const text of refused) {
if (!DOTTED_TOKEN.test(text)) continue;
const key = `${route} ${text}`;
if (!found.includes(key)) found.push(key);
}
}
return found;
@@ -1,7 +1,7 @@
/**
* Structural guard: the three read routes that used to write must stay reads.
*
* `src/lib/api-handler.ts` states the invariant next to `READ_HTTP_METHODS` —
* `src/lib/auth/guards/context.ts` states the invariant next to `READ_HTTP_METHODS` —
* an MCP-audience token is admitted on GET/HEAD/OPTIONS on the REST surface
* because those methods are assumed side-effect-free, and adding a
* side-effecting GET silently widens that token's reach. These three routes
+5 -16
View File
@@ -54,13 +54,8 @@ import {
requireRecentProof,
MFA_STEP_UP_MAX_AGE_SECONDS,
} from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { getAuditLogRetentionDays } from "@/lib/jobs/audit-log-cleanup";
@@ -163,17 +158,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("Too many invitations, try again later", 429);
}
const { data: rawBody, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, inviteGrantSchema, {
maxBytes: 8 * 1024,
errorCode: "sharing.invite.invalid",
});
if (jsonError) return jsonError;
const parsed = inviteGrantSchema.safeParse(rawBody);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "sharing.invite.invalid",
});
}
if (!parsed.ok) return parsed.error;
const { identifier, expiresAt, access, scope } = parsed.data;
+4 -10
View File
@@ -19,12 +19,8 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import {
decideHandover,
@@ -55,12 +51,10 @@ export const GET = apiHandler(async (_request: NextRequest) => {
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, handoverDecisionSchema, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = handoverDecisionSchema.safeParse(body);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
if (!parsed.ok) return parsed.error;
let result;
try {
+6 -17
View File
@@ -55,13 +55,8 @@ import {
requireActorAuth,
SharingAccessDeniedError,
} from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
@@ -69,7 +64,7 @@ import {
pointSessionAt,
pointSessionAtIfUnchanged,
} from "@/lib/sharing/acting-session";
import { findActiveGrant } from "@/lib/sharing/grants";
import { findActiveGrant } from "@/lib/sharing/grant-access";
import { switchAccountSchema } from "@/lib/validations/account-sharing";
export const POST = apiHandler(async (request: NextRequest) => {
@@ -83,17 +78,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
);
}
const { data: rawBody, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, switchAccountSchema, {
maxBytes: 4 * 1024,
errorCode: "sharing.switch.invalid",
});
if (jsonError) return jsonError;
const parsed = switchAccountSchema.safeParse(rawBody);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "sharing.switch.invalid",
});
}
if (!parsed.ok) return parsed.error;
const { accountId, expectedEpoch } = parsed.data;
const ip = getClientIp(request);
+5 -14
View File
@@ -8,12 +8,8 @@
* (`activity_goals_json`) and the activity calendar reads them.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { auditLog } from "@/lib/auth/audit";
import { Prisma } from "@/generated/prisma/client";
import { prisma, toJson } from "@/lib/db";
@@ -56,16 +52,11 @@ export const GET = apiHandler(async () => {
export const PATCH = apiHandler(async (request: Request) => {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, activityGoalsSchema, {
maxBytes: 1024,
action: "activity.goals.validation-failed",
});
if (jsonError) return jsonError;
const parsed = activityGoalsSchema.safeParse(body);
if (!parsed.success) {
annotate({ action: { name: "activity.goals.validation-failed" } });
return returnAllZodIssues(parsed.error, 422);
}
if (!parsed.ok) return parsed.error;
const next = mergeActivityGoals(await readGoals(user.id), parsed.data);
await prisma.user.update({
+1 -1
View File
@@ -32,10 +32,10 @@ import {
import { readBackupPassActivity } from "@/lib/jobs/backup-pass-attempts";
import { summariseBackupSchedule } from "@/lib/jobs/backup-schedule-status";
import {
OFFHOST_BACKUP_QUEUE,
offhostBackupConfigured,
probeOffhostLifecycle,
} from "@/lib/jobs/offhost-backup";
import { OFFHOST_BACKUP_QUEUE } from "@/lib/jobs/offhost-backup-queue";
import {
classifyOffhostBackup,
OFFHOST_BACKUP_PERIOD_HOURS,
@@ -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,
+1 -1
View File
@@ -31,10 +31,10 @@ import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import {
ENCRYPTION_KEY_ROTATE_QUEUE,
ENCRYPTION_KEY_ROTATE_SINGLETON,
ENCRYPTION_KEY_ROTATE_EXPIRE_SECONDS,
} from "@/lib/jobs/encryption-key-rotate";
import { ENCRYPTION_KEY_ROTATE_QUEUE } from "@/lib/jobs/encryption-key-rotate-queue";
import { getActiveKeyId } from "@/lib/crypto";
import { NextRequest } from "next/server";
@@ -21,12 +21,12 @@ import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit } from "@/lib/rate-limit";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import {
APPLE_HEALTH_IMPORT_V2_QUEUE,
APPLE_HEALTH_IMPORT_KIND,
APPLE_HEALTH_IMPORT_PARSER_REVISION,
APPLE_HEALTH_IMPORT_SEND_OPTIONS,
type AppleHealthImportPayload,
} from "@/lib/jobs/apple-health-import-worker";
import { APPLE_HEALTH_IMPORT_V2_QUEUE } from "@/lib/jobs/apple-health-import-queue";
import { streamMultipartToDisk } from "@/lib/multipart/stream-to-disk";
import {
createImportJobUnlessBusy,
+5 -13
View File
@@ -13,12 +13,8 @@
*/
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import {
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { hashToken } from "@/lib/auth/hmac";
@@ -62,15 +58,11 @@ export const GET = apiHandler(async () => {
export const POST = apiHandler(async (req: Request) => {
const { user } = await requireAdmin();
const { data: body, error: jsonError } = await safeJson(req, {
const parsed = await parseJsonBody(req, inviteCreateSchema, {
maxBytes: 16 * 1024,
input: (body) => body ?? {},
});
if (jsonError) return jsonError;
const parsed = inviteCreateSchema.safeParse(body ?? {});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422);
}
if (!parsed.ok) return parsed.error;
const { expiresInDays, maxUses } = parsed.data;
const rawToken = generateInviteToken();
@@ -15,13 +15,8 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
@@ -99,12 +94,10 @@ export const GET = apiHandler(async () => {
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAdmin();
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, pulseDayMeanActionSchema, {
maxBytes: 1024,
});
if (jsonError) return jsonError;
const parsed = pulseDayMeanActionSchema.safeParse(body);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
if (!parsed.ok) return parsed.error;
const action = parsed.data.action;
annotate({
action: { name: "admin.pulse_day_mean.requested" },
@@ -52,13 +52,8 @@ import type { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { resolveAssistantFlags } from "@/lib/feature-flags";
@@ -180,16 +175,10 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAdmin();
annotate({ action: { name: "admin.settings.assistant-flags.update" } });
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, assistantFlagsSchema, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = assistantFlagsSchema.safeParse(body);
if (!parsed.success) {
// v1.4.43 — multi-issue 422.
return returnAllZodIssues(parsed.error, 422);
}
if (!parsed.ok) return parsed.error;
// Field by field from the parsed body; the schema is strict, so every key
// here is one the route owns.
@@ -33,13 +33,8 @@ import type { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
@@ -95,15 +90,10 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
const { user } = await requireAdmin();
annotate({ action: { name: "admin.settings.module-availability.update" } });
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, moduleAvailabilitySchema, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = moduleAvailabilitySchema.safeParse(body);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422);
}
if (!parsed.ok) return parsed.error;
const patch: Record<string, boolean> = {};
for (const [key, value] of Object.entries(parsed.data)) {
+4 -15
View File
@@ -1,13 +1,8 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { encrypt } from "@/lib/crypto";
import { adminSettingsSchema } from "@/lib/validations/admin";
@@ -109,16 +104,10 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAdmin();
annotate({ action: { name: "admin.settings.update" } });
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, adminSettingsSchema, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = adminSettingsSchema.safeParse(body);
if (!parsed.success) {
// v1.4.43 — multi-issue 422.
return returnAllZodIssues(parsed.error, 422);
}
if (!parsed.ok) return parsed.error;
const data = parsed.data;
const updates: Record<string, unknown> = {};
@@ -1,14 +1,8 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { encrypt } from "@/lib/crypto";
import { z } from "zod/v4";
@@ -53,19 +47,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAdmin();
annotate({ action: { name: "admin.webPushVapid.generate" } });
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, generateSchema, {
maxBytes: 4 * 1024,
input: (body) => body ?? {},
message: "Invalid request",
});
if (jsonError) return jsonError;
const parsed = generateSchema.safeParse(body ?? {});
if (!parsed.success) {
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
if (!parsed.ok) return parsed.error;
const existing = await prisma.appSettings.findUnique({
where: { id: "singleton" },
+4 -15
View File
@@ -1,13 +1,8 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
@@ -49,16 +44,10 @@ export const PUT = apiHandler(
},
});
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, updateUserSchema, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = updateUserSchema.safeParse(body);
if (!parsed.success) {
// v1.4.43 — multi-issue 422.
return returnAllZodIssues(parsed.error, 422);
}
if (!parsed.ok) return parsed.error;
const target = await prisma.user.findUnique({
where: { id },
+5 -16
View File
@@ -19,13 +19,8 @@ import {
destroyedDetails,
overwriteDetails,
} from "@/lib/sharing/audit-details";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { allergyUpdateSchema } from "@/lib/validations/allergy";
import { toAllergyDTO } from "@/lib/records/dto";
@@ -82,17 +77,11 @@ export const PATCH = apiHandler(
return apiError("Allergy not found", 404);
}
const { data: rawBody, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, allergyUpdateSchema, {
maxBytes: 16 * 1024,
errorCode: "allergy.invalid",
});
if (jsonError) return jsonError;
const parsed = allergyUpdateSchema.safeParse(rawBody);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "allergy.invalid",
});
}
if (!parsed.ok) return parsed.error;
const entry = parsed.data;
// Field-by-field — only fields actually present in the body are written.
+6 -14
View File
@@ -18,8 +18,8 @@ import {
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { withIdempotency } from "@/lib/idempotency";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import {
@@ -92,21 +92,13 @@ async function postAllergy(request: NextRequest): Promise<Response> {
// regardless.
const { user } = await requireRecordAuth("manage", "profile");
const { data: rawBody, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, allergyCreateSchema, {
maxBytes: 16 * 1024,
action: "allergy.validation-failed",
countIssues: true,
errorCode: "allergy.invalid",
});
if (jsonError) return jsonError;
const parsed = allergyCreateSchema.safeParse(rawBody);
if (!parsed.success) {
annotate({
action: { name: "allergy.validation-failed" },
meta: { issue_count: parsed.error.issues.length },
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "allergy.invalid",
});
}
if (!parsed.ok) return parsed.error;
const entry = parsed.data;
+5 -15
View File
@@ -16,13 +16,8 @@
import { NextRequest } from "next/server";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { auditLog } from "@/lib/auth/audit";
import { withIdempotency } from "@/lib/idempotency";
import { annotate } from "@/lib/logging/context";
@@ -46,16 +41,11 @@ async function postAuditC(request: NextRequest): Promise<Response> {
});
}
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, auditCRequestSchema, {
maxBytes: 4 * 1024,
errorCode: "anamnesis.auditC.invalid",
});
if (jsonError) return jsonError;
const parsed = auditCRequestSchema.safeParse(body);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "anamnesis.auditC.invalid",
});
}
if (!parsed.ok) return parsed.error;
const input = parsed.data;
+4 -11
View File
@@ -1,12 +1,8 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import {
@@ -31,13 +27,10 @@ export const GET = apiHandler(async () => {
export const PATCH = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { data: rawBody, error } = await safeJson(request, {
const parsed = await parseJsonBody(request, emergencyProfileUpdateSchema, {
maxBytes: 16 * 1024,
});
if (error) return error;
const parsed = emergencyProfileUpdateSchema.safeParse(rawBody);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
if (!parsed.ok) return parsed.error;
const payload = await writeEmergencyProfile(user.id, parsed.data);
await auditLog("anamnesis.emergency.update", {
+4 -12
View File
@@ -1,13 +1,8 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
@@ -39,13 +34,10 @@ export const PATCH = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const { data: rawBody, error } = await safeJson(request, {
const parsed = await parseJsonBody(request, factCorrectionSchema, {
maxBytes: 8 * 1024,
});
if (error) return error;
const parsed = factCorrectionSchema.safeParse(rawBody);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
if (!parsed.ok) return parsed.error;
const current = await prisma.healthProfileFactRevision.findFirst({
where: {
+4 -12
View File
@@ -1,13 +1,8 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { auditLog } from "@/lib/auth/audit";
import { withIdempotency } from "@/lib/idempotency";
import { annotate } from "@/lib/logging/context";
@@ -41,13 +36,10 @@ export const GET = apiHandler(async () => {
async function postFact(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
const { data: rawBody, error } = await safeJson(request, {
const parsed = await parseJsonBody(request, factWriteSchema, {
maxBytes: 8 * 1024,
});
if (error) return error;
const parsed = factWriteSchema.safeParse(rawBody);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
if (!parsed.ok) return parsed.error;
const now = new Date();
const validFrom = parsed.data.validFrom
@@ -12,13 +12,8 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import { updateLifeAiSections } from "@/lib/profile/life-circumstances";
@@ -27,16 +22,11 @@ import { lifeAiSectionsPatchSchema } from "@/lib/validations/life-circumstances"
export const PATCH = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, lifeAiSectionsPatchSchema, {
maxBytes: 4 * 1024,
errorCode: "anamnesis.lifeSections.invalid",
});
if (jsonError) return jsonError;
const parsed = lifeAiSectionsPatchSchema.safeParse(body);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "anamnesis.lifeSections.invalid",
});
}
if (!parsed.ok) return parsed.error;
const outcome = await updateLifeAiSections(
user.id,
@@ -8,7 +8,8 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
import { apiSuccess } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { confirmLifeSection } from "@/lib/profile/life-circumstances";
import { lifeConfirmSchema } from "@/lib/validations/life-circumstances";
@@ -16,16 +17,11 @@ import { lifeConfirmSchema } from "@/lib/validations/life-circumstances";
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, lifeConfirmSchema, {
maxBytes: 1024,
errorCode: "anamnesis.lifeConfirm.invalid",
});
if (jsonError) return jsonError;
const parsed = lifeConfirmSchema.safeParse(body);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "anamnesis.lifeConfirm.invalid",
});
}
if (!parsed.ok) return parsed.error;
const lastConfirmedAt = await confirmLifeSection(
user.id,
+4 -5
View File
@@ -14,7 +14,8 @@
import { NextRequest } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
import { apiSuccess } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { getSession } from "@/lib/auth/session";
import { annotate } from "@/lib/logging/context";
import {
@@ -35,12 +36,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
);
if (gate.refusal) return gate.refusal;
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, claimPreviewSchema, {
maxBytes: 4 * 1024,
});
if (jsonError) return jsonError;
const parsed = claimPreviewSchema.safeParse(body);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
if (!parsed.ok) return parsed.error;
const preview = await previewHandover(parsed.data.token);
annotate({
+4 -10
View File
@@ -24,12 +24,8 @@
import { NextRequest } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { auditLog } from "@/lib/auth/audit";
import { recordSignInDevice } from "@/lib/auth/login-alert";
import { checkPasswordStrength, hashPassword } from "@/lib/auth/password";
@@ -59,12 +55,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (gate.refusal) return gate.refusal;
const ip = gate.ip ?? "unknown";
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, claimSchema, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = claimSchema.safeParse(body);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
if (!parsed.ok) return parsed.error;
const { token, username, email, password } = parsed.data;
// The link first. Without this the collision probe below would answer 409
+4 -10
View File
@@ -20,12 +20,8 @@
import { NextRequest } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { auditLog } from "@/lib/auth/audit";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
@@ -44,12 +40,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
const gate = await claimGate(request, "sso", (await getSession()) !== null);
if (gate.refusal) return gate.refusal;
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, claimSsoSchema, {
maxBytes: 4 * 1024,
});
if (jsonError) return jsonError;
const parsed = claimSsoSchema.safeParse(body);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
if (!parsed.ok) return parsed.error;
const { token, username } = parsed.data;
// The link first, so the username probe below is no oracle for anybody
+5 -15
View File
@@ -10,13 +10,8 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
@@ -40,16 +35,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
});
}
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, aiUsagePutSchema, {
maxBytes: 1024,
errorCode: "ai.usage.invalid",
});
if (jsonError) return jsonError;
const parsed = aiUsagePutSchema.safeParse(body);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "ai.usage.invalid",
});
}
if (!parsed.ok) return parsed.error;
const { level } = parsed.data;
const settings = await loadAiUsageSettings();
+1 -1
View File
@@ -27,7 +27,7 @@ import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { getOrCreateCycleProfile } from "@/lib/cycle/profile";
import { isCycleEnabled } from "@/lib/cycle/gate";
import { isCycleEnabled } from "@/lib/cycle/enabled";
import { invalidateUserHealthContext } from "@/lib/cache/invalidate";
import { toCycleProfileDTO } from "@/lib/cycle/dto";
import { cyclePrefsSchema } from "@/lib/validations/cycle";
+4 -12
View File
@@ -25,12 +25,8 @@
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -56,14 +52,10 @@ export const PATCH = apiHandler(
const { user } = await requireAuth();
const { id } = await context.params;
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, devicePatchSchema, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = devicePatchSchema.safeParse(body);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422);
}
if (!parsed.ok) return parsed.error;
// Ownership boundary — scope the update by userId so a cross-user id
// can't be patched, and 404 rather than leak existence.
+5 -15
View File
@@ -20,13 +20,8 @@
import { z } from "zod";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -79,16 +74,11 @@ export const PATCH = apiHandler(async (req: Request) => {
// The body is a single boolean — bound the parse so a malformed or
// oversized payload is rejected before it is materialised.
const { data: body, error: jsonError } = await safeJson(req, {
const parsed = await parseJsonBody(req, patchBodySchema, {
maxBytes: 1024,
action: "auth.me.diabetes.patch.invalid_shape",
});
if (jsonError) return jsonError;
const parsed = patchBodySchema.safeParse(body);
if (!parsed.success) {
annotate({ action: { name: "auth.me.diabetes.patch.invalid_shape" } });
return returnAllZodIssues(parsed.error, 422);
}
if (!parsed.ok) return parsed.error;
const next = parsed.data.hasDiabetes;
@@ -26,13 +26,8 @@
* resolved next state so the client can hard-set the optimistic update.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
@@ -134,18 +129,11 @@ export const PATCH = apiHandler(async (req: Request) => {
// The body is a single boolean — bound the parse so a malformed or oversized
// payload is rejected before it is materialised.
const { data: body, error: jsonError } = await safeJson(req, {
const parsed = await parseJsonBody(req, documentsAutoAiReadPatchSchema, {
maxBytes: 1024,
action: "auth.me.documentsAutoAiRead.patch.invalid_shape",
});
if (jsonError) return jsonError;
const parsed = documentsAutoAiReadPatchSchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "auth.me.documentsAutoAiRead.patch.invalid_shape" },
});
return returnAllZodIssues(parsed.error, 422);
}
if (!parsed.ok) return parsed.error;
const next = parsed.data.documentsAutoAiRead;
+5 -15
View File
@@ -25,13 +25,8 @@
* update without an extra round-trip.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -81,16 +76,11 @@ export const PATCH = apiHandler(async (req: Request) => {
// The body is a single enum value — bound the parse so a malformed or
// oversized payload is rejected before it is materialised.
const { data: body, error: jsonError } = await safeJson(req, {
const parsed = await parseJsonBody(req, glucoseUnitPatchSchema, {
maxBytes: 1024,
action: "auth.me.glucose-unit.patch.invalid_shape",
});
if (jsonError) return jsonError;
const parsed = glucoseUnitPatchSchema.safeParse(body);
if (!parsed.success) {
annotate({ action: { name: "auth.me.glucose-unit.patch.invalid_shape" } });
return returnAllZodIssues(parsed.error, 422);
}
if (!parsed.ok) return parsed.error;
const next = parsed.data.glucoseUnit;
@@ -19,13 +19,8 @@
* field-by-field write (no mass assignment).
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -77,18 +72,11 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
return response;
}
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, injectionSitePrefsPatchSchema, {
maxBytes: 64 * 1024,
action: "auth.me.injection-site-prefs.patch.invalid_shape",
});
if (jsonError) return jsonError;
const parsed = injectionSitePrefsPatchSchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "auth.me.injection-site-prefs.patch.invalid_shape" },
});
return returnAllZodIssues(parsed.error, 422);
}
if (!parsed.ok) return parsed.error;
// Dedup while preserving canonical order from the enum tuple.
const submitted = new Set(parsed.data.globalExcludedInjectionSites);
+5 -15
View File
@@ -17,13 +17,8 @@
* resolved next state so the client can hard-set the optimistic update.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -71,16 +66,11 @@ export const PATCH = apiHandler(async (req: Request) => {
// The body is a single boolean — bound the parse so a malformed or oversized
// payload is rejected before it is materialised.
const { data: body, error: jsonError } = await safeJson(req, {
const parsed = await parseJsonBody(req, labsLocalOcrPatchSchema, {
maxBytes: 1024,
action: "auth.me.labsLocalOcr.patch.invalid_shape",
});
if (jsonError) return jsonError;
const parsed = labsLocalOcrPatchSchema.safeParse(body);
if (!parsed.success) {
annotate({ action: { name: "auth.me.labsLocalOcr.patch.invalid_shape" } });
return returnAllZodIssues(parsed.error, 422);
}
if (!parsed.ok) return parsed.error;
const next = parsed.data.labsLocalOcrEnabled;
+5 -19
View File
@@ -10,14 +10,8 @@
*/
import { NextRequest } from "next/server";
import { apiHandler, requireMfaManagementAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -34,19 +28,11 @@ export const PATCH = apiHandler(
const { user } = auth;
const { id } = await params;
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, mfaWebauthnRenameSchema, {
maxBytes: 4 * 1024,
message: "Invalid request",
});
if (jsonError) return jsonError;
const parsed = mfaWebauthnRenameSchema.safeParse(body);
if (!parsed.success) {
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
if (!parsed.ok) return parsed.error;
const existing = await prisma.webauthnMfaCredential.findUnique({
where: { id },
@@ -8,14 +8,8 @@
*/
import { NextRequest } from "next/server";
import { apiHandler, requireMfaManagementAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -30,19 +24,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
const auth = await requireMfaManagementAuth();
const { user } = auth;
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, mfaWebauthnRegisterVerifySchema, {
maxBytes: 64 * 1024,
message: "Invalid request",
});
if (jsonError) return jsonError;
const parsed = mfaWebauthnRegisterVerifySchema.safeParse(body);
if (!parsed.success) {
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
if (!parsed.ok) return parsed.error;
const { challengeId, credential, name } = parsed.data;
const verification = await verifyMfaRegistration(
+1 -1
View File
@@ -50,7 +50,7 @@ import { buildAvatarUrl } from "@/lib/avatar";
import { decrypt } from "@/lib/crypto";
import { prisma } from "@/lib/db";
import { resolveStoredTimezone } from "@/lib/tz/resolver";
import { isCycleEnabled } from "@/lib/cycle/gate";
import { isCycleEnabled } from "@/lib/cycle/enabled";
import {
resolveModuleMap,
getOperatorModuleAvailability,
+1 -1
View File
@@ -38,7 +38,7 @@ import {
} from "@/lib/auth/session";
import { lookupIpLocation } from "@/lib/geo";
import { GRANT_PARTY_SELECT } from "@/lib/sharing/grant-view";
import { isGrantActive } from "@/lib/sharing/grants";
import { isGrantActive } from "@/lib/sharing/grant-access";
import { coarseDeviceLabel, maskIp } from "@/lib/auth/device-fingerprint";
export const dynamic = "force-dynamic";
+5 -17
View File
@@ -25,13 +25,8 @@
* `res.ok` and never the status.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -85,18 +80,11 @@ export const PATCH = apiHandler(async (req: Request) => {
// The body is a single enum value — bound the parse so a malformed or
// oversized payload is rejected before it is materialised.
const { data: body, error: jsonError } = await safeJson(req, {
const parsed = await parseJsonBody(req, unitPreferencePatchSchema, {
maxBytes: 1024,
action: "auth.me.unit-preference.patch.invalid_shape",
});
if (jsonError) return jsonError;
const parsed = unitPreferencePatchSchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "auth.me.unit-preference.patch.invalid_shape" },
});
return returnAllZodIssues(parsed.error, 422);
}
if (!parsed.ok) return parsed.error;
const next = parsed.data.unitPreference;
+5 -17
View File
@@ -17,13 +17,8 @@
* assignment). Idempotent — always returns the resolved next state.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -69,18 +64,11 @@ export const PATCH = apiHandler(async (req: Request) => {
return response;
}
const { data: body, error: jsonError } = await safeJson(req, {
const parsed = await parseJsonBody(req, useCentralCodexPatchSchema, {
maxBytes: 1024,
action: "auth.me.useCentralCodex.patch.invalid_shape",
});
if (jsonError) return jsonError;
const parsed = useCentralCodexPatchSchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "auth.me.useCentralCodex.patch.invalid_shape" },
});
return returnAllZodIssues(parsed.error, 422);
}
if (!parsed.ok) return parsed.error;
const next = parsed.data.useCentralCodex;
+5 -15
View File
@@ -15,13 +15,8 @@
* the resolved next state in the response.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -67,16 +62,11 @@ export const PATCH = apiHandler(async (req: Request) => {
return response;
}
const { data: body, error: jsonError } = await safeJson(req, {
const parsed = await parseJsonBody(req, workoutRoutesPatchSchema, {
maxBytes: 1024,
action: "auth.me.workoutRoutes.patch.invalid_shape",
});
if (jsonError) return jsonError;
const parsed = workoutRoutesPatchSchema.safeParse(body);
if (!parsed.success) {
annotate({ action: { name: "auth.me.workoutRoutes.patch.invalid_shape" } });
return returnAllZodIssues(parsed.error, 422);
}
if (!parsed.ok) return parsed.error;
const next = parsed.data.storeWorkoutRoutes;
@@ -13,13 +13,8 @@
*/
import { NextRequest } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { apiError, apiSuccess } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
@@ -44,19 +39,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
await ensureDbCompatibility();
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, mfaWebauthnLoginOptionsSchema, {
maxBytes: 16 * 1024,
message: "Invalid request",
});
if (jsonError) return jsonError;
const parsed = mfaWebauthnLoginOptionsSchema.safeParse(body);
if (!parsed.success) {
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
if (!parsed.ok) return parsed.error;
const challenge = await loadActiveChallenge(parsed.data.mfaTicket);
if (!challenge) {
@@ -11,6 +11,8 @@ import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { guardSourceOf } from "@/__tests__/helpers/auth-guards";
const SRC = join(process.cwd(), "src");
const read = (rel: string) => readFileSync(join(SRC, rel), "utf8");
@@ -94,18 +96,15 @@ describe("T11/T13 — cookie-only boundaries and no bundle-in-browser", () => {
});
it("requireAdmin and requireFreshMfa remain cookie-only (getSession)", () => {
const handler = read("lib/api-handler.ts");
// Both resolve via getSession() (cookie-only) — restated as a tripwire so a
// future edit that threads a Bearer into either trips this test.
const requireAdmin = handler.slice(
handler.indexOf("export async function requireAdmin"),
handler.indexOf("export async function requireAdmin") + 400,
);
expect(requireAdmin).toMatch(/getSession\(\)/);
const requireFresh = handler.slice(
handler.indexOf("export async function requireFreshMfa"),
handler.indexOf("export async function requireFreshMfa") + 400,
);
expect(requireFresh).toMatch(/getSession\(\)/);
// future edit that threads a Bearer into either trips this test. Read from
// the guard module that declares each, which throws if it is gone.
const opening = (name: string) => {
const src = guardSourceOf(name, [`export async function ${name}(`]);
const start = src.indexOf(`export async function ${name}(`);
return src.slice(start, start + 600);
};
expect(opening("requireAdmin")).toMatch(/getSession\(\)/);
expect(opening("requireFreshMfa")).toMatch(/getSession\(\)/);
});
});
+5 -17
View File
@@ -20,12 +20,8 @@
* `requireFreshMfa` step-up.
*/
import { NextRequest } from "next/server";
import {
apiError,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { apiError } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
@@ -81,19 +77,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
await ensureDbCompatibility();
// (3) Body — bounded, then the exact wire contract.
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, nativeHandoffTokenSchema, {
maxBytes: 16 * 1024,
message: "Invalid request",
});
if (jsonError) return jsonError;
const parsed = nativeHandoffTokenSchema.safeParse(body);
if (!parsed.success) {
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
if (!parsed.ok) return parsed.error;
const { code, codeVerifier } = parsed.data;
// (4) Lookup + flow check + replay/expiry/PKCE + atomic single-use consume.
+5 -17
View File
@@ -23,12 +23,8 @@
* `requireFreshMfa` step-up, so an SSO login can never satisfy step-up.
*/
import { NextRequest } from "next/server";
import {
apiError,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { apiError } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
@@ -87,19 +83,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
await ensureDbCompatibility();
// (3) Body — bounded, then the exact wire contract.
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, oidcNativeTokenSchema, {
maxBytes: 16 * 1024,
message: "Invalid request",
});
if (jsonError) return jsonError;
const parsed = oidcNativeTokenSchema.safeParse(body);
if (!parsed.success) {
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
if (!parsed.ok) return parsed.error;
const { code, codeVerifier } = parsed.data;
// (4–8) Lookup + replay/expiry/PKCE + atomic single-use consume.
+4 -19
View File
@@ -1,12 +1,7 @@
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { NextRequest } from "next/server";
import {
apiHandler,
@@ -24,20 +19,10 @@ export const PATCH = apiHandler(
const { user } = await requireAuth();
const { id } = await params;
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, passkeyRenameSchema, {
maxBytes: 4 * 1024,
});
if (jsonError) return jsonError;
const parsed = passkeyRenameSchema.safeParse(body);
if (!parsed.success) {
// Multi-issue, like every sibling that parses a body. The flat
// `apiError("Invalid request", 422)` this replaces discarded the issues,
// so a person renaming a passkey could not tell an empty name from one
// over the 64-character limit — the form had nothing to put beside the
// field.
return returnAllZodIssues(parsed.error, 422);
}
if (!parsed.ok) return parsed.error;
const passkey = await prisma.passkey.findUnique({
where: { id },
+4 -15
View File
@@ -7,13 +7,8 @@ import {
} from "@/lib/auth/password";
import { changePasswordSchema } from "@/lib/validations/auth";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import {
recordReproofFailure,
refundReproof,
@@ -46,16 +41,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
const limited = await throttleReproof(user.id, ip, "password_change");
if (limited) return limited;
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, changePasswordSchema, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = changePasswordSchema.safeParse(body);
if (!parsed.success) {
// v1.4.43 — multi-issue 422.
return returnAllZodIssues(parsed.error, 422);
}
if (!parsed.ok) return parsed.error;
const { currentPassword, newPassword } = parsed.data;
const credential = await prisma.user.findUnique({
+4 -15
View File
@@ -9,12 +9,8 @@ import { hashPassword, checkPasswordStrength } from "@/lib/auth/password";
import { createSession, getSession } from "@/lib/auth/session";
import { recordSignInDevice } from "@/lib/auth/login-alert";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
apiError,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiSuccess, apiError } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { NextRequest, NextResponse } from "next/server";
@@ -100,17 +96,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
} catch {
// Table may not exist yet; allow registration
}
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, registerSchema, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = registerSchema.safeParse(body);
if (!parsed.success) {
// v1.4.43 — multi-issue 422.
return returnAllZodIssues(parsed.error, 422);
}
if (!parsed.ok) return parsed.error;
const {
email,
+5 -19
View File
@@ -42,14 +42,8 @@ import {
recentProofMethods,
requireCookieAuth,
} from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
@@ -68,19 +62,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
const { user, session } = await requireCookieAuth();
const ip = getClientIp(request);
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, stepUpMintSchema, {
maxBytes: 64 * 1024,
message: "Invalid request",
});
if (jsonError) return jsonError;
const parsed = stepUpMintSchema.safeParse(body);
if (!parsed.success) {
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
if (!parsed.ok) return parsed.error;
const proof = parsed.data;
// Refuse a proof that could not satisfy the gate BEFORE spending a guess on
+5 -18
View File
@@ -18,13 +18,8 @@
*/
import { NextRequest } from "next/server";
import { apiHandler, requireBearerAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { apiError, apiSuccess } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
@@ -49,19 +44,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
}
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, stepUpOptionsSchema, {
maxBytes: 4 * 1024,
message: "Invalid request",
});
if (jsonError) return jsonError;
const parsed = stepUpOptionsSchema.safeParse(body);
if (!parsed.success) {
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
if (!parsed.ok) return parsed.error;
if (parsed.data.method === "webauthn") {
const result = await createMfaAuthenticationOptions(user.id);
+5 -19
View File
@@ -30,14 +30,8 @@
*/
import { NextRequest } from "next/server";
import { apiHandler, requireBearerAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
@@ -110,19 +104,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
}
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, stepUpMintSchema, {
maxBytes: 64 * 1024,
message: "Invalid request",
});
if (jsonError) return jsonError;
const parsed = stepUpMintSchema.safeParse(body);
if (!parsed.success) {
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
if (!parsed.ok) return parsed.error;
const method: StepUpMethod = parsed.data.method;
// The shared verifier: the same challenge ownership and ceremony checks, and
+5 -15
View File
@@ -19,13 +19,8 @@
import { NextRequest } from "next/server";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { auditLog } from "@/lib/auth/audit";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import { withIdempotency } from "@/lib/idempotency";
@@ -47,16 +42,11 @@ async function mergeBiomarkers(
const { user } = await requireRecordAuth("manage", "labs");
const { id } = await params;
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, biomarkerMergeSchema, {
maxBytes: 4 * 1024,
errorCode: "labs.biomarker.merge.invalid",
});
if (jsonError) return jsonError;
const parsed = biomarkerMergeSchema.safeParse(body);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "labs.biomarker.merge.invalid",
});
}
if (!parsed.ok) return parsed.error;
if (parsed.data.sourceIds.includes(id)) {
return apiError("The target cannot be one of the sources", 422, {
errorCode: "labs.biomarker.merge.invalid",
+22 -28
View File
@@ -5,10 +5,9 @@ import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { auditLog } from "@/lib/auth/audit";
import {
destroyedDetails,
@@ -122,34 +121,29 @@ export const PUT = apiHandler(
});
}
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, updateBiomarkerSchema, {
maxBytes: 16 * 1024,
action: "labs.biomarker.update.validation-failed",
countIssues: true,
meta: { biomarkerId: id },
errorCode: "labs.biomarker.update.invalid",
onInvalid: (error) => {
const auditIssues = sanitiseZodIssues(error.issues, {
stripValuesFromMessage: true,
});
// v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog
// .create`, because that helper is the only writer that stamps
// `actorUserId`. Filed under the resolved record either way; without
// the stamp a manager's malformed payload would read as the owner's own.
void auditLog("labs.biomarker.update.validation-failed", {
userId: user.id,
details: { issues: auditIssues, biomarkerId: id },
}).catch(() => {
/* swallow — the 422 response is the contract */
});
},
});
if (jsonError) return jsonError;
const parsed = updateBiomarkerSchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "labs.biomarker.update.validation-failed" },
meta: { issue_count: parsed.error.issues.length, biomarkerId: id },
});
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
// v1.37.0 — through `auditLog()` rather than a bare `prisma.auditLog
// .create`, because that helper is the only writer that stamps
// `actorUserId`. Filed under the resolved record either way; without the
// stamp a manager's malformed payload would read as the owner's own.
void auditLog("labs.biomarker.update.validation-failed", {
userId: user.id,
details: { issues: auditIssues, biomarkerId: id },
}).catch(() => {
/* swallow — the 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "labs.biomarker.update.invalid",
});
}
if (!parsed.ok) return parsed.error;
const d = parsed.data;
+18 -25
View File
@@ -5,10 +5,9 @@ import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { withIdempotency } from "@/lib/idempotency";
@@ -77,31 +76,25 @@ async function postBiomarker(request: NextRequest) {
// tracks gets the ordinary 409 rather than a second copy.
const { user } = await requireRecordAuth("write", "labs");
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, createBiomarkerSchema, {
maxBytes: 16 * 1024,
action: "labs.biomarker.create.validation-failed",
countIssues: true,
errorCode: "labs.biomarker.create.invalid",
onInvalid: (error) => {
const auditIssues = sanitiseZodIssues(error.issues, {
stripValuesFromMessage: true,
});
// v1.36.x — through `auditLog()`, the only writer that stamps the actor.
void auditLog("labs.biomarker.create.validation-failed", {
userId: user.id,
details: { issues: auditIssues },
}).catch(() => {
/* swallow — the 422 response is the contract */
});
},
});
if (jsonError) return jsonError;
const parsed = createBiomarkerSchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "labs.biomarker.create.validation-failed" },
meta: { issue_count: parsed.error.issues.length },
});
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
// v1.36.x — through `auditLog()`, the only writer that stamps the actor.
void auditLog("labs.biomarker.create.validation-failed", {
userId: user.id,
details: { issues: auditIssues },
}).catch(() => {
/* swallow — the 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "labs.biomarker.create.invalid",
});
}
if (!parsed.ok) return parsed.error;
const { name, unit, lowerBound, upperBound, context, panel } = parsed.data;
+4 -11
View File
@@ -23,12 +23,8 @@ import { NextRequest } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiError, apiSuccess } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
import { resolveShareGateState } from "@/lib/clinician-share/resolve-share-token";
@@ -69,13 +65,10 @@ export const POST = apiHandler(
return apiError("Too many attempts", 429);
}
const { data: body, error: jsonError } = await safeJson(request, {
const parsed = await parseJsonBody(request, unlockShareLinkSchema, {
maxBytes: 4 * 1024,
});
if (jsonError) return jsonError;
const parsed = unlockShareLinkSchema.safeParse(body);
if (!parsed.success) return returnAllZodIssues(parsed.error);
if (!parsed.ok) return parsed.error;
// A token with no live gate, or one carrying no passphrase, cannot be
// unlocked. `verifyPassphrase` is constant-time and returns false for a
Loaded 100 of 968 files, more files were not shown because too many files have changed in this diff. Show more