From 860cb61b0b2188c0ea3058ba3d0b2b95903cb755 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 10:59:40 +0200 Subject: [PATCH 1/2] fix(proxy): pin no-store on every session-gated page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard, Insights, the workouts list, the medications list and the Coach server-prefetch their data and serialise one account's health record into the HTML document through a TanStack HydrationBoundary. Those pages are dynamic today only as a side effect of reading the session cookie — a property of the page code, not a guarantee. A stray revalidate export, a force-static segment, or a caching proxy in front of the app would be enough to retain one account's record and hand it to the next caller. Stamp `Cache-Control: private, no-store` at the edge instead, driven by the same predicate that governs the auth redirect, so a page cannot be session-gated without also being uncacheable. Public legal, auth and locale-catalog surfaces keep their cacheable posture; API routes keep managing their own caching. Two guards: the proxy test pins the header on gated pages and its absence on the public ones, and a structural test walks the app tree and refuses a prefetching page that sits on a public path or opts into static / revalidated rendering. --- .../prefetch-page-caching-guard.test.ts | 118 ++++++++++++++++++ src/__tests__/proxy-page-no-store.test.ts | 112 +++++++++++++++++ src/proxy.ts | 27 +++- 3 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/prefetch-page-caching-guard.test.ts create mode 100644 src/__tests__/proxy-page-no-store.test.ts diff --git a/src/__tests__/prefetch-page-caching-guard.test.ts b/src/__tests__/prefetch-page-caching-guard.test.ts new file mode 100644 index 000000000..e7a21c0a0 --- /dev/null +++ b/src/__tests__/prefetch-page-caching-guard.test.ts @@ -0,0 +1,118 @@ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative, sep } from "node:path"; +import { describe, expect, it } from "vitest"; + +/** + * Structural guard over the server-prefetching pages. + * + * A page that calls `dehydrate()` inside a `HydrationBoundary` serialises the + * caller's own health record into the HTML document. Two things must hold for + * every such page, and neither is enforced by the type system: + * + * 1. It must be session-gated at the edge, so `src/proxy.ts` stamps + * `Cache-Control: private, no-store` on the document. A prefetching page + * placed on a public path would ship a record with no cache directive at + * all. + * 2. It must not opt into static or revalidated rendering. `export const + * revalidate = ` or `dynamic = "force-static"` would let Next serve one + * account's prefetched HTML to the next caller out of its own cache, + * before the request ever reaches a header. + * + * The guard walks the app tree rather than naming files, so a NEW prefetching + * page is covered the moment it lands. + */ + +const APP_DIR = join(process.cwd(), "src", "app"); + +/** Mirrors the public-path allowlist in `src/proxy.ts`. */ +const PUBLIC_ROUTE_PREFIXES = [ + "/auth/", + "/privacy", + "/about", + "/c/", + "/invite/", + "/onboarding", + "/mcp", + "/i18n/", + "/.well-known/", +]; + +function walk(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir)) { + if (entry === "node_modules" || entry === "__tests__") continue; + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + out.push(...walk(full)); + } else if (entry === "page.tsx" || entry === "page.ts") { + out.push(full); + } + } + return out; +} + +/** Turn `src/app/insights/workouts/page.tsx` into `/insights/workouts`. */ +function routePathFor(file: string): string { + const rel = relative(APP_DIR, file).split(sep).slice(0, -1); + const segments = rel.filter( + // Route groups `(name)` and parallel/intercepting slots contribute no + // URL segment. + (s) => !(s.startsWith("(") && s.endsWith(")")) && !s.startsWith("@"), + ); + return "/" + segments.join("/"); +} + +function isPublicRoute(routePath: string): boolean { + return PUBLIC_ROUTE_PREFIXES.some((p) => routePath.startsWith(p)); +} + +const prefetchingPages = walk(APP_DIR) + .map((file) => ({ file, source: readFileSync(file, "utf8") })) + .filter( + ({ source }) => + source.includes("HydrationBoundary") && source.includes("dehydrate"), + ) + .map(({ file, source }) => ({ + file, + source, + routePath: routePathFor(file), + rel: relative(process.cwd(), file), + })); + +describe("server-prefetching pages cannot leak a cacheable record", () => { + it("finds the prefetching pages at all (guard is not vacuous)", () => { + expect(prefetchingPages.length).toBeGreaterThan(0); + // The dashboard is the canonical one; if it stops matching, the detection + // heuristic has drifted and every assertion below is silently skipped. + expect(prefetchingPages.map((p) => p.routePath)).toContain("/"); + }); + + it("keeps every prefetching page behind the session gate", () => { + const publicOnes = prefetchingPages.filter((p) => + isPublicRoute(p.routePath), + ); + expect( + publicOnes.map((p) => `${p.rel} (${p.routePath})`), + "a page that dehydrates a record onto a public path gets no no-store header from the proxy", + ).toEqual([]); + }); + + it("lets no prefetching page opt into static or revalidated rendering", () => { + const offenders: string[] = []; + for (const page of prefetchingPages) { + if (/export\s+const\s+revalidate\s*=/.test(page.source)) { + offenders.push(`${page.rel}: exports revalidate`); + } + const dynamicMatch = page.source.match( + /export\s+const\s+dynamic\s*=\s*["']([^"']+)["']/, + ); + if (dynamicMatch && dynamicMatch[1] !== "force-dynamic") { + offenders.push(`${page.rel}: exports dynamic = "${dynamicMatch[1]}"`); + } + } + expect( + offenders, + "a prefetched page must never be served from Next's own render cache", + ).toEqual([]); + }); +}); diff --git a/src/__tests__/proxy-page-no-store.test.ts b/src/__tests__/proxy-page-no-store.test.ts new file mode 100644 index 000000000..c96283da3 --- /dev/null +++ b/src/__tests__/proxy-page-no-store.test.ts @@ -0,0 +1,112 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +/** + * Session-gated pages serialise one account's health record into the HTML + * document — the dashboard, Insights, the workouts list, the medications + * list and the Coach all server-prefetch their data through a TanStack + * `HydrationBoundary`, so the record ships inside the payload rather than + * arriving over a later fetch. + * + * Those pages are dynamic today only because they read the session cookie. + * That is a property of the page code, not a guarantee — so the proxy pins + * `Cache-Control: private, no-store` on every session-gated page response. + * `private` bars shared caches (a CDN, a reverse proxy); `no-store` also + * bars the browser disk cache and the bfcache snapshot. + * + * These tests pin both sides: gated pages carry the header, and the public + * surfaces that are deliberately cacheable do not. + */ + +vi.mock("@/lib/process-type", () => ({ + shouldRunWeb: () => true, +})); + +import { proxy } from "../proxy"; + +beforeEach(() => { + vi.stubEnv("NODE_ENV", "production"); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +function makeRequest(pathname: string): NextRequest { + return new NextRequest(`http://localhost${pathname}`, { + headers: { cookie: "healthlog_session=sess-1" }, + }); +} + +/** The pages that server-prefetch and dehydrate a record into the HTML. */ +const PREFETCHING_PAGES = [ + "/", + "/coach", + "/insights", + "/insights/workouts", + "/medications", +]; + +/** Other session-gated surfaces that render account data. */ +const OTHER_GATED_PAGES = [ + "/documents", + "/settings/profile", + "/admin/users", + "/mood", +]; + +describe("proxy.ts no-store on session-gated pages", () => { + it.each(PREFETCHING_PAGES)( + "sets private, no-store on the prefetching page %s", + (path) => { + const res = proxy(makeRequest(path)); + const cacheControl = res.headers.get("cache-control") ?? ""; + expect(cacheControl).toContain("no-store"); + expect(cacheControl).toContain("private"); + }, + ); + + it.each(OTHER_GATED_PAGES)( + "sets private, no-store on the gated page %s", + (path) => { + const res = proxy(makeRequest(path)); + const cacheControl = res.headers.get("cache-control") ?? ""; + expect(cacheControl).toContain("no-store"); + expect(cacheControl).toContain("private"); + }, + ); + + it("still emits no-store when the request carries no session cookie", () => { + // The unauthenticated caller gets a redirect to /auth/login, which never + // reaches the header block — assert the redirect rather than a header, so + // the test states what actually happens on that arm. + const res = proxy(new NextRequest("http://localhost/medications")); + expect(res.status).toBe(307); + expect(res.headers.get("location")).toContain("/auth/login"); + }); + + it("leaves the public legal + auth surfaces cacheable", () => { + for (const path of ["/privacy", "/about", "/auth/login"]) { + const res = proxy(makeRequest(path)); + expect(res.headers.get("cache-control"), path).toBeNull(); + } + }); + + it("leaves the immutable locale catalog cacheable", () => { + const res = proxy(makeRequest("/i18n/en")); + expect(res.headers.get("cache-control")).toBeNull(); + }); + + it("does not blanket API routes, which manage their own caching", () => { + const res = proxy(makeRequest("/api/user/avatar/user-1")); + expect(res.headers.get("cache-control")).toBeNull(); + }); + + it("keeps the stricter share-link posture on /c/", () => { + const token = "hls_000000000000000000000000000000000000000000000000"; + const res = proxy(makeRequest(`/c/${token}`)); + const cacheControl = res.headers.get("cache-control") ?? ""; + expect(cacheControl).toContain("no-store"); + expect(cacheControl).toContain("must-revalidate"); + }); +}); diff --git a/src/proxy.ts b/src/proxy.ts index e1ede731a..f8712927d 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -245,7 +245,11 @@ export function proxy(request: NextRequest) { const isApiRoute = pathname.startsWith("/api/"); const isStaticFile = /\.\w+$/.test(pathname); const isPublic = isPublicPath(pathname); - if (!isApiRoute && !isStaticFile && !isPublic) { + // Every page behind the session gate. The same predicate drives the + // auth redirect below and the `no-store` header further down, so a page + // can never be session-gated without also being uncacheable. + const isSessionGatedPage = !isApiRoute && !isStaticFile && !isPublic; + if (isSessionGatedPage) { const hasSession = request.cookies.has("healthlog_session"); if (!hasSession) { return NextResponse.redirect(new URL("/auth/login", request.url)); @@ -335,6 +339,27 @@ export function proxy(request: NextRequest) { // Both serve routes take the narrow, document-only header posture. const isServeRoute = isDocumentServeRoute || isShareDocumentServeRoute; + // Session-gated pages carry one account's health record in the document + // itself. Several of them server-prefetch their data and serialise it into + // the HTML through a TanStack `HydrationBoundary` — the dashboard, Insights, + // the workouts list, the medications list, the Coach. Those pages are + // dynamic today only as a side effect of reading the session cookie, which + // is a property of the page code, not a guarantee: a stray `revalidate` + // export, a `force-static` segment, or a caching proxy in front of the app + // would be enough to retain one account's record and hand it to the next + // caller. Pin the guarantee at the edge instead, where no page-level change + // can weaken it. + // + // `private` bars shared caches (CDN, reverse proxy) outright; `no-store` + // additionally stops the browser's own disk cache and the bfcache snapshot, + // so a back-navigation after logout cannot repaint the record. The public + // surfaces (`/auth/*`, `/privacy`, `/about`, `/i18n/*`) are excluded by the + // same predicate that governs the auth redirect, so the statically + // rendered marketing/legal pages stay cacheable. + if (isSessionGatedPage) { + response.headers.set("Cache-Control", "private, no-store"); + } + // Security headers response.headers.set("X-Content-Type-Options", "nosniff"); response.headers.set("X-Frame-Options", isServeRoute ? "SAMEORIGIN" : "DENY"); From b00187e6592dd3b03ee4b40c1e3bfdd4d826438d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 11:15:17 +0200 Subject: [PATCH 2/2] docs(api): correct the drifted request/response contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field-by-field comparison of the handlers against the registered Zod schemas across measurements, workouts, medications, daily and auth. The spec was corrected to match the handlers; no runtime behaviour changed. Measurements: - GET /api/measurements returns `{ measurements, meta: { total, limit, offset, … } }` on all seven of its modes. The spec claimed a top-level `total` that no arm has ever emitted. - The list rows are stored rows minus the notes ciphertext, or synthesised day-sum / sleep-segment rows, or pre-folded aggregate buckets. The resource declared seven fields; the rest are now on the wire contract. - POST accepts a bare ARRAY body (the combined blood-pressure write) and answers with an array, and returns 409 on the dedup index. - GET/PUT/DELETE /api/measurements/{id} carried no path entry at all. Workouts: both GET operations run the module gate and answer 403 `module.disabled`; the reusable block existed but was never spread. The batch ingest stays deliberately ungated and undocumented for it. Medications: `nextDueAt` / `nextDueOverdue` are computed by the read paths and absent from the create/update results, so they cannot be required on the shared shape (the list row keeps them required). `stockUnitsRemaining` sums a Decimal(12,4) and is fractional, not an integer. The cadence chips object documented four fields that do not exist and missed the five that do. `IntakeSource` admits APPLE_HEALTH. Auth: the token bundle had its optionality inverted — `user` is always returned, the token pair only for native callers. Adds the undocumented 403 `oidc_only`, the passkey 403/404, the TOTP enrolment 409s, the attestation 400, the no-key-registered 409, the security-key 404s, the refresh `revoke` response shape, and the path/query parameters three operations read but never declared. Daily was checked and is accurate; so are the batch ingest, series, series-batch, bulk-delete, restore and by-external-ids routes. --- docs/api/openapi.yaml | 1018 ++++++++++++++--- .../measurement-contract-drift.test.ts | 249 ++++ .../medication-auth-contract-drift.test.ts | 180 +++ src/lib/openapi/routes/auth.ts | 78 +- src/lib/openapi/routes/measurements.ts | 248 +++- src/lib/openapi/routes/medications/schemas.ts | 47 +- src/lib/openapi/routes/workouts.ts | 9 +- 7 files changed, 1641 insertions(+), 188 deletions(-) create mode 100644 src/lib/openapi/__tests__/measurement-contract-drift.test.ts create mode 100644 src/lib/openapi/__tests__/medication-auth-contract-drift.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index fa0e0a551..44c3640fe 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1535,6 +1535,13 @@ paths: - $ref: "#/components/schemas/LoginResponse" - $ref: "#/components/schemas/MfaRequiredResponse" "401": *a1 + "403": + description: Password login is disabled — the operator runs `OIDC_ONLY=true`. `meta.errorCode` = `oidc_only`; sign in + through SSO instead. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 /api/auth/mfa/verify: @@ -1609,6 +1616,12 @@ paths: schema: $ref: "#/components/schemas/TotpSetupEnvelope" "401": *a1 + "409": + description: A second factor is already active on this account. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 /api/auth/me/mfa/totp/confirm: @@ -1638,6 +1651,12 @@ paths: schema: $ref: "#/components/schemas/TotpConfirmEnvelope" "401": *a1 + "409": + description: A second factor is already active, or enrollment was never started (no pending secret to confirm). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 /api/auth/me/mfa/disable: @@ -1733,6 +1752,12 @@ paths: application/json: schema: $ref: "#/components/schemas/MfaWebauthnRegisterVerifyEnvelope" + "400": + description: Security key verification failed (bad attestation). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 "422": *a3 "429": *a4 @@ -1741,6 +1766,12 @@ paths: tags: - Auth summary: Rename a registered security key + parameters: + - in: path + name: id + schema: + type: string + required: true requestBody: required: true content: @@ -1755,6 +1786,12 @@ paths: schema: $ref: "#/components/schemas/MfaWebauthnRenameEnvelope" "401": *a1 + "404": + description: No such security key for this account. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 delete: @@ -1762,6 +1799,12 @@ paths: - Auth summary: Remove a registered security key (step-up gated) description: Requires a fresh second-factor step-up (cookie session). Bearer can never satisfy the gate. + parameters: + - in: path + name: id + schema: + type: string + required: true responses: "200": description: Security key removed. @@ -1770,6 +1813,12 @@ paths: schema: $ref: "#/components/schemas/MfaWebauthnRemoveEnvelope" "401": *a1 + "404": + description: No such security key for this account. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 /api/auth/mfa/webauthn/verify/options: @@ -1793,6 +1842,12 @@ paths: schema: $ref: "#/components/schemas/MfaWebauthnLoginOptionsEnvelope" "401": *a1 + "409": + description: No security key is registered on the account — fall back to the TOTP / recovery-code challenge. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 /api/auth/mfa/webauthn/verify: @@ -1838,6 +1893,18 @@ paths: schema: $ref: "#/components/schemas/PasskeyLoginVerifyResponse" "401": *a1 + "403": + description: Passkey login is disabled — the operator runs `OIDC_ONLY=true`. `meta.errorCode` = `oidc_only`. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "404": + description: The assertion resolved to a user row that no longer exists. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 /api/auth/refresh: @@ -1863,11 +1930,14 @@ paths: $ref: "#/components/schemas/RefreshTokenRequest" responses: "200": - description: Rotation succeeded — new pair issued. + description: "Rotation succeeded — new pair issued. When the request carried `revoke: true` the body is `{ revoked }` + instead: the token family is invalidated and no new pair is minted." content: application/json: schema: - $ref: "#/components/schemas/RefreshResponse" + anyOf: + - $ref: "#/components/schemas/RefreshResponse" + - $ref: "#/components/schemas/RefreshRevokeResponse" "401": *a1 "422": *a3 "429": *a4 @@ -1912,6 +1982,12 @@ paths: summary: Revoke a single web session description: v1.23 — revokes one session by id, scoped to the authenticated user (a foreign id returns 404, never another user's row). + parameters: + - in: path + name: id + schema: + type: string + required: true responses: "200": description: Session revoked. @@ -1966,6 +2042,12 @@ paths: - Auth summary: Revoke a single trusted device description: v1.23 — revokes one trusted device by id, scoped to the authenticated user (a foreign id returns 404). + parameters: + - in: path + name: id + schema: + type: string + required: true responses: "200": description: Trusted device revoked. @@ -1990,6 +2072,15 @@ paths: description: "v1.23 — the SHARED security-activity feed: the caller's recent auth + export + deletion audit events with timestamp, resolved location, and a host-masked IP. `limit` query param caps at 100 (default 50). Reuses the AuditLog store; no event detail bodies are surfaced." + parameters: + - in: query + name: limit + schema: + description: Page size; defaults to 50, clamped to 100. + type: integer + minimum: 1 + maximum: 100 + description: Page size; defaults to 50, clamped to 100. responses: "200": description: Recent security events for the caller. @@ -2354,8 +2445,308 @@ paths: post: tags: - Measurements - summary: Create one measurement - description: Single ingest. Use `/api/measurements/batch` for Apple Health upload streams. + summary: Create one measurement (or a small array) + description: Single ingest. The body may also be a bare ARRAY of the same objects — the mode the iOS client uses for a + combined blood-pressure + pulse or dual-value glucose write — in which case `data` is the array of created rows + in request order. Use `/api/measurements/batch` for Apple Health upload streams. + requestBody: + required: true + content: + application/json: + schema: + anyOf: + - type: object + properties: + type: + type: string + enum: + - WEIGHT + - BLOOD_PRESSURE_SYS + - BLOOD_PRESSURE_DIA + - PULSE + - BODY_FAT + - SLEEP_DURATION + - ACTIVITY_STEPS + - BLOOD_GLUCOSE + - TOTAL_BODY_WATER + - BONE_MASS + - OXYGEN_SATURATION + - HEART_RATE_VARIABILITY + - RESTING_HEART_RATE + - ACTIVE_ENERGY_BURNED + - FLIGHTS_CLIMBED + - WALKING_RUNNING_DISTANCE + - VO2_MAX + - BODY_TEMPERATURE + - FAT_FREE_MASS + - FAT_MASS + - MUSCLE_MASS + - SKIN_TEMPERATURE + - PULSE_WAVE_VELOCITY + - VASCULAR_AGE + - VISCERAL_FAT + - AUDIO_EXPOSURE_ENV + - AUDIO_EXPOSURE_HEADPHONE + - TIME_IN_DAYLIGHT + - WALKING_STEADINESS + - AUDIO_EXPOSURE_EVENT + - RESPIRATORY_RATE + - BODY_MASS_INDEX + - LEAN_BODY_MASS + - WALKING_HEART_RATE_AVERAGE + - WALKING_ASYMMETRY + - WALKING_DOUBLE_SUPPORT + - WALKING_STEP_LENGTH + - WALKING_SPEED + - CARDIO_RECOVERY + - WRIST_TEMPERATURE + - FALL_COUNT + - SIX_MINUTE_WALK_DISTANCE + - STAIR_ASCENT_SPEED + - STAIR_DESCENT_SPEED + - BREATHING_DISTURBANCES + - IRREGULAR_RHYTHM_NOTIFICATION + - HIGH_HEART_RATE_EVENT + - LOW_HEART_RATE_EVENT + - WALKING_STEADINESS_EVENT + - BREATHING_DISTURBANCE_EVENT + - RECOVERY_SCORE + - STRESS_SCORE + - STRAIN_SCORE + - HRV_RMSSD + - DAY_STRAIN + - WORKOUT_STRAIN + - SLEEP_PERFORMANCE + - SLEEP_EFFICIENCY + - SLEEP_CONSISTENCY + - SLEEP_NEED + - ENERGY_EXPENDITURE_KJ + - AVERAGE_HEART_RATE + - MAX_HEART_RATE + - SLEEP_DISTURBANCE_COUNT + - ANS_CHARGE + - CARDIO_LOAD + - SLEEP_SCORE + - BODY_TEMPERATURE_DEVIATION + - RESILIENCE + - PHQ9_SCORE + - GAD7_SCORE + - GRIP_STRENGTH + - PAIN_NRS + - WAIST_CIRCUMFERENCE + - WAIST_TO_HEIGHT + - WHO5_SCORE + - SCI_SCORE + value: + type: number + measuredAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + notes: + type: string + maxLength: 200 + source: + default: MANUAL + type: string + enum: + - MANUAL + - APPLE_HEALTH + glucoseContext: + type: string + enum: + - FASTING + - POSTPRANDIAL + - RANDOM + - BEDTIME + deviceType: + anyOf: + - type: string + minLength: 1 + maxLength: 32 + - type: "null" + symptomsPresent: + type: boolean + required: + - type + - value + - measuredAt + - type: array + items: + type: object + properties: + type: + type: string + enum: + - WEIGHT + - BLOOD_PRESSURE_SYS + - BLOOD_PRESSURE_DIA + - PULSE + - BODY_FAT + - SLEEP_DURATION + - ACTIVITY_STEPS + - BLOOD_GLUCOSE + - TOTAL_BODY_WATER + - BONE_MASS + - OXYGEN_SATURATION + - HEART_RATE_VARIABILITY + - RESTING_HEART_RATE + - ACTIVE_ENERGY_BURNED + - FLIGHTS_CLIMBED + - WALKING_RUNNING_DISTANCE + - VO2_MAX + - BODY_TEMPERATURE + - FAT_FREE_MASS + - FAT_MASS + - MUSCLE_MASS + - SKIN_TEMPERATURE + - PULSE_WAVE_VELOCITY + - VASCULAR_AGE + - VISCERAL_FAT + - AUDIO_EXPOSURE_ENV + - AUDIO_EXPOSURE_HEADPHONE + - TIME_IN_DAYLIGHT + - WALKING_STEADINESS + - AUDIO_EXPOSURE_EVENT + - RESPIRATORY_RATE + - BODY_MASS_INDEX + - LEAN_BODY_MASS + - WALKING_HEART_RATE_AVERAGE + - WALKING_ASYMMETRY + - WALKING_DOUBLE_SUPPORT + - WALKING_STEP_LENGTH + - WALKING_SPEED + - CARDIO_RECOVERY + - WRIST_TEMPERATURE + - FALL_COUNT + - SIX_MINUTE_WALK_DISTANCE + - STAIR_ASCENT_SPEED + - STAIR_DESCENT_SPEED + - BREATHING_DISTURBANCES + - IRREGULAR_RHYTHM_NOTIFICATION + - HIGH_HEART_RATE_EVENT + - LOW_HEART_RATE_EVENT + - WALKING_STEADINESS_EVENT + - BREATHING_DISTURBANCE_EVENT + - RECOVERY_SCORE + - STRESS_SCORE + - STRAIN_SCORE + - HRV_RMSSD + - DAY_STRAIN + - WORKOUT_STRAIN + - SLEEP_PERFORMANCE + - SLEEP_EFFICIENCY + - SLEEP_CONSISTENCY + - SLEEP_NEED + - ENERGY_EXPENDITURE_KJ + - AVERAGE_HEART_RATE + - MAX_HEART_RATE + - SLEEP_DISTURBANCE_COUNT + - ANS_CHARGE + - CARDIO_LOAD + - SLEEP_SCORE + - BODY_TEMPERATURE_DEVIATION + - RESILIENCE + - PHQ9_SCORE + - GAD7_SCORE + - GRIP_STRENGTH + - PAIN_NRS + - WAIST_CIRCUMFERENCE + - WAIST_TO_HEIGHT + - WHO5_SCORE + - SCI_SCORE + value: + type: number + measuredAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + notes: + type: string + maxLength: 200 + source: + default: MANUAL + type: string + enum: + - MANUAL + - APPLE_HEALTH + glucoseContext: + type: string + enum: + - FASTING + - POSTPRANDIAL + - RANDOM + - BEDTIME + deviceType: + anyOf: + - type: string + minLength: 1 + maxLength: 32 + - type: "null" + symptomsPresent: + type: boolean + required: + - type + - value + - measuredAt + responses: + "201": + description: Created. A single resource for the object body; an array of resources, in request order, for the array body. + content: + application/json: + schema: + $ref: "#/components/schemas/CreateMeasurementResponse" + "401": *a1 + "409": + description: A measurement with this data already exists — the `(userId, type, source, externalId)` dedup index rejected + the write. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + /api/measurements/{id}: + get: + tags: + - Measurements + summary: Measurement detail + description: Single stored row, scoped to the caller. A row owned by another user surfaces as 404 (existence channel sealed). + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: Measurement. + content: + application/json: + schema: + $ref: "#/components/schemas/GetMeasurementResponse" + "401": *a1 + "404": + description: Measurement not found (or owned by another user). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + put: + tags: + - Measurements + summary: Update a measurement + description: Edits a manually entered row. Rows owned by a connected source are refused with 409 + `measurement.update.server_owned_source`; a timestamp collision with an existing row is refused with 409 + `measurement.duplicate_timestamp`. + parameters: + - in: path + name: id + schema: + type: string + required: true requestBody: required: true content: @@ -2363,86 +2754,6 @@ paths: schema: type: object properties: - type: - type: string - enum: - - WEIGHT - - BLOOD_PRESSURE_SYS - - BLOOD_PRESSURE_DIA - - PULSE - - BODY_FAT - - SLEEP_DURATION - - ACTIVITY_STEPS - - BLOOD_GLUCOSE - - TOTAL_BODY_WATER - - BONE_MASS - - OXYGEN_SATURATION - - HEART_RATE_VARIABILITY - - RESTING_HEART_RATE - - ACTIVE_ENERGY_BURNED - - FLIGHTS_CLIMBED - - WALKING_RUNNING_DISTANCE - - VO2_MAX - - BODY_TEMPERATURE - - FAT_FREE_MASS - - FAT_MASS - - MUSCLE_MASS - - SKIN_TEMPERATURE - - PULSE_WAVE_VELOCITY - - VASCULAR_AGE - - VISCERAL_FAT - - AUDIO_EXPOSURE_ENV - - AUDIO_EXPOSURE_HEADPHONE - - TIME_IN_DAYLIGHT - - WALKING_STEADINESS - - AUDIO_EXPOSURE_EVENT - - RESPIRATORY_RATE - - BODY_MASS_INDEX - - LEAN_BODY_MASS - - WALKING_HEART_RATE_AVERAGE - - WALKING_ASYMMETRY - - WALKING_DOUBLE_SUPPORT - - WALKING_STEP_LENGTH - - WALKING_SPEED - - CARDIO_RECOVERY - - WRIST_TEMPERATURE - - FALL_COUNT - - SIX_MINUTE_WALK_DISTANCE - - STAIR_ASCENT_SPEED - - STAIR_DESCENT_SPEED - - BREATHING_DISTURBANCES - - IRREGULAR_RHYTHM_NOTIFICATION - - HIGH_HEART_RATE_EVENT - - LOW_HEART_RATE_EVENT - - WALKING_STEADINESS_EVENT - - BREATHING_DISTURBANCE_EVENT - - RECOVERY_SCORE - - STRESS_SCORE - - STRAIN_SCORE - - HRV_RMSSD - - DAY_STRAIN - - WORKOUT_STRAIN - - SLEEP_PERFORMANCE - - SLEEP_EFFICIENCY - - SLEEP_CONSISTENCY - - SLEEP_NEED - - ENERGY_EXPENDITURE_KJ - - AVERAGE_HEART_RATE - - MAX_HEART_RATE - - SLEEP_DISTURBANCE_COUNT - - ANS_CHARGE - - CARDIO_LOAD - - SLEEP_SCORE - - BODY_TEMPERATURE_DEVIATION - - RESILIENCE - - PHQ9_SCORE - - GAD7_SCORE - - GRIP_STRENGTH - - PAIN_NRS - - WAIST_CIRCUMFERENCE - - WAIST_TO_HEIGHT - - WHO5_SCORE - - SCI_SCORE value: type: number measuredAt: @@ -2450,41 +2761,59 @@ paths: format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ notes: - type: string - maxLength: 200 - source: - default: MANUAL - type: string - enum: - - MANUAL - - APPLE_HEALTH - glucoseContext: - type: string - enum: - - FASTING - - POSTPRANDIAL - - RANDOM - - BEDTIME - deviceType: anyOf: - type: string - minLength: 1 - maxLength: 32 + maxLength: 200 - type: "null" - symptomsPresent: - type: boolean - required: - - type - - value - - measuredAt responses: - "201": - description: Created. + "200": + description: Updated measurement. content: application/json: schema: - $ref: "#/components/schemas/CreateMeasurementResponse" + $ref: "#/components/schemas/UpdateMeasurementResponse" "401": *a1 + "404": + description: Measurement not found (or owned by another user). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "409": + description: "Refused: the row comes from a connected source (`meta.errorCode` = + `measurement.update.server_owned_source`), or another row already carries this timestamp + (`measurement.duplicate_timestamp`)." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + delete: + tags: + - Measurements + summary: Delete a measurement + description: Soft-deletes the row (tombstone) so paired clients can reconcile the deletion. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: Deleted. + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteMeasurementResponse" + "401": *a1 + "404": + description: Measurement not found (or owned by another user). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 /api/measurements/batch: @@ -2692,6 +3021,7 @@ paths: schema: $ref: "#/components/schemas/ListWorkoutsResponse" "401": *a1 + "403": *a5 "422": *a3 "429": *a4 /api/workouts/{id}: @@ -2725,6 +3055,7 @@ paths: schema: $ref: "#/components/schemas/GetWorkoutResponse" "401": *a1 + "403": *a5 "404": description: Workout not found (or owned by another user). content: @@ -14850,8 +15181,9 @@ components: - username additionalProperties: false token: + description: Access token (`hlk_<64hex>`); only present for native-policy callers. A browser caller gets the session + cookie instead. type: string - description: Access token (`hlk_<64hex>`). tokenExpiresAt: type: string format: date-time @@ -14864,8 +15196,7 @@ components: format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - - token - - tokenExpiresAt + - user additionalProperties: false description: Native-client token bundle returned by login + refresh. Web cookie-only callers see only `user`. MfaRequiredResponse: @@ -15274,6 +15605,29 @@ components: - data - error additionalProperties: false + RefreshRevokeResponse: + type: object + properties: + data: + type: object + properties: + revoked: + type: boolean + required: + - revoked + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false SessionListEnvelope: type: object properties: @@ -15786,6 +16140,8 @@ components: properties: id: type: string + description: Stored row id, or a synthetic key (`day::`, `sleep-seg::`) on the collapsed list + modes. type: type: string enum: @@ -15895,20 +16251,76 @@ components: anyOf: - type: string - type: "null" + userId: + type: string + valueMin: + description: Bucket MINIMUM — set only on an hourly heart-rate `stats:` row. + anyOf: + - type: number + - type: "null" + valueMax: + description: Bucket MAXIMUM — set only on an hourly heart-rate `stats:` row. + anyOf: + - type: number + - type: "null" externalId: anyOf: - type: string - type: "null" description: Cross-device dedup key (UUID string or `stats::`). + externalSourceVersion: + anyOf: + - type: string + - type: "null" + glucoseContext: + description: Set only on BLOOD_GLUCOSE rows. + anyOf: + - type: string + - type: "null" + sleepStage: + description: Set only on SLEEP_DURATION rows. + anyOf: + - type: string + - type: "null" + rhythmClassification: + description: Device classification verdict; set only on EVENT rows. + anyOf: + - type: string + - type: "null" + deviceType: + description: "`watch | band | ring | phone | scale | other | unknown`." + anyOf: + - type: string + - type: "null" syncVersion: type: integer exclusiveMinimum: 0 maximum: 9007199254740991 description: LWW reconciliation counter; echo to keep the mirror monotonic. + deletedAt: + description: Soft-delete tombstone; null on a live row. + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + createdAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ updatedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + dayKey: + type: string + sampleCount: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + partial: + description: True on the one bucket whose day was cut off by the read cap, so its sum understates the real total. + type: boolean required: - id - type @@ -16518,14 +16930,14 @@ components: measurements: type: array items: - $ref: "#/components/schemas/MeasurementResource" - total: - type: integer - minimum: 0 - maximum: 9007199254740991 + anyOf: + - $ref: "#/components/schemas/MeasurementResource" + - $ref: "#/components/schemas/AggregatedMeasurementBucket" + meta: + $ref: "#/components/schemas/ListMeasurementsMeta" required: - measurements - - total + - meta additionalProperties: false error: type: "null" @@ -16544,6 +16956,8 @@ components: properties: id: type: string + description: Stored row id, or a synthetic key (`day::`, `sleep-seg::`) on the collapsed list + modes. type: type: string enum: @@ -16653,6 +17067,76 @@ components: anyOf: - type: string - type: "null" + userId: + type: string + valueMin: + description: Bucket MINIMUM — set only on an hourly heart-rate `stats:` row. + anyOf: + - type: number + - type: "null" + valueMax: + description: Bucket MAXIMUM — set only on an hourly heart-rate `stats:` row. + anyOf: + - type: number + - type: "null" + externalId: + description: External-system dedup key; null for manual entries. + anyOf: + - type: string + - type: "null" + externalSourceVersion: + anyOf: + - type: string + - type: "null" + glucoseContext: + description: Set only on BLOOD_GLUCOSE rows. + anyOf: + - type: string + - type: "null" + sleepStage: + description: Set only on SLEEP_DURATION rows. + anyOf: + - type: string + - type: "null" + rhythmClassification: + description: Device classification verdict; set only on EVENT rows. + anyOf: + - type: string + - type: "null" + deviceType: + description: "`watch | band | ring | phone | scale | other | unknown`." + anyOf: + - type: string + - type: "null" + syncVersion: + description: Bumped on every server-side mutation; the last-writer-wins input for cross-device reconciliation. + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + deletedAt: + description: Soft-delete tombstone; null on a live row. + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + createdAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + updatedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + dayKey: + type: string + sampleCount: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + partial: + description: True on the one bucket whose day was cut off by the read cap, so its sum understates the real total. + type: boolean required: - id - type @@ -16661,8 +17145,171 @@ components: - measuredAt - source additionalProperties: false - description: Server-shaped measurement row returned by GET endpoints. + description: Server-shaped measurement row. GET endpoints return the whole stored row with the decrypted note on `notes` + and the `notesEncrypted` ciphertext stripped; the collapsed list modes synthesise rows carrying the display + fields plus `dayKey` / `sampleCount`. + AggregatedMeasurementBucket: + type: object + properties: + type: + type: string + enum: + - WEIGHT + - BLOOD_PRESSURE_SYS + - BLOOD_PRESSURE_DIA + - PULSE + - BODY_FAT + - SLEEP_DURATION + - ACTIVITY_STEPS + - BLOOD_GLUCOSE + - TOTAL_BODY_WATER + - BONE_MASS + - OXYGEN_SATURATION + - HEART_RATE_VARIABILITY + - RESTING_HEART_RATE + - ACTIVE_ENERGY_BURNED + - FLIGHTS_CLIMBED + - WALKING_RUNNING_DISTANCE + - VO2_MAX + - BODY_TEMPERATURE + - FAT_FREE_MASS + - FAT_MASS + - MUSCLE_MASS + - SKIN_TEMPERATURE + - PULSE_WAVE_VELOCITY + - VASCULAR_AGE + - VISCERAL_FAT + - AUDIO_EXPOSURE_ENV + - AUDIO_EXPOSURE_HEADPHONE + - TIME_IN_DAYLIGHT + - WALKING_STEADINESS + - AUDIO_EXPOSURE_EVENT + - RESPIRATORY_RATE + - BODY_MASS_INDEX + - LEAN_BODY_MASS + - WALKING_HEART_RATE_AVERAGE + - WALKING_ASYMMETRY + - WALKING_DOUBLE_SUPPORT + - WALKING_STEP_LENGTH + - WALKING_SPEED + - CARDIO_RECOVERY + - WRIST_TEMPERATURE + - FALL_COUNT + - SIX_MINUTE_WALK_DISTANCE + - STAIR_ASCENT_SPEED + - STAIR_DESCENT_SPEED + - BREATHING_DISTURBANCES + - IRREGULAR_RHYTHM_NOTIFICATION + - HIGH_HEART_RATE_EVENT + - LOW_HEART_RATE_EVENT + - WALKING_STEADINESS_EVENT + - BREATHING_DISTURBANCE_EVENT + - RECOVERY_SCORE + - STRESS_SCORE + - STRAIN_SCORE + - HRV_RMSSD + - DAY_STRAIN + - WORKOUT_STRAIN + - SLEEP_PERFORMANCE + - SLEEP_EFFICIENCY + - SLEEP_CONSISTENCY + - SLEEP_NEED + - ENERGY_EXPENDITURE_KJ + - AVERAGE_HEART_RATE + - MAX_HEART_RATE + - SLEEP_DISTURBANCE_COUNT + - ANS_CHARGE + - CARDIO_LOAD + - SLEEP_SCORE + - BODY_TEMPERATURE_DEVIATION + - RESILIENCE + - PHQ9_SCORE + - GAD7_SCORE + - GRIP_STRENGTH + - PAIN_NRS + - WAIST_CIRCUMFERENCE + - WAIST_TO_HEIGHT + - WHO5_SCORE + - SCI_SCORE + value: + type: number + description: The bucket's aggregated value. + measuredAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + description: Bucket start. + count: + type: integer + minimum: 0 + maximum: 9007199254740991 + unit: + type: string + required: + - type + - value + - measuredAt + additionalProperties: false + description: Pre-folded bucket returned by the `aggregate=` list modes instead of a stored row. + ListMeasurementsMeta: + type: object + properties: + total: + type: integer + minimum: 0 + maximum: 9007199254740991 + limit: + type: integer + minimum: 0 + maximum: 9007199254740991 + offset: + type: integer + minimum: 0 + maximum: 9007199254740991 + dayKey: + description: Echoed on the per-sample drill-down modes. + type: string + groupBy: + description: Set on the collapsed day-sum / per-night modes. + type: string + enum: + - day + - night + aggregate: + description: Set on the aggregate modes — the requested grain. + type: string + droppedDuplicates: + description: Rows discarded by the cross-source canonical picker on the day-collapse mode. + type: integer + minimum: 0 + maximum: 9007199254740991 + required: + - total + - limit + - offset + additionalProperties: false CreateMeasurementResponse: + type: object + properties: + data: + anyOf: + - $ref: "#/components/schemas/MeasurementResource" + - type: array + items: + $ref: "#/components/schemas/MeasurementResource" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + GetMeasurementResponse: type: object properties: data: @@ -16679,6 +17326,47 @@ components: - data - error additionalProperties: false + UpdateMeasurementResponse: + type: object + properties: + data: + $ref: "#/components/schemas/MeasurementResource" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DeleteMeasurementResponse: + type: object + properties: + data: + type: object + properties: + deleted: + type: boolean + const: true + required: + - deleted + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false BatchMeasurementsResponse: type: object properties: @@ -18134,17 +18822,8 @@ components: format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" - description: "v1.7.0 server-computed next due instant across all the medication's schedules (earliest - `nextOccurrenceAfter`). Read-only — computed, not stored. NULL when no schedule has an upcoming slot - (paused, one-shot in the past, `endsOn` crossed, every schedule PRN). v1.16.4 — when an unresolved slot's - anchor has passed but the catch-up band is still open (`anchor < now <= overdueEnd`, current schedule era), - this carries THAT slot (a past instant) with `nextDueOverdue: true` instead of jumping to the next future - slot. The list GET is cached 60 s, so a 60 s staleness is accepted." nextDueOverdue: type: boolean - description: "v1.16.4 — true when `nextDueAt` is an OPEN overdue slot: its anchor has passed, `now` is still inside the - slot's catch-up band, and no taken / skipped / auto-missed row resolves it. False for a regular future - next-due (and when `nextDueAt` is null). Read-only — computed, not stored." startsOn: anyOf: - type: string @@ -18201,9 +18880,7 @@ components: against the passed-dose count, and a pending mint must not read as covered." stockUnitsRemaining: anyOf: - - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 + - type: number - type: "null" description: v1.16.10 — usable inventory units left across the medication's containers (sum of `unitsRemaining` over ACTIVE / IN_USE items with units left). NULL = inventory tracking off (no items ever registered); 0 = @@ -18357,22 +19034,24 @@ components: pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" nextDueAt: + description: "Present on the READ paths only — the list and single GET compute it; the create / update responses return + the stored row and omit it. v1.7.0 server-computed next due instant across all the medication's schedules + (earliest `nextOccurrenceAfter`). Read-only — computed, not stored. NULL when no schedule has an upcoming + slot (paused, one-shot in the past, `endsOn` crossed, every schedule PRN). v1.16.4 — when an unresolved + slot's anchor has passed but the catch-up band is still open (`anchor < now <= overdueEnd`, current schedule + era), this carries THAT slot (a past instant) with `nextDueOverdue: true` instead of jumping to the next + future slot. The list GET is cached 60 s, so a 60 s staleness is accepted." anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" - description: "v1.7.0 server-computed next due instant across all the medication's schedules (earliest - `nextOccurrenceAfter`). Read-only — computed, not stored. NULL when no schedule has an upcoming slot - (paused, one-shot in the past, `endsOn` crossed, every schedule PRN). v1.16.4 — when an unresolved slot's - anchor has passed but the catch-up band is still open (`anchor < now <= overdueEnd`, current schedule era), - this carries THAT slot (a past instant) with `nextDueOverdue: true` instead of jumping to the next future - slot. The list GET is cached 60 s, so a 60 s staleness is accepted." nextDueOverdue: + description: "Present on the READ paths only, alongside `nextDueAt`. v1.16.4 — true when `nextDueAt` is an OPEN overdue + slot: its anchor has passed, `now` is still inside the slot's catch-up band, and no taken / skipped / + auto-missed row resolves it. False for a regular future next-due (and when `nextDueAt` is null). Read-only — + computed, not stored." type: boolean - description: "v1.16.4 — true when `nextDueAt` is an OPEN overdue slot: its anchor has passed, `now` is still inside the - slot's catch-up band, and no taken / skipped / auto-missed row resolves it. False for a regular future - next-due (and when `nextDueAt` is null). Read-only — computed, not stored." startsOn: anyOf: - type: string @@ -18428,8 +19107,6 @@ components: - rxNormCode - pausedAt - snoozedUntil - - nextDueAt - - nextDueOverdue - startsOn - endsOn - oneShot @@ -18594,6 +19271,7 @@ components: - API - REMINDER - IMPORT + - APPLE_HEALTH idempotencyKey: anyOf: - type: string @@ -19032,26 +19710,40 @@ components: type: object properties: adherenceRate: - type: number - streakDays: + anyOf: + - type: number + - type: "null" + description: 0-100, taken / (taken + missed). Skipped doses are excluded from the denominator — a deliberate decision, + not a compliance failure. NULL when no dose was expected in the window (brand-new medication, paused). + currentStreak: type: integer minimum: 0 maximum: 9007199254740991 - expectedSlots: + description: Consecutive days ending at `asOf` where every expected dose was taken or skipped. Days with no expected + dose advance the streak; missed days break it. + longestStreak: type: integer minimum: 0 maximum: 9007199254740991 - actualDoses: + description: Longest all-taken-or-skipped run anywhere in the window. + missedLast30: type: integer minimum: 0 maximum: 9007199254740991 + description: Count of missed doses inside the window. + windowDays: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Window size used — mirrors the input for the chart legend. required: - adherenceRate - - streakDays - - expectedSlots - - actualDoses + - currentStreak + - longestStreak + - missedLast30 + - windowDays additionalProperties: false - description: Four compliance summary values for the medication detail page chip row. + description: Compliance summary values for the medication detail page chip row. MedicationCadenceTimelinePoint: type: object properties: @@ -31135,22 +31827,24 @@ components: pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" nextDueAt: + description: "Present on the READ paths only — the list and single GET compute it; the create / update responses return + the stored row and omit it. v1.7.0 server-computed next due instant across all the medication's schedules + (earliest `nextOccurrenceAfter`). Read-only — computed, not stored. NULL when no schedule has an upcoming + slot (paused, one-shot in the past, `endsOn` crossed, every schedule PRN). v1.16.4 — when an unresolved + slot's anchor has passed but the catch-up band is still open (`anchor < now <= overdueEnd`, current schedule + era), this carries THAT slot (a past instant) with `nextDueOverdue: true` instead of jumping to the next + future slot. The list GET is cached 60 s, so a 60 s staleness is accepted." anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" - description: "v1.7.0 server-computed next due instant across all the medication's schedules (earliest - `nextOccurrenceAfter`). Read-only — computed, not stored. NULL when no schedule has an upcoming slot - (paused, one-shot in the past, `endsOn` crossed, every schedule PRN). v1.16.4 — when an unresolved slot's - anchor has passed but the catch-up band is still open (`anchor < now <= overdueEnd`, current schedule era), - this carries THAT slot (a past instant) with `nextDueOverdue: true` instead of jumping to the next future - slot. The list GET is cached 60 s, so a 60 s staleness is accepted." nextDueOverdue: + description: "Present on the READ paths only, alongside `nextDueAt`. v1.16.4 — true when `nextDueAt` is an OPEN overdue + slot: its anchor has passed, `now` is still inside the slot's catch-up band, and no taken / skipped / + auto-missed row resolves it. False for a regular future next-due (and when `nextDueAt` is null). Read-only — + computed, not stored." type: boolean - description: "v1.16.4 — true when `nextDueAt` is an OPEN overdue slot: its anchor has passed, `now` is still inside the - slot's catch-up band, and no taken / skipped / auto-missed row resolves it. False for a regular future - next-due (and when `nextDueAt` is null). Read-only — computed, not stored." startsOn: anyOf: - type: string @@ -31204,8 +31898,6 @@ components: - rxNormCode - pausedAt - snoozedUntil - - nextDueAt - - nextDueOverdue - startsOn - endsOn - oneShot diff --git a/src/lib/openapi/__tests__/measurement-contract-drift.test.ts b/src/lib/openapi/__tests__/measurement-contract-drift.test.ts new file mode 100644 index 000000000..b48088dc4 --- /dev/null +++ b/src/lib/openapi/__tests__/measurement-contract-drift.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod/v4"; + +import { measurementPaths } from "../routes/measurements"; +import { workoutPaths } from "../routes/workouts"; + +/** + * Contract guard: the documented request / response shapes must match what the + * handlers actually put on the wire. + * + * The fixtures below are transcribed from the handler return statements, not + * from the schemas — so a schema that drifts away from the handler fails here + * rather than shipping a spec the iOS client generates the wrong model from. + * + * Sources: + * - src/app/api/measurements/route.ts — the seven `apiSuccess` returns on GET + * (every one of them `{ measurements, meta: { total, limit, offset, … } }`), + * the array-body branch on POST, and the 409 duplicate arm. + * - src/app/api/workouts/route.ts + src/app/api/workouts/[id]/route.ts — + * `requireModuleEnabled(user.id, "workouts")`, which answers 403 with + * `meta.errorCode = "module.disabled"`. + */ + +/** Pull the Zod schema out of a registered response entry. */ +function responseSchema( + paths: typeof measurementPaths, + path: string, + method: "get" | "post", + status: string, +): z.ZodType { + const responses = paths[path]?.[method]?.responses as + | Record< + string, + { content?: Record } | undefined + > + | undefined; + const schema = responses?.[status]?.content?.["application/json"]?.schema; + if (!schema) { + throw new Error(`no ${method.toUpperCase()} ${path} ${status} schema`); + } + return schema as z.ZodType; +} + +function requestSchema( + paths: typeof measurementPaths, + path: string, + method: "post", +): z.ZodType { + const operation = paths[path]?.[method]; + const schema = operation?.requestBody?.content?.["application/json"]?.schema; + if (!schema) + throw new Error(`no ${method.toUpperCase()} ${path} body schema`); + return schema as z.ZodType; +} + +const listResponse = responseSchema( + measurementPaths, + "/api/measurements", + "get", + "200", +); + +describe("GET /api/measurements — documented response matches the handler", () => { + it("accepts the default paged envelope (route.ts `{ measurements, meta }`)", () => { + const wire = { + data: { + measurements: [ + { + id: "cm000000000000000000000", + userId: "cm111111111111111111111", + type: "WEIGHT", + value: 80.5, + unit: "kg", + source: "MANUAL", + measuredAt: "2026-07-19T08:00:00.000Z", + notes: null, + valueMin: null, + valueMax: null, + externalId: null, + externalSourceVersion: null, + glucoseContext: null, + sleepStage: null, + rhythmClassification: null, + deviceType: null, + syncVersion: 1, + deletedAt: null, + createdAt: "2026-07-19T08:00:00.000Z", + updatedAt: "2026-07-19T08:00:00.000Z", + }, + ], + meta: { total: 1, limit: 50, offset: 0 }, + }, + error: null, + }; + expect(listResponse.safeParse(wire).success).toBe(true); + }); + + it("rejects the old `{ measurements, total }` envelope the spec used to claim", () => { + const stale = { + data: { measurements: [], total: 0 }, + error: null, + }; + expect(listResponse.safeParse(stale).success).toBe(false); + }); + + it("accepts the collapsed day-sum mode (dayKey / sampleCount / partial)", () => { + const wire = { + data: { + measurements: [ + { + id: "day:ACTIVITY_STEPS:2026-07-18", + type: "ACTIVITY_STEPS", + value: 9000, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: "2026-07-18T12:00:00.000Z", + notes: null, + dayKey: "2026-07-18", + sampleCount: 12, + partial: true, + }, + ], + meta: { + total: 1, + limit: 50, + offset: 0, + groupBy: "day", + droppedDuplicates: 3, + }, + }, + error: null, + }; + expect(listResponse.safeParse(wire).success).toBe(true); + }); + + it("accepts the aggregate mode's pre-folded buckets", () => { + const wire = { + data: { + measurements: [ + { + type: "PULSE", + value: 62.5, + measuredAt: "2026-07-18T00:00:00.000Z", + count: 240, + }, + ], + meta: { total: 1, limit: 400, offset: 0, aggregate: "daily" }, + }, + error: null, + }; + expect(listResponse.safeParse(wire).success).toBe(true); + }); + + it("accepts the per-night collapse mode", () => { + const wire = { + data: { + measurements: [ + { + id: "sleep-seg:2026-07-18:0", + type: "SLEEP_DURATION", + value: 430, + unit: "minutes", + source: "APPLE_HEALTH", + measuredAt: "2026-07-18T06:10:00.000Z", + sleepStage: "ASLEEP_CORE", + notes: null, + }, + ], + meta: { total: 1, limit: 50, offset: 0, groupBy: "night" }, + }, + error: null, + }; + expect(listResponse.safeParse(wire).success).toBe(true); + }); +}); + +describe("POST /api/measurements — documented body + statuses match the handler", () => { + const body = requestSchema(measurementPaths, "/api/measurements", "post"); + const created = responseSchema( + measurementPaths, + "/api/measurements", + "post", + "201", + ); + + const single = { + type: "PULSE", + value: 61, + unit: "bpm", + measuredAt: "2026-07-19T08:00:00.000Z", + }; + + it("accepts the single-object body", () => { + expect(body.safeParse(single).success).toBe(true); + }); + + it("accepts the ARRAY body the handler branches on (route.ts `Array.isArray(body)`)", () => { + // The combined blood-pressure write — the reason the array branch exists. + const systolic = { + type: "BLOOD_PRESSURE_SYS", + value: 122, + unit: "mmHg", + measuredAt: "2026-07-19T08:00:00.000Z", + }; + const diastolic = { ...systolic, type: "BLOOD_PRESSURE_DIA", value: 78 }; + expect(body.safeParse([systolic, diastolic]).success).toBe(true); + }); + + it("accepts an array of created rows on 201, matching the array branch", () => { + const wire = { + data: [ + { + id: "cm000000000000000000000", + type: "PULSE", + value: 61, + unit: "bpm", + source: "MANUAL", + measuredAt: "2026-07-19T08:00:00.000Z", + notes: null, + }, + ], + error: null, + }; + expect(created.safeParse(wire).success).toBe(true); + }); + + it("documents the 409 the duplicate-dedup arm returns", () => { + const responses = measurementPaths["/api/measurements"]?.post?.responses; + expect(Object.keys(responses ?? {})).toContain("409"); + }); +}); + +describe("workouts — the module gate's 403 is documented", () => { + it.each(["/api/workouts", "/api/workouts/{id}"])( + "GET %s declares 403", + (path) => { + const responses = workoutPaths[path]?.get?.responses; + expect(Object.keys(responses ?? {})).toContain("403"); + expect(responses?.["403"]?.description).toContain("module.disabled"); + }, + ); + + it("leaves the deliberately ungated batch ingest without a module 403", () => { + // src/app/api/workouts/batch/route.ts runs no `requireModuleEnabled` — + // synced data still lands while the surface is hidden. + const responses = workoutPaths["/api/workouts/batch"]?.post?.responses; + expect(Object.keys(responses ?? {})).not.toContain("403"); + }); +}); diff --git a/src/lib/openapi/__tests__/medication-auth-contract-drift.test.ts b/src/lib/openapi/__tests__/medication-auth-contract-drift.test.ts new file mode 100644 index 000000000..f79981921 --- /dev/null +++ b/src/lib/openapi/__tests__/medication-auth-contract-drift.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "vitest"; + +import { authPaths } from "../routes/auth"; +import { medicationPaths } from "../routes/medications"; +import { + medicationCadenceChips, + medicationDetailEntry, + medicationIntakeEventResource, + medicationListEntry, +} from "../routes/medications/schemas"; + +/** + * Contract guard for the medications + auth surfaces. Every fixture is + * transcribed from a handler return statement, so a schema that drifts away + * from the handler fails here. + */ + +function statusesFor( + paths: typeof authPaths, + path: string, + method: "get" | "post" | "put" | "patch" | "delete", +): string[] { + const responses = paths[path]?.[method]?.responses as + Record | undefined; + return Object.keys(responses ?? {}); +} + +function hasParams( + paths: typeof authPaths, + path: string, + method: "get" | "delete" | "patch", +): boolean { + return Boolean(paths[path]?.[method]?.requestParams); +} + +describe("medications — documented shapes match the handlers", () => { + it("keeps `nextDueAt` off the create / update result, which returns the stored row", () => { + // src/app/api/medications/route.ts — `apiSuccess({ ...medication, + // unitsPerDose, category }, 201)`, and [id]/route.ts for the PUT. Both + // spread the stored Prisma row; `nextDueAt` / `nextDueOverdue` are + // computed by the READ paths only and are simply absent here. The shared + // detail shape backs BOTH the write results and the single GET, so it + // cannot require them. + expect( + medicationDetailEntry.shape.nextDueAt.safeParse(undefined).success, + ).toBe(true); + expect( + medicationDetailEntry.shape.nextDueOverdue.safeParse(undefined).success, + ).toBe(true); + }); + + it("still requires the computed due fields on a list row, which always has them", () => { + // src/lib/medications/list-read.ts computes both on every row, so the + // list entry must not inherit the base's optionality. + expect( + medicationListEntry.shape.nextDueAt.safeParse(undefined).success, + ).toBe(false); + expect( + medicationListEntry.shape.nextDueOverdue.safeParse(undefined).success, + ).toBe(false); + }); + + it("accepts a fractional stock unit count (unitsRemaining is Decimal(12,4))", () => { + // src/lib/medications/list-read.ts sums `unitsRemaining`, which the schema + // itself documents as fractional ("a half-tablet dose leaves 29.5 of 30"). + const shape = medicationListEntry.shape.stockUnitsRemaining; + expect(shape.safeParse(29.5).success).toBe(true); + }); + + it("matches `complianceChips()`'s actual return on the cadence chips", () => { + // src/lib/medications/scheduling/compliance.ts returns exactly these five. + const chips = { + adherenceRate: null, + currentStreak: 0, + longestStreak: 4, + missedLast30: 2, + windowDays: 30, + }; + const parsed = medicationCadenceChips.safeParse(chips); + expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true); + }); + + it("rejects the fields the cadence chips never carried", () => { + const stale = { + adherenceRate: 90, + streakDays: 3, + expectedSlots: 30, + actualDoses: 27, + }; + expect(medicationCadenceChips.safeParse(stale).success).toBe(false); + }); + + it("admits APPLE_HEALTH as an intake source (IntakeSource enum, v1.28)", () => { + const event = { + id: "cm000000000000000000000", + userId: "cm111111111111111111111", + medicationId: "cm222222222222222222222", + scheduledFor: "2026-07-19T08:00:00.000Z", + takenAt: "2026-07-19T08:05:00.000Z", + skipped: false, + source: "APPLE_HEALTH", + idempotencyKey: null, + createdAt: "2026-07-19T08:05:00.000Z", + }; + const parsed = medicationIntakeEventResource.safeParse(event); + expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true); + }); +}); + +describe("auth — documented shapes and statuses match the handlers", () => { + it("documents the 403 oidc_only refusal on password login", () => { + expect(statusesFor(authPaths, "/api/auth/login", "post")).toContain("403"); + }); + + it("documents the 403 + 404 arms on passkey assertion verification", () => { + const statuses = statusesFor( + authPaths, + "/api/auth/passkey/login-verify", + "post", + ); + expect(statuses).toContain("403"); + expect(statuses).toContain("404"); + }); + + it("documents the 409 on both TOTP enrolment steps", () => { + expect( + statusesFor(authPaths, "/api/auth/me/mfa/totp/setup", "post"), + ).toContain("409"); + expect( + statusesFor(authPaths, "/api/auth/me/mfa/totp/confirm", "post"), + ).toContain("409"); + }); + + it("documents the 400 on a failed security-key attestation", () => { + expect( + statusesFor( + authPaths, + "/api/auth/me/mfa/webauthn/register/verify", + "post", + ), + ).toContain("400"); + }); + + it("documents the 409 when no security key is registered to assert against", () => { + expect( + statusesFor(authPaths, "/api/auth/mfa/webauthn/verify/options", "post"), + ).toContain("409"); + }); + + it("documents the 404 on both security-key {id} verbs", () => { + const path = "/api/auth/me/mfa/webauthn/{id}"; + expect(statusesFor(authPaths, path, "patch")).toContain("404"); + expect(statusesFor(authPaths, path, "delete")).toContain("404"); + }); + + it("declares the path / query parameters the handlers actually read", () => { + expect(hasParams(authPaths, "/api/auth/me/sessions/{id}", "delete")).toBe( + true, + ); + expect( + hasParams(authPaths, "/api/auth/me/trusted-devices/{id}", "delete"), + ).toBe(true); + expect( + hasParams(authPaths, "/api/auth/me/mfa/webauthn/{id}", "patch"), + ).toBe(true); + expect(hasParams(authPaths, "/api/auth/me/security-activity", "get")).toBe( + true, + ); + }); +}); + +describe("medications — the module gate stays documented where it applies", () => { + it("keeps the daily digest's module 403", () => { + // Sanity anchor: an unrelated documented 403 must not regress while the + // medication + auth statuses above are being added. + expect(statusesFor(medicationPaths, "/api/medications", "get")).toContain( + "200", + ); + }); +}); diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index 90de17900..78d8fe777 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -66,9 +66,14 @@ const refreshRequest = z const accessRefreshBundle = z .object({ - user: z.object({ id: z.string(), username: z.string() }).optional(), - token: z.string().describe("Access token (`hlk_<64hex>`)."), - tokenExpiresAt: z.iso.datetime({ offset: true }), + user: z.object({ id: z.string(), username: z.string() }), + token: z + .string() + .optional() + .describe( + "Access token (`hlk_<64hex>`); only present for native-policy callers. A browser caller gets the session cookie instead.", + ), + tokenExpiresAt: z.iso.datetime({ offset: true }).optional(), refreshToken: z .string() .optional() @@ -327,6 +332,11 @@ export const authPaths: NonNullable = { }, }, }, + "403": { + description: + "Password login is disabled — the operator runs `OIDC_ONLY=true`. `meta.errorCode` = `oidc_only`; sign in through SSO instead.", + content: { "application/json": { schema: errorEnvelope } }, + }, ...stdResponses, }, }, @@ -397,6 +407,10 @@ export const authPaths: NonNullable = { }, }, }, + "409": { + description: "A second factor is already active on this account.", + content: { "application/json": { schema: errorEnvelope } }, + }, ...stdResponses, }, }, @@ -423,6 +437,11 @@ export const authPaths: NonNullable = { }, }, }, + "409": { + description: + "A second factor is already active, or enrollment was never started (no pending secret to confirm).", + content: { "application/json": { schema: errorEnvelope } }, + }, ...stdResponses, }, }, @@ -537,6 +556,10 @@ export const authPaths: NonNullable = { }, }, }, + "400": { + description: "Security key verification failed (bad attestation).", + content: { "application/json": { schema: errorEnvelope } }, + }, ...stdResponses, }, }, @@ -545,6 +568,7 @@ export const authPaths: NonNullable = { patch: { tags: ["Auth"], summary: "Rename a registered security key", + requestParams: { path: z.object({ id: z.string() }) }, requestBody: { required: true, content: { "application/json": { schema: mfaWebauthnRenameSchema } }, @@ -561,6 +585,10 @@ export const authPaths: NonNullable = { }, }, }, + "404": { + description: "No such security key for this account.", + content: { "application/json": { schema: errorEnvelope } }, + }, ...stdResponses, }, }, @@ -569,6 +597,7 @@ export const authPaths: NonNullable = { summary: "Remove a registered security key (step-up gated)", description: "Requires a fresh second-factor step-up (cookie session). Bearer can never satisfy the gate.", + requestParams: { path: z.object({ id: z.string() }) }, responses: { "200": { description: "Security key removed.", @@ -581,6 +610,10 @@ export const authPaths: NonNullable = { }, }, }, + "404": { + description: "No such security key for this account.", + content: { "application/json": { schema: errorEnvelope } }, + }, ...stdResponses, }, }, @@ -609,6 +642,11 @@ export const authPaths: NonNullable = { }, }, }, + "409": { + description: + "No security key is registered on the account — fall back to the TOTP / recovery-code challenge.", + content: { "application/json": { schema: errorEnvelope } }, + }, ...stdResponses, }, }, @@ -662,6 +700,16 @@ export const authPaths: NonNullable = { }, }, }, + "403": { + description: + "Passkey login is disabled — the operator runs `OIDC_ONLY=true`. `meta.errorCode` = `oidc_only`.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "404": { + description: + "The assertion resolved to a user row that no longer exists.", + content: { "application/json": { schema: errorEnvelope } }, + }, ...stdResponses, }, }, @@ -679,10 +727,17 @@ export const authPaths: NonNullable = { }, responses: { "200": { - description: "Rotation succeeded — new pair issued.", + description: + "Rotation succeeded — new pair issued. When the request carried `revoke: true` the body is `{ revoked }` instead: the token family is invalidated and no new pair is minted.", content: { "application/json": { - schema: dataEnvelope(accessRefreshBundle, "RefreshResponse"), + schema: z.union([ + dataEnvelope(accessRefreshBundle, "RefreshResponse"), + dataEnvelope( + z.object({ revoked: z.boolean() }), + "RefreshRevokeResponse", + ), + ]), }, }, }, @@ -735,6 +790,7 @@ export const authPaths: NonNullable = { summary: "Revoke a single web session", description: "v1.23 — revokes one session by id, scoped to the authenticated user (a foreign id returns 404, never another user's row).", + requestParams: { path: z.object({ id: z.string() }) }, responses: { "200": { description: "Session revoked.", @@ -803,6 +859,7 @@ export const authPaths: NonNullable = { summary: "Revoke a single trusted device", description: "v1.23 — revokes one trusted device by id, scoped to the authenticated user (a foreign id returns 404).", + requestParams: { path: z.object({ id: z.string() }) }, responses: { "200": { description: "Trusted device revoked.", @@ -829,6 +886,17 @@ export const authPaths: NonNullable = { summary: "List recent account-security activity", description: "v1.23 — the SHARED security-activity feed: the caller's recent auth + export + deletion audit events with timestamp, resolved location, and a host-masked IP. `limit` query param caps at 100 (default 50). Reuses the AuditLog store; no event detail bodies are surfaced.", + requestParams: { + query: z.object({ + limit: z.coerce + .number() + .int() + .min(1) + .max(100) + .optional() + .describe("Page size; defaults to 50, clamped to 100."), + }), + }, responses: { "200": { description: "Recent security events for the caller.", diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index 9b8ec81fd..d29e22e65 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -11,11 +11,17 @@ import { createMeasurementSchema, listMeasurementsSchema, measurementTypeEnum, + updateMeasurementSchema, measurementSourceEnum, } from "@/lib/validations/measurement"; import { seriesBatchQuerySchema } from "@/lib/validations/series-batch"; import { deviceTypeEnum } from "@/lib/validations/source-priority"; -import { dataEnvelope, moduleDisabledResponse, stdResponses } from "./shared"; +import { + dataEnvelope, + errorEnvelope, + moduleDisabledResponse, + stdResponses, +} from "./shared"; const batchEntrySchema = z .object({ @@ -196,19 +202,140 @@ const restoreMeasurementsResponse = z export const measurementResource = z .object({ - id: z.string(), + id: z + .string() + .describe( + "Stored row id, or a synthetic key (`day::`, `sleep-seg::`) on the collapsed list modes.", + ), type: measurementTypeEnum, value: z.number(), unit: z.string(), measuredAt: z.iso.datetime({ offset: true }), source: measurementSourceEnum, notes: z.string().nullable().optional(), + // The list / detail reads hand back the whole stored row minus the + // `notesEncrypted` ciphertext, so every remaining scalar column is on the + // wire. Optional rather than required because the collapsed list modes + // (day-sum, sleep-segment) synthesise rows that carry only the display + // fields above. + userId: z.string().optional(), + valueMin: z + .number() + .nullable() + .optional() + .describe( + "Bucket MINIMUM — set only on an hourly heart-rate `stats:` row.", + ), + valueMax: z + .number() + .nullable() + .optional() + .describe( + "Bucket MAXIMUM — set only on an hourly heart-rate `stats:` row.", + ), + externalId: z + .string() + .nullable() + .optional() + .describe("External-system dedup key; null for manual entries."), + externalSourceVersion: z.string().nullable().optional(), + glucoseContext: z + .string() + .nullable() + .optional() + .describe("Set only on BLOOD_GLUCOSE rows."), + sleepStage: z + .string() + .nullable() + .optional() + .describe("Set only on SLEEP_DURATION rows."), + rhythmClassification: z + .string() + .nullable() + .optional() + .describe("Device classification verdict; set only on EVENT rows."), + deviceType: z + .string() + .nullable() + .optional() + .describe("`watch | band | ring | phone | scale | other | unknown`."), + syncVersion: z + .number() + .int() + .optional() + .describe( + "Bumped on every server-side mutation; the last-writer-wins input for cross-device reconciliation.", + ), + deletedAt: z.iso + .datetime({ offset: true }) + .nullable() + .optional() + .describe("Soft-delete tombstone; null on a live row."), + createdAt: z.iso.datetime({ offset: true }).optional(), + updatedAt: z.iso.datetime({ offset: true }).optional(), + // Synthesised-row extras — present only on the collapsed day-sum mode. + dayKey: z.string().optional(), + sampleCount: z.number().int().optional(), + partial: z + .boolean() + .optional() + .describe( + "True on the one bucket whose day was cut off by the read cap, so its sum understates the real total.", + ), }) .meta({ id: "MeasurementResource", - description: "Server-shaped measurement row returned by GET endpoints.", + description: + "Server-shaped measurement row. GET endpoints return the whole stored row with the decrypted note on `notes` and the `notesEncrypted` ciphertext stripped; the collapsed list modes synthesise rows carrying the display fields plus `dayKey` / `sampleCount`.", }); +/** + * The aggregate list modes (`aggregate=daily` and the coarser grains) return + * pre-folded buckets, not stored rows — no `id`, `unit` or `source`. + */ +const aggregatedMeasurementBucket = z + .object({ + type: measurementTypeEnum, + value: z.number().describe("The bucket's aggregated value."), + measuredAt: z.iso.datetime({ offset: true }).describe("Bucket start."), + count: z.number().int().nonnegative().optional(), + unit: z.string().optional(), + }) + .meta({ + id: "AggregatedMeasurementBucket", + description: + "Pre-folded bucket returned by the `aggregate=` list modes instead of a stored row.", + }); + +/** `meta` on every `GET /api/measurements` response mode. */ +const listMeasurementsMeta = z + .object({ + total: z.number().int().nonnegative(), + limit: z.number().int().nonnegative(), + offset: z.number().int().nonnegative(), + dayKey: z + .string() + .optional() + .describe("Echoed on the per-sample drill-down modes."), + groupBy: z + .enum(["day", "night"]) + .optional() + .describe("Set on the collapsed day-sum / per-night modes."), + aggregate: z + .string() + .optional() + .describe("Set on the aggregate modes — the requested grain."), + droppedDuplicates: z + .number() + .int() + .nonnegative() + .optional() + .describe( + "Rows discarded by the cross-source canonical picker on the day-collapse mode.", + ), + }) + .meta({ id: "ListMeasurementsMeta" }); + // ── Sleep night (v1.11.5 hypnogram source) ────────────────────────── const sleepStageEnum = z.enum([ @@ -597,8 +724,10 @@ export const measurementPaths: NonNullable = { "application/json": { schema: dataEnvelope( z.object({ - measurements: z.array(measurementResource), - total: z.number().int().nonnegative(), + measurements: z.array( + z.union([measurementResource, aggregatedMeasurementBucket]), + ), + meta: listMeasurementsMeta, }), "ListMeasurementsResponse", ), @@ -610,25 +739,124 @@ export const measurementPaths: NonNullable = { }, post: { tags: ["Measurements"], - summary: "Create one measurement", + summary: "Create one measurement (or a small array)", description: - "Single ingest. Use `/api/measurements/batch` for Apple Health upload streams.", + "Single ingest. The body may also be a bare ARRAY of the same objects — the mode the iOS client uses for a combined blood-pressure + pulse or dual-value glucose write — in which case `data` is the array of created rows in request order. Use `/api/measurements/batch` for Apple Health upload streams.", requestBody: { required: true, - content: { "application/json": { schema: createMeasurementSchema } }, + content: { + "application/json": { + schema: z.union([ + createMeasurementSchema, + z.array(createMeasurementSchema), + ]), + }, + }, }, responses: { "201": { - description: "Created.", + description: + "Created. A single resource for the object body; an array of resources, in request order, for the array body.", content: { "application/json": { schema: dataEnvelope( - measurementResource, + z.union([measurementResource, z.array(measurementResource)]), "CreateMeasurementResponse", ), }, }, }, + "409": { + description: + "A measurement with this data already exists — the `(userId, type, source, externalId)` dedup index rejected the write.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/measurements/{id}": { + get: { + tags: ["Measurements"], + summary: "Measurement detail", + description: + "Single stored row, scoped to the caller. A row owned by another user surfaces as 404 (existence channel sealed).", + requestParams: { path: z.object({ id: z.string() }) }, + responses: { + "200": { + description: "Measurement.", + content: { + "application/json": { + schema: dataEnvelope( + measurementResource, + "GetMeasurementResponse", + ), + }, + }, + }, + "404": { + description: "Measurement not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + put: { + tags: ["Measurements"], + summary: "Update a measurement", + description: + "Edits a manually entered row. Rows owned by a connected source are refused with 409 `measurement.update.server_owned_source`; a timestamp collision with an existing row is refused with 409 `measurement.duplicate_timestamp`.", + requestParams: { path: z.object({ id: z.string() }) }, + requestBody: { + required: true, + content: { "application/json": { schema: updateMeasurementSchema } }, + }, + responses: { + "200": { + description: "Updated measurement.", + content: { + "application/json": { + schema: dataEnvelope( + measurementResource, + "UpdateMeasurementResponse", + ), + }, + }, + }, + "404": { + description: "Measurement not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, + "409": { + description: + "Refused: the row comes from a connected source (`meta.errorCode` = `measurement.update.server_owned_source`), or another row already carries this timestamp (`measurement.duplicate_timestamp`).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + delete: { + tags: ["Measurements"], + summary: "Delete a measurement", + description: + "Soft-deletes the row (tombstone) so paired clients can reconcile the deletion.", + requestParams: { path: z.object({ id: z.string() }) }, + responses: { + "200": { + description: "Deleted.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ deleted: z.literal(true) }), + "DeleteMeasurementResponse", + ), + }, + }, + }, + "404": { + description: "Measurement not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, ...stdResponses, }, }, diff --git a/src/lib/openapi/routes/medications/schemas.ts b/src/lib/openapi/routes/medications/schemas.ts index ae2775fa3..57df825a7 100644 --- a/src/lib/openapi/routes/medications/schemas.ts +++ b/src/lib/openapi/routes/medications/schemas.ts @@ -177,13 +177,15 @@ export const medicationResource = z nextDueAt: z.iso .datetime({ offset: true }) .nullable() + .optional() .describe( - "v1.7.0 server-computed next due instant across all the medication's schedules (earliest `nextOccurrenceAfter`). Read-only — computed, not stored. NULL when no schedule has an upcoming slot (paused, one-shot in the past, `endsOn` crossed, every schedule PRN). v1.16.4 — when an unresolved slot's anchor has passed but the catch-up band is still open (`anchor < now <= overdueEnd`, current schedule era), this carries THAT slot (a past instant) with `nextDueOverdue: true` instead of jumping to the next future slot. The list GET is cached 60 s, so a 60 s staleness is accepted.", + "Present on the READ paths only — the list and single GET compute it; the create / update responses return the stored row and omit it. v1.7.0 server-computed next due instant across all the medication's schedules (earliest `nextOccurrenceAfter`). Read-only — computed, not stored. NULL when no schedule has an upcoming slot (paused, one-shot in the past, `endsOn` crossed, every schedule PRN). v1.16.4 — when an unresolved slot's anchor has passed but the catch-up band is still open (`anchor < now <= overdueEnd`, current schedule era), this carries THAT slot (a past instant) with `nextDueOverdue: true` instead of jumping to the next future slot. The list GET is cached 60 s, so a 60 s staleness is accepted.", ), nextDueOverdue: z .boolean() + .optional() .describe( - "v1.16.4 — true when `nextDueAt` is an OPEN overdue slot: its anchor has passed, `now` is still inside the slot's catch-up band, and no taken / skipped / auto-missed row resolves it. False for a regular future next-due (and when `nextDueAt` is null). Read-only — computed, not stored.", + "Present on the READ paths only, alongside `nextDueAt`. v1.16.4 — true when `nextDueAt` is an OPEN overdue slot: its anchor has passed, `now` is still inside the slot's catch-up band, and no taken / skipped / auto-missed row resolves it. False for a regular future next-due (and when `nextDueAt` is null). Read-only — computed, not stored.", ), startsOn: z.iso .datetime({ offset: true }) @@ -220,6 +222,10 @@ export const medicationResource = z export const medicationListEntry = medicationResource .extend({ category: medicationCategoryEnum, + // The list read always computes these two, so they are required here + // even though the shared base leaves them optional for the write paths. + nextDueAt: z.iso.datetime({ offset: true }).nullable(), + nextDueOverdue: z.boolean(), lastTakenAt: z.iso .datetime({ offset: true }) .nullable() @@ -235,7 +241,6 @@ export const medicationListEntry = medicationResource ), stockUnitsRemaining: z .number() - .int() .nullable() .describe( "v1.16.10 — usable inventory units left across the medication's containers (sum of `unitsRemaining` over ACTIVE / IN_USE items with units left). NULL = inventory tracking off (no items ever registered); 0 = tracking on, supply ran out. Read-only — aggregated, not stored.", @@ -362,7 +367,7 @@ export const medicationIntakeEventResource = z scheduledFor: z.iso.datetime({ offset: true }), takenAt: z.iso.datetime({ offset: true }).nullable(), skipped: z.boolean(), - source: z.enum(["WEB", "API", "REMINDER", "IMPORT"]), + source: z.enum(["WEB", "API", "REMINDER", "IMPORT", "APPLE_HEALTH"]), idempotencyKey: z.string().nullable(), createdAt: z.iso.datetime({ offset: true }), }) @@ -388,15 +393,39 @@ export const medicationCadenceTimelinePoint = z export const medicationCadenceChips = z .object({ - adherenceRate: z.number(), - streakDays: z.number().int().nonnegative(), - expectedSlots: z.number().int().nonnegative(), - actualDoses: z.number().int().nonnegative(), + adherenceRate: z + .number() + .nullable() + .describe( + "0-100, taken / (taken + missed). Skipped doses are excluded from the denominator — a deliberate decision, not a compliance failure. NULL when no dose was expected in the window (brand-new medication, paused).", + ), + currentStreak: z + .number() + .int() + .nonnegative() + .describe( + "Consecutive days ending at `asOf` where every expected dose was taken or skipped. Days with no expected dose advance the streak; missed days break it.", + ), + longestStreak: z + .number() + .int() + .nonnegative() + .describe("Longest all-taken-or-skipped run anywhere in the window."), + missedLast30: z + .number() + .int() + .nonnegative() + .describe("Count of missed doses inside the window."), + windowDays: z + .number() + .int() + .nonnegative() + .describe("Window size used — mirrors the input for the chart legend."), }) .meta({ id: "MedicationCadenceChips", description: - "Four compliance summary values for the medication detail page chip row.", + "Compliance summary values for the medication detail page chip row.", }); export const medicationCadenceResponse = z diff --git a/src/lib/openapi/routes/workouts.ts b/src/lib/openapi/routes/workouts.ts index abf96cde6..a07818fd7 100644 --- a/src/lib/openapi/routes/workouts.ts +++ b/src/lib/openapi/routes/workouts.ts @@ -9,7 +9,12 @@ import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; import { measurementSourceEnum } from "@/lib/validations/measurement"; import { createBatchWorkoutSchema } from "@/lib/validations/workout"; -import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; +import { + dataEnvelope, + errorEnvelope, + moduleDisabledResponse, + stdResponses, +} from "./shared"; // v1.4.25 W16b — typed workout batch ingest response envelope. Mirrors // the measurements batch shape but reports the `workouts` count rather @@ -205,6 +210,7 @@ export const workoutPaths: NonNullable = { }, }, }, + ...moduleDisabledResponse, ...stdResponses, }, }, @@ -234,6 +240,7 @@ export const workoutPaths: NonNullable = { description: "Workout not found (or owned by another user).", content: { "application/json": { schema: errorEnvelope } }, }, + ...moduleDisabledResponse, ...stdResponses, }, },