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

+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,
},