diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 847d89471..23d2b8062 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,3 +1,541 @@ +components: + schemas: + AccessRefreshBundle: + additionalProperties: false + description: Native-client token bundle returned by login + refresh. Web cookie-only callers see only `user`. + properties: + refreshToken: + description: Refresh token (`hlr_<64hex>`); only present for native-policy callers. + type: string + refreshTokenExpiresAt: + 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: string + token: + description: Access token (`hlk_<64hex>`). + type: string + tokenExpiresAt: + 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: string + user: + additionalProperties: false + properties: + id: + type: string + username: + type: string + required: + - id + - username + type: object + required: + - token + - tokenExpiresAt + type: object + AppleHealthBatchEntry: + properties: + endDate: + 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: string + externalId: + description: HKSample.uuid string — the dedup key. + maxLength: 120 + minLength: 1 + type: string + externalSourceVersion: + maxLength: 120 + minLength: 1 + type: string + hkIdentifier: + description: HealthKit identifier (e.g. `HKQuantityTypeIdentifierBodyMass`). + maxLength: 120 + minLength: 1 + type: string + sleepStage: + description: HKCategoryValueSleepAnalysis codepoint; only for sleep samples. + maximum: 20 + minimum: 0 + type: integer + startDate: + 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: string + unit: + maxLength: 60 + minLength: 1 + type: string + value: + type: number + required: + - hkIdentifier + - value + - unit + - startDate + - endDate + - externalId + type: object + AppleHealthBatchEntryResult: + additionalProperties: false + properties: + index: + maximum: 9007199254740991 + minimum: 0 + type: integer + reason: + type: string + status: + enum: + - inserted + - duplicate + - skipped + type: string + required: + - index + - status + type: object + AppleHealthBatchRequest: + description: Apple Health batch ingest. ≤500 entries per call; idempotent via `Idempotency-Key`. + properties: + entries: + items: + $ref: "#/components/schemas/AppleHealthBatchEntry" + maxItems: 500 + minItems: 1 + type: array + required: + - entries + type: object + AppleHealthBatchResponse: + additionalProperties: false + properties: + duplicate: + maximum: 9007199254740991 + minimum: 0 + type: integer + entries: + items: + $ref: "#/components/schemas/AppleHealthBatchEntryResult" + type: array + inserted: + maximum: 9007199254740991 + minimum: 0 + type: integer + skipped: + maximum: 9007199254740991 + minimum: 0 + type: integer + required: + - inserted + - duplicate + - skipped + - entries + type: object + BatchMeasurementsResponse: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/AppleHealthBatchResponse" + error: + type: "null" + meta: + additionalProperties: false + properties: + requestId: + type: string + type: object + required: + - data + - error + type: object + CreateMeasurementResponse: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/MeasurementResource" + error: + type: "null" + meta: + additionalProperties: false + properties: + requestId: + type: string + type: object + required: + - data + - error + type: object + DeviceRegisterRequest: + description: Native device registration. Re-registering an APNs token belonging to another user returns 409 + (cross-user-hijack guard). + properties: + apnsEnvironment: + description: Gateway the iOS client received `apnsToken` from. Server never auto-detects. + enum: + - sandbox + - production + type: string + apnsToken: + description: Hex-encoded APNs device token. Must be paired with `apnsEnvironment`. + maxLength: 256 + minLength: 8 + pattern: ^[A-Fa-f0-9]+$ + type: string + appVersion: + maxLength: 32 + minLength: 1 + type: string + bundleId: + maxLength: 128 + minLength: 1 + type: string + locale: + maxLength: 16 + minLength: 2 + type: string + model: + maxLength: 64 + minLength: 1 + type: string + token: + description: Generic device identifier (legacy; APNs token below). + maxLength: 512 + minLength: 8 + pattern: ^[A-Za-z0-9+/=._:-]+$ + type: string + required: + - token + - bundleId + type: object + DeviceRegisterResponse: + additionalProperties: false + properties: + data: + additionalProperties: false + properties: + id: + type: string + required: + - id + type: object + error: + type: "null" + meta: + additionalProperties: false + properties: + requestId: + type: string + type: object + required: + - data + - error + type: object + ErrorEnvelope: + additionalProperties: false + description: "Standard error response: data is null, error is human prose." + properties: + data: + type: "null" + error: + type: string + meta: + additionalProperties: false + properties: + errorCode: + type: string + requestId: + type: string + type: object + required: + - data + - error + type: object + InsightsComprehensiveResponse: + additionalProperties: false + description: AI-generated insights bundle. Strict-schema validated server-side; Coach-routed when the insight surface + needs day-level grounding. + properties: + citations: + items: + additionalProperties: {} + propertyNames: + type: string + type: object + type: array + dailyBriefing: + anyOf: + - additionalProperties: {} + propertyNames: + type: string + type: object + - type: "null" + metricSource: + additionalProperties: {} + propertyNames: + type: string + type: object + recommendations: + items: + additionalProperties: {} + propertyNames: + type: string + type: object + type: array + storyboardAnnotations: + items: + additionalProperties: {} + propertyNames: + type: string + type: object + type: array + summary: + type: string + trendAnnotations: + anyOf: + - additionalProperties: {} + propertyNames: + type: string + type: object + - type: "null" + warnings: + items: + additionalProperties: {} + propertyNames: + type: string + type: object + type: array + weeklyReport: + anyOf: + - additionalProperties: {} + propertyNames: + type: string + type: object + - type: "null" + required: + - summary + - recommendations + - citations + - warnings + type: object + InsightsComprehensiveResponseEnvelope: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/InsightsComprehensiveResponse" + error: + type: "null" + meta: + additionalProperties: false + properties: + requestId: + type: string + type: object + required: + - data + - error + type: object + ListMeasurementsResponse: + additionalProperties: false + properties: + data: + additionalProperties: false + properties: + measurements: + items: + $ref: "#/components/schemas/MeasurementResource" + type: array + total: + maximum: 9007199254740991 + minimum: 0 + type: integer + required: + - measurements + - total + type: object + error: + type: "null" + meta: + additionalProperties: false + properties: + requestId: + type: string + type: object + required: + - data + - error + type: object + LoginResponse: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/AccessRefreshBundle" + error: + type: "null" + meta: + additionalProperties: false + properties: + requestId: + type: string + type: object + required: + - data + - error + type: object + MeasurementResource: + additionalProperties: false + description: Server-shaped measurement row returned by GET endpoints. + properties: + id: + type: string + measuredAt: + 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: string + notes: + anyOf: + - type: string + - type: "null" + source: + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + type: string + type: + 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 + type: string + unit: + type: string + value: + type: number + required: + - id + - type + - value + - unit + - measuredAt + - source + type: object + PasskeyLoginVerifyRequest: + description: Passkey assertion verification. Same native-client token issuance as the password path. + properties: + challengeId: + description: Server-issued WebAuthn challenge id. + minLength: 1 + type: string + credential: + description: SimpleWebAuthn-style assertion response payload. + properties: + authenticatorAttachment: + enum: + - platform + - cross-platform + type: string + clientExtensionResults: + additionalProperties: {} + propertyNames: + type: string + type: object + id: + type: string + rawId: + type: string + response: + additionalProperties: {} + propertyNames: + type: string + type: object + type: + const: public-key + type: string + required: + - id + - rawId + - type + - response + type: object + required: + - challengeId + - credential + type: object + PasskeyLoginVerifyResponse: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/AccessRefreshBundle" + error: + type: "null" + meta: + additionalProperties: false + properties: + requestId: + type: string + type: object + required: + - data + - error + type: object + RefreshResponse: + additionalProperties: false + properties: + data: + $ref: "#/components/schemas/AccessRefreshBundle" + error: + type: "null" + meta: + additionalProperties: false + properties: + requestId: + type: string + type: object + required: + - data + - error + type: object + RefreshTokenRequest: + description: Exchange a one-time-use refresh token for a fresh access + refresh pair. + properties: + refreshToken: + description: Caller-presented refresh token (`hlr_<64hex>`). + minLength: 1 + type: string + revoke: + description: When true, revoke the supplied token instead of rotating. + type: boolean + required: + - refreshToken + type: object + securitySchemes: + bearerAuth: + bearerFormat: hlk_<64hex> + description: Native-client API token. Use the `token` field returned by /api/auth/login on a native client. + scheme: bearer + type: http + cookieAuth: + description: Browser session cookie — set by /api/auth/login. + in: cookie + name: healthlog_session + type: apiKey info: contact: name: HealthLog @@ -9,7 +547,324 @@ info: title: HealthLog API version: 1.4.23 openapi: 3.1.0 -paths: {} +paths: + /api/auth/login: + post: + description: "Browser callers receive a session cookie. Native callers (X-Client-Type: native or HealthLog-iOS UA + prefix) additionally receive a paired access + refresh token." + requestBody: + content: + application/json: + schema: + properties: + email: + minLength: 1 + type: string + password: + minLength: 1 + type: string + required: + - email + - password + type: object + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/LoginResponse" + description: Login succeeded. + "401": &a1 + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + description: Authentication required or invalid credentials. + "422": &a2 + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + description: Request validation failed. + "429": &a3 + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + description: Rate limit exceeded. + summary: Email-or-username login (password) + tags: + - Auth + /api/auth/passkey/login-verify: + post: + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PasskeyLoginVerifyRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PasskeyLoginVerifyResponse" + description: Assertion verified — session + optional bearer issued. + "401": *a1 + "422": *a2 + "429": *a3 + summary: Passkey assertion verification + tags: + - Auth + /api/auth/refresh: + post: + description: Reuse of a consumed refresh token revokes every refresh token still active for the originating device + (per-device blast radius from v1.4.23). Legacy tokens issued before v1.4.23 with a null deviceId fall back to + revoke-all-for-user. + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/RefreshTokenRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/RefreshResponse" + description: Rotation succeeded — new pair issued. + "401": *a1 + "422": *a2 + "429": *a3 + summary: Rotate refresh token (one-time use) + tags: + - Auth + /api/devices: + post: + description: Idempotent upsert by `token`. APNs token + environment are paired — supplying one without the other returns + 422. Cross-user re-registration of either identifier returns 409. + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/DeviceRegisterRequest" + required: true + responses: + "201": + content: + application/json: + schema: + $ref: "#/components/schemas/DeviceRegisterResponse" + description: Device registered or refreshed. + "401": *a1 + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + description: Device or APNs token already registered to another user. + "422": *a2 + "429": *a3 + summary: Register native device + APNs token + tags: + - Devices + /api/insights/comprehensive: + get: + description: Full Insights surface — daily briefing, recommendations with rationale, optional weekly report + storyboard + annotations. Strict-schema validated server-side. + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/InsightsComprehensiveResponseEnvelope" + description: Insights bundle. + "401": *a1 + "422": *a2 + "429": *a3 + summary: Comprehensive AI insights bundle + tags: + - Insights + /api/measurements: + get: + description: Filter by type + date range. Response is paged via offset/limit; default limit 100, hard cap 500. + parameters: + - in: query + name: type + schema: + 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 + type: string + - in: query + name: from + schema: + 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: string + - in: query + name: to + schema: + 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: string + - in: query + name: limit + schema: + default: 100 + maximum: 500 + minimum: 1 + type: integer + - in: query + name: offset + schema: + default: 0 + maximum: 9007199254740991 + minimum: 0 + type: integer + - in: query + name: sortBy + schema: + default: measuredAt + enum: + - type + - value + - measuredAt + - source + type: string + - in: query + name: sortDir + schema: + default: desc + enum: + - asc + - desc + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/ListMeasurementsResponse" + description: Measurement page. + "401": *a1 + "422": *a2 + "429": *a3 + summary: List measurements + tags: + - Measurements + post: + description: Single ingest. Use `/api/measurements/batch` for Apple Health upload streams. + requestBody: + content: + application/json: + schema: + properties: + glucoseContext: + enum: + - FASTING + - POSTPRANDIAL + - RANDOM + - BEDTIME + type: string + measuredAt: + 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: string + notes: + maxLength: 25 + type: string + source: + default: MANUAL + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + type: string + type: + 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 + type: string + value: + type: number + required: + - type + - value + - measuredAt + type: object + required: true + responses: + "201": + content: + application/json: + schema: + $ref: "#/components/schemas/CreateMeasurementResponse" + description: Created. + "401": *a1 + "422": *a2 + "429": *a3 + summary: Create one measurement + tags: + - Measurements + /api/measurements/batch: + post: + description: Up to 500 HealthKit entries per call. Idempotent via the `Idempotency-Key` header (replay window 24h). + Per-entry status lets the iOS client advance its sync cursor accurately. + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/AppleHealthBatchRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/BatchMeasurementsResponse" + description: Batch processed. + "401": *a1 + "422": *a2 + "429": *a3 + summary: Apple Health batch ingest + tags: + - Measurements servers: - description: Production url: https://healthlog.bombeck.io diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index de232f0d3..07e36dbd9 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -1,22 +1,522 @@ /** - * OpenAPI route table — populated incrementally per W4 F5b and beyond. + * OpenAPI route table — populated incrementally. * - * Routes are registered as `paths[][] = ZodOpenApiOperationObject`. - * Schemas exported from `src/lib/validations/*` carry their own - * `.meta({ id, title, description })` annotations so they show up in - * `components.schemas` automatically when referenced via `ref:` or - * direct schema inclusion. + * v1.4.23 baseline covers the routes the v1.5 iOS app touches end-to-end: + * - POST /api/auth/login + * - POST /api/auth/passkey/login-verify + * - POST /api/auth/refresh + * - GET /api/measurements + * - POST /api/measurements + * - POST /api/measurements/batch (W2 — Apple Health ingest) + * - POST /api/devices (W3 — APNs registration) + * - GET /api/insights/comprehensive * - * The registry intentionally starts at zero coverage. Commit 2 of the - * v1.4.23 W4 batch wires the iOS-touched routes; later waves expand - * coverage organically. The CI gate at - * `.github/workflows/security.yml` runs in `continue-on-error: true` - * mode while coverage grows. + * Schemas come from `src/lib/validations/*` so the wire contract stays + * single-source-of-truth. The `.meta()` annotations on each schema land + * the title + description in `components.schemas.*` automatically. + * + * Routes are intentionally registered as inline operation objects + * rather than via a wrapping helper — `zod-openapi`'s `createDocument` + * type-checks the route table directly, which catches schema-shape + * regressions at typecheck time. */ +import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; -export const openApiPaths: NonNullable = {}; +import { + createMeasurementSchema, + listMeasurementsSchema, + measurementTypeEnum, + measurementSourceEnum, +} from "@/lib/validations/measurement"; +import { loginPasswordSchema } from "@/lib/validations/auth"; + +/** + * Common envelopes — every HealthLog API response wraps payload in + * `{ data, error, meta? }`. The OpenAPI surface mirrors that contract + * so iOS / external-ingest clients can decode uniformly. + */ +const errorEnvelope = z + .object({ + data: z.null(), + error: z.string(), + meta: z + .object({ + requestId: z.string().optional(), + errorCode: z.string().optional(), + }) + .optional(), + }) + .meta({ + id: "ErrorEnvelope", + description: "Standard error response: data is null, error is human prose.", + }); + +function dataEnvelope(payload: T, id: string) { + return z + .object({ + data: payload, + error: z.null(), + meta: z + .object({ requestId: z.string().optional() }) + .optional(), + }) + .meta({ id }); +} + +// ── Schemas — annotated for spec emission ──────────────────────────── + +measurementTypeEnum.meta({ + id: "MeasurementType", + description: + "DB-stored measurement category. v1.4.23 added 7 Apple Health values (HRV, resting HR, active energy, flights, walking/running distance, VO2 max, body temperature).", +}); + +measurementSourceEnum.meta({ + id: "MeasurementSource", + description: + "Origin of the measurement. v1.4.23 added APPLE_HEALTH for the iOS HealthKit batch ingest path.", +}); + +loginPasswordSchema.meta({ + id: "LoginPasswordRequest", + description: + "Email-or-username login. The native-client flow returns a paired access + refresh token when X-Client-Type: native or the iOS UA prefix is present.", +}); + +createMeasurementSchema.meta({ + id: "CreateMeasurementRequest", + description: + "Single-measurement ingest body. Plausibility-range guard runs server-side; out-of-range values fail 422.", +}); + +listMeasurementsSchema.meta({ + id: "ListMeasurementsQuery", + description: + "Query params for the measurements list endpoint. `limit` capped at 500.", +}); + +// ── Sub-schemas owned here (route-specific shapes) ─────────────────── + +const passkeyLoginVerifyRequest = z + .object({ + challengeId: z.string().min(1).describe("Server-issued WebAuthn challenge id."), + credential: z + .object({ + id: z.string(), + rawId: z.string(), + type: z.literal("public-key"), + response: z.record(z.string(), z.unknown()), + authenticatorAttachment: z + .enum(["platform", "cross-platform"]) + .optional(), + clientExtensionResults: z.record(z.string(), z.unknown()).optional(), + }) + .describe("SimpleWebAuthn-style assertion response payload."), + }) + .meta({ + id: "PasskeyLoginVerifyRequest", + description: + "Passkey assertion verification. Same native-client token issuance as the password path.", + }); + +const refreshRequest = z + .object({ + refreshToken: z + .string() + .min(1) + .describe("Caller-presented refresh token (`hlr_<64hex>`)."), + revoke: z + .boolean() + .optional() + .describe("When true, revoke the supplied token instead of rotating."), + }) + .meta({ + id: "RefreshTokenRequest", + description: + "Exchange a one-time-use refresh token for a fresh access + refresh pair.", + }); + +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 }), + refreshToken: z + .string() + .optional() + .describe( + "Refresh token (`hlr_<64hex>`); only present for native-policy callers.", + ), + refreshTokenExpiresAt: z.iso.datetime({ offset: true }).optional(), + }) + .meta({ + id: "AccessRefreshBundle", + description: + "Native-client token bundle returned by login + refresh. Web cookie-only callers see only `user`.", + }); + +const deviceRegisterRequest = z + .object({ + token: z + .string() + .min(8) + .max(512) + .regex(/^[A-Za-z0-9+/=._:-]+$/) + .describe("Generic device identifier (legacy; APNs token below)."), + bundleId: z.string().min(1).max(128), + locale: z.string().min(2).max(16).optional(), + appVersion: z.string().min(1).max(32).optional(), + model: z.string().min(1).max(64).optional(), + apnsToken: z + .string() + .min(8) + .max(256) + .regex(/^[A-Fa-f0-9]+$/) + .optional() + .describe( + "Hex-encoded APNs device token. Must be paired with `apnsEnvironment`.", + ), + apnsEnvironment: z + .enum(["sandbox", "production"]) + .optional() + .describe( + "Gateway the iOS client received `apnsToken` from. Server never auto-detects.", + ), + }) + .meta({ + id: "DeviceRegisterRequest", + description: + "Native device registration. Re-registering an APNs token belonging to another user returns 409 (cross-user-hijack guard).", + }); + +const batchEntrySchema = z + .object({ + hkIdentifier: z + .string() + .min(1) + .max(120) + .describe("HealthKit identifier (e.g. `HKQuantityTypeIdentifierBodyMass`)."), + value: z.number().finite(), + unit: z.string().min(1).max(60), + startDate: z.iso.datetime({ offset: true }), + endDate: z.iso.datetime({ offset: true }), + sleepStage: z + .number() + .int() + .min(0) + .max(20) + .optional() + .describe( + "HKCategoryValueSleepAnalysis codepoint; only for sleep samples.", + ), + externalId: z + .string() + .min(1) + .max(120) + .describe("HKSample.uuid string — the dedup key."), + externalSourceVersion: z.string().min(1).max(120).optional(), + }) + .meta({ id: "AppleHealthBatchEntry" }); + +const batchPayloadSchema = z + .object({ + entries: z.array(batchEntrySchema).min(1).max(500), + }) + .meta({ + id: "AppleHealthBatchRequest", + description: + "Apple Health batch ingest. ≤500 entries per call; idempotent via `Idempotency-Key`.", + }); + +const batchEntryResult = z + .object({ + index: z.number().int().nonnegative(), + status: z.enum(["inserted", "duplicate", "skipped"]), + reason: z.string().optional(), + }) + .meta({ id: "AppleHealthBatchEntryResult" }); + +const batchResponse = z + .object({ + inserted: z.number().int().nonnegative(), + duplicate: z.number().int().nonnegative(), + skipped: z.number().int().nonnegative(), + entries: z.array(batchEntryResult), + }) + .meta({ id: "AppleHealthBatchResponse" }); + +const measurementResource = z + .object({ + id: z.string(), + type: measurementTypeEnum, + value: z.number(), + unit: z.string(), + measuredAt: z.iso.datetime({ offset: true }), + source: measurementSourceEnum, + notes: z.string().nullable().optional(), + }) + .meta({ + id: "MeasurementResource", + description: "Server-shaped measurement row returned by GET endpoints.", + }); + +const insightsComprehensiveResponse = z + .object({ + summary: z.string(), + recommendations: z.array(z.record(z.string(), z.unknown())), + citations: z.array(z.record(z.string(), z.unknown())), + warnings: z.array(z.record(z.string(), z.unknown())), + dailyBriefing: z.record(z.string(), z.unknown()).nullable().optional(), + trendAnnotations: z.record(z.string(), z.unknown()).nullable().optional(), + weeklyReport: z.record(z.string(), z.unknown()).nullable().optional(), + storyboardAnnotations: z + .array(z.record(z.string(), z.unknown())) + .optional(), + metricSource: z.record(z.string(), z.unknown()).optional(), + }) + .meta({ + id: "InsightsComprehensiveResponse", + description: + "AI-generated insights bundle. Strict-schema validated server-side; Coach-routed when the insight surface needs day-level grounding.", + }); + +// ── Standard 401 / 422 / 429 responses ─────────────────────────────── + +const stdResponses = { + "401": { + description: "Authentication required or invalid credentials.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "422": { + description: "Request validation failed.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "429": { + description: "Rate limit exceeded.", + content: { "application/json": { schema: errorEnvelope } }, + }, +}; + +// ── Path table ─────────────────────────────────────────────────────── + +export const openApiPaths: NonNullable = { + "/api/auth/login": { + post: { + tags: ["Auth"], + summary: "Email-or-username login (password)", + description: + "Browser callers receive a session cookie. Native callers (X-Client-Type: native or HealthLog-iOS UA prefix) additionally receive a paired access + refresh token.", + requestBody: { + required: true, + content: { "application/json": { schema: loginPasswordSchema } }, + }, + responses: { + "200": { + description: "Login succeeded.", + content: { + "application/json": { + schema: dataEnvelope(accessRefreshBundle, "LoginResponse"), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/auth/passkey/login-verify": { + post: { + tags: ["Auth"], + summary: "Passkey assertion verification", + requestBody: { + required: true, + content: { "application/json": { schema: passkeyLoginVerifyRequest } }, + }, + responses: { + "200": { + description: "Assertion verified — session + optional bearer issued.", + content: { + "application/json": { + schema: dataEnvelope( + accessRefreshBundle, + "PasskeyLoginVerifyResponse", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/auth/refresh": { + post: { + tags: ["Auth"], + summary: "Rotate refresh token (one-time use)", + description: + "Reuse of a consumed refresh token revokes every refresh token still active for the originating device (per-device blast radius from v1.4.23). Legacy tokens issued before v1.4.23 with a null deviceId fall back to revoke-all-for-user.", + requestBody: { + required: true, + content: { "application/json": { schema: refreshRequest } }, + }, + responses: { + "200": { + description: "Rotation succeeded — new pair issued.", + content: { + "application/json": { + schema: dataEnvelope(accessRefreshBundle, "RefreshResponse"), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/measurements": { + get: { + tags: ["Measurements"], + summary: "List measurements", + description: + "Filter by type + date range. Response is paged via offset/limit; default limit 100, hard cap 500.", + requestParams: { + query: listMeasurementsSchema, + }, + responses: { + "200": { + description: "Measurement page.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ + measurements: z.array(measurementResource), + total: z.number().int().nonnegative(), + }), + "ListMeasurementsResponse", + ), + }, + }, + }, + ...stdResponses, + }, + }, + post: { + tags: ["Measurements"], + summary: "Create one measurement", + description: + "Single ingest. Use `/api/measurements/batch` for Apple Health upload streams.", + requestBody: { + required: true, + content: { "application/json": { schema: createMeasurementSchema } }, + }, + responses: { + "201": { + description: "Created.", + content: { + "application/json": { + schema: dataEnvelope( + measurementResource, + "CreateMeasurementResponse", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/measurements/batch": { + post: { + tags: ["Measurements"], + summary: "Apple Health batch ingest", + description: + "Up to 500 HealthKit entries per call. Idempotent via the `Idempotency-Key` header (replay window 24h). Per-entry status lets the iOS client advance its sync cursor accurately.", + requestBody: { + required: true, + content: { "application/json": { schema: batchPayloadSchema } }, + }, + responses: { + "200": { + description: "Batch processed.", + content: { + "application/json": { + schema: dataEnvelope(batchResponse, "BatchMeasurementsResponse"), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/devices": { + post: { + tags: ["Devices"], + summary: "Register native device + APNs token", + description: + "Idempotent upsert by `token`. APNs token + environment are paired — supplying one without the other returns 422. Cross-user re-registration of either identifier returns 409.", + requestBody: { + required: true, + content: { "application/json": { schema: deviceRegisterRequest } }, + }, + responses: { + "201": { + description: "Device registered or refreshed.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ id: z.string() }), + "DeviceRegisterResponse", + ), + }, + }, + }, + "409": { + description: + "Device or APNs token already registered to another user.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/insights/comprehensive": { + get: { + tags: ["Insights"], + summary: "Comprehensive AI insights bundle", + description: + "Full Insights surface — daily briefing, recommendations with rationale, optional weekly report + storyboard annotations. Strict-schema validated server-side.", + responses: { + "200": { + description: "Insights bundle.", + content: { + "application/json": { + schema: dataEnvelope( + insightsComprehensiveResponse, + "InsightsComprehensiveResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, +}; export const openApiComponents: NonNullable = { - schemas: {}, + securitySchemes: { + bearerAuth: { + type: "http", + scheme: "bearer", + bearerFormat: "hlk_<64hex>", + description: + "Native-client API token. Use the `token` field returned by /api/auth/login on a native client.", + }, + cookieAuth: { + type: "apiKey", + in: "cookie", + name: "healthlog_session", + description: "Browser session cookie — set by /api/auth/login.", + }, + }, };