From 8162afc0e1c37fc5b06863f1e26585104b68c350 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 11:09:50 +0200 Subject: [PATCH] docs(api): publish the connection health state --- docs/api/openapi.yaml | 216 +++++++++++++++++++++++++ src/lib/openapi/routes/integrations.ts | 142 ++++++++++++++++ 2 files changed, 358 insertions(+) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index c1dbd36c3..317c79c73 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -73461,6 +73461,12 @@ components: anyOf: - $ref: "#/components/schemas/AppleHealthSyncProgress" - type: "null" + health: + description: The unified Apple Health connection state. Null when the phone has never delivered anything (or the read + failed). Present on the GET read; omitted from the PATCH echo. + anyOf: + - $ref: "#/components/schemas/ConnectionHealth" + - type: "null" required: - entries - lastSyncedAt @@ -73598,6 +73604,171 @@ components: additionalProperties: false description: "Server-side Apple Health backfill progress: how many rows have been accepted and how far back in time they reach. Only what the server actually knows — no ETA, no device-side queue state." + ConnectionHealth: + type: object + properties: + state: + type: string + enum: + - ok + - limited + - degraded + - action_required + description: "`ok` data is arriving (or the first sync is still due). `limited` it works, but the types in `unavailable` + cannot be delivered (provider plan or missing permission). `degraded` temporary provider trouble or + unexpected silence; the server keeps retrying, `since` dates it. `action_required` the user has to do the + one thing in `action`." + reason: + type: string + enum: + - data_arriving + - first_sync_pending + - plan_limited + - permission_limited + - provider_errors + - sync_stalled + - no_new_data + - unexpected_silence + - reauth_required + - paused_after_errors + description: "Why the state is what it is. ok: `data_arriving`, `first_sync_pending`. limited: `plan_limited`, + `permission_limited`. degraded: `provider_errors`, `sync_stalled`, `no_new_data`, `unexpected_silence` (the + integration watchdog saw a source that normally delivers daily go quiet). action_required: + `reauth_required`, `paused_after_errors`. Pick the sentence from this code alone." + since: + 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 the current degraded / action_required condition started. Null for ok and limited. + action: + anyOf: + - type: string + enum: + - reconnect + - resume + - type: "null" + description: "The one action that resolves the state: `reconnect` (run the provider's connect flow again) or `resume` + (POST the provider's resume route). Also `reconnect` on a `limited` connection whose gap is a missing + permission. Null when nothing the user can do helps." + unavailable: + type: array + items: + type: object + properties: + item: + type: string + description: Provider data-type key, e.g. `spo2`, `vo2Max`, `activity`. + cause: + type: string + enum: + - plan + - permission + required: + - item + - cause + additionalProperties: false + description: Data types this connection cannot deliver. Present in every state, empty when nothing is withheld. + lastSuccessAt: + 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: Last successful sync. For Apple Health, the last accepted batch. + lastDataAt: + 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: "Newest data this source delivered: for polled providers when the newest reading or workout was taken, for + Apple Health when a sync last brought a new sample. Null when nothing has arrived." + diagnostics: + type: object + properties: + ledgerState: + anyOf: + - type: string + - type: "null" + description: The stored sync-ledger state. Null for Apple Health. + failingLegs: + type: array + items: + type: string + failingSince: + 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" + lastAttemptAt: + 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" + verdict: + type: string + description: The `SyncHealth` verdict the state was derived from. + recentCalls: + maxItems: 10 + type: array + items: + type: object + properties: + endpoint: + type: string + description: Sync leg name (`sleep`, `measures`, `sync`, `token_refresh`), or `batch:` for Apple Health. Never a + URL. + status: + type: string + enum: + - ok + - transient + - persistent + - reauth_required + at: + 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)))$ + errorCode: + anyOf: + - type: string + - type: "null" + description: Short error code (`401`, `invalid_grant`, `scope_missing`); anything not shaped like one is dropped. + required: + - endpoint + - status + - at + - errorCode + additionalProperties: false + description: The last ten sync attempts, newest first. + required: + - ledgerState + - failingLegs + - failingSince + - lastAttemptAt + - verdict + - recentCalls + additionalProperties: false + description: Redacted diagnostics for a bug report. Carries no token, no error message, no health value and no account + identifier, so a client may offer it for copying as is. + required: + - state + - reason + - since + - action + - unavailable + - lastSuccessAt + - lastDataAt + - diagnostics + additionalProperties: false + description: The one server-resolved health state of a connection (v1.42.4). It folds the ledger state, the liveness + verdict, plan and permission gaps, provider reauth flags and the integration watchdog's silence flag into four + states. Clients render it and never recompute it. The older health-shaped fields beside it stay for one release + after the clients read `health`, then go. HealthKitConfigPatchEnvelope: type: object properties: @@ -73933,6 +74104,12 @@ components: - type: "null" syncHealth: $ref: "#/components/schemas/SyncHealth" + health: + description: The unified connection state. Present whenever the connection exists; null in the rare case it resolved to + none. Absent on the not-connected body. + anyOf: + - $ref: "#/components/schemas/ConnectionHealth" + - type: "null" required: - connected - configured @@ -74129,6 +74306,12 @@ components: anyOf: - type: string - type: "null" + health: + description: The unified connection state. Present whenever the connection exists; null in the rare case it resolved to + none. Absent on the not-connected body. + anyOf: + - $ref: "#/components/schemas/ConnectionHealth" + - type: "null" required: - connected - configured @@ -74217,6 +74400,12 @@ components: hasActivityScope: description: Pre-computed convenience flag the reconnect banner reads. A null `scope` counts as missing activity. type: boolean + health: + description: The unified connection state. Present whenever the connection exists; null in the rare case it resolved to + none. Absent on the not-connected body. + anyOf: + - $ref: "#/components/schemas/ConnectionHealth" + - type: "null" required: - connected - configured @@ -74531,6 +74720,12 @@ components: anyOf: - type: string - type: "null" + health: + description: The unified connection state. Present whenever the connection exists; null in the rare case it resolved to + none. Absent on the not-connected body. + anyOf: + - $ref: "#/components/schemas/ConnectionHealth" + - type: "null" required: - connected - configured @@ -74694,6 +74889,12 @@ components: anyOf: - type: string - type: "null" + health: + description: The unified connection state. Present whenever the connection exists; null in the rare case it resolved to + none. Absent on the not-connected body. + anyOf: + - $ref: "#/components/schemas/ConnectionHealth" + - type: "null" needsReauth: description: "The stored refresh token was rejected with `invalid_grant` — Google expires it after 7 days while the OAuth client is in Testing publishing mode, and a user-revoked grant lands here too. Distinct from `parked` @@ -74929,6 +75130,12 @@ components: - type: "null" syncHealth: $ref: "#/components/schemas/SyncHealth" + health: + description: The unified connection state. Present whenever the connection exists; null in the rare case it resolved to + none. Absent on the not-connected body. + anyOf: + - $ref: "#/components/schemas/ConnectionHealth" + - type: "null" required: - connected - configured @@ -75468,6 +75675,14 @@ components: allowPrivateHost: description: Nightscout only. type: boolean + health: + anyOf: + - $ref: "#/components/schemas/ConnectionHealth" + - type: "null" + description: The unified connection state, null when the provider is not connected. It supersedes `state`, `lastError`, + `consecutiveFailuresByKind`, `syncHealth`, `tokenExpired`, `needsReauth`, `hasActivityScope`, + `planUnavailable` and `scopeReconnect` on this entry, which go once the settings surface renders `health` + (no other client reads this envelope). required: - integration - state @@ -75479,6 +75694,7 @@ components: - metricFreshness - connected - configured + - health additionalProperties: false description: "One provider's entry in the consolidated envelope: the ledger core and the liveness verdict, which every entry carries, plus the extras that provider has. The optional fields are not optional per request — each is diff --git a/src/lib/openapi/routes/integrations.ts b/src/lib/openapi/routes/integrations.ts index eb8be83ed..9919a20c4 100644 --- a/src/lib/openapi/routes/integrations.ts +++ b/src/lib/openapi/routes/integrations.ts @@ -31,6 +31,12 @@ * come from `src/lib/validations/*` so the wire contract stays single-source. */ import { z } from "zod/v4"; +import { + CONNECTION_HEALTH_ACTIONS, + CONNECTION_HEALTH_REASONS, + CONNECTION_HEALTH_STATES, + RECENT_CALL_STATUSES, +} from "@/lib/integrations/connection-health"; import type { ZodOpenApiObject } from "zod-openapi"; import { fitbitCredentialsSchema } from "@/lib/validations/fitbit"; import { googleHealthCredentialsSchema } from "@/lib/validations/google-health"; @@ -97,6 +103,101 @@ const syncHealth = z "Server-resolved sync-health verdict. The single source of liveness truth — `lastSyncedAt` alone cannot express 'this pipe stopped delivering'.", }); +// Mirrors `ConnectionHealth` in `src/lib/integrations/connection-health.ts`: +// the one server-resolved state per connection every client renders. +const connectionHealth = z + .object({ + state: z + .enum(CONNECTION_HEALTH_STATES) + .describe( + "`ok` data is arriving (or the first sync is still due). `limited` it works, but the types in `unavailable` cannot be delivered (provider plan or missing permission). `degraded` temporary provider trouble or unexpected silence; the server keeps retrying, `since` dates it. `action_required` the user has to do the one thing in `action`.", + ), + reason: z + .enum(CONNECTION_HEALTH_REASONS) + .describe( + "Why the state is what it is. ok: `data_arriving`, `first_sync_pending`. limited: `plan_limited`, `permission_limited`. degraded: `provider_errors`, `sync_stalled`, `no_new_data`, `unexpected_silence` (the integration watchdog saw a source that normally delivers daily go quiet). action_required: `reauth_required`, `paused_after_errors`. Pick the sentence from this code alone.", + ), + since: z.iso + .datetime({ offset: true }) + .nullable() + .describe( + "When the current degraded / action_required condition started. Null for ok and limited.", + ), + action: z + .enum(CONNECTION_HEALTH_ACTIONS) + .nullable() + .describe( + "The one action that resolves the state: `reconnect` (run the provider's connect flow again) or `resume` (POST the provider's resume route). Also `reconnect` on a `limited` connection whose gap is a missing permission. Null when nothing the user can do helps.", + ), + unavailable: z + .array( + z.object({ + item: z + .string() + .describe( + "Provider data-type key, e.g. `spo2`, `vo2Max`, `activity`.", + ), + cause: z.enum(["plan", "permission"]), + }), + ) + .describe( + "Data types this connection cannot deliver. Present in every state, empty when nothing is withheld.", + ), + lastSuccessAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe( + "Last successful sync. For Apple Health, the last accepted batch.", + ), + lastDataAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe( + "Newest data this source delivered: for polled providers when the newest reading or workout was taken, for Apple Health when a sync last brought a new sample. Null when nothing has arrived.", + ), + diagnostics: z + .object({ + ledgerState: z + .string() + .nullable() + .describe("The stored sync-ledger state. Null for Apple Health."), + failingLegs: z.array(z.string()), + failingSince: z.iso.datetime({ offset: true }).nullable(), + lastAttemptAt: z.iso.datetime({ offset: true }).nullable(), + verdict: z + .string() + .describe("The `SyncHealth` verdict the state was derived from."), + recentCalls: z + .array( + z.object({ + endpoint: z + .string() + .describe( + "Sync leg name (`sleep`, `measures`, `sync`, `token_refresh`), or `batch:` for Apple Health. Never a URL.", + ), + status: z.enum(RECENT_CALL_STATUSES), + at: z.iso.datetime({ offset: true }), + errorCode: z + .string() + .nullable() + .describe( + "Short error code (`401`, `invalid_grant`, `scope_missing`); anything not shaped like one is dropped.", + ), + }), + ) + .max(10) + .describe("The last ten sync attempts, newest first."), + }) + .describe( + "Redacted diagnostics for a bug report. Carries no token, no error message, no health value and no account identifier, so a client may offer it for copying as is.", + ), + }) + .meta({ + id: "ConnectionHealth", + description: + "The one server-resolved health state of a connection (v1.42.4). It folds the ledger state, the liveness verdict, plan and permission gaps, provider reauth flags and the integration watchdog's silence flag into four states. Clients render it and never recompute it. The older health-shaped fields beside it stay for one release after the clients read `health`, then go.", + }); + const metricFreshnessEntry = z .object({ type: z @@ -212,6 +313,12 @@ const healthKitConfigResponse = z .describe( "Backfill/sync progress summary. Present on the GET read (null when the progress read failed); omitted from the PATCH echo.", ), + health: connectionHealth + .nullable() + .optional() + .describe( + "The unified Apple Health connection state. Null when the phone has never delivered anything (or the read failed). Present on the GET read; omitted from the PATCH echo.", + ), }) .meta({ id: "HealthKitConfigResponse", @@ -405,6 +512,12 @@ const nightscoutStatusResponse = z "Last recorded failure message, decrypted from the ledger. Null when the last attempt succeeded or none has run.", ), syncHealth: syncHealth.optional(), + health: connectionHealth + .nullable() + .optional() + .describe( + "The unified connection state. Present whenever the connection exists; null in the rare case it resolved to none. Absent on the not-connected body.", + ), }) .meta({ id: "NightscoutStatusResponse", @@ -483,6 +596,12 @@ const whoopStatusResponse = z .describe( "Space-separated OAuth scope string granted by WHOOP. Null on a legacy connection that predates scope tracking.", ), + health: connectionHealth + .nullable() + .optional() + .describe( + "The unified connection state. Present whenever the connection exists; null in the rare case it resolved to none. Absent on the not-connected body.", + ), }) .meta({ id: "WhoopStatusResponse", @@ -529,6 +648,12 @@ const withingsStatusResponse = z .describe( "Pre-computed convenience flag the reconnect banner reads. A null `scope` counts as missing activity.", ), + health: connectionHealth + .nullable() + .optional() + .describe( + "The unified connection state. Present whenever the connection exists; null in the rare case it resolved to none. Absent on the not-connected body.", + ), }) .meta({ id: "WithingsStatusResponse", @@ -810,6 +935,12 @@ function connectionStatusShape() { .describe( "OAuth scope string granted at authorisation. Null on a legacy connection that predates scope tracking.", ), + health: connectionHealth + .nullable() + .optional() + .describe( + "The unified connection state. Present whenever the connection exists; null in the rare case it resolved to none. Absent on the not-connected body.", + ), }; } @@ -863,6 +994,12 @@ const oauthProviderStatusResponse = z lastAttemptAt: z.iso.datetime({ offset: true }).nullable().optional(), lastError: z.string().nullable().optional(), syncHealth: syncHealth.optional(), + health: connectionHealth + .nullable() + .optional() + .describe( + "The unified connection state. Present whenever the connection exists; null in the rare case it resolved to none. Absent on the not-connected body.", + ), }) .meta({ id: "OAuthProviderStatusResponse", @@ -947,6 +1084,11 @@ const integrationEnvelopeEntry = z ), hasToken: z.boolean().optional().describe("Nightscout only."), allowPrivateHost: z.boolean().optional().describe("Nightscout only."), + health: connectionHealth + .nullable() + .describe( + "The unified connection state, null when the provider is not connected. It supersedes `state`, `lastError`, `consecutiveFailuresByKind`, `syncHealth`, `tokenExpired`, `needsReauth`, `hasActivityScope`, `planUnavailable` and `scopeReconnect` on this entry, which go once the settings surface renders `health` (no other client reads this envelope).", + ), }) .meta({ id: "IntegrationStatusEntry",