feat(openapi): publish the Apple Health write-back contract

Declare appleHealthMirror on measurement rows and sync upserts, the list
filter, the measurementMirror sync domain, the token setting on the list,
the mint and the new PATCH, and the unattributed-readings summary.
This commit is contained in:
Marc-André Bombeck committed 2026-10-23 19:28:05 +02:00
1 parent bfa6873d96
commit d6774b770d
5 files changed
+354 -18

No files matched your search

+5 -1
View File
@@ -66,7 +66,11 @@ before treating a warning as a regression.
`health:write`), audience-bound to `/mcp` and refused on REST writes;
- `POST /api/tokens/measurements` mints `measurements:write`, accepted by
`POST /api/measurements` and `POST /api/measurements/batch` only, on the
holder's own record only;
holder's own record only. The readings it writes carry `source: EXTERNAL`
and are linked to the token; whether the HealthLog app writes them into
Apple Health is the token's `mirrorToAppleHealth` setting (off by
default, changed with `PATCH /api/tokens/{id}`), resolved onto every
measurement row as `appleHealthMirror`;
- `POST /api/tokens/workouts` mints `workouts:write`, accepted by
`POST /api/workouts/batch` only, on the holder's own record only; the
workouts it writes carry `source: EXTERNAL`;
+223 -14
View File
@@ -3372,6 +3372,25 @@ paths:
"422": *a4
"429": *a5
/api/tokens/measurements:
get:
tags:
- Auth
summary: Readings no measurement token reaches
description: Counts the caller's live EXTERNAL readings that are linked to no token. Readings are linked to the token
that sent them since v1.46; older ones were linked by a one-time pass only when the account held exactly one
measurement token, so the rest belong to none and no token's Apple Health setting reaches them. Cookie session
only.
responses:
"200":
description: The count.
content:
application/json:
schema:
$ref: "#/components/schemas/MeasurementTokenSummaryEnvelope"
"401": *a1
"403": *a9
"422": *a4
"429": *a5
post:
tags:
- Auth
@@ -3690,6 +3709,53 @@ paths:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a4
"429": *a5
patch:
tags:
- Auth
summary: Change a measurement token's Apple Health setting
description: "Sets whether the HealthLog app writes the readings this measurement token sent into Apple Health. The
readings keep their `EXTERNAL` source and are not rewritten: every measurement row resolves `appleHealthMirror`
from the token at read time, and a paired client learns of the change through the `measurementMirror` domain of
`GET /api/sync/changes`. Turning it off stops further writes; samples already in Apple Health stay. A revoked
token is accepted, since its readings stay in the record. Cookie session only, the caller's own tokens only
(another account's answers 404). Audited as `tokens.measurements.mirror.update` when the value changes. 30
changes per account per minute. Not gated on the instance-wide API switch: no credential is created or
extended."
parameters:
- in: path
name: id
schema:
type: string
required: true
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UpdateMeasurementTokenRequest"
responses:
"200":
description: The setting as it now stands.
content:
application/json:
schema:
$ref: "#/components/schemas/UpdateMeasurementTokenEnvelope"
"401": *a1
"403": *a9
"404":
description: Token not found or not owned by the caller.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422":
description: "Validation failed, or the token is not a measurement token (`meta.errorCode` =
`tokens.mirror.not_measurement_token`): a wildcard, connector or other narrow token has no such setting."
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"429": *a5
/api/auth/register:
post:
security: []
@@ -4224,10 +4290,11 @@ paths:
- Sync
summary: Multi-domain delta feed
description: "Incremental catch-up after the first-pair backfill (never a replacement for it). Pages over one opaque
keyset cursor spanning five domains — measurements, mood, medication intakes, cycle day logs and cycles. Each
domain carries `tombstones` (soft-deleted rows) and `upserts` (live rows); apply tombstones before upserts
within a domain. Tombstone identity is `externalId` for measurements and the server `id` everywhere else.
`cursorExpired: true` forces a clean re-init and the page is empty in every domain."
keyset cursor spanning six domains — measurements, mood, medication intakes, cycle day logs, cycles, and
`measurementMirror` (the Apple Health write-back of readings whose token setting changed). Each domain carries
`tombstones` (soft-deleted rows) and `upserts` (live rows); apply tombstones before upserts within a domain.
Tombstone identity is `externalId` for measurements and the server `id` everywhere else. `cursorExpired: true`
forces a clean re-init and the page is empty in every domain."
parameters:
- in: query
name: cursor
@@ -4554,6 +4621,12 @@ paths:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
- in: query
name: appleHealthMirror
schema:
type: string
enum:
- "true"
- in: query
name: valueMin
schema:
@@ -28142,12 +28215,15 @@ components:
type: integer
minimum: 1
maximum: 365
mirrorToAppleHealth:
type: boolean
required:
- name
description: "Mint a measurement-ingest Bearer. There is no `scope` field: this endpoint mints one shape and the
permission array is a literal, so no request can widen it. `name` is what the token is listed under — name it
after the thing you are pasting it into, because that is what you will be reading when you decide whether to
revoke it. `expiresInDays` defaults to 365."
revoke it. `expiresInDays` defaults to 365. `mirrorToAppleHealth` (default false) lets the HealthLog app write
the token's readings into Apple Health; it can be changed later at `PATCH /api/tokens/{id}`."
CreateWorkoutTokenRequest:
type: object
properties:
@@ -28178,6 +28254,15 @@ components:
- name
description: "Mint a document-upload Bearer. No `scope` field: the endpoint mints one shape. `name` is what the token is
listed under; name it after the system it is pasted into. `expiresInDays` defaults to 365."
UpdateMeasurementTokenRequest:
type: object
properties:
mirrorToAppleHealth:
type: boolean
required:
- mirrorToAppleHealth
additionalProperties: false
description: "The one setting a measurement token has. Strict: any other key is refused 422."
RegisterRequest:
type: object
properties:
@@ -38032,11 +38117,12 @@ components:
source-priority.body.invalid_json, source-priority.body.invalid_shape; symptoms —
symptoms.definition.limitReached, symptoms.definition.notFound, symptoms.episode.notOpen,
symptoms.event.notFound, symptoms.invalid; timeline — timeline.invalid; tokens —
tokens.documents.ceiling_reached, tokens.measurements.ceiling_reached, tokens.workouts.ceiling_reached;
vaccination — vaccination.booster-invalid, vaccination.booster-no-antigen,
vaccination.booster-out-of-scope, vaccination.custom-vaccine-not-found, vaccination.custom.invalid,
vaccination.custom.name-taken, vaccination.custom.not-found, vaccination.encounter-not-found,
vaccination.identity-required, vaccination.invalid, vaccination.link.invalid, vaccination.not-found,
tokens.documents.ceiling_reached, tokens.measurements.ceiling_reached,
tokens.mirror.not_measurement_token, tokens.workouts.ceiling_reached; vaccination —
vaccination.booster-invalid, vaccination.booster-no-antigen, vaccination.booster-out-of-scope,
vaccination.custom-vaccine-not-found, vaccination.custom.invalid, vaccination.custom.name-taken,
vaccination.custom.not-found, vaccination.encounter-not-found, vaccination.identity-required,
vaccination.invalid, vaccination.link.invalid, vaccination.not-found,
vaccination.practitioner-not-found; workout — workout.batch.invalid, workout.batch.payload_too_large,
workout.batch.source_not_permitted, workout.batch.too_large, workout.delete.synced_source; (unprefixed)
— about_me_conflict, ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file,
@@ -40800,6 +40886,11 @@ components:
type:
- string
- "null"
ingestTokenId:
anyOf:
- type: string
maxLength: 64
- type: "null"
syncVersion:
type: integer
minimum: -9007199254740991
@@ -47391,6 +47482,10 @@ components:
type: boolean
description: Revoked tokens stay in the list rather than disappearing, so a client must filter on this rather than treat
presence as validity.
mirrorToAppleHealth:
type: boolean
description: "A measurement token's Apple Health setting: whether the HealthLog app writes the readings this token sent
into Apple Health. Changed with `PATCH /api/tokens/{id}`; false on every other token."
required:
- id
- name
@@ -47399,6 +47494,36 @@ components:
- expiresAt
- createdAt
- revoked
- mirrorToAppleHealth
additionalProperties: false
MeasurementTokenSummaryEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/MeasurementTokenSummaryResponse"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
MeasurementTokenSummaryResponse:
type: object
properties:
unattributedReadings:
type: integer
minimum: 0
maximum: 9007199254740991
description: "Live EXTERNAL readings that belong to no token: sent before readings were linked to their token, by an
account that held more than one. No token's Apple Health setting reaches them."
required:
- unattributedReadings
additionalProperties: false
CreateMeasurementTokenEnvelope:
type: object
@@ -47532,6 +47657,34 @@ components:
- data
- error
additionalProperties: false
UpdateMeasurementTokenEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/UpdateMeasurementTokenResponse"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
UpdateMeasurementTokenResponse:
type: object
properties:
id:
type: string
mirrorToAppleHealth:
type: boolean
required:
- id
- mirrorToAppleHealth
additionalProperties: false
RegisterEnvelope:
type: object
properties:
@@ -48111,12 +48264,37 @@ components:
additionalProperties: false
description: Menstrual cycles, observed and forward-predicted. Present on every page, empty arrays when the account has
the cycle module off.
measurementMirror:
type: object
properties:
upserts:
type: array
items:
$ref: "#/components/schemas/SyncMeasurementMirrorUpsert"
tombstones:
maxItems: 0
type: array
items:
type: object
properties:
id:
type: string
required:
- id
additionalProperties: false
description: "Always empty: a deleted reading is a measurement tombstone."
required:
- upserts
- tombstones
additionalProperties: false
description: Apple Health write-back changes for readings that are otherwise unchanged. Apply after `measurements`.
required:
- measurements
- mood
- intakes
- cycleDays
- cycles
- measurementMirror
additionalProperties: false
required:
- serverNow
@@ -48125,10 +48303,11 @@ components:
- cursorExpired
- changes
additionalProperties: false
description: "Multi-domain delta page: measurements, mood, intakes, cycle day logs and cycles. One opaque multi-domain
keyset cursor; tombstones apply before upserts within each domain. Tombstone identity: measurements key on
externalId, every other domain on server id. All five keys are present on every response, including the
`cursorExpired` short-circuit — a domain with nothing to report sends empty arrays rather than being omitted."
description: "Multi-domain delta page: measurements, mood, intakes, cycle day logs, cycles, and the Apple Health
write-back changes of otherwise unchanged readings. One opaque multi-domain keyset cursor; tombstones apply
before upserts within each domain. Tombstone identity: measurements key on externalId, every other domain on
server id. All six keys are present on every response, including the `cursorExpired` short-circuit — a domain
with nothing to report sends empty arrays rather than being omitted."
SyncMeasurementUpsert:
type: object
properties:
@@ -48221,6 +48400,10 @@ components:
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)))$
appleHealthMirror:
type: boolean
description: Whether the HealthLog app writes this reading into Apple Health. Server-resolved; mirror exactly the rows
that say true.
dayKey:
type: string
sampleCount:
@@ -48280,6 +48463,7 @@ components:
- externalId
- syncVersion
- updatedAt
- appleHealthMirror
additionalProperties: false
MeasurementSource:
type: string
@@ -48582,6 +48766,21 @@ components:
- updatedAt
additionalProperties: false
description: A soft-deleted menstrual cycle, keyed on server `id`. Apply before upserts within the domain page.
SyncMeasurementMirrorUpsert:
type: object
properties:
id:
type: string
description: The measurement's server id.
appleHealthMirror:
type: boolean
description: The reading's write-back as it now stands. False means stop writing it; samples already in Apple Health stay.
required:
- id
- appleHealthMirror
additionalProperties: false
description: A reading whose `appleHealthMirror` changed because the account switched the Apple Health setting of the
token that sent it. The reading itself did not change and is not re-sent in `measurements`.
GetMeasurementsSeriesBatchResponse:
type: object
properties:
@@ -49017,6 +49216,11 @@ components:
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)))$
appleHealthMirror:
description: "Whether the HealthLog app writes this reading into Apple Health. Resolved by the server on every stored
row: true for WITHINGS, IMPORT and MANUAL, for an EXTERNAL row whatever its account chose for the token that
sent it, false otherwise. Absent on the synthesised list rows."
type: boolean
dayKey:
type: string
sampleCount:
@@ -49258,6 +49462,11 @@ components:
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)))$
appleHealthMirror:
description: "Whether the HealthLog app writes this reading into Apple Health. Resolved by the server on every stored
row: true for WITHINGS, IMPORT and MANUAL, for an EXTERNAL row whatever its account chose for the token that
sent it, false otherwise. Absent on the synthesised list rows."
type: boolean
dayKey:
type: string
sampleCount:
+85 -1
View File
@@ -34,6 +34,7 @@ import { oidcNativeTokenSchema } from "@/lib/validations/oidc-native";
import {
createDocumentTokenSchema,
createMeasurementTokenSchema,
updateMeasurementTokenSchema,
createWorkoutTokenSchema,
} from "@/lib/validations/tokens";
import { nativeHandoffTokenSchema } from "@/lib/validations/native-handoff";
@@ -484,9 +485,36 @@ const apiTokenInfo = z
.describe(
"Revoked tokens stay in the list rather than disappearing, so a client must filter on this rather than treat presence as validity.",
),
mirrorToAppleHealth: z
.boolean()
.describe(
"A measurement token's Apple Health setting: whether the HealthLog app writes the readings this token sent into Apple Health. Changed with `PATCH /api/tokens/{id}`; false on every other token.",
),
})
.meta({ id: "ApiTokenInfo" });
const updateMeasurementTokenRequest = updateMeasurementTokenSchema.meta({
id: "UpdateMeasurementTokenRequest",
description:
"The one setting a measurement token has. Strict: any other key is refused 422.",
});
const updateMeasurementTokenResponse = z
.object({ id: z.string(), mirrorToAppleHealth: z.boolean() })
.meta({ id: "UpdateMeasurementTokenResponse" });
const measurementTokenSummaryResponse = z
.object({
unattributedReadings: z
.number()
.int()
.nonnegative()
.describe(
"Live EXTERNAL readings that belong to no token: sent before readings were linked to their token, by an account that held more than one. No token's Apple Health setting reaches them.",
),
})
.meta({ id: "MeasurementTokenSummaryResponse" });
const apiTokenListResponse = z
.array(apiTokenInfo)
.meta({ id: "ApiTokenListResponse" });
@@ -494,7 +522,7 @@ const apiTokenListResponse = z
const createMeasurementTokenRequest = createMeasurementTokenSchema.meta({
id: "CreateMeasurementTokenRequest",
description:
"Mint a measurement-ingest Bearer. There is no `scope` field: this endpoint mints one shape and the permission array is a literal, so no request can widen it. `name` is what the token is listed under — name it after the thing you are pasting it into, because that is what you will be reading when you decide whether to revoke it. `expiresInDays` defaults to 365.",
"Mint a measurement-ingest Bearer. There is no `scope` field: this endpoint mints one shape and the permission array is a literal, so no request can widen it. `name` is what the token is listed under — name it after the thing you are pasting it into, because that is what you will be reading when you decide whether to revoke it. `expiresInDays` defaults to 365. `mirrorToAppleHealth` (default false) lets the HealthLog app write the token's readings into Apple Health; it can be changed later at `PATCH /api/tokens/{id}`.",
});
const createMeasurementTokenResponse = z
@@ -1944,6 +1972,26 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
"/api/tokens/measurements": {
get: {
tags: ["Auth"],
summary: "Readings no measurement token reaches",
description:
"Counts the caller's live EXTERNAL readings that are linked to no token. Readings are linked to the token that sent them since v1.46; older ones were linked by a one-time pass only when the account held exactly one measurement token, so the rest belong to none and no token's Apple Health setting reaches them. Cookie session only.",
responses: {
"200": {
description: "The count.",
content: {
"application/json": {
schema: dataEnvelope(
measurementTokenSummaryResponse,
"MeasurementTokenSummaryEnvelope",
),
},
},
},
...stdResponses,
},
},
post: {
tags: ["Auth"],
summary: "Mint a measurement-ingest token",
@@ -2170,6 +2218,42 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...stdResponses,
},
},
patch: {
tags: ["Auth"],
summary: "Change a measurement token's Apple Health setting",
description:
"Sets whether the HealthLog app writes the readings this measurement token sent into Apple Health. The readings keep their `EXTERNAL` source and are not rewritten: every measurement row resolves `appleHealthMirror` from the token at read time, and a paired client learns of the change through the `measurementMirror` domain of `GET /api/sync/changes`. Turning it off stops further writes; samples already in Apple Health stay. A revoked token is accepted, since its readings stay in the record. Cookie session only, the caller's own tokens only (another account's answers 404). Audited as `tokens.measurements.mirror.update` when the value changes. 30 changes per account per minute. Not gated on the instance-wide API switch: no credential is created or extended.",
requestParams: { path: z.object({ id: z.string() }) },
requestBody: {
required: true,
content: {
"application/json": { schema: updateMeasurementTokenRequest },
},
},
responses: {
"200": {
description: "The setting as it now stands.",
content: {
"application/json": {
schema: dataEnvelope(
updateMeasurementTokenResponse,
"UpdateMeasurementTokenEnvelope",
),
},
},
},
"404": {
description: "Token not found or not owned by the caller.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
"422": {
description:
"Validation failed, or the token is not a measurement token (`meta.errorCode` = `tokens.mirror.not_measurement_token`): a wildcard, connector or other narrow token has no such setting.",
content: { "application/json": { schema: errorEnvelope } },
},
},
},
},
// ── Appended: registration, credential rotation, the OIDC entry point and
// the Codex device-auth flow.
+6
View File
@@ -332,6 +332,12 @@ export const measurementResource = z
.describe("Soft-delete tombstone; null on a live row."),
createdAt: isoDateTime().optional(),
updatedAt: isoDateTime().optional(),
appleHealthMirror: z
.boolean()
.optional()
.describe(
"Whether the HealthLog app writes this reading into Apple Health. Resolved by the server on every stored row: true for WITHINGS, IMPORT and MANUAL, for an EXTERNAL row whatever its account chose for the token that sent it, false otherwise. Absent on the synthesised list rows.",
),
// Synthesised-row extras — present only on the collapsed day-sum mode.
dayKey: z.string().optional(),
sampleCount: z.number().int().optional(),
+35 -2
View File
@@ -75,9 +75,29 @@ const syncMeasurementUpsert = measurementResource
"LWW reconciliation counter; echo to keep the mirror monotonic.",
),
updatedAt: isoDateTime(),
appleHealthMirror: z
.boolean()
.describe(
"Whether the HealthLog app writes this reading into Apple Health. Server-resolved; mirror exactly the rows that say true.",
),
})
.meta({ id: "SyncMeasurementUpsert" });
const syncMeasurementMirrorUpsert = z
.object({
id: z.string().describe("The measurement's server id."),
appleHealthMirror: z
.boolean()
.describe(
"The reading's write-back as it now stands. False means stop writing it; samples already in Apple Health stay.",
),
})
.meta({
id: "SyncMeasurementMirrorUpsert",
description:
"A reading whose `appleHealthMirror` changed because the account switched the Apple Health setting of the token that sent it. The reading itself did not change and is not re-sent in `measurements`.",
});
const syncMeasurementTombstone = z
.object({
id: z.string(),
@@ -294,12 +314,25 @@ export const syncChangesResponse = z
.describe(
"Menstrual cycles, observed and forward-predicted. Present on every page, empty arrays when the account has the cycle module off.",
),
measurementMirror: z
.object({
upserts: z.array(syncMeasurementMirrorUpsert),
tombstones: z
.array(z.object({ id: z.string() }))
.max(0)
.describe(
"Always empty: a deleted reading is a measurement tombstone.",
),
})
.describe(
"Apple Health write-back changes for readings that are otherwise unchanged. Apply after `measurements`.",
),
}),
})
.meta({
id: "SyncChangesResponse",
description:
"Multi-domain delta page: measurements, mood, intakes, cycle day logs and cycles. One opaque multi-domain keyset cursor; tombstones apply before upserts within each domain. Tombstone identity: measurements key on externalId, every other domain on server id. All five keys are present on every response, including the `cursorExpired` short-circuit — a domain with nothing to report sends empty arrays rather than being omitted.",
"Multi-domain delta page: measurements, mood, intakes, cycle day logs, cycles, and the Apple Health write-back changes of otherwise unchanged readings. One opaque multi-domain keyset cursor; tombstones apply before upserts within each domain. Tombstone identity: measurements key on externalId, every other domain on server id. All six keys are present on every response, including the `cursorExpired` short-circuit — a domain with nothing to report sends empty arrays rather than being omitted.",
});
export const syncPaths: NonNullable<ZodOpenApiObject["paths"]> = {
@@ -327,7 +360,7 @@ export const syncPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Sync"],
summary: "Multi-domain delta feed",
description:
"Incremental catch-up after the first-pair backfill (never a replacement for it). Pages over one opaque keyset cursor spanning five domains — measurements, mood, medication intakes, cycle day logs and cycles. Each domain carries `tombstones` (soft-deleted rows) and `upserts` (live rows); apply tombstones before upserts within a domain. Tombstone identity is `externalId` for measurements and the server `id` everywhere else. `cursorExpired: true` forces a clean re-init and the page is empty in every domain.",
"Incremental catch-up after the first-pair backfill (never a replacement for it). Pages over one opaque keyset cursor spanning six domains — measurements, mood, medication intakes, cycle day logs, cycles, and `measurementMirror` (the Apple Health write-back of readings whose token setting changed). Each domain carries `tombstones` (soft-deleted rows) and `upserts` (live rows); apply tombstones before upserts within a domain. Tombstone identity is `externalId` for measurements and the server `id` everywhere else. `cursorExpired: true` forces a clean re-init and the page is empty in every domain.",
requestParams: {
query: syncChangesQuery,
},