mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Merge work/contract-touchups
This commit is contained in:
commit
f6edff9b82
14 files changed
+583
-111
No files matched your search
+194
-81
@@ -2196,6 +2196,24 @@ paths:
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/auth/passkeys:
|
||||
get:
|
||||
tags:
|
||||
- Auth
|
||||
summary: List the caller's registered passkeys
|
||||
description: The passkeys registered for primary sign-in, newest first. Distinct from the second-factor security keys in
|
||||
`GET /api/auth/2fa/status` (`webauthn`) — a passkey replaces the password, a security key is asserted after one.
|
||||
`lastUsedAt` is stamped on every verified assertion and is null only for a passkey that has never signed in.
|
||||
responses:
|
||||
"200":
|
||||
description: The caller's passkeys, newest first.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PasskeyListEnvelope"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/auth/me/sessions:
|
||||
get:
|
||||
tags:
|
||||
@@ -3685,7 +3703,14 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"422": &a6
|
||||
description: Request validation failed. When the body carried a `baseUpdatedAt` that could not be parsed as an ISO-8601
|
||||
timestamp — including an explicit `null` — `meta.errorCode` = `invalid_base_updated_at` and nothing was
|
||||
written. Omit the key entirely for the unconditional write; do not send `null` for it.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"429": *a4
|
||||
delete:
|
||||
tags:
|
||||
@@ -4688,7 +4713,7 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"422": *a6
|
||||
"429": *a4
|
||||
/api/auth/me/disable-coach:
|
||||
get:
|
||||
@@ -4821,7 +4846,7 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"422": *a6
|
||||
"429": *a4
|
||||
/api/auth/me/source-priority:
|
||||
get:
|
||||
@@ -5847,7 +5872,7 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"422": *a6
|
||||
"429": *a4
|
||||
/api/auth/me/report-selection:
|
||||
get:
|
||||
@@ -6291,7 +6316,7 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"422": *a6
|
||||
"429": *a4
|
||||
delete:
|
||||
tags:
|
||||
@@ -6469,8 +6494,11 @@ paths:
|
||||
baseUpdatedAt:
|
||||
description: "Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the
|
||||
legacy unconditional write (older clients are unaffected). When present, the write is guarded on the
|
||||
stored row still carrying this exact value — a stale token fails with 409 and changes nothing. Treat
|
||||
as opaque: only ever echo a server-returned value, never parse or synthesise it."
|
||||
stored row still carrying this exact value — a stale token fails with 409 and changes nothing. A
|
||||
present-but-unparseable value fails with 422 and `meta.errorCode` = `invalid_base_updated_at` — note
|
||||
that this is NOT the unconditional write: sending `null` or a malformed string is rejected rather
|
||||
than treated as an omitted token. Treat as opaque: only ever echo a server-returned value, never
|
||||
parse or synthesise it."
|
||||
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)))$
|
||||
@@ -6491,7 +6519,7 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"422": *a6
|
||||
"429": *a4
|
||||
/api/anamnesis/facts:
|
||||
get:
|
||||
@@ -6616,7 +6644,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CorrectAnamnesisFactResponse"
|
||||
"401": *a1
|
||||
"404": &a6
|
||||
"404": &a7
|
||||
description: No current revision with this id exists for the authenticated account.
|
||||
content:
|
||||
application/json:
|
||||
@@ -6652,7 +6680,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/RemoveAnamnesisFactResponse"
|
||||
"401": *a1
|
||||
"404": *a6
|
||||
"404": *a7
|
||||
"409":
|
||||
description: Anamnesis fact changed since it was loaded (optimistic-concurrency conflict). No write happened. Re-read
|
||||
the resource, re-apply the user's change against the fresh state, and resend with the new token.
|
||||
@@ -7783,9 +7811,11 @@ paths:
|
||||
- in: query
|
||||
name: type
|
||||
schema:
|
||||
description: For VITALS_BASELINE only — the single vital to band (defaults to RESTING_HEART_RATE). Ignored by
|
||||
composites. An unsupported value yields an `insufficient` value rather than a 422 so iOS metric
|
||||
combinations stay forgiving.
|
||||
description: The single measurement type a baseline metric works over. `VITALS_BASELINE` takes one of the eleven vitals
|
||||
(`RESTING_HEART_RATE` through `WEIGHT`; defaults to `RESTING_HEART_RATE`). `SAME_TIME_BASELINE` takes one
|
||||
of the four cumulative day metrics — `ACTIVITY_STEPS`, `ACTIVE_ENERGY_BURNED`, `WALKING_RUNNING_DISTANCE`,
|
||||
`FLIGHTS_CLIMBED`. Ignored by the composite metrics. A type the named metric does not support yields an
|
||||
`insufficient` value rather than a 422, so client metric combinations stay forgiving.
|
||||
type: string
|
||||
enum:
|
||||
- RESTING_HEART_RATE
|
||||
@@ -7799,9 +7829,15 @@ paths:
|
||||
- BLOOD_GLUCOSE
|
||||
- PULSE
|
||||
- WEIGHT
|
||||
description: For VITALS_BASELINE only — the single vital to band (defaults to RESTING_HEART_RATE). Ignored by
|
||||
composites. An unsupported value yields an `insufficient` value rather than a 422 so iOS metric combinations
|
||||
stay forgiving.
|
||||
- ACTIVITY_STEPS
|
||||
- ACTIVE_ENERGY_BURNED
|
||||
- WALKING_RUNNING_DISTANCE
|
||||
- FLIGHTS_CLIMBED
|
||||
description: The single measurement type a baseline metric works over. `VITALS_BASELINE` takes one of the eleven vitals
|
||||
(`RESTING_HEART_RATE` through `WEIGHT`; defaults to `RESTING_HEART_RATE`). `SAME_TIME_BASELINE` takes one of
|
||||
the four cumulative day metrics — `ACTIVITY_STEPS`, `ACTIVE_ENERGY_BURNED`, `WALKING_RUNNING_DISTANCE`,
|
||||
`FLIGHTS_CLIMBED`. Ignored by the composite metrics. A type the named metric does not support yields an
|
||||
`insufficient` value rather than a 422, so client metric combinations stay forgiving.
|
||||
responses:
|
||||
"200":
|
||||
description: The flat derived-metric value (ok or insufficient).
|
||||
@@ -7830,12 +7866,16 @@ paths:
|
||||
minLength: 1
|
||||
maxLength: 1024
|
||||
description: Comma-separated derived-metric tokens. Each is a `<DERIVED_METRIC_ID>` or
|
||||
`<DERIVED_METRIC_ID>:<MeasurementType>` (the colon sub-targets a VITALS_BASELINE vital). An unknown id
|
||||
422s; a `type` outside the MeasurementType enum 422s; at most 24 tokens; duplicates collapse.
|
||||
`<DERIVED_METRIC_ID>:<MeasurementType>` (the colon sub-targets the single measurement type a baseline
|
||||
metric works over — a vital for `VITALS_BASELINE`, a cumulative day metric for `SAME_TIME_BASELINE`; see
|
||||
the `type` parameter on `GET /api/insights/derived`). An unknown id 422s; a `type` outside the
|
||||
MeasurementType enum 422s; at most 24 tokens; duplicates collapse.
|
||||
required: true
|
||||
description: Comma-separated derived-metric tokens. Each is a `<DERIVED_METRIC_ID>` or
|
||||
`<DERIVED_METRIC_ID>:<MeasurementType>` (the colon sub-targets a VITALS_BASELINE vital). An unknown id 422s;
|
||||
a `type` outside the MeasurementType enum 422s; at most 24 tokens; duplicates collapse.
|
||||
`<DERIVED_METRIC_ID>:<MeasurementType>` (the colon sub-targets the single measurement type a baseline metric
|
||||
works over — a vital for `VITALS_BASELINE`, a cumulative day metric for `SAME_TIME_BASELINE`; see the `type`
|
||||
parameter on `GET /api/insights/derived`). An unknown id 422s; a `type` outside the MeasurementType enum
|
||||
422s; at most 24 tokens; duplicates collapse.
|
||||
responses:
|
||||
"200":
|
||||
description: The map of derived-metric values, keyed by token.
|
||||
@@ -8222,7 +8262,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MoodCustomTagPatchEnvelope"
|
||||
"401": *a1
|
||||
"404": &a7
|
||||
"404": &a8
|
||||
description: Not found / not owned by the caller.
|
||||
content:
|
||||
application/json:
|
||||
@@ -8257,7 +8297,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MoodCustomTagDeleteEnvelope"
|
||||
"401": *a1
|
||||
"404": *a7
|
||||
"404": *a8
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/mood/tags/{key}/hidden:
|
||||
@@ -8298,7 +8338,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"401": *a1
|
||||
"404": *a7
|
||||
"404": *a8
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/mood/tags/groups:
|
||||
@@ -8373,7 +8413,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MoodTagGroupPatchEnvelope"
|
||||
"401": *a1
|
||||
"404": *a7
|
||||
"404": *a8
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
delete:
|
||||
@@ -8404,7 +8444,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MoodTagGroupDeleteEnvelope"
|
||||
"401": *a1
|
||||
"404": *a7
|
||||
"404": *a8
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/mood/tags/layout:
|
||||
@@ -8452,7 +8492,7 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"422": *a6
|
||||
"429": *a4
|
||||
/api/settings/reminder-thresholds:
|
||||
get:
|
||||
@@ -8648,7 +8688,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MeasurementReminderEnvelope"
|
||||
"401": *a1
|
||||
"404": &a8
|
||||
"404": &a9
|
||||
description: Measurement reminder not found / not owned.
|
||||
content:
|
||||
application/json:
|
||||
@@ -8682,7 +8722,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MeasurementReminderPatchEnvelope"
|
||||
"401": *a1
|
||||
"404": *a8
|
||||
"404": *a9
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
delete:
|
||||
@@ -8704,7 +8744,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MeasurementReminderDeleteEnvelope"
|
||||
"401": *a1
|
||||
"404": *a8
|
||||
"404": *a9
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/measurement-reminders/{id}/satisfy:
|
||||
@@ -8728,7 +8768,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MeasurementReminderSatisfyEnvelope"
|
||||
"401": *a1
|
||||
"404": *a8
|
||||
"404": *a9
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/measurement-reminders/{id}/complete:
|
||||
@@ -8755,7 +8795,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MeasurementReminderCompleteEnvelope"
|
||||
"401": *a1
|
||||
"404": *a8
|
||||
"404": *a9
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/labs:
|
||||
@@ -8920,7 +8960,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GetLabResultResponse"
|
||||
"401": *a1
|
||||
"404": &a9
|
||||
"404": &a10
|
||||
description: Lab result not found (or owned by another user).
|
||||
content:
|
||||
application/json:
|
||||
@@ -8998,7 +9038,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UpdateLabResultResponse"
|
||||
"401": *a1
|
||||
"404": *a9
|
||||
"404": *a10
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
delete:
|
||||
@@ -9020,7 +9060,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DeleteLabResultResponse"
|
||||
"401": *a1
|
||||
"404": *a9
|
||||
"404": *a10
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/labs/restore:
|
||||
@@ -9237,7 +9277,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CreateBiomarkerResponse"
|
||||
"401": *a1
|
||||
"409": &a11
|
||||
"409": &a12
|
||||
description: A biomarker with this name already exists for the caller.
|
||||
content:
|
||||
application/json:
|
||||
@@ -9265,7 +9305,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GetBiomarkerResponse"
|
||||
"401": *a1
|
||||
"404": &a10
|
||||
"404": &a11
|
||||
description: Biomarker not found (or owned by another user).
|
||||
content:
|
||||
application/json:
|
||||
@@ -9332,8 +9372,8 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UpdateBiomarkerResponse"
|
||||
"401": *a1
|
||||
"404": *a10
|
||||
"409": *a11
|
||||
"404": *a11
|
||||
"409": *a12
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
delete:
|
||||
@@ -9356,7 +9396,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DeleteBiomarkerResponse"
|
||||
"401": *a1
|
||||
"404": *a10
|
||||
"404": *a11
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/illness/episodes:
|
||||
@@ -9481,7 +9521,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GetIllnessEpisodeEnvelope"
|
||||
"401": *a1
|
||||
"404": &a12
|
||||
"404": &a13
|
||||
description: Illness episode not found (or owned by another user).
|
||||
content:
|
||||
application/json:
|
||||
@@ -9559,7 +9599,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UpdateIllnessEpisodeEnvelope"
|
||||
"401": *a1
|
||||
"404": *a12
|
||||
"404": *a13
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
delete:
|
||||
@@ -9582,7 +9622,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DeleteIllnessEpisodeEnvelope"
|
||||
"401": *a1
|
||||
"404": *a12
|
||||
"404": *a13
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/illness/episodes/{id}/restore:
|
||||
@@ -9608,7 +9648,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/RestoreIllnessEpisodeEnvelope"
|
||||
"401": *a1
|
||||
"404": *a12
|
||||
"404": *a13
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/illness/episodes/{id}/resolve:
|
||||
@@ -9644,7 +9684,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ResolveIllnessEpisodeEnvelope"
|
||||
"401": *a1
|
||||
"404": *a12
|
||||
"404": *a13
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/illness/episodes/{id}/day-logs:
|
||||
@@ -9694,7 +9734,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GetIllnessDayLogEnvelope"
|
||||
"401": *a1
|
||||
"404": *a12
|
||||
"404": *a13
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
post:
|
||||
@@ -9773,7 +9813,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UpsertIllnessDayLogCreatedEnvelope"
|
||||
"401": *a1
|
||||
"404": *a12
|
||||
"404": *a13
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/illness/episodes/{id}/correlation:
|
||||
@@ -9800,7 +9840,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/IllnessCorrelationEnvelope"
|
||||
"401": *a1
|
||||
"404": *a12
|
||||
"404": *a13
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/illness/insights:
|
||||
@@ -10068,7 +10108,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GetAllergyEnvelope"
|
||||
"401": *a1
|
||||
"404": &a13
|
||||
"404": &a14
|
||||
description: Allergy not found (or owned by another user).
|
||||
content:
|
||||
application/json:
|
||||
@@ -10150,7 +10190,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UpdateAllergyEnvelope"
|
||||
"401": *a1
|
||||
"404": *a13
|
||||
"404": *a14
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
delete:
|
||||
@@ -10173,7 +10213,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DeleteAllergyEnvelope"
|
||||
"401": *a1
|
||||
"404": *a13
|
||||
"404": *a14
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/family-history:
|
||||
@@ -10278,7 +10318,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GetFamilyHistoryEnvelope"
|
||||
"401": *a1
|
||||
"404": &a14
|
||||
"404": &a15
|
||||
description: Family history entry not found (or owned by another user).
|
||||
content:
|
||||
application/json:
|
||||
@@ -10346,7 +10386,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UpdateFamilyHistoryEnvelope"
|
||||
"401": *a1
|
||||
"404": *a14
|
||||
"404": *a15
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
delete:
|
||||
@@ -10369,7 +10409,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DeleteFamilyHistoryEnvelope"
|
||||
"401": *a1
|
||||
"404": *a14
|
||||
"404": *a15
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/documents/inbound:
|
||||
@@ -11478,7 +11518,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CreateCustomMetricResponse"
|
||||
"401": *a1
|
||||
"409": &a16
|
||||
"409": &a17
|
||||
description: A custom metric with this name already exists for the caller.
|
||||
content:
|
||||
application/json:
|
||||
@@ -11506,7 +11546,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GetCustomMetricResponse"
|
||||
"401": *a1
|
||||
"404": &a15
|
||||
"404": &a16
|
||||
description: Custom metric not found (or owned by another user).
|
||||
content:
|
||||
application/json:
|
||||
@@ -11570,8 +11610,8 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UpdateCustomMetricResponse"
|
||||
"401": *a1
|
||||
"404": *a15
|
||||
"409": *a16
|
||||
"404": *a16
|
||||
"409": *a17
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
delete:
|
||||
@@ -11594,7 +11634,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DeleteCustomMetricResponse"
|
||||
"401": *a1
|
||||
"404": *a15
|
||||
"404": *a16
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/custom-metrics/{id}/entries:
|
||||
@@ -11637,7 +11677,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ListCustomMetricEntriesEnvelope"
|
||||
"401": *a1
|
||||
"404": *a15
|
||||
"404": *a16
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
post:
|
||||
@@ -11681,7 +11721,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CreateCustomMetricEntryResponse"
|
||||
"401": *a1
|
||||
"404": *a15
|
||||
"404": *a16
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/custom-metrics/{id}/entries/{entryId}:
|
||||
@@ -11731,7 +11771,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UpdateCustomMetricEntryResponse"
|
||||
"401": *a1
|
||||
"404": &a17
|
||||
"404": &a18
|
||||
description: Custom metric entry not found (or owned by another user).
|
||||
content:
|
||||
application/json:
|
||||
@@ -11763,7 +11803,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DeleteCustomMetricEntryResponse"
|
||||
"401": *a1
|
||||
"404": *a17
|
||||
"404": *a18
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/onboarding/disclaimer:
|
||||
@@ -11899,7 +11939,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/NutrientBatchEnvelope"
|
||||
"401": *a1
|
||||
"403": &a18
|
||||
"403": &a19
|
||||
description: The opt-in `nutrients` module is off for this account (errorCode `module.disabled`). Stop syncing and do
|
||||
not retry; offer the enable-in-settings hint. Only run the HealthKit read-authorization flow while the
|
||||
module is on.
|
||||
@@ -11933,7 +11973,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/NutrientOverviewEnvelope"
|
||||
"401": *a1
|
||||
"403": *a18
|
||||
"403": *a19
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/nutrients/daily:
|
||||
@@ -11969,7 +12009,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/NutrientDailySeriesEnvelope"
|
||||
"401": *a1
|
||||
"403": *a18
|
||||
"403": *a19
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/nutrients/water:
|
||||
@@ -11996,7 +12036,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/NutrientWaterWriteEnvelope"
|
||||
"401": *a1
|
||||
"403": *a18
|
||||
"403": *a19
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/integrations/healthkit:
|
||||
@@ -12271,7 +12311,7 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"422": *a6
|
||||
"429": *a4
|
||||
delete:
|
||||
tags:
|
||||
@@ -13297,8 +13337,10 @@ components:
|
||||
baseUpdatedAt:
|
||||
description: "Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the
|
||||
legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored
|
||||
row still carrying this exact value — a stale token fails with 409 and changes nothing. Treat as opaque:
|
||||
only ever echo a server-returned value, never parse or synthesise it."
|
||||
row still carrying this exact value — a stale token fails with 409 and changes nothing. A
|
||||
present-but-unparseable value fails with 422 and `meta.errorCode` = `invalid_base_updated_at` — note that
|
||||
this is NOT the unconditional write: sending `null` or a malformed string is rejected rather than treated as
|
||||
an omitted token. Treat as opaque: only ever echo a server-returned value, never parse or synthesise it."
|
||||
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)))$
|
||||
@@ -13772,8 +13814,10 @@ components:
|
||||
baseUpdatedAt:
|
||||
description: "Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the
|
||||
legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored
|
||||
row still carrying this exact value — a stale token fails with 409 and changes nothing. Treat as opaque:
|
||||
only ever echo a server-returned value, never parse or synthesise it."
|
||||
row still carrying this exact value — a stale token fails with 409 and changes nothing. A
|
||||
present-but-unparseable value fails with 422 and `meta.errorCode` = `invalid_base_updated_at` — note that
|
||||
this is NOT the unconditional write: sending `null` or a malformed string is rejected rather than treated as
|
||||
an omitted token. Treat as opaque: only ever echo a server-returned value, never parse or synthesise it."
|
||||
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)))$
|
||||
@@ -13836,8 +13880,10 @@ components:
|
||||
baseUpdatedAt:
|
||||
description: "Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the
|
||||
legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored
|
||||
row still carrying this exact value — a stale token fails with 409 and changes nothing. Treat as opaque:
|
||||
only ever echo a server-returned value, never parse or synthesise it."
|
||||
row still carrying this exact value — a stale token fails with 409 and changes nothing. A
|
||||
present-but-unparseable value fails with 422 and `meta.errorCode` = `invalid_base_updated_at` — note that
|
||||
this is NOT the unconditional write: sending `null` or a malformed string is rejected rather than treated as
|
||||
an omitted token. Treat as opaque: only ever echo a server-returned value, never parse or synthesise it."
|
||||
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)))$
|
||||
@@ -14000,8 +14046,10 @@ components:
|
||||
baseUpdatedAt:
|
||||
description: "Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the
|
||||
legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored
|
||||
row still carrying this exact value — a stale token fails with 409 and changes nothing. Treat as opaque:
|
||||
only ever echo a server-returned value, never parse or synthesise it."
|
||||
row still carrying this exact value — a stale token fails with 409 and changes nothing. A
|
||||
present-but-unparseable value fails with 422 and `meta.errorCode` = `invalid_base_updated_at` — note that
|
||||
this is NOT the unconditional write: sending `null` or a malformed string is rejected rather than treated as
|
||||
an omitted token. Treat as opaque: only ever echo a server-returned value, never parse or synthesise it."
|
||||
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)))$
|
||||
@@ -14207,8 +14255,10 @@ components:
|
||||
baseUpdatedAt:
|
||||
description: "Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the
|
||||
legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored
|
||||
row still carrying this exact value — a stale token fails with 409 and changes nothing. Treat as opaque:
|
||||
only ever echo a server-returned value, never parse or synthesise it."
|
||||
row still carrying this exact value — a stale token fails with 409 and changes nothing. A
|
||||
present-but-unparseable value fails with 422 and `meta.errorCode` = `invalid_base_updated_at` — note that
|
||||
this is NOT the unconditional write: sending `null` or a malformed string is rejected rather than treated as
|
||||
an omitted token. Treat as opaque: only ever echo a server-returned value, never parse or synthesise it."
|
||||
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)))$
|
||||
@@ -14445,8 +14495,10 @@ components:
|
||||
baseUpdatedAt:
|
||||
description: "Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the
|
||||
legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored
|
||||
row still carrying this exact value — a stale token fails with 409 and changes nothing. Treat as opaque:
|
||||
only ever echo a server-returned value, never parse or synthesise it."
|
||||
row still carrying this exact value — a stale token fails with 409 and changes nothing. A
|
||||
present-but-unparseable value fails with 422 and `meta.errorCode` = `invalid_base_updated_at` — note that
|
||||
this is NOT the unconditional write: sending `null` or a malformed string is rejected rather than treated as
|
||||
an omitted token. Treat as opaque: only ever echo a server-returned value, never parse or synthesise it."
|
||||
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)))$
|
||||
@@ -15027,8 +15079,10 @@ components:
|
||||
baseUpdatedAt:
|
||||
description: "Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the
|
||||
legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored
|
||||
row still carrying this exact value — a stale token fails with 409 and changes nothing. Treat as opaque:
|
||||
only ever echo a server-returned value, never parse or synthesise it."
|
||||
row still carrying this exact value — a stale token fails with 409 and changes nothing. A
|
||||
present-but-unparseable value fails with 422 and `meta.errorCode` = `invalid_base_updated_at` — note that
|
||||
this is NOT the unconditional write: sending `null` or a malformed string is rejected rather than treated as
|
||||
an omitted token. Treat as opaque: only ever echo a server-returned value, never parse or synthesise it."
|
||||
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)))$
|
||||
@@ -17425,6 +17479,65 @@ components:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
PasskeyListEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/PasskeyListResponse"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
PasskeyListResponse:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/PasskeyInfo"
|
||||
PasskeyInfo:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: Row id; the handle for DELETE.
|
||||
name:
|
||||
type: string
|
||||
description: User-supplied label, defaulting to “Passkey”.
|
||||
credentialDeviceType:
|
||||
type: string
|
||||
description: "Authenticator device type as reported at registration: `singleDevice` for a credential bound to one
|
||||
authenticator, `multiDevice` for a syncable one."
|
||||
credentialBackedUp:
|
||||
type: boolean
|
||||
description: Whether the authenticator reported the credential as backed up (synced to the platform keychain) at
|
||||
registration time. Not re-read afterwards.
|
||||
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)))$
|
||||
description: Registered at.
|
||||
lastUsedAt:
|
||||
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: When this passkey last completed a verified assertion; null when it has never been used to sign in. Stamped
|
||||
on every successful login, so a client can render a real 'last used' rather than assuming never.
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- credentialDeviceType
|
||||
- credentialBackedUp
|
||||
- createdAt
|
||||
- lastUsedAt
|
||||
additionalProperties: false
|
||||
SessionListEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* The bytes a client actually receives from an idempotency 409.
|
||||
*
|
||||
* `POST /api/anamnesis/facts` is the route the drift was reported against
|
||||
* (iOS #75), but nothing here is specific to it — the 409 comes from
|
||||
* `withIdempotency`, so every route under that wrapper serves the same
|
||||
* envelope. Until v1.35.1 both in-flight arms sent `error` as
|
||||
* `{ message: … }`, the only object-shaped error the app emitted and not the
|
||||
* `{ data, error: <string> }` envelope the published contract promises.
|
||||
*
|
||||
* This runs the REAL chain — the real route module, the real `apiHandler`,
|
||||
* the real `withIdempotency` — with only the data layer and the session
|
||||
* mocked, and asserts on the parsed response body. A schema test would not
|
||||
* have caught this: the schema was already right.
|
||||
*
|
||||
* Both 409 arms are covered:
|
||||
* - a `PENDING_STATUS` row already in the table (the in-flight lookup);
|
||||
* - a lost claim race (the row appears between the lookup and the insert).
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.stubEnv("ENCRYPTION_KEYS", "");
|
||||
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
|
||||
vi.stubEnv("ENCRYPTION_KEY", "0".repeat(64));
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
getSession: vi.fn(),
|
||||
createFact: vi.fn(),
|
||||
factFindFirst: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
idempotencyKey: {
|
||||
findUnique: mocks.findUnique,
|
||||
create: mocks.create,
|
||||
deleteMany: mocks.deleteMany,
|
||||
updateMany: mocks.updateMany,
|
||||
delete: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
apiToken: { findUnique: vi.fn() },
|
||||
healthProfileFactRevision: { findFirst: mocks.factFindFirst },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: mocks.getSession }));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
vi.mock("@/lib/profile/health-facts", () => ({
|
||||
readHealthProfileFacts: vi.fn(),
|
||||
createHealthProfileFact: mocks.createFact,
|
||||
correctHealthProfileFact: vi.fn(),
|
||||
removeHealthProfileFact: vi.fn(),
|
||||
}));
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
import { POST } from "../route";
|
||||
|
||||
/** The pending sentinel `findCached` recognises as "another call is running". */
|
||||
const PENDING_STATUS = 0;
|
||||
|
||||
function request(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/anamnesis/facts", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"idempotency-key": "key-in-flight",
|
||||
},
|
||||
body: JSON.stringify({ kind: "SMOKING_STATUS", value: "FORMER" }),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.getSession.mockResolvedValue({
|
||||
user: { id: "user-1", role: "USER" },
|
||||
});
|
||||
mocks.findUnique.mockResolvedValue(null);
|
||||
mocks.create.mockResolvedValue({});
|
||||
mocks.deleteMany.mockResolvedValue({ count: 0 });
|
||||
mocks.updateMany.mockResolvedValue({ count: 1 });
|
||||
mocks.factFindFirst.mockResolvedValue(null);
|
||||
mocks.createFact.mockResolvedValue({ id: "fact-1" });
|
||||
});
|
||||
|
||||
describe("idempotency 409 envelope, over the real route", () => {
|
||||
it("serves `error` as a string when a request is already in flight", async () => {
|
||||
mocks.findUnique.mockResolvedValue({
|
||||
id: "idem-1",
|
||||
responseStatus: PENDING_STATUS,
|
||||
responseBody: "",
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
});
|
||||
|
||||
const response = await POST(request());
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(body.data).toBeNull();
|
||||
expect(typeof body.error).toBe("string");
|
||||
expect(body.error).toBe(
|
||||
"A request with this Idempotency-Key is already in progress",
|
||||
);
|
||||
// The shape the drift produced, named explicitly so a revert cannot
|
||||
// pass by satisfying only the `typeof` check above.
|
||||
expect(body.error).not.toHaveProperty("message");
|
||||
expect(response.headers.get("X-Idempotent-Replay")).toBe("false");
|
||||
// The side effect must not have run on a refused call.
|
||||
expect(mocks.createFact).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("serves `error` as a string when the claim race is lost", async () => {
|
||||
// No row on lookup, but the claiming insert collides with the racing
|
||||
// request that got there first.
|
||||
mocks.findUnique.mockResolvedValue(null);
|
||||
mocks.create.mockRejectedValue(
|
||||
Object.assign(new Error("Unique constraint failed"), { code: "P2002" }),
|
||||
);
|
||||
|
||||
const response = await POST(request());
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(body.data).toBeNull();
|
||||
expect(typeof body.error).toBe("string");
|
||||
expect(body.error).toBe(
|
||||
"A request with this Idempotency-Key is already in progress",
|
||||
);
|
||||
expect(body.error).not.toHaveProperty("message");
|
||||
expect(response.headers.get("X-Idempotent-Replay")).toBe("false");
|
||||
expect(mocks.createFact).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,14 @@
|
||||
*
|
||||
* The write half (the stamp on a verified assertion) is pinned in
|
||||
* `src/lib/auth/__tests__/passkey.test.ts`.
|
||||
*
|
||||
* v1.35.1 adds the published half. The route was absent from
|
||||
* `docs/api/openapi.yaml` entirely — no path entry, no schema — while iOS
|
||||
* had been told to generate against one (iOS #60). The last case below
|
||||
* compares the projection the route actually asks Prisma for against the
|
||||
* property set the contract publishes, so the two cannot drift: a column
|
||||
* added to the select without a schema entry fails, and so does a schema
|
||||
* property the route never serves.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
@@ -29,6 +37,7 @@ vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
import { GET } from "../route";
|
||||
import { requireAuth } from "@/lib/api-handler";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { buildOpenApiDocument } from "@/lib/openapi/registry";
|
||||
|
||||
const USER = { id: "user-1", username: "u", email: "u@example.com" };
|
||||
|
||||
@@ -81,6 +90,29 @@ describe("GET /api/auth/passkeys", () => {
|
||||
expect(Object.keys(body.data[0])).toContain("lastUsedAt");
|
||||
expect(body.data[0].lastUsedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("publishes exactly the columns it selects", async () => {
|
||||
await (GET as unknown as () => Promise<Response>)();
|
||||
const args = vi.mocked(prisma.passkey.findMany).mock.calls[0][0];
|
||||
const selected = Object.keys(args?.select ?? {}).sort();
|
||||
|
||||
const doc = buildOpenApiDocument() as {
|
||||
paths?: Record<string, { get?: { responses?: Record<string, unknown> } }>;
|
||||
components?: {
|
||||
schemas?: Record<string, { properties?: Record<string, unknown> }>;
|
||||
};
|
||||
};
|
||||
|
||||
expect(
|
||||
doc.paths?.["/api/auth/passkeys"]?.get,
|
||||
"GET /api/auth/passkeys is not registered in the OpenAPI document",
|
||||
).toBeDefined();
|
||||
|
||||
const published = doc.components?.schemas?.["PasskeyInfo"]?.properties;
|
||||
expect(published, "PasskeyInfo schema is not emitted").toBeDefined();
|
||||
|
||||
expect(Object.keys(published ?? {}).sort()).toEqual(selected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("primary-passkey enrollment route guards", () => {
|
||||
|
||||
@@ -406,9 +406,12 @@ function coerceChartOverlayPrefsMap(value: unknown): ChartOverlayPrefsMap {
|
||||
*
|
||||
* - `READINESS` / `RECOVERY_SCORE` / `SLEEP_SCORE` resolve through the
|
||||
* derived registry's engines (module-gated like the derived routes);
|
||||
* - `MED_COMPLIANCE` is the pooled 7-day medication adherence from the
|
||||
* canonical compliance engine — the ring that absorbs the retired
|
||||
* hero dose row's information role.
|
||||
* - `MED_COMPLIANCE` is TODAY's dose progress (taken of scheduled) from
|
||||
* the snapshot's own `medsToday` block — the ring that absorbs the
|
||||
* retired hero dose row's information role. The v1.27.7 first cut put
|
||||
* the pooled 7-day adherence percentage here; it has been today's
|
||||
* progress since v1.27.8. Resolution lives in
|
||||
* `@/lib/dashboard/score-rings`.
|
||||
*
|
||||
* The preference piggy-backs on the layout blob like `chartOverlayPrefs`
|
||||
* (a UI affordance, no Prisma migration). The resolver drops unknown ids,
|
||||
|
||||
+27
-20
@@ -329,6 +329,31 @@ export async function defaultUserIdResolver(): Promise<string | null> {
|
||||
*
|
||||
* No-op when the header is missing or the value is malformed.
|
||||
*/
|
||||
/**
|
||||
* The 409 both in-flight arms return.
|
||||
*
|
||||
* `error` is a STRING, matching the envelope `@/lib/api-response` defines
|
||||
* for every other route (`{ data: null, error: <prose>, meta? }`). Until
|
||||
* v1.35.1 these two literals sent `error` as `{ message }` — the only
|
||||
* object-shaped error the app ever emitted, inherited by every route under
|
||||
* `withIdempotency`, and never what the published contract promised. A
|
||||
* client decoding the documented envelope now gets what it was generated
|
||||
* against.
|
||||
*
|
||||
* Built once rather than twice so the two arms cannot drift apart again.
|
||||
* The response is hand-built rather than routed through `apiError` because
|
||||
* the 409 carries the `X-Idempotent-Replay` header.
|
||||
*/
|
||||
function inflightConflictResponse(): Response {
|
||||
return NextResponse.json(
|
||||
{
|
||||
data: null,
|
||||
error: "A request with this Idempotency-Key is already in progress",
|
||||
},
|
||||
{ status: 409, headers: { "X-Idempotent-Replay": "false" } },
|
||||
);
|
||||
}
|
||||
|
||||
export function withIdempotency<
|
||||
Args extends [Request | NextRequest, ...unknown[]],
|
||||
>(
|
||||
@@ -368,16 +393,7 @@ export function withIdempotency<
|
||||
action: { name: "idempotency.inflight_conflict" },
|
||||
meta: { method: ctx.method, path: ctx.path },
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
data: null,
|
||||
error: {
|
||||
message:
|
||||
"A request with this Idempotency-Key is already in progress",
|
||||
},
|
||||
},
|
||||
{ status: 409, headers: { "X-Idempotent-Replay": "false" } },
|
||||
);
|
||||
return inflightConflictResponse();
|
||||
}
|
||||
|
||||
// Claim the key before running the handler. If a racing request beats
|
||||
@@ -389,16 +405,7 @@ export function withIdempotency<
|
||||
action: { name: "idempotency.inflight_conflict" },
|
||||
meta: { method: ctx.method, path: ctx.path },
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
data: null,
|
||||
error: {
|
||||
message:
|
||||
"A request with this Idempotency-Key is already in progress",
|
||||
},
|
||||
},
|
||||
{ status: 409, headers: { "X-Idempotent-Replay": "false" } },
|
||||
);
|
||||
return inflightConflictResponse();
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* The `type` query parameter on `GET /api/insights/derived` sub-targets the
|
||||
* single measurement type a baseline metric works over. Two engines define
|
||||
* one: `VITALS_BASELINE` over a vital, `SAME_TIME_BASELINE` over a cumulative
|
||||
* day metric. For most of a year the published enum carried the vitals alone,
|
||||
* so a spec-conformant client could not send the four same-time types at all
|
||||
* (iOS #75).
|
||||
*
|
||||
* This guard asserts the published parameter enumerates exactly the union of
|
||||
* the two registry sets, read out of the assembled OpenAPI document rather
|
||||
* than out of the schema module — the same object the generator writes to
|
||||
* `docs/api/openapi.yaml`, so a value that reaches the registry but not the
|
||||
* contract fails here.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildOpenApiDocument } from "../registry";
|
||||
import {
|
||||
VITALS_BASELINE_TYPES,
|
||||
SAME_TIME_BASELINE_TYPES,
|
||||
} from "@/lib/insights/derived/registry";
|
||||
|
||||
/** Pull the `type` query parameter's enum out of the assembled document. */
|
||||
function publishedTypeEnum(): string[] {
|
||||
const doc = buildOpenApiDocument() as {
|
||||
paths?: Record<
|
||||
string,
|
||||
{ get?: { parameters?: unknown[]; requestParams?: unknown } }
|
||||
>;
|
||||
};
|
||||
const operation = doc.paths?.["/api/insights/derived"]?.get;
|
||||
expect(
|
||||
operation,
|
||||
"GET /api/insights/derived is missing from the document",
|
||||
).toBeDefined();
|
||||
|
||||
const parameters = (operation?.parameters ?? []) as Array<{
|
||||
name?: string;
|
||||
in?: string;
|
||||
schema?: { enum?: string[] };
|
||||
}>;
|
||||
const typeParam = parameters.find(
|
||||
(p) => p.name === "type" && p.in === "query",
|
||||
);
|
||||
expect(typeParam, "the `type` query parameter is missing").toBeDefined();
|
||||
|
||||
const values = typeParam?.schema?.enum;
|
||||
expect(Array.isArray(values), "`type` is not published as an enum").toBe(
|
||||
true,
|
||||
);
|
||||
return values as string[];
|
||||
}
|
||||
|
||||
describe("derived `type` parameter stays in sync with the registry", () => {
|
||||
it("publishes exactly the union of the two baseline type sets", () => {
|
||||
const expected = [
|
||||
...VITALS_BASELINE_TYPES,
|
||||
...SAME_TIME_BASELINE_TYPES,
|
||||
].map(String);
|
||||
|
||||
expect([...publishedTypeEnum()].sort()).toEqual([...expected].sort());
|
||||
});
|
||||
|
||||
it("publishes every same-time baseline type", () => {
|
||||
// Named separately: this is the half that was missing, and a regression
|
||||
// here is the one a client feels immediately (a 422-shaped dead end on
|
||||
// a value the server accepts).
|
||||
const published = publishedTypeEnum();
|
||||
for (const type of SAME_TIME_BASELINE_TYPES) {
|
||||
expect(published).toContain(String(type));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -230,6 +230,40 @@ const webauthnCredentialInfo = z
|
||||
})
|
||||
.meta({ id: "WebauthnMfaCredentialInfo" });
|
||||
|
||||
// ── Passkey list (primary sign-in credentials) ───────────────────────
|
||||
// A DIFFERENT surface from `WebauthnMfaCredentialInfo` above: these are
|
||||
// the credentials a passkey LOGIN asserts against, not the security keys
|
||||
// registered as a second factor. The two lists are served by different
|
||||
// routes and are never merged.
|
||||
|
||||
const passkeyInfo = z
|
||||
.object({
|
||||
id: z.string().describe("Row id; the handle for DELETE."),
|
||||
name: z.string().describe("User-supplied label, defaulting to “Passkey”."),
|
||||
credentialDeviceType: z
|
||||
.string()
|
||||
.describe(
|
||||
"Authenticator device type as reported at registration: `singleDevice` for a credential bound to one authenticator, `multiDevice` for a syncable one.",
|
||||
),
|
||||
credentialBackedUp: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"Whether the authenticator reported the credential as backed up (synced to the platform keychain) at registration time. Not re-read afterwards.",
|
||||
),
|
||||
createdAt: z.iso.datetime({ offset: true }).describe("Registered at."),
|
||||
lastUsedAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.nullable()
|
||||
.describe(
|
||||
"When this passkey last completed a verified assertion; null when it has never been used to sign in. Stamped on every successful login, so a client can render a real 'last used' rather than assuming never.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "PasskeyInfo" });
|
||||
|
||||
const passkeyListResponse = z
|
||||
.array(passkeyInfo)
|
||||
.meta({ id: "PasskeyListResponse" });
|
||||
|
||||
const mfaStatusResponse = z
|
||||
.object({
|
||||
totp: z.object({ enabled: z.boolean() }),
|
||||
@@ -922,6 +956,25 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/auth/passkeys": {
|
||||
get: {
|
||||
tags: ["Auth"],
|
||||
summary: "List the caller's registered passkeys",
|
||||
description:
|
||||
"The passkeys registered for primary sign-in, newest first. Distinct from the second-factor security keys in `GET /api/auth/2fa/status` (`webauthn`) — a passkey replaces the password, a security key is asserted after one. `lastUsedAt` is stamped on every verified assertion and is null only for a passkey that has never signed in.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The caller's passkeys, newest first.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(passkeyListResponse, "PasskeyListEnvelope"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/auth/me/sessions": {
|
||||
get: {
|
||||
tags: ["Auth"],
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
stdResponses,
|
||||
baseUpdatedAtField,
|
||||
conflictResponse409,
|
||||
invalidBaseTokenResponse,
|
||||
} from "./shared";
|
||||
|
||||
// ── Coach cadence suggestions (v1.18.1) ──────────────────────────────
|
||||
@@ -1011,8 +1012,10 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
...conflictResponse409("Insights layout", "insights_layout_conflict"),
|
||||
// 422 (multi-issue validation envelope) comes from stdResponses.
|
||||
// 422 (multi-issue validation envelope) comes from stdResponses;
|
||||
// the base-token variant of it narrows that description afterwards.
|
||||
...stdResponses,
|
||||
...invalidBaseTokenResponse,
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
@@ -1233,6 +1236,7 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
...conflictResponse409("Self-context", "about_me_conflict"),
|
||||
...stdResponses,
|
||||
...invalidBaseTokenResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
stdResponses,
|
||||
baseUpdatedAtField,
|
||||
conflictResponse409,
|
||||
invalidBaseTokenResponse,
|
||||
} from "./shared";
|
||||
|
||||
const dashboardWidgetConfig = z.object({
|
||||
@@ -143,6 +144,7 @@ export const dashboardWidgetPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
...conflictResponse409("Dashboard layout", "dashboard_layout_conflict"),
|
||||
...stdResponses,
|
||||
...invalidBaseTokenResponse,
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { METRIC_STATUS_IDS } from "@/lib/insights/metric-status-registry";
|
||||
import {
|
||||
DERIVED_METRIC_IDS,
|
||||
VITALS_BASELINE_TYPES,
|
||||
SAME_TIME_BASELINE_TYPES,
|
||||
} from "@/lib/insights/derived/registry";
|
||||
import { ANALYTICS_RANGES } from "@/lib/analytics/range-delta";
|
||||
|
||||
@@ -108,8 +109,26 @@ export const metricStatusResponse = z
|
||||
|
||||
// v1.10.0 — generic derived-wellness-metric route. The query enum is
|
||||
// derived from the same registry the route validates against, so spec +
|
||||
// route + cache scope cannot drift. `type` sub-targets the single vital
|
||||
// a baseline metric (VITALS_BASELINE) bands over.
|
||||
// route + cache scope cannot drift. `type` sub-targets the single
|
||||
// measurement type a baseline metric works over.
|
||||
|
||||
/**
|
||||
* Every value the `type` query parameter accepts, assembled from the two
|
||||
* registry sets that define one: `VITALS_BASELINE` bands a single vital,
|
||||
* `SAME_TIME_BASELINE` compares one cumulative day metric against the same
|
||||
* hour of a typical day. Published as one enum because `type` is a single
|
||||
* parameter shared by both metrics — a client picks the subset that belongs
|
||||
* to the `metric` it names.
|
||||
*
|
||||
* Built from the registry rather than restated here, so a type added to
|
||||
* either engine reaches the published contract in the same commit.
|
||||
* `derived-type-enum-sync.test.ts` pins the equality.
|
||||
*/
|
||||
export const DERIVED_TYPE_PARAM_VALUES: string[] = [
|
||||
...VITALS_BASELINE_TYPES,
|
||||
...SAME_TIME_BASELINE_TYPES,
|
||||
];
|
||||
|
||||
export const derivedMetricQuery = z
|
||||
.object({
|
||||
metric: z
|
||||
@@ -118,10 +137,10 @@ export const derivedMetricQuery = z
|
||||
"Derived-metric id to compute (e.g. VITALS_BASELINE, FITNESS_AGE, VASCULAR_AGE_DELTA, HRV_BALANCE, BMI, READINESS). Closed enum: an unknown id 422s. Metrics whose compute has not yet landed return an `insufficient` value with reason `not_implemented`.",
|
||||
),
|
||||
type: z
|
||||
.enum(VITALS_BASELINE_TYPES as [string, ...string[]])
|
||||
.enum(DERIVED_TYPE_PARAM_VALUES as [string, ...string[]])
|
||||
.optional()
|
||||
.describe(
|
||||
"For VITALS_BASELINE only — the single vital to band (defaults to RESTING_HEART_RATE). Ignored by composites. An unsupported value yields an `insufficient` value rather than a 422 so iOS metric combinations stay forgiving.",
|
||||
"The single measurement type a baseline metric works over. `VITALS_BASELINE` takes one of the eleven vitals (`RESTING_HEART_RATE` through `WEIGHT`; defaults to `RESTING_HEART_RATE`). `SAME_TIME_BASELINE` takes one of the four cumulative day metrics — `ACTIVITY_STEPS`, `ACTIVE_ENERGY_BURNED`, `WALKING_RUNNING_DISTANCE`, `FLIGHTS_CLIMBED`. Ignored by the composite metrics. A type the named metric does not support yields an `insufficient` value rather than a 422, so client metric combinations stay forgiving.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "DerivedMetricQuery" });
|
||||
@@ -253,7 +272,7 @@ export const derivedBatchQuery = z
|
||||
.min(1)
|
||||
.max(1024)
|
||||
.describe(
|
||||
"Comma-separated derived-metric tokens. Each is a `<DERIVED_METRIC_ID>` or `<DERIVED_METRIC_ID>:<MeasurementType>` (the colon sub-targets a VITALS_BASELINE vital). An unknown id 422s; a `type` outside the MeasurementType enum 422s; at most 24 tokens; duplicates collapse.",
|
||||
"Comma-separated derived-metric tokens. Each is a `<DERIVED_METRIC_ID>` or `<DERIVED_METRIC_ID>:<MeasurementType>` (the colon sub-targets the single measurement type a baseline metric works over — a vital for `VITALS_BASELINE`, a cumulative day metric for `SAME_TIME_BASELINE`; see the `type` parameter on `GET /api/insights/derived`). An unknown id 422s; a `type` outside the MeasurementType enum 422s; at most 24 tokens; duplicates collapse.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "DerivedBatchQuery" });
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
errorEnvelope,
|
||||
stdResponses,
|
||||
conflictResponse409,
|
||||
invalidBaseTokenResponse,
|
||||
} from "../shared";
|
||||
|
||||
efficacyTargetOverrideSchema.meta({
|
||||
@@ -289,6 +290,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"medication_layout_conflict",
|
||||
),
|
||||
...stdResponses,
|
||||
...invalidBaseTokenResponse,
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
baseUpdatedAtField,
|
||||
updatedAtTokenField,
|
||||
conflictResponse409,
|
||||
invalidBaseTokenResponse,
|
||||
} from "./shared";
|
||||
|
||||
// ── Request schemas — annotated for spec emission ────────────────────
|
||||
@@ -566,6 +567,7 @@ export const moodPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
...conflictResponse409("Mood-tag layout", "mood_tag_layout_conflict"),
|
||||
...stdResponses,
|
||||
...invalidBaseTokenResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
baseUpdatedAtField,
|
||||
updatedAtTokenField,
|
||||
conflictResponse409,
|
||||
invalidBaseTokenResponse,
|
||||
} from "./shared";
|
||||
|
||||
// v1.18.0 — module enable/disable. The PATCH request is the REAL runtime
|
||||
@@ -499,6 +500,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
...conflictResponse409("Coach preferences", "coach_prefs_conflict"),
|
||||
...stdResponses,
|
||||
...invalidBaseTokenResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -634,6 +636,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
...conflictResponse409("Module preferences", "modules_conflict"),
|
||||
...stdResponses,
|
||||
...invalidBaseTokenResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -875,6 +878,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"notification_prefs_conflict",
|
||||
),
|
||||
...stdResponses,
|
||||
...invalidBaseTokenResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -109,7 +109,7 @@ export const baseUpdatedAtField = z.iso
|
||||
.datetime({ offset: true })
|
||||
.optional()
|
||||
.describe(
|
||||
"Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored row still carrying this exact value — a stale token fails with 409 and changes nothing. Treat as opaque: only ever echo a server-returned value, never parse or synthesise it.",
|
||||
"Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored row still carrying this exact value — a stale token fails with 409 and changes nothing. A present-but-unparseable value fails with 422 and `meta.errorCode` = `invalid_base_updated_at` — note that this is NOT the unconditional write: sending `null` or a malformed string is rejected rather than treated as an omitted token. Treat as opaque: only ever echo a server-returned value, never parse or synthesise it.",
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -138,6 +138,28 @@ export function conflictResponse409(resource: string, errorCode: string) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The 422 a malformed `baseUpdatedAt` earns, for the write endpoints that
|
||||
* accept the token in their body.
|
||||
*
|
||||
* Spread AFTER `...stdResponses` — the generic 422 there would otherwise
|
||||
* overwrite this one and the errorCode would vanish from the contract.
|
||||
*
|
||||
* Worth stating outright because the shape surprises implementers: an
|
||||
* unparseable token is NOT silently downgraded to the unconditional write.
|
||||
* `null`, an empty string and a non-ISO string all 422. The unconditional
|
||||
* write is reached by OMITTING the key, nothing else. `invalid_base_updated_at`
|
||||
* had lived only in route tests since v1.32.21, so a client had no way to
|
||||
* learn this from the published spec.
|
||||
*/
|
||||
export const invalidBaseTokenResponse = {
|
||||
"422": {
|
||||
description:
|
||||
"Request validation failed. When the body carried a `baseUpdatedAt` that could not be parsed as an ISO-8601 timestamp — including an explicit `null` — `meta.errorCode` = `invalid_base_updated_at` and nothing was written. Omit the key entirely for the unconditional write; do not send `null` for it.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
};
|
||||
|
||||
// ── Standard 401 / 422 / 429 responses ───────────────────────────────
|
||||
|
||||
export const stdResponses = {
|
||||
|
||||
Reference in new issue
Block a user