mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(openapi): register iOS-touched routes from Zod schemas
Wires the v1.4.23 baseline route table — auth/login, passkey verify, refresh, measurements GET + POST + batch, devices POST, insights comprehensive — into the new generator. Schemas reuse the existing validation files via Zod v4's `.meta()`, so the iOS DTO contract stays single-source-of-truth. The freshly emitted spec covers ~880 lines (down from the 5468-line hand-maintained legacy preserved in `openapi-v1422-legacy.yaml`). Future routes get added incrementally via `src/lib/openapi/routes.ts`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
a8b233bf86
commit
6a22064668
2 files changed
+1369
-14
No files matched your search
+856
-1
@@ -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
|
||||
|
||||
+513
-13
@@ -1,22 +1,522 @@
|
||||
/**
|
||||
* OpenAPI route table — populated incrementally per W4 F5b and beyond.
|
||||
* OpenAPI route table — populated incrementally.
|
||||
*
|
||||
* Routes are registered as `paths[<url>][<method>] = 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<ZodOpenApiObject["paths"]> = {};
|
||||
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<T extends z.ZodType>(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<ZodOpenApiObject["paths"]> = {
|
||||
"/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<ZodOpenApiObject["components"]> = {
|
||||
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.",
|
||||
},
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user