From c8035fdd7855f71eb6924126202e23d72920584d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 22 Aug 2026 12:21:16 +0200 Subject: [PATCH 1/4] Describe the integration, notification and MCP surfaces in the contract Sixteen routes the native client already calls were absent from the OpenAPI registry, so the published spec said nothing about them. `openapi:check` compares the registry against the YAML and never the routes against the registry, which is why the gap survived. Nightscout status / test / disconnect, the WHOOP credential and status reads, and the Withings status / disconnect pair land in the integrations module. The three per-channel configs under `/api/settings/*` join the settings module; the notification preference and delivery-health surfaces get their own module, as do the MCP connector connections and tokens. These are credential-bearing surfaces, so the contract states what each response actually carries. Every channel GET reduces its secret to a boolean, and the descriptions say so, along with the consequence: a client cannot read the value back, so an empty value on the way in preserves the stored one rather than clearing it. `POST /api/mcp/tokens` is the one operation that returns a usable Bearer, and its description says outright that this is the only copy. Two inline request schemas move to the validations modules they belong in, so the registry documents the same object the handler parses rather than a restatement of it. --- src/app/api/mcp/tokens/route.ts | 18 +- src/app/api/notifications/status/route.ts | 8 +- src/lib/openapi/routes/index.ts | 9 + src/lib/openapi/routes/integrations.ts | 425 +++++++++++++++++++++- src/lib/openapi/routes/mcp.ts | 276 ++++++++++++++ src/lib/openapi/routes/notifications.ts | 275 ++++++++++++++ src/lib/openapi/routes/settings.ts | 313 +++++++++++++++- src/lib/validations/mcp.ts | 18 + src/lib/validations/notifications.ts | 9 + 9 files changed, 1330 insertions(+), 21 deletions(-) create mode 100644 src/lib/openapi/routes/mcp.ts create mode 100644 src/lib/openapi/routes/notifications.ts create mode 100644 src/lib/validations/mcp.ts diff --git a/src/app/api/mcp/tokens/route.ts b/src/app/api/mcp/tokens/route.ts index dfa644806..0fe706e7d 100644 --- a/src/app/api/mcp/tokens/route.ts +++ b/src/app/api/mcp/tokens/route.ts @@ -18,7 +18,6 @@ * every REST write) — it only admits the confirmed in-process write tools. */ import { NextRequest } from "next/server"; -import { z } from "zod/v4"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { @@ -33,16 +32,11 @@ import { issueApiToken } from "@/lib/auth/issue-token"; import { isApiGloballyEnabled } from "@/lib/app-settings"; import { prisma } from "@/lib/db"; import { SCOPE_HEALTH_READ, SCOPE_HEALTH_WRITE } from "@/lib/mcp/oauth/config"; - -const createSchema = z.object({ - name: z.string().min(1, "Name required").max(100), - expiresInDays: z.number().int().min(1).max(365).optional(), - // The ONLY two scope shapes this endpoint will mint. `read` (default) is the - // least-privilege read token; `read_write` adds `health:write` so the - // confirmed `/mcp` write tools become available. Anything else is rejected - // by the enum — the endpoint can never mint a wildcard or arbitrary grant. - scope: z.enum(["read", "read_write"]).optional().default("read"), -}); +// The ONLY two scope shapes this endpoint will mint. `read` (default) is the +// least-privilege read token; `read_write` adds `health:write` so the confirmed +// `/mcp` write tools become available. Anything else is rejected by the enum — +// the endpoint can never mint a wildcard or arbitrary grant. +import { createMcpTokenSchema } from "@/lib/validations/mcp"; export const GET = apiHandler(async () => { const { user } = await requireAuth(); @@ -89,7 +83,7 @@ export const POST = apiHandler(async (request: NextRequest) => { }); if (jsonError) return jsonError; - const parsed = createSchema.safeParse(body); + const parsed = createMcpTokenSchema.safeParse(body); if (!parsed.success) { return returnAllZodIssues(parsed.error, 422); } diff --git a/src/app/api/notifications/status/route.ts b/src/app/api/notifications/status/route.ts index 402521ee7..54fb387cc 100644 --- a/src/app/api/notifications/status/route.ts +++ b/src/app/api/notifications/status/route.ts @@ -1,5 +1,4 @@ import { NextRequest } from "next/server"; -import { z } from "zod/v4"; import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; @@ -7,6 +6,7 @@ import { apiSuccess, apiError } from "@/lib/api-response"; import { CHANNEL_TYPE_LABELS, EVENT_TYPES } from "@/lib/notifications/types"; import type { ChannelType, EventType } from "@/lib/notifications/types"; import { reEnableChannel } from "@/lib/notifications/channel-state"; +import { reEnableChannelSchema } from "@/lib/validations/notifications"; export const dynamic = "force-dynamic"; @@ -131,10 +131,6 @@ export const GET = apiHandler(async () => { return apiSuccess({ channels: data, events }); }); -const reEnableSchema = z.object({ - channelId: z.string().min(1).max(64), -}); - /** * POST /api/notifications/status * @@ -159,7 +155,7 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("Invalid JSON data", 422); } - const parsed = reEnableSchema.safeParse(body); + const parsed = reEnableChannelSchema.safeParse(body); if (!parsed.success) return apiError("Invalid data", 422); const channel = await prisma.notificationChannel.findFirst({ diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index 1d053c5ee..4d91aadcf 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -65,8 +65,10 @@ import { medicationResource, medicationDoseHistoryImportFatalReasonEnum, } from "./medications"; +import { mcpPaths } from "./mcp"; import { metaPaths } from "./meta"; import { moodPaths } from "./mood"; +import { notificationChannelPaths } from "./notifications"; import { nutrientPaths } from "./nutrients"; import { onboardingPaths } from "./onboarding"; import { profilePaths } from "./profile"; @@ -128,6 +130,13 @@ export const openApiPaths: NonNullable = { // v1.36.0 — account sharing: the grant lifecycle and the switch endpoint // (appended, spread order is load-bearing). ...accountSharingPaths, + // Notification channels + MCP connector credentials: both families answered + // the native client for several releases while being absent from the + // registry, because `openapi:check` compares the registry against the YAML + // and never the routes against the registry (appended, spread order is + // load-bearing). + ...notificationChannelPaths, + ...mcpPaths, }; export const openApiComponents: NonNullable = { diff --git a/src/lib/openapi/routes/integrations.ts b/src/lib/openapi/routes/integrations.ts index f40e24eb2..9b257147b 100644 --- a/src/lib/openapi/routes/integrations.ts +++ b/src/lib/openapi/routes/integrations.ts @@ -1,5 +1,6 @@ /** - * OpenAPI route table — third-party integration config (HealthKit). + * OpenAPI route table — third-party integration config (HealthKit) and the + * per-provider connection surfaces (Nightscout / WHOOP / Withings). * * Part of the OpenAPI route table; aggregated in `./index.ts`. * Response DTOs are declared here mirroring the route handler under @@ -8,6 +9,7 @@ */ import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; +import { whoopCredentialsSchema } from "@/lib/validations/whoop"; import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; // Mirror the route's `directionEnum` — per-metric sync direction. @@ -276,6 +278,193 @@ const googleHealthSyncStatusResponse = z "The caller's current/most-recent Google Health sync run, or null when none has ever run. `data` carries this shape directly (not nested under a named key).", }); +// ── Per-provider connection surfaces ───────────────────────────────── +// Nightscout / WHOOP / Withings each expose a status read plus a teardown. +// They answered iOS since v1.11 and were absent from the registry entirely, +// so nothing below is new behaviour — it is the shape the handlers already +// send, written down. + +// Mirrors `IntegrationState` in `src/lib/integrations/status.ts`. The ledger +// records what the last ATTEMPT did; the verdict in `syncHealth` is what says +// whether the pipe is still alive. Both are published because they answer +// different questions and a client that reads only `state` cannot tell a row +// retrying hourly from one that stopped being tried at all. +const integrationLedgerState = z + .enum([ + "connected", + "error_transient", + "error_reauth", + "disconnected", + "parked", + ]) + .describe( + "Last recorded ledger state for this provider. `connected` is also the value a user who has never synced sees — the ledger row is created on the first attempt, and its absence reads as `connected`, not as an error.", + ); + +const nightscoutStatusResponse = z + .object({ + connected: z + .boolean() + .describe( + "A connection exists. Not a liveness claim — a pipe dead for a fortnight still answers `true`; read `syncHealth.verdict` for that.", + ), + configured: z + .boolean() + .describe("An instance URL is stored. The connect marker."), + hasToken: z + .boolean() + .optional() + .describe( + "Whether an API token is stored alongside the URL. A fully-public Nightscout instance needs none, so `false` is not a misconfiguration. The token itself is never returned.", + ), + allowPrivateHost: z + .boolean() + .optional() + .describe( + "The operator has approved this user's private-origin (RFC 1918 / loopback / link-local) Nightscout instance. False for every public instance.", + ), + state: integrationLedgerState.optional(), + lastSuccessAt: z.iso.datetime({ offset: true }).nullable().optional(), + lastAttemptAt: z.iso.datetime({ offset: true }).nullable().optional(), + lastError: z + .string() + .nullable() + .optional() + .describe( + "Last recorded failure message, decrypted from the ledger. Null when the last attempt succeeded or none has run.", + ), + syncHealth: syncHealth.optional(), + }) + .meta({ + id: "NightscoutStatusResponse", + description: + "Nightscout connection status. When nothing is configured the body is exactly `{ connected: false, configured: false }` — every other field is absent, not null. The stored instance URL is the user's own and is NOT echoed here; the API token never is.", + }); + +const nightscoutTestResponse = z + .object({ + ok: z.literal(true), + latencyMs: z + .number() + .int() + .describe("Round-trip time of the single-entry probe, in milliseconds."), + }) + .meta({ + id: "NightscoutTestResponse", + description: + "A live probe that reached the configured instance and read one SGV entry. Only ever returned on success — a failed probe is an error status, never `ok: false`.", + }); + +const whoopCredentialsStatusResponse = z + .object({ + hasCredentials: z + .boolean() + .describe( + "Both the client id and the client secret are stored. Neither value is ever returned.", + ), + }) + .meta({ + id: "WhoopCredentialsStatusResponse", + description: + "Presence check for the per-user WHOOP BYO-key credentials. Presence only: this endpoint has no read path for the stored id or secret.", + }); + +const whoopCredentialsRequest = whoopCredentialsSchema.meta({ + id: "WhoopCredentialsRequest", + description: + "Per-user WHOOP BYO-key credentials. Each self-hoster registers their own WHOOP developer app — the per-app authorized-user cap makes one shared app unworkable for a multi-operator product. Both values are trimmed (a trailing space from the portal's copy button reaches WHOOP verbatim and answers as 'unknown client') and stored encrypted at rest. Write-only: no endpoint reads them back.", +}); + +const whoopStatusResponse = z + .object({ + connected: z + .boolean() + .describe("A WHOOP connection row exists and carries a WHOOP user id."), + configured: z + .boolean() + .describe("Per-user BYO-key client id AND secret are both stored."), + lastSyncedAt: z.iso + .datetime({ offset: true }) + .nullable() + .optional() + .describe("Null when no sync has completed yet."), + connectedAt: z.iso + .datetime({ offset: true }) + .optional() + .describe("When the connection row was created."), + tokenExpired: z + .boolean() + .optional() + .describe( + "The stored access token's expiry is in the past. Read straight off the row — unlike the Withings status read, this endpoint does not refresh; the sync path refreshes lazily instead.", + ), + tokenExpiresAt: z.iso.datetime({ offset: true }).optional(), + backfillCompleted: z + .boolean() + .optional() + .describe( + "The self-converging history backfill has walked every collection to completion.", + ), + scope: z + .string() + .nullable() + .optional() + .describe( + "Space-separated OAuth scope string granted by WHOOP. Null on a legacy connection that predates scope tracking.", + ), + }) + .meta({ + id: "WhoopStatusResponse", + description: + "WHOOP connection status. When no connection row carries a WHOOP user id the body is exactly `{ connected: false, configured }` — every other field is absent, not null.", + }); + +const withingsStatusResponse = z + .object({ + connected: z.boolean().describe("A Withings connection row exists."), + configured: z + .boolean() + .describe("Per-user client id AND secret are both stored."), + lastSyncedAt: z.iso.datetime({ offset: true }).nullable().optional(), + connectedAt: z.iso.datetime({ offset: true }).optional(), + tokenExpired: z + .boolean() + .optional() + .describe( + "True after a refresh attempt that did not produce a live token.", + ), + tokenRefreshFailed: z + .boolean() + .optional() + .describe( + "The read tried to refresh an expired token and could not — either the per-user credentials are gone or Withings refused. A permanently revoked grant additionally parks the ledger at `error_reauth` as a side effect of this read.", + ), + tokenExpiresAt: z.iso + .datetime({ offset: true }) + .optional() + .describe( + "Post-refresh expiry when the read refreshed, the stored expiry otherwise.", + ), + scope: z + .string() + .nullable() + .optional() + .describe( + "Comma-separated OAuth scope string from the authorisation flow. Null on a legacy connection that has never re-authed.", + ), + hasActivityScope: z + .boolean() + .optional() + .describe( + "Pre-computed convenience flag the reconnect banner reads. A null `scope` counts as missing activity.", + ), + }) + .meta({ + id: "WithingsStatusResponse", + description: + "Withings connection status. When no connection row exists the body is exactly `{ connected: false, configured }` — every other field is absent, not null. Note that this read is not side-effect free: a token within 60 s of expiry is refreshed and re-persisted before the response is built, so a GET here can rotate the stored token.", + }); + export const integrationPaths: NonNullable = { "/api/integrations/healthkit": { get: { @@ -452,4 +641,238 @@ export const integrationPaths: NonNullable = { }, }, }, + "/api/nightscout/status": { + get: { + tags: ["Integrations"], + summary: "Read the caller's Nightscout connection status", + description: + "Returns the connect marker plus the shared `nightscout` ledger snapshot and the server-resolved liveness verdict, so the Settings card and the iOS client paint the same pill from one read. The stored API token is never returned — `hasToken` reports its presence and nothing more. Auth via cookie or Bearer.", + responses: { + "200": { + description: "Connection status.", + content: { + "application/json": { + schema: dataEnvelope( + nightscoutStatusResponse, + "NightscoutStatusEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/nightscout/test": { + post: { + tags: ["Integrations"], + summary: "Probe the configured Nightscout instance", + description: + "Pulls a single SGV entry through the same client the sync uses, to re-validate the stored URL and token without waiting for a sync window. No body. Nothing is written: this neither ingests the entry nor touches the ledger. Rate-limited 5 requests / 60 s per user. Auth via cookie or Bearer.", + responses: { + "200": { + description: "The probe reached the instance.", + content: { + "application/json": { + schema: dataEnvelope( + nightscoutTestResponse, + "NightscoutTestEnvelope", + ), + }, + }, + }, + "502": { + description: + "The probe did not complete. `meta.errorCode` names the class: `credentials_rejected` (401/403 from Nightscout), `rate_limited` (429 from Nightscout — distinct from this endpoint's own 429), `upstream_error` (5xx), `timeout` (no answer within 5 s), `url_not_public` (the stored origin resolves to a private host and the operator has not approved it), `connection_failed` (everything else). The message is fixed per class and never the caught error's own: a Nightscout base URL carries its API token as a query parameter, so an upstream message could leak it.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + "422": { + description: + "No Nightscout instance is configured for this user. `meta.errorCode` = `not_configured`.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "429": { + description: + "More than 5 probes in 60 s from this user. `meta.errorCode` = `rate_limited_self` — this is the local limiter, not Nightscout's.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, + "/api/nightscout/disconnect": { + post: { + tags: ["Integrations"], + summary: "Disconnect the caller's Nightscout instance", + description: + "Clears the stored URL, token and private-host approval, and parks the `nightscout` ledger at `disconnected`. Glucose rows already ingested are left in place — this stops future syncs, it does not delete history. No body. Rate-limited 20 requests / 60 s per user. Auth via cookie or Bearer.", + responses: { + "200": { + description: "The connection was cleared.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ disconnected: z.literal(true) }), + "NightscoutDisconnectEnvelope", + ), + }, + }, + }, + "404": { + description: + "No Nightscout connection to clear. Note that this is NOT an idempotent no-op: a second disconnect answers 404 rather than 200.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/whoop/credentials": { + get: { + tags: ["Integrations"], + summary: "Check whether WHOOP BYO-key credentials are stored", + description: + "Presence only. There is no read path for the stored client id or secret — the response carries a single boolean. Auth via cookie or Bearer.", + responses: { + "200": { + description: "Credential presence.", + content: { + "application/json": { + schema: dataEnvelope( + whoopCredentialsStatusResponse, + "WhoopCredentialsStatusEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + put: { + tags: ["Integrations"], + summary: "Store the caller's WHOOP BYO-key credentials", + description: + "Replaces both values; there is no partial update. Both are encrypted at rest and never read back. Body capped at 64 KiB. Auth via cookie or Bearer.", + requestBody: { + required: true, + content: { + "application/json": { schema: whoopCredentialsRequest }, + }, + }, + responses: { + "200": { + description: "Credentials stored.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ updated: z.literal(true) }), + "WhoopCredentialsUpdateEnvelope", + ), + }, + }, + }, + "400": { + description: "Body is not parseable JSON.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "413": { + description: "Body exceeds 64 KiB.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "415": { + description: "Content-Type is not `application/json`.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + delete: { + tags: ["Integrations"], + summary: "Delete the caller's WHOOP credentials and connection", + description: + "Deletes the connection row (with its encrypted OAuth tokens), clears both credential columns, audits the teardown and parks the `whoop` ledger at `disconnected`. Idempotent: a missing connection row is a benign no-op, so a repeat call still answers 200. Auth via cookie or Bearer.", + responses: { + "200": { + description: "Credentials and connection removed.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ deleted: z.literal(true) }), + "WhoopCredentialsDeleteEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/whoop/status": { + get: { + tags: ["Integrations"], + summary: "Read the caller's WHOOP connection status", + description: + "Reports the BYO-key configuration marker plus the connection row's last sync, token expiry, granted scope and backfill progress. Read-only — token refresh happens lazily on the sync path, not here. Auth via cookie or Bearer.", + responses: { + "200": { + description: "Connection status.", + content: { + "application/json": { + schema: dataEnvelope(whoopStatusResponse, "WhoopStatusEnvelope"), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/withings/status": { + get: { + tags: ["Integrations"], + summary: "Read the caller's Withings connection status", + description: + "Reports the per-user credential marker plus the connection row's last sync, token expiry and granted scope. Not side-effect free: a token within 60 s of expiry is refreshed and the new pair re-persisted before the response is built, so the reported `tokenExpiresAt` may be newer than the stored one was at request time, and a permanently revoked grant parks the ledger at `error_reauth` during this read. Auth via cookie or Bearer.", + responses: { + "200": { + description: "Connection status.", + content: { + "application/json": { + schema: dataEnvelope( + withingsStatusResponse, + "WithingsStatusEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/withings/disconnect": { + post: { + tags: ["Integrations"], + summary: "Disconnect the caller's Withings account", + description: + "Best-effort unsubscribes every webhook category, deletes the connection row, audits the teardown and parks the `withings` ledger at `disconnected`. A failing unsubscribe (expired token, category never subscribed) does not stop the teardown. Measurements already ingested are left in place. No body. Auth via cookie or Bearer.", + responses: { + "200": { + description: "The connection was removed.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ disconnected: z.literal(true) }), + "WithingsDisconnectEnvelope", + ), + }, + }, + }, + "404": { + description: + "No Withings connection to remove. Note that this is NOT an idempotent no-op: a second disconnect answers 404 rather than 200.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, }; diff --git a/src/lib/openapi/routes/mcp.ts b/src/lib/openapi/routes/mcp.ts new file mode 100644 index 000000000..c1f8b22e7 --- /dev/null +++ b/src/lib/openapi/routes/mcp.ts @@ -0,0 +1,276 @@ +/** + * OpenAPI route table — MCP connector credentials. + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. The request + * body comes from `src/lib/validations/mcp.ts` so the wire contract stays + * single-source; the response DTOs mirror the handlers under + * `src/app/api/mcp/*`. + * + * Two credential families reach the `/mcp` endpoint and both are managed here, + * because a user revoking access needs one place that lists everything that can + * read their record over MCP: + * + * - A **connection** is what the OAuth bridge (`/api/mcp/oauth/*`) creates + * for a remote connector. It outlives the 60-minute access tokens it + * issues, so revoking the connection ends the whole refresh chain. + * - A **connector token** is the manual / stdio path: a `hlk_` Bearer minted + * here, scoped `health:read` (optionally plus `health:write`), never the + * `["*"]` wildcard. + * + * The OAuth routes themselves are deliberately absent: they answer RFC-format + * error bodies rather than the standard envelope, and they are described by the + * OAuth metadata documents, not by this contract. + */ +import { z } from "zod/v4"; +import type { ZodOpenApiObject } from "zod-openapi"; + +import { createMcpTokenSchema } from "@/lib/validations/mcp"; +import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; + +/** + * The operator kill switch. `isApiGloballyEnabled()` gates every route in this + * module, so an instance with the API turned off refuses connector management + * as well as connector traffic — a valid session does not get past it. + */ +const apiDisabledResponse = { + "403": { + description: + "The operator has disabled the API instance-wide. Every route in this module refuses while that setting is off, for a cookie session as well as for a Bearer token; it is not a per-user permission and retrying will not help.", + content: { "application/json": { schema: errorEnvelope } }, + }, +}; + +const mcpConnection = z + .object({ + id: z.string(), + clientName: z + .string() + .describe( + "Name the OAuth client registered itself under. Client-supplied at registration time — display it as such, do not treat it as verified.", + ), + scope: z + .string() + .describe("Space-separated scope string granted to this connection."), + createdAt: z.iso.datetime({ offset: true }), + lastUsedAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe("Null when the connection has never been exercised."), + }) + .meta({ + id: "McpConnection", + description: + "A live remote-connector authorization created through the MCP OAuth bridge. Carries no token material: the access tokens it issued are not addressable here, they are ended by revoking the connection.", + }); + +const mcpConnectorToken = z + .object({ + id: z.string(), + name: z.string(), + permissions: z + .array(z.string()) + .describe( + 'The granted scopes — `["health:read"]` or `["health:read", "health:write"]`. A `health:write` token is audience-bound to `/mcp`: the resource-server guard refuses it on every REST write, so it only admits the confirmed in-process write tools.', + ), + lastUsedAt: z.iso.datetime({ offset: true }).nullable(), + expiresAt: z.iso.datetime({ offset: true }).nullable(), + createdAt: z.iso.datetime({ offset: true }), + revoked: z + .boolean() + .describe( + "Revoked tokens stay in this list rather than disappearing from it, so a client must filter if it wants live credentials only.", + ), + }) + .meta({ + id: "McpConnectorToken", + description: + "A manually-minted MCP connector token. The token VALUE is not part of this shape — it exists in one response only, the 201 that mints it.", + }); + +const createMcpTokenRequest = createMcpTokenSchema.meta({ + id: "CreateMcpTokenRequest", + description: + "Mint a connector Bearer. `scope` is a closed two-value choice, not a permission list: `read` (the default) grants `health:read`, `read_write` adds `health:write`. The permission array is built field by field from that choice, so no request shape can widen the grant. `expiresInDays` defaults to 90.", +}); + +const createMcpTokenResponse = z + .object({ + token: z + .string() + .describe( + "The raw `hlk_` Bearer, RETURNED EXACTLY ONCE. It is stored only as an HMAC-SHA256 hash, so there is no way to retrieve it later; a client that does not capture it here has to mint a replacement. Treat it as a secret in logs, crash reports and analytics.", + ), + name: z.string(), + expiresAt: z.iso.datetime({ offset: true }), + }) + .meta({ + id: "CreateMcpTokenResponse", + description: + "The one response in the API that carries a usable Bearer token value for an MCP connector.", + }); + +export const mcpPaths: NonNullable = { + "/api/mcp/connections": { + get: { + tags: ["MCP"], + summary: "List the caller's live MCP connector connections", + description: + "Returns the caller's non-revoked OAuth-bridge connections, newest first. `data` is the array itself, not nested under a named key. Auth via cookie or Bearer.", + responses: { + ...apiDisabledResponse, + "200": { + description: "Live connections.", + content: { + "application/json": { + schema: dataEnvelope( + z.array(mcpConnection), + "McpConnectionListEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/mcp/connections/{id}": { + delete: { + tags: ["MCP"], + summary: "Revoke an MCP connector connection", + description: + "Stamps the connection revoked so every future refresh fails, and revokes every access token it ever issued. Ownership is enforced inside the revoke, so a connection id alone cannot reach somebody else's connection — an id the caller does not own is indistinguishable from one that does not exist. Auth via cookie or Bearer.", + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string" }, + description: "Connection id from the list endpoint.", + }, + ], + responses: { + ...apiDisabledResponse, + "200": { + description: "The connection was revoked.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ revoked: z.literal(true) }), + "McpConnectionRevokeEnvelope", + ), + }, + }, + }, + "404": { + description: + "No live connection with this id for this caller — also the answer for an already-revoked one, so a repeat revoke is 404 rather than an idempotent 200.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/mcp/tokens": { + get: { + tags: ["MCP"], + summary: "List the caller's MCP connector tokens", + description: + "Returns the caller's manually-minted `health:read` tokens, newest first, revoked ones included. Access tokens issued by the OAuth bridge are excluded — they are transient 60-minute rows that would flood the list, and they are surfaced and revoked as connections instead. `data` is the array itself, not nested under a named key. Auth via cookie or Bearer.", + responses: { + ...apiDisabledResponse, + "200": { + description: "Connector tokens.", + content: { + "application/json": { + schema: dataEnvelope( + z.array(mcpConnectorToken), + "McpConnectorTokenListEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + post: { + tags: ["MCP"], + summary: "Mint an MCP connector token", + description: + "Mints a `health:read` (optionally read+write) Bearer for the manual / stdio path and audits the mint. THE RESPONSE CARRIES THE RAW TOKEN — this is the only place it ever exists, and it cannot be retrieved again. Body capped at 16 KiB. Auth via cookie or Bearer.", + requestBody: { + required: true, + content: { "application/json": { schema: createMcpTokenRequest } }, + }, + responses: { + ...apiDisabledResponse, + "201": { + description: + "Token minted. The `token` field is the only copy of the secret.", + content: { + "application/json": { + schema: dataEnvelope( + createMcpTokenResponse, + "CreateMcpTokenEnvelope", + ), + }, + }, + }, + "400": { + description: "Body is not parseable JSON.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "413": { + description: "Body exceeds 16 KiB.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "415": { + description: "Content-Type is not `application/json`.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + "422": { + description: + "Validation failed. The envelope carries every offending issue, not just the first.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, + "/api/mcp/tokens/{id}": { + delete: { + tags: ["MCP"], + summary: "Revoke an MCP connector token", + description: + "Marks the token revoked and audits it. The lookup requires the token to be the caller's own AND to carry `health:read`, so this endpoint can never revoke a differently-scoped credential by id alone — a native-client or ingest token is a 404 here. Auth via cookie or Bearer.", + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string" }, + description: "Token id from the list endpoint.", + }, + ], + responses: { + ...apiDisabledResponse, + "200": { + description: "The token was revoked.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ revoked: z.literal(true) }), + "McpConnectorTokenRevokeEnvelope", + ), + }, + }, + }, + "404": { + description: + "No `health:read` token with this id for this caller. An already-revoked token is still found, so a repeat revoke answers 200.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, +}; diff --git a/src/lib/openapi/routes/notifications.ts b/src/lib/openapi/routes/notifications.ts new file mode 100644 index 000000000..ddc95d5ab --- /dev/null +++ b/src/lib/openapi/routes/notifications.ts @@ -0,0 +1,275 @@ +/** + * OpenAPI route table — notification channels: per-event preferences and + * per-channel delivery health. + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. The request + * bodies come from `src/lib/validations/notifications.ts` so the wire contract + * stays single-source; the response DTOs mirror the handlers under + * `src/app/api/notifications/*`. + * + * The channel and event vocabularies are read from + * `src/lib/notifications/types.ts` rather than restated, so a new channel type + * or event category cannot be added to the dispatcher and quietly go missing + * from the published contract. + */ +import { z } from "zod/v4"; +import type { ZodOpenApiObject } from "zod-openapi"; + +import { CHANNEL_TYPES, EVENT_TYPES } from "@/lib/notifications/types"; +import { + notificationPreferenceSchema, + reEnableChannelSchema, +} from "@/lib/validations/notifications"; +import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; + +const channelTypeEnum = z + .enum(CHANNEL_TYPES) + .describe("Delivery transport for a notification channel."); + +const eventTypeEnum = z + .enum(EVENT_TYPES) + .describe("Notification category a per-channel preference can toggle."); + +const notificationChannel = z + .object({ + id: z.string(), + type: channelTypeEnum, + label: z + .string() + .describe( + "Human-readable channel name. Falls back to the raw `type` when no label is registered.", + ), + enabled: z.boolean().describe("The user's own per-channel toggle."), + globallyEnabled: z + .boolean() + .describe( + "The operator's instance-wide toggle for this transport. Only TELEGRAM, NTFY and WEB_PUSH have one; every other transport reports `true`. A channel the user has enabled still sends nothing while this is false.", + ), + }) + .meta({ + id: "NotificationChannelSummary", + description: + "One of the caller's notification channels, with both the per-user and the instance-wide toggle so a client can explain why an enabled channel is silent.", + }); + +const notificationPreference = z + .object({ + channelId: z.string(), + eventType: eventTypeEnum, + enabled: z.boolean(), + }) + .meta({ + id: "NotificationPreference", + description: + "An explicit per-(channel, event) override. Only STORED rows appear — an event with no row here follows the server-side default for that category, which is ON for most and OFF for the opt-in ones (personal records, mood and cycle reminders). Absence is therefore not 'disabled'.", + }); + +const notificationPreferencesResponse = z + .object({ + channels: z.array(notificationChannel), + preferences: z.array(notificationPreference), + eventTypes: z + .array(z.string()) + .describe( + "Every event category the server knows, in declaration order — the list a client renders rows for.", + ), + }) + .meta({ + id: "NotificationPreferencesResponse", + description: + "The caller's notification channels, their stored per-event overrides, and the full event vocabulary.", + }); + +const notificationPreferenceRequest = notificationPreferenceSchema.meta({ + id: "NotificationPreferenceRequest", + description: + "Set one per-(channel, event) override. `channelId` must belong to the caller — a channel id owned by somebody else answers 404, not 403. Upsert: the row is created when absent.", +}); + +const channelDeliveryStatus = z + .object({ + id: z.string(), + type: channelTypeEnum, + label: z.string(), + enabled: z.boolean(), + state: z + .enum(["active", "auto_disabled", "manually_disabled", "sending_paused"]) + .describe( + "Server-derived. `auto_disabled` — the dispatcher turned it off after repeated hard rejects (`disabledReason` says why). `manually_disabled` — the user turned it off. `sending_paused` — still enabled but inside a retry cooldown until `nextRetryAt`. `active` — enabled and sending.", + ), + disabledReason: z + .string() + .nullable() + .describe( + "Why the dispatcher auto-disabled the channel; null when it did not. This is the field that separates `auto_disabled` from `manually_disabled`.", + ), + consecutiveFailures: z.number().int(), + lastSuccessAt: z.iso.datetime({ offset: true }).nullable(), + lastFailureAt: z.iso.datetime({ offset: true }).nullable(), + lastFailureReason: z.string().nullable(), + nextRetryAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe("End of the current cooldown; null when not paused."), + }) + .meta({ + id: "NotificationChannelDeliveryStatus", + description: + "Delivery health for one channel: the derived state plus the raw counters and timestamps behind it.", + }); + +const eventDeliveryStatus = z + .object({ + lastDeliveredAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe("Null when this category has never been delivered."), + }) + .meta({ id: "NotificationEventDeliveryStatus" }); + +const notificationStatusResponse = z + .object({ + channels: z.array(channelDeliveryStatus), + events: z + .record(z.string(), eventDeliveryStatus) + .describe( + "Keyed by event category. EVERY known category is present, with `lastDeliveredAt: null` when no data exists, so a client can render an empty row without conditional plumbing. Only categories with their own dispatch ledger can ever report a non-null instant — today that is `MOOD_REMINDER` alone; every other category reports null regardless of what was actually delivered.", + ), + }) + .meta({ + id: "NotificationStatusResponse", + description: + "Per-channel delivery health plus a per-category last-delivered map.", + }); + +const reEnableChannelRequest = reEnableChannelSchema.meta({ + id: "ReEnableNotificationChannelRequest", + description: + "Clear the auto-disable on one of the caller's own channels. A channel id the caller does not own answers 404.", +}); + +export const notificationChannelPaths: NonNullable = + { + "/api/notifications/preferences": { + get: { + tags: ["Notifications"], + summary: "Read the caller's notification channels and preferences", + description: + "Returns every channel the caller has, its stored per-event overrides, and the full event vocabulary. Not a pure read: a user whose Telegram config still lives only on the legacy `User` columns has it migrated into a channel row as a side effect of this call, and that migration failing is swallowed as a warning rather than surfaced. Auth via cookie or Bearer.", + responses: { + "200": { + description: "Channels, stored overrides and event vocabulary.", + content: { + "application/json": { + schema: dataEnvelope( + notificationPreferencesResponse, + "GetNotificationPreferences", + ), + }, + }, + }, + ...stdResponses, + }, + }, + put: { + tags: ["Notifications"], + summary: "Set one per-channel notification preference", + description: + "Upserts a single (channel, event) override and echoes the stored row. One preference per call — there is no batch arm. Body capped at 64 KiB. Auth via cookie or Bearer.", + requestBody: { + required: true, + content: { + "application/json": { schema: notificationPreferenceRequest }, + }, + }, + responses: { + "200": { + description: "The stored preference.", + content: { + "application/json": { + schema: dataEnvelope( + notificationPreference, + "PutNotificationPreferenceResponse", + ), + }, + }, + }, + "404": { + description: "No such channel for this caller.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "413": { + description: "Body exceeds 64 KiB.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + "422": { + description: + "Body is not parseable JSON, or failed validation. Note that unlike the routes wrapped in `safeJson`, a malformed body here is 422 rather than 400, and no Content-Type check runs.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, + "/api/notifications/status": { + get: { + tags: ["Notifications"], + summary: "Read per-channel delivery health", + description: + "What the notification settings surface paints into its Active / Auto-disabled / Sending-paused badges, plus a per-category last-delivered map. Auth via cookie or Bearer.", + responses: { + "200": { + description: "Delivery health per channel and per category.", + content: { + "application/json": { + schema: dataEnvelope( + notificationStatusResponse, + "GetNotificationStatus", + ), + }, + }, + }, + ...stdResponses, + }, + }, + post: { + tags: ["Notifications"], + summary: "Re-enable an auto-disabled channel", + description: + "Clears `disabledReason`, `consecutiveFailures` and `nextRetryAt`, sets `enabled` true, and audits the change. Accepted on any of the caller's channels, not only auto-disabled ones — re-enabling an already-active channel is a no-op that still answers 200. Follow up with a test send if the client wants confirmation the transport works again. Body capped at 64 KiB. Auth via cookie or Bearer.", + requestBody: { + required: true, + content: { + "application/json": { schema: reEnableChannelRequest }, + }, + }, + responses: { + "200": { + description: "The channel was re-enabled.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ ok: z.literal(true) }), + "ReEnableNotificationChannelResponse", + ), + }, + }, + }, + "404": { + description: "No such channel for this caller.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "413": { + description: "Body exceeds 64 KiB.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + "422": { + description: + "Body is not parseable JSON, or failed validation. As above, a malformed body here is 422 rather than 400.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, + }; diff --git a/src/lib/openapi/routes/settings.ts b/src/lib/openapi/routes/settings.ts index ecb83a289..60fbe29f1 100644 --- a/src/lib/openapi/routes/settings.ts +++ b/src/lib/openapi/routes/settings.ts @@ -1,5 +1,6 @@ /** - * OpenAPI route table — settings read surfaces. + * OpenAPI route table — settings read surfaces and the per-channel + * notification config under `/api/settings/*`. * * Part of the OpenAPI route table; aggregated in `./index.ts`. * Schemas come from `src/lib/validations/*` where shared with the @@ -7,7 +8,18 @@ */ import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; -import { dataEnvelope, recordRefusal, stdResponses } from "./shared"; +import { + notificationChannelEnabledSchema, + ntfySettingsSchema, + webhookSettingsSchema, +} from "@/lib/validations/notifications"; +import { telegramSettingsSchema } from "@/lib/validations/telegram"; +import { + dataEnvelope, + errorEnvelope, + recordRefusal, + stdResponses, +} from "./shared"; // v1.16.11 — the one threshold read every dose-status consumer makes // (cards, table, take-all-due derivation). `lateMinutes` / @@ -52,6 +64,147 @@ const reminderThresholdsResponse = z "Medication reminder thresholds: the operator-level late/missed minute marks plus the calling user's low-stock runway threshold (v1.16.11).", }); +// ── Per-channel notification config ────────────────────────────────── +// Three credential-bearing channels the Settings page and the iOS client both +// write. Every GET here reports the SECRET AS A BOOLEAN and never the value: +// `hasAuthToken` (ntfy), `hasBotToken` (Telegram), `hasHeaderValue` (webhook). +// The PUT side is what that costs: because a client cannot read the secret back, +// an empty value on the way in means "keep what is stored", never "clear it". + +const jsonBodyRefusals = { + "400": { + description: "Body is not parseable JSON.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "413": { + description: "Body exceeds 64 KiB.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "415": { + description: "Content-Type is not `application/json`.", + content: { "application/json": { schema: errorEnvelope } }, + }, +}; + +const channelEnabledOnlyRequest = notificationChannelEnabledSchema.meta({ + id: "NotificationChannelEnabledRequest", + description: + "The toggle-only arm of a channel PUT: exactly `{ enabled }` and nothing else. Any additional key falls through to the channel's full config body instead. Enabling a channel whose stored config is incomplete fails with 422.", +}); + +const ntfySettingsRequest = ntfySettingsSchema.meta({ + id: "NtfySettingsRequest", + description: + "ntfy channel config. `serverUrl` must pass the SSRF floor — a URL resolving into a private range is refused at input time. An EMPTY OR OMITTED `authToken` preserves the stored one rather than clearing it: the GET never returns the secret, so a client round-tripping the form would otherwise wipe it on every unrelated save. Sending a non-empty value replaces it. There is no way to clear a stored auth token through this endpoint.", +}); + +const telegramSettingsRequest = telegramSettingsSchema.meta({ + id: "TelegramSettingsRequest", + description: + "Telegram channel config. Both credential fields are optional and behave by key PRESENCE, not by value: omit `botToken` to keep the stored one, send an empty string to clear it. Same for `chatId`. Enabling the channel registers the Telegram webhook against `APP_URL` as part of the write, so a deployment whose `APP_URL` is not a public HTTPS host on port 80/88/443/8443 cannot enable Telegram at all.", +}); + +const webhookSettingsRequest = webhookSettingsSchema.meta({ + id: "WebhookSettingsRequest", + description: + "Generic-webhook channel config — one channel covering Gotify, Discord, Slack, a Matrix bridge, Home Assistant, or any relay accepting an inbound JSON POST. `url` must pass the SSRF floor at input time and is re-checked at dispatch time. An EMPTY OR OMITTED `headerValue` preserves the stored one, for the same reason as ntfy's `authToken`: the GET never returns it. There is no way to clear a stored header value through this endpoint.", +}); + +/** + * The two shapes every channel PUT accepts, as one `anyOf`. + * + * The handler tries the strict `{ enabled }` object first and falls through to + * the full config schema, so the wire genuinely admits either. Publishing only + * the full schema would tell a client it has to resend the whole config to flip + * a toggle — exactly the round-trip the preserve-on-empty rule exists to + * survive, and exactly the round-trip that used to wipe the stored secret. + */ +const ntfyPutRequest = z + .union([channelEnabledOnlyRequest, ntfySettingsRequest]) + .meta({ + id: "NtfySettingsPutRequest", + description: + "Either the toggle-only body or the full ntfy config. The toggle arm is tried first and matches only an object with `enabled` and no other key.", + }); + +const telegramPutRequest = z + .union([channelEnabledOnlyRequest, telegramSettingsRequest]) + .meta({ + id: "TelegramSettingsPutRequest", + description: + "Either the toggle-only body or the full Telegram config. The toggle arm is tried first and matches only an object with `enabled` and no other key.", + }); + +const webhookPutRequest = z + .union([channelEnabledOnlyRequest, webhookSettingsRequest]) + .meta({ + id: "WebhookSettingsPutRequest", + description: + "Either the toggle-only body or the full webhook config. The toggle arm is tried first and matches only an object with `enabled` and no other key.", + }); + +const ntfySettingsResponse = z + .object({ + enabled: z.boolean(), + serverUrl: z + .string() + .describe("Defaults to `https://ntfy.sh` when no channel row exists."), + topic: z.string().describe("Empty string when unset."), + hasAuthToken: z + .boolean() + .describe( + "Whether an auth token is stored. The token itself is never returned.", + ), + }) + .meta({ + id: "NtfySettingsResponse", + description: + "Stored ntfy channel config, with the auth token reduced to a presence flag.", + }); + +const telegramSettingsResponse = z + .object({ + enabled: z.boolean(), + hasBotToken: z + .boolean() + .describe( + "Whether a bot token is stored. The token itself is never returned.", + ), + chatId: z + .string() + .nullable() + .describe( + "The stored chat id, returned in full. It is an addressing value, not a credential — the bot token is what authorises a send.", + ), + }) + .meta({ + id: "TelegramSettingsResponse", + description: + "Stored Telegram channel config, with the bot token reduced to a presence flag.", + }); + +const webhookSettingsResponse = z + .object({ + enabled: z.boolean(), + url: z.string().describe("Empty string when no channel row exists."), + headerName: z + .string() + .describe("Empty string when unset. The header NAME is not a secret."), + hasHeaderValue: z + .boolean() + .describe( + "Whether a header value (the shared secret) is stored. The value itself is never returned.", + ), + }) + .meta({ + id: "WebhookSettingsResponse", + description: + "Stored generic-webhook channel config, with the secret header value reduced to a presence flag.", + }); + +const channelSaved = z.object({ saved: z.literal(true) }); +const channelUpdated = z.object({ updated: z.literal(true) }); + export const settingsPaths: NonNullable = { "/api/settings/reminder-thresholds": { get: { @@ -76,4 +229,160 @@ export const settingsPaths: NonNullable = { }, }, }, + "/api/settings/ntfy": { + get: { + tags: ["Notifications"], + summary: "Read the caller's ntfy channel config", + description: + 'Returns the stored server URL, topic and enabled flag. The auth token is reported as `hasAuthToken` only. When the user has no ntfy channel row the defaults `{ enabled: false, serverUrl: "https://ntfy.sh", topic: "", hasAuthToken: false }` are returned rather than a 404. Auth via cookie or Bearer.', + responses: { + "200": { + description: "Stored (or default) ntfy config.", + content: { + "application/json": { + schema: dataEnvelope(ntfySettingsResponse, "GetNtfySettings"), + }, + }, + }, + ...stdResponses, + }, + }, + put: { + tags: ["Notifications"], + summary: "Update the caller's ntfy channel config", + description: + "Upserts the channel. Accepts either the toggle-only body or the full config. The response is `{ saved: true }` and carries no config echo, so re-read the GET if the client needs the resolved state. Auth via cookie or Bearer.", + requestBody: { + required: true, + content: { "application/json": { schema: ntfyPutRequest } }, + }, + responses: { + "200": { + description: "Config stored.", + content: { + "application/json": { + schema: dataEnvelope(channelSaved, "PutNtfySettingsResponse"), + }, + }, + }, + ...jsonBodyRefusals, + ...stdResponses, + "422": { + description: + "Body matched neither accepted shape, or `enabled` is true while the stored config has no server URL and topic.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, + "/api/settings/telegram": { + get: { + tags: ["Notifications"], + summary: "Read the caller's Telegram channel config", + description: + "Returns the enabled flag, the chat id and `hasBotToken`. The bot token is never returned. Auth via cookie or Bearer.", + responses: { + "200": { + description: "Stored Telegram config.", + content: { + "application/json": { + schema: dataEnvelope( + telegramSettingsResponse, + "GetTelegramSettings", + ), + }, + }, + }, + ...stdResponses, + }, + }, + put: { + tags: ["Notifications"], + summary: "Update the caller's Telegram channel config", + description: + "Accepts either the toggle-only body or the full config, then registers (on enable) or deletes (on disable) the Telegram webhook for the bot before persisting. Webhook deletion is best-effort and its failure does not fail the write; webhook REGISTRATION is not — a rejected registration aborts the whole call and nothing is stored. The response is `{ updated: true }` with no config echo. Auth via cookie or Bearer.", + requestBody: { + required: true, + content: { "application/json": { schema: telegramPutRequest } }, + }, + responses: { + "200": { + description: "Config stored.", + content: { + "application/json": { + schema: dataEnvelope( + channelUpdated, + "PutTelegramSettingsResponse", + ), + }, + }, + }, + "404": { + description: "The session's user row no longer exists.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...jsonBodyRefusals, + "500": { + description: + "The deployment cannot register a Telegram webhook because `APP_URL` (or `NEXT_PUBLIC_APP_URL`) is missing or unparseable. An operator configuration problem, not a client one — retrying will not help.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + "422": { + description: + "Body matched neither accepted shape; or `enabled` is true without both a bot token and a chat id; or the deployment's `APP_URL` is not a public HTTPS host on port 80/88/443/8443; or Telegram refused the webhook registration.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, + "/api/settings/webhook": { + get: { + tags: ["Notifications"], + summary: "Read the caller's generic-webhook channel config", + description: + 'Returns the stored URL, header name and enabled flag. The secret header value is reported as `hasHeaderValue` only. When the user has no webhook channel row the defaults `{ enabled: false, url: "", headerName: "", hasHeaderValue: false }` are returned rather than a 404. Auth via cookie or Bearer.', + responses: { + "200": { + description: "Stored (or default) webhook config.", + content: { + "application/json": { + schema: dataEnvelope( + webhookSettingsResponse, + "GetWebhookSettings", + ), + }, + }, + }, + ...stdResponses, + }, + }, + put: { + tags: ["Notifications"], + summary: "Update the caller's generic-webhook channel config", + description: + "Upserts the channel. Accepts either the toggle-only body or the full config. The response is `{ saved: true }` and carries no config echo. Auth via cookie or Bearer.", + requestBody: { + required: true, + content: { "application/json": { schema: webhookPutRequest } }, + }, + responses: { + "200": { + description: "Config stored.", + content: { + "application/json": { + schema: dataEnvelope(channelSaved, "PutWebhookSettingsResponse"), + }, + }, + }, + ...jsonBodyRefusals, + ...stdResponses, + "422": { + description: + "Body matched neither accepted shape, or `enabled` is true while no webhook URL is stored.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, }; diff --git a/src/lib/validations/mcp.ts b/src/lib/validations/mcp.ts new file mode 100644 index 000000000..2ba60a5ec --- /dev/null +++ b/src/lib/validations/mcp.ts @@ -0,0 +1,18 @@ +import { z } from "zod/v4"; + +/** + * Body of `POST /api/mcp/tokens` — mint a connector Bearer for the manual / + * stdio path. + * + * `scope` is a closed two-value choice, not a permission list: `read` maps to + * `["health:read"]` and `read_write` adds `health:write`. The route builds the + * `permissions` array from that choice field by field, so no request shape can + * coerce this endpoint into minting a wildcard or any other grant. + */ +export const createMcpTokenSchema = z.object({ + name: z.string().min(1, "Name required").max(100), + expiresInDays: z.number().int().min(1).max(365).optional(), + scope: z.enum(["read", "read_write"]).optional().default("read"), +}); + +export type CreateMcpTokenInput = z.infer; diff --git a/src/lib/validations/notifications.ts b/src/lib/validations/notifications.ts index 2ee35963d..f5aeca557 100644 --- a/src/lib/validations/notifications.ts +++ b/src/lib/validations/notifications.ts @@ -12,6 +12,15 @@ export const notificationChannelEnabledSchema = z.strictObject({ enabled: z.boolean(), }); +/** + * Body of `POST /api/notifications/status` — clear the auto-disable on one of + * the caller's own channels. The channel is looked up scoped to the session + * user, so an id alone cannot re-enable somebody else's channel. + */ +export const reEnableChannelSchema = z.object({ + channelId: z.string().min(1).max(64), +}); + /** * Parse a dotted-quad IPv4 string into normalized octets. * From 7f67c24eccb30ba4f56ce94569bef4517867c62e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 22 Aug 2026 12:44:36 +0200 Subject: [PATCH 2/4] Describe the rest of the integration surface in the contract Thirty-seven more paths the clients already call and the published spec did not describe: the six OAuth providers' credential, status, disconnect, sync and probe endpoints, the cross-provider surfaces under `/api/integrations/*`, the managed record's configuration tree, and the native device-revoke path. Where providers share a verb name they do not always share a contract, and the descriptions say so instead of averaging it out. Three divergences are stated per path. A disconnect refuses a repeat with 404 on six providers and answers an idempotent 200 on two. `/api//test` answers a bare probe result while the same-named `/api/integrations//test` adds the connection's last sync, and the Google Health one accepts a body that replaces the response shape outright. `GET /api/withings/status` refreshes and re-persists the OAuth token while it reads; the other six status endpoints write nothing, and each now says which it is. Two further facts a client cannot infer. The Withings credential DELETE writes no audit row and leaves the ledger at its last state, unlike every sibling, so its `state` is not proof of a live connection afterwards. The WHOOP connect ticket is a credential in a response body, returned once and stored only as a hash. The Google Health credential schema moves to the validations module; the managed record's six strict patch schemas are exported from where they already lived, so the contract publishes the objects the handler parses rather than a restatement. --- .../api/google-health/credentials/route.ts | 13 +- src/lib/openapi/routes/devices.ts | 47 + src/lib/openapi/routes/index.ts | 6 + src/lib/openapi/routes/integrations.ts | 1583 ++++++++++++++++- src/lib/openapi/routes/record-settings.ts | 453 +++++ src/lib/record-settings/configuration.ts | 8 +- src/lib/record-settings/index.ts | 1 + src/lib/validations/google-health.ts | 17 + 8 files changed, 2110 insertions(+), 18 deletions(-) create mode 100644 src/lib/openapi/routes/record-settings.ts create mode 100644 src/lib/validations/google-health.ts diff --git a/src/app/api/google-health/credentials/route.ts b/src/app/api/google-health/credentials/route.ts index e7d11e867..d7edaa328 100644 --- a/src/app/api/google-health/credentials/route.ts +++ b/src/app/api/google-health/credentials/route.ts @@ -6,21 +6,10 @@ import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { encrypt } from "@/lib/crypto"; import { markDisconnected } from "@/lib/integrations/status"; +import { googleHealthCredentialsSchema } from "@/lib/validations/google-health"; import { NextRequest } from "next/server"; import { z } from "zod/v4"; -/** - * Per-user Google Health BYO-key credentials. Each self-hoster registers their - * own Google Cloud OAuth client (the Restricted-scope brand verification + CASA - * assessment is per-OAuth-client, so a single shared app is unworkable for a - * multi-operator product) and pastes the client id/secret into Settings. Stored - * encrypted on `User`. - */ -const googleHealthCredentialsSchema = z.object({ - clientId: z.string().min(1).max(200), - clientSecret: z.string().min(1).max(200), -}); - /** * Check whether the user has Google Health BYO-key credentials stored * (v1.26.0). diff --git a/src/lib/openapi/routes/devices.ts b/src/lib/openapi/routes/devices.ts index 361b31b51..d3534737c 100644 --- a/src/lib/openapi/routes/devices.ts +++ b/src/lib/openapi/routes/devices.ts @@ -92,4 +92,51 @@ export const devicePaths: NonNullable = { }, }, }, + "/api/devices/{id}": { + delete: { + tags: ["Devices"], + summary: "Revoke a device and everything bound to it", + description: + "Deletes the device and, in one transaction, revokes every refresh and access token bound to it — the cleanup a native client runs when it rotates its own registration. The counts come back so the caller can log what the cascade actually killed. Scoped to the caller's own devices: an id belonging to somebody else is indistinguishable from one that does not exist. This is a second URL for `DELETE /api/auth/me/devices/{id}`; the two share one transactional helper and behave identically, and the duplication exists so the native rotation loop has a path that is not the settings-surface one. Auth via cookie or Bearer.", + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string" }, + description: "Device id.", + }, + ], + responses: { + "200": { + description: "The device and its tokens were revoked.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ + id: z.string(), + revoked: z.literal(true), + refreshTokensRevoked: z + .number() + .int() + .describe("Refresh tokens the cascade revoked."), + accessTokensRevoked: z + .number() + .int() + .describe("Access tokens the cascade revoked."), + }), + "DeviceRevokeResponse", + ), + }, + }, + }, + "404": { + description: + "No such device for this caller — also the answer for one already revoked, so a retry after a successful revoke is a 404 rather than an idempotent 200.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, }; diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index 4d91aadcf..b24badb48 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -72,6 +72,7 @@ import { notificationChannelPaths } from "./notifications"; import { nutrientPaths } from "./nutrients"; import { onboardingPaths } from "./onboarding"; import { profilePaths } from "./profile"; +import { recordSettingsPaths } from "./record-settings"; import { settingsPaths } from "./settings"; import { syncPaths } from "./sync"; import { workoutPaths } from "./workouts"; @@ -137,6 +138,11 @@ export const openApiPaths: NonNullable = { // load-bearing). ...notificationChannelPaths, ...mcpPaths, + // The managed record's own configuration tree. Kept out of the sharing module + // because it is the one surface fenced by `requireGuardianAuth`, and its 403 + // therefore has to be the shared refusal sentence rather than the neighbouring + // module's local wording (appended, spread order is load-bearing). + ...recordSettingsPaths, }; export const openApiComponents: NonNullable = { diff --git a/src/lib/openapi/routes/integrations.ts b/src/lib/openapi/routes/integrations.ts index 9b257147b..7b52b18ff 100644 --- a/src/lib/openapi/routes/integrations.ts +++ b/src/lib/openapi/routes/integrations.ts @@ -1,15 +1,42 @@ /** - * OpenAPI route table — third-party integration config (HealthKit) and the - * per-provider connection surfaces (Nightscout / WHOOP / Withings). + * OpenAPI route table — third-party integrations. + * + * Three families live here: + * + * * the HealthKit config the iOS client drives, + * * the per-provider surfaces under `/api//*` — credentials, + * status, disconnect, sync and the token probe, for Fitbit, Google Health, + * Nightscout, Oura, Polar, Strava, WHOOP and Withings, + * * the cross-provider surfaces under `/api/integrations/*` — the + * consolidated status envelope, the park-resume endpoints and the + * connection probes for the four providers that have one there. + * + * The same verb name does NOT mean the same contract across providers, and the + * descriptions say so per path rather than smoothing it over. Three divergences + * are worth knowing before reading further: + * + * * A disconnect is 404-on-repeat for six providers and an idempotent 200 for + * two. Which is which is stated on each path. + * * `/api//test` answers `{ ok, latencyMs }`; the same-named + * `/api/integrations//test` answers `{ ok, lastSyncedAt, + * latencyMs }`, and the Google Health one additionally accepts a body that + * changes the response shape entirely. + * * `GET /api/withings/status` refreshes and re-persists the OAuth token as a + * side effect. No other status read writes anything. * * Part of the OpenAPI route table; aggregated in `./index.ts`. - * Response DTOs are declared here mirroring the route handler under - * `src/app/api/integrations/healthkit/route.ts`; the request schema - * mirrors the handler's `patchSchema`. + * Response DTOs are declared here mirroring the route handlers; request schemas + * come from `src/lib/validations/*` so the wire contract stays single-source. */ import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; +import { fitbitCredentialsSchema } from "@/lib/validations/fitbit"; +import { googleHealthCredentialsSchema } from "@/lib/validations/google-health"; +import { ouraCredentialsSchema } from "@/lib/validations/oura"; +import { polarCredentialsSchema } from "@/lib/validations/polar"; +import { stravaCredentialsSchema } from "@/lib/validations/strava"; import { whoopCredentialsSchema } from "@/lib/validations/whoop"; +import { withingsCredentialsSchema } from "@/lib/validations/withings"; import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; // Mirror the route's `directionEnum` — per-metric sync direction. @@ -465,6 +492,432 @@ const withingsStatusResponse = z "Withings connection status. When no connection row exists the body is exactly `{ connected: false, configured }` — every other field is absent, not null. Note that this read is not side-effect free: a token within 60 s of expiry is refreshed and re-persisted before the response is built, so a GET here can rotate the stored token.", }); +// ── Shapes shared across the provider surfaces ─────────────────────── + +/** Every credentials GET answers exactly this, and never the values. */ +const credentialsPresenceResponse = z + .object({ + hasCredentials: z + .boolean() + .describe( + "Both the client id and the client secret are stored. Neither value is ever returned by any endpoint.", + ), + }) + .meta({ + id: "CredentialsPresenceResponse", + description: + "Presence check for a provider's per-user BYO-key credentials. Presence only — the stored id and secret are write-only.", + }); + +// The BYO-key request bodies. Five providers trim their inputs and two do not +// (Fitbit / Google Health / Withings take the string verbatim); the difference +// is real and is stated per schema rather than averaged away. +const fitbitCredentialsRequest = fitbitCredentialsSchema.meta({ + id: "FitbitCredentialsRequest", + description: + "Per-user Fitbit BYO-key credentials, 1–200 characters each, stored encrypted at rest and never read back. NOT trimmed: a trailing space or newline from the developer portal's copy button is stored verbatim and reaches Fitbit as part of the id.", +}); + +const googleHealthCredentialsRequest = googleHealthCredentialsSchema.meta({ + id: "GoogleHealthCredentialsRequest", + description: + "Per-user Google Health BYO-key credentials — each self-hoster registers their own Google Cloud OAuth client, because the Restricted-scope brand verification and CASA assessment are per-client. 1–200 characters each, stored encrypted, never read back. NOT trimmed.", +}); + +const ouraCredentialsRequest = ouraCredentialsSchema.meta({ + id: "OuraCredentialsRequest", + description: + "Per-user Oura BYO-key credentials. Trimmed on the way in, because a trailing space from the portal's copy button reaches Oura verbatim and answers as 'unknown client'. Stored encrypted, never read back. Leaving these unset is valid: the integration then falls back to the operator's shared env app.", +}); + +const polarCredentialsRequest = polarCredentialsSchema.meta({ + id: "PolarCredentialsRequest", + description: + "Per-user Polar AccessLink BYO-key credentials. Trimmed on the way in. Stored encrypted, never read back. Leaving these unset falls back to the operator's shared env app.", +}); + +const stravaCredentialsRequest = stravaCredentialsSchema.meta({ + id: "StravaCredentialsRequest", + description: + "Per-user Strava BYO-key credentials. Strava caps every newly-created API app at athlete capacity 1, so one shared app cannot serve several self-hosters and each registers their own. Trimmed on the way in. Stored encrypted, never read back. Leaving these unset falls back to the operator's shared env app.", +}); + +const withingsCredentialsRequest = withingsCredentialsSchema.meta({ + id: "WithingsCredentialsRequest", + description: + "Per-user Withings BYO-key credentials, 1–200 characters each, stored encrypted at rest and never read back. NOT trimmed.", +}); + +const updatedAck = z.object({ updated: z.literal(true) }); +const deletedAck = z.object({ deleted: z.literal(true) }); +const disconnectedAck = z.object({ disconnected: z.literal(true) }); + +/** Body refusals shared by every `safeJson(request, { maxBytes: 64 * 1024 })`. */ +const jsonBody64k = { + "400": { + description: "Body is not parseable JSON.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "413": { + description: "Body exceeds 64 KiB.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "415": { + description: "Content-Type is not `application/json`.", + content: { "application/json": { schema: errorEnvelope } }, + }, +}; + +const credentialsRejected422 = { + "422": { + description: + "`clientId` and `clientSecret` are both required and each must be 1–200 characters. The envelope carries a single fixed message, not per-field issues.", + content: { "application/json": { schema: errorEnvelope } }, + }, +}; + +/** + * The token probe under `/api//test` (Nightscout / Oura / Polar / + * Strava). Bare success — no `lastSyncedAt`, which is the difference from the + * same-named probe under `/api/integrations//test`. + */ +const providerProbeResponse = z + .object({ + ok: z.literal(true), + latencyMs: z + .number() + .int() + .describe("Round-trip time of the probe request, in milliseconds."), + }) + .meta({ + id: "ProviderProbeResponse", + description: + "A probe that reached the provider with the stored grant. Only ever returned on success — a failed probe is an error status, never `ok: false`.", + }); + +/** + * The probe under `/api/integrations//test` (Fitbit / Google Health / + * WHOOP / Withings). Same idea, one field more. + */ +const integrationProbeResponse = z + .object({ + ok: z.literal(true), + lastSyncedAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe( + "The connection row's last successful sync; null when it has never synced. Present here and absent from the `/api//test` family — the one shape difference between the two probe families.", + ), + latencyMs: z.number().int(), + }) + .meta({ + id: "IntegrationProbeResponse", + description: + "A probe that reached the provider with a freshly-resolved access token, plus the connection's last sync instant.", + }); + +/** + * The 502 both probe families answer, with the error-code set they share. + * + * The set is identical across Fitbit / Google Health / Oura / Polar / Strava / + * WHOOP / Withings. Two providers add one code of their own and say so on their + * own path: Nightscout adds `url_not_public`, Withings adds + * `upstream_invalid_json`. + */ +const probeFailure502 = { + "502": { + description: + "The probe did not complete. `meta.errorCode` names the class: `credentials_rejected` (the provider answered 401/403 — the stored grant is gone), `rate_limited` (the PROVIDER answered 429, which is not this endpoint's own limiter), `upstream_error` (5xx), `timeout` (no answer within 5 s), `connection_failed` (everything else). The message is fixed per class; the upstream body never reaches the client.", + content: { "application/json": { schema: errorEnvelope } }, + }, +}; + +const probeNotConfigured422 = { + "422": { + description: + "No usable connection for this provider — nothing stored, or the stored grant could no longer be resolved into an access token. `meta.errorCode` = `not_configured`.", + content: { "application/json": { schema: errorEnvelope } }, + }, +}; + +const selfRateLimited429 = { + "429": { + description: + "More than 5 requests in 60 s from this user. `meta.errorCode` = `rate_limited_self` — this is the local limiter, distinct from a `rate_limited` 502 which is the provider's.", + content: { "application/json": { schema: errorEnvelope } }, + }, +}; + +/** + * The manual-sync body. Every sync route parses it the same way and the parse + * is deliberately forgiving: a missing body, an unparseable body and a body + * without the key all mean the same thing — an incremental run. Only a literal + * `true` selects the full-history walk. + */ +const syncTriggerRequest = z + .object({ + fullSync: z + .boolean() + .optional() + .describe( + "`true` walks full history; anything else (including a malformed body, which is swallowed rather than refused) runs the incremental sync.", + ), + }) + .meta({ + id: "SyncTriggerRequest", + description: + "Optional flag-only body for a manual sync. Note that this body is NOT validated: a parse failure silently degrades to `fullSync: false` rather than answering 422, so a client typo reads as a successful incremental run.", + }); + +const syncTriggerOutcome = z + .object({ + imported: z + .number() + .int() + .describe("Rows that reached the database this run."), + failed: z.boolean().describe("True when any part of the run did not land."), + outcome: z + .enum(["empty", "failed", "partial", "success"]) + .describe( + 'Server-resolved tone, never derived from the HTTP status: a run where everything was refused still answers 200 with `outcome: "failed"`.', + ), + fullSync: z.boolean().describe("Echo of the flag this run used."), + }) + .meta({ + id: "SyncTriggerOutcome", + description: + "The result of a manual sync trigger, with the flag echoed back so a client can tell an incremental run from a full one in the response alone.", + }); + +const googleHealthSyncOutcome = syncTriggerOutcome + .extend({ + runId: z + .string() + .optional() + .describe("Truncated to 128 chars. Absent when the run produced none."), + state: z + .enum([ + "in_progress", + "complete", + "partial", + "zero", + "truncated", + "failed", + "interrupted", + ]) + .optional(), + resources: z + .array(googleHealthResourceOutcome) + .max(16) + .optional() + .describe( + "Per-collection outcome, capped at 16 entries and re-sanitised on the way out exactly as the status endpoint does.", + ), + }) + .meta({ + id: "GoogleHealthSyncOutcome", + description: + "The Google Health manual-sync result: the shared outcome plus the run identity and the per-collection breakdown.", + }); + +const resumeResponse = z + .object({ + resumed: z.literal(true), + wasParked: z + .boolean() + .describe( + "Whether the ledger was actually parked before this call. False means the call changed nothing — render it as a no-op rather than a 'resumed' confirmation.", + ), + }) + .meta({ + id: "IntegrationResumeResponse", + description: + "The result of clearing a park. Idempotent: a second call answers 200 with `wasParked: false` and writes no further audit row.", + }); + +/** + * The connection-row status shape Fitbit and Google Health share. WHOOP's is + * the same minus nothing and plus nothing, but it is declared separately above + * because it predates this batch and iOS reads it by its own component name. + */ +function connectionStatusShape() { + return { + connected: z.boolean().describe("A connection row exists."), + configured: z + .boolean() + .describe("Per-user BYO-key client id AND secret are both stored."), + lastSyncedAt: z.iso.datetime({ offset: true }).nullable().optional(), + connectedAt: z.iso.datetime({ offset: true }).optional(), + tokenExpired: z + .boolean() + .optional() + .describe( + "Read straight off the row — this endpoint does not refresh; the sync path refreshes lazily.", + ), + tokenExpiresAt: z.iso.datetime({ offset: true }).optional(), + backfillCompleted: z + .boolean() + .optional() + .describe("The history backfill has walked every collection."), + scope: z + .string() + .nullable() + .optional() + .describe( + "OAuth scope string granted at authorisation. Null on a legacy connection that predates scope tracking.", + ), + }; +} + +const fitbitStatusResponse = z.object(connectionStatusShape()).meta({ + id: "FitbitStatusResponse", + description: + "Fitbit connection status. When no connection row exists the body is exactly `{ connected: false, configured }` — every other field is absent, not null. Pure read.", +}); + +const googleHealthStatusResponse = z + .object({ + ...connectionStatusShape(), + needsReauth: z + .boolean() + .optional() + .describe( + "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` and from `disconnected`: the connection still exists, it just cannot mint a token until the user reconnects. Only Google Health has this field.", + ), + }) + .meta({ + id: "GoogleHealthStatusResponse", + description: + "Google Health connection status. When no connection row exists the body is exactly `{ connected: false, configured }` — every other field is absent, not null. Pure read.", + }); + +/** + * Oura / Polar / Strava share one status shape. They differ from the + * connection-row providers in that `configured` MIRRORS `connected` rather than + * reporting credentials: there is no "credentials saved but disconnected" state + * on these three, and `hasOwnCredentials` is the field that reports the BYO + * pair instead. + */ +const oauthProviderStatusResponse = z + .object({ + connected: z.boolean().describe("An access token is stored."), + configured: z + .boolean() + .describe( + "Mirrors `connected` exactly on these three providers. It is NOT a credentials marker — read `hasOwnCredentials` for that.", + ), + available: z + .boolean() + .describe( + "Usable OAuth client credentials resolve for this user: the per-user BYO pair first, then the operator's shared env app. False means the connect button cannot work at all.", + ), + hasOwnCredentials: z + .boolean() + .describe("The user has stored their own BYO client id and secret."), + state: integrationLedgerState.optional(), + lastSuccessAt: z.iso.datetime({ offset: true }).nullable().optional(), + lastAttemptAt: z.iso.datetime({ offset: true }).nullable().optional(), + lastError: z.string().nullable().optional(), + syncHealth: syncHealth.optional(), + }) + .meta({ + id: "OAuthProviderStatusResponse", + description: + "Connection status for an OAuth provider whose grant lives on the user row (Oura / Polar / Strava). When no token is stored the body is exactly `{ connected: false, configured: false, available, hasOwnCredentials }` — the ledger fields are absent, not null. Tokens and client secrets are never returned. Pure read.", + }); + +// ── The consolidated envelope (`GET /api/integrations/status`) ─────── + +const consecutiveFailuresByKind = z + .object({ + transient: z.number().int(), + reauth_required: z.number().int(), + persistent: z.number().int(), + }) + .nullable() + .describe( + "Per-kind consecutive-failure counters, or null when the row has never written a bucket payload. Compare against the top-level `threshold` to know how close the provider is to the operator alert.", + ); + +const integrationEnvelopeEntry = z + .object({ + integration: z + .enum([ + "withings", + "whoop", + "fitbit", + "google-health", + "polar", + "oura", + "strava", + "nightscout", + ]) + .describe("Which provider this entry describes."), + state: integrationLedgerState, + lastSuccessAt: z.iso.datetime({ offset: true }).nullable(), + lastAttemptAt: z.iso.datetime({ offset: true }).nullable(), + lastError: z.string().nullable(), + consecutiveFailuresByKind, + syncHealth, + metricFreshness: z + .array(metricFreshnessEntry) + .describe( + "Per-metric-type freshness for this provider's data. Empty when the provider has delivered nothing, and also empty when the freshness read itself failed — the endpoint degrades to no data rather than failing the whole envelope, so an empty list is not proof of no data.", + ), + connected: z.boolean(), + configured: z.boolean(), + connectedAt: z.iso.datetime({ offset: true }).nullable().optional(), + legacyLastSyncedAt: z.iso + .datetime({ offset: true }) + .nullable() + .optional() + .describe( + "The connection row's own last-sync instant, for legacy connections established before the ledger existed. Not present on the three user-row OAuth providers or on Nightscout.", + ), + tokenExpiresAt: z.iso.datetime({ offset: true }).nullable().optional(), + tokenExpired: z + .boolean() + .nullable() + .optional() + .describe("Null — not false — when there is no connection to judge."), + backfillCompleted: z.boolean().nullable().optional(), + scope: z.string().nullable().optional().describe("Withings only."), + hasActivityScope: z.boolean().optional().describe("Withings only."), + needsReauth: z.boolean().optional().describe("Google Health only."), + available: z.boolean().optional().describe("Oura / Polar / Strava only."), + hasOwnCredentials: z + .boolean() + .optional() + .describe("Oura / Polar / Strava only."), + hasToken: z.boolean().optional().describe("Nightscout only."), + allowPrivateHost: z.boolean().optional().describe("Nightscout only."), + }) + .meta({ + id: "IntegrationStatusEntry", + 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 present exactly for the providers named in its description and absent for the rest, so branch on `integration`, not on presence.", + }); + +const integrationStatusEnvelope = z + .object({ + threshold: z + .number() + .int() + .describe( + "Consecutive persistent failures at which the operator is alerted. Operator-level, configurable via env.", + ), + integrations: z + .array(integrationEnvelopeEntry) + .describe( + "All eight providers, always, in the fixed order withings, whoop, fitbit, google-health, polar, oura, strava, nightscout. A provider the user has never touched still appears, with `connected: false` and a synthesised `connected` ledger state — absence from this list never means anything, because nothing is ever absent from it.", + ), + }) + .meta({ + id: "IntegrationStatusEnvelope", + description: + "The one read behind the integrations settings surface. It carries every field the per-provider status routes carry, so a client has the choice of eight round-trips or this one; the per-provider routes remain for callers that want a single provider.", + }); + export const integrationPaths: NonNullable = { "/api/integrations/healthkit": { get: { @@ -875,4 +1328,1124 @@ export const integrationPaths: NonNullable = { }, }, }, + "/api/withings/credentials": { + get: { + tags: ["Integrations"], + summary: "Check whether Withings BYO-key credentials are stored", + description: "Presence only. Auth via cookie or Bearer.", + responses: { + "200": { + description: "Credential presence.", + content: { + "application/json": { + schema: dataEnvelope( + credentialsPresenceResponse, + "WithingsCredentialsStatusEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + put: { + tags: ["Integrations"], + summary: "Store the caller's Withings BYO-key credentials", + description: + "Replaces both values; there is no partial update. Auth via cookie or Bearer.", + requestBody: { + required: true, + content: { + "application/json": { schema: withingsCredentialsRequest }, + }, + }, + responses: { + "200": { + description: "Credentials stored.", + content: { + "application/json": { + schema: dataEnvelope( + updatedAck, + "WithingsCredentialsUpdateEnvelope", + ), + }, + }, + }, + ...jsonBody64k, + ...stdResponses, + ...credentialsRejected422, + }, + }, + delete: { + tags: ["Integrations"], + summary: "Delete the caller's Withings credentials and connection", + description: + "Deletes the connection row (with its encrypted OAuth tokens) and clears both credential columns. Idempotent — a missing connection row is a benign no-op, so a repeat call still answers 200. Unlike every sibling provider's credential DELETE, this one writes NO audit row and does NOT park the `withings` ledger: the ledger keeps whatever state it last held, so a client should not read `state` as proof of a live connection after this call. Auth via cookie or Bearer.", + responses: { + "200": { + description: "Credentials and connection removed.", + content: { + "application/json": { + schema: dataEnvelope( + deletedAck, + "WithingsCredentialsDeleteEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/withings/sync": { + post: { + tags: ["Integrations"], + summary: "Trigger a Withings sync", + description: + "Incremental by default; `{ fullSync: true }` walks full history. NOT rate-limited — the only manual sync in this group without a limiter, alongside WHOOP's. Auth via cookie or Bearer.", + requestBody: { + required: false, + content: { "application/json": { schema: syncTriggerRequest } }, + }, + responses: { + "200": { + description: "Sync run complete.", + content: { + "application/json": { + schema: dataEnvelope( + syncTriggerOutcome, + "WithingsSyncTriggerEnvelope", + ), + }, + }, + }, + "413": { + description: "Body exceeds 64 KiB.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/whoop/sync": { + post: { + tags: ["Integrations"], + summary: "Trigger a WHOOP sync", + description: + "Incremental by default; `{ fullSync: true }` walks full history. NOT rate-limited — the only manual sync in this group without a limiter, alongside Withings'. Auth via cookie or Bearer.", + requestBody: { + required: false, + content: { "application/json": { schema: syncTriggerRequest } }, + }, + responses: { + "200": { + description: "Sync run complete.", + content: { + "application/json": { + schema: dataEnvelope( + syncTriggerOutcome, + "WhoopSyncTriggerEnvelope", + ), + }, + }, + }, + "413": { + description: "Body exceeds 64 KiB.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/whoop/connect/ticket": { + post: { + tags: ["Integrations"], + summary: "Mint a one-time WHOOP connect ticket", + description: + "Bearer-capable by design: a native client with no browser session mints a ticket here, then opens `GET /api/whoop/connect?ticket=` in an in-app web session to run the WHOOP OAuth handshake. THE RESPONSE CARRIES THE RAW TICKET — only its hash is stored, so it cannot be retrieved again; it is short-lived and single-use. No body. Rate-limited 10 requests / 60 s per user. Auth via cookie or Bearer.", + responses: { + "200": { + description: + "Ticket minted. The `ticket` field is the only copy of the credential.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ + ticket: z + .string() + .describe( + "Opaque one-time connect ticket, returned exactly once. Treat as a secret: pass it only as the `ticket` query parameter of the connect URL, and never log it.", + ), + }), + "WhoopConnectTicketEnvelope", + ), + }, + }, + }, + "400": { + description: + "No WHOOP client id and secret are stored for this user, so a ticket could not complete the handshake. Store the credentials first. Note that this is 400, not the 422 the probe endpoints answer for the same condition.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + "429": { + description: "More than 10 ticket mints in 60 s from this user.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, + "/api/fitbit/credentials": { + get: { + tags: ["Integrations"], + summary: "Check whether Fitbit BYO-key credentials are stored", + description: "Presence only. Auth via cookie or Bearer.", + responses: { + "200": { + description: "Credential presence.", + content: { + "application/json": { + schema: dataEnvelope( + credentialsPresenceResponse, + "FitbitCredentialsStatusEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + put: { + tags: ["Integrations"], + summary: "Store the caller's Fitbit BYO-key credentials", + description: + "Replaces both values; there is no partial update. Auth via cookie or Bearer.", + requestBody: { + required: true, + content: { "application/json": { schema: fitbitCredentialsRequest } }, + }, + responses: { + "200": { + description: "Credentials stored.", + content: { + "application/json": { + schema: dataEnvelope( + updatedAck, + "FitbitCredentialsUpdateEnvelope", + ), + }, + }, + }, + ...jsonBody64k, + ...stdResponses, + ...credentialsRejected422, + }, + }, + delete: { + tags: ["Integrations"], + summary: "Delete the caller's Fitbit credentials and connection", + description: + "Deletes the connection row, clears both credential columns, audits the teardown and parks the `fitbit` ledger at `disconnected`. Idempotent — a missing connection row is a benign no-op, so a repeat call still answers 200. Auth via cookie or Bearer.", + responses: { + "200": { + description: "Credentials and connection removed.", + content: { + "application/json": { + schema: dataEnvelope( + deletedAck, + "FitbitCredentialsDeleteEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/fitbit/status": { + get: { + tags: ["Integrations"], + summary: "Read the caller's Fitbit connection status", + description: + "Reports the BYO-key marker plus the connection row's last sync, token expiry, granted scope and backfill progress. Pure read — no refresh, no ledger write. Auth via cookie or Bearer.", + responses: { + "200": { + description: "Connection status.", + content: { + "application/json": { + schema: dataEnvelope( + fitbitStatusResponse, + "FitbitStatusEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/fitbit/disconnect": { + post: { + tags: ["Integrations"], + summary: "Disconnect the caller's Fitbit account", + description: + "Deletes the connection row, audits the teardown and parks the `fitbit` ledger at `disconnected`. There is nothing to unsubscribe upstream — the integration polls. The BYO-key credentials are deliberately LEFT IN PLACE so a reconnect does not force the user to re-paste them; use the credentials DELETE to remove those too. No body. Auth via cookie or Bearer.", + responses: { + "200": { + description: "The connection was removed.", + content: { + "application/json": { + schema: dataEnvelope(disconnectedAck, "FitbitDisconnectEnvelope"), + }, + }, + }, + "404": { + description: + "No Fitbit connection to remove. NOT an idempotent no-op: a second disconnect answers 404 rather than 200.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/fitbit/sync": { + post: { + tags: ["Integrations"], + summary: "Trigger a Fitbit sync", + description: + "Incremental by default; `{ fullSync: true }` walks full history across four paginated resources. Two limiters apply: 5 requests / 60 s for any run, and additionally 1 per hour for a full one, because the classic Web API budget is 150 requests per hour per user. Auth via cookie or Bearer.", + requestBody: { + required: false, + content: { "application/json": { schema: syncTriggerRequest } }, + }, + responses: { + "200": { + description: "Sync run complete.", + content: { + "application/json": { + schema: dataEnvelope( + syncTriggerOutcome, + "FitbitSyncTriggerEnvelope", + ), + }, + }, + }, + "413": { + description: "Body exceeds 64 KiB.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + "429": { + description: + "Either limiter tripped: more than 5 syncs in 60 s, or a second full sync within the hour. `meta.errorCode` = `rate_limited_self` for both; the message distinguishes them.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, + "/api/google-health/credentials": { + get: { + tags: ["Integrations"], + summary: "Check whether Google Health BYO-key credentials are stored", + description: "Presence only. Auth via cookie or Bearer.", + responses: { + "200": { + description: "Credential presence.", + content: { + "application/json": { + schema: dataEnvelope( + credentialsPresenceResponse, + "GoogleHealthCredentialsStatusEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + put: { + tags: ["Integrations"], + summary: "Store the caller's Google Health BYO-key credentials", + description: + "Replaces both values; there is no partial update. Auth via cookie or Bearer.", + requestBody: { + required: true, + content: { + "application/json": { schema: googleHealthCredentialsRequest }, + }, + }, + responses: { + "200": { + description: "Credentials stored.", + content: { + "application/json": { + schema: dataEnvelope( + updatedAck, + "GoogleHealthCredentialsUpdateEnvelope", + ), + }, + }, + }, + ...jsonBody64k, + ...stdResponses, + ...credentialsRejected422, + }, + }, + delete: { + tags: ["Integrations"], + summary: "Delete the caller's Google Health credentials and connection", + description: + "Deletes the connection row, clears both credential columns, audits the teardown and parks the `google-health` ledger at `disconnected`. Idempotent — a missing connection row is a benign no-op, so a repeat call still answers 200. Auth via cookie or Bearer.", + responses: { + "200": { + description: "Credentials and connection removed.", + content: { + "application/json": { + schema: dataEnvelope( + deletedAck, + "GoogleHealthCredentialsDeleteEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/google-health/status": { + get: { + tags: ["Integrations"], + summary: "Read the caller's Google Health connection status", + description: + "Reports the BYO-key marker plus the connection row's last sync, token expiry, granted scope, backfill progress and the `needsReauth` soft-disconnect flag. Pure read — no refresh, no ledger write. Auth via cookie or Bearer.", + responses: { + "200": { + description: "Connection status.", + content: { + "application/json": { + schema: dataEnvelope( + googleHealthStatusResponse, + "GoogleHealthStatusEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/google-health/disconnect": { + post: { + tags: ["Integrations"], + summary: "Disconnect the caller's Google Health account", + description: + "Deletes the connection row, audits the teardown and parks the `google-health` ledger at `disconnected`. Deleting the encrypted token pair is the effective revocation; there is no subscription to tear down. The BYO-key credentials are deliberately LEFT IN PLACE. No body. Auth via cookie or Bearer.", + responses: { + "200": { + description: "The connection was removed.", + content: { + "application/json": { + schema: dataEnvelope( + disconnectedAck, + "GoogleHealthDisconnectEnvelope", + ), + }, + }, + }, + "404": { + description: + "No Google Health connection to remove. NOT an idempotent no-op: a second disconnect answers 404 rather than 200.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/google-health/sync": { + post: { + tags: ["Integrations"], + summary: "Trigger a Google Health sync", + description: + 'Incremental by default; `{ fullSync: true }` walks full history across every data type, each capped at 1000 pages. Two limiters apply: 5 requests / 60 s for any run, and additionally 1 per hour for a full one. Unlike every sibling sync, this one can answer 502: a run that failed AND wrote nothing is an error, while a run that failed after writing some resources is a 200 with `outcome: "partial"` — the honest half of the result is not thrown away. Auth via cookie or Bearer.', + requestBody: { + required: false, + content: { "application/json": { schema: syncTriggerRequest } }, + }, + responses: { + "200": { + description: + "Sync run complete, wholly or partly. Carries the per-collection breakdown.", + content: { + "application/json": { + schema: dataEnvelope( + googleHealthSyncOutcome, + "GoogleHealthSyncTriggerEnvelope", + ), + }, + }, + }, + "413": { + description: "Body exceeds 64 KiB.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "502": { + description: + "The run failed and wrote nothing. A run that wrote something before failing answers 200 instead.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + "429": { + description: + "Either limiter tripped: more than 5 syncs in 60 s, or a second full sync within the hour. `meta.errorCode` = `rate_limited_self`.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, + "/api/oura/credentials": { + get: { + tags: ["Integrations"], + summary: "Check whether Oura BYO-key credentials are stored", + description: + "Presence only. `false` is not a misconfiguration: with no BYO pair the integration falls back to the operator's shared env app. Auth via cookie or Bearer.", + responses: { + "200": { + description: "Credential presence.", + content: { + "application/json": { + schema: dataEnvelope( + credentialsPresenceResponse, + "OuraCredentialsStatusEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + put: { + tags: ["Integrations"], + summary: "Store the caller's Oura BYO-key credentials", + description: + "Replaces both values; there is no partial update. Auth via cookie or Bearer.", + requestBody: { + required: true, + content: { "application/json": { schema: ouraCredentialsRequest } }, + }, + responses: { + "200": { + description: "Credentials stored.", + content: { + "application/json": { + schema: dataEnvelope(updatedAck, "OuraCredentialsUpdateEnvelope"), + }, + }, + }, + ...jsonBody64k, + ...stdResponses, + ...credentialsRejected422, + }, + }, + delete: { + tags: ["Integrations"], + summary: "Delete the caller's Oura credentials and connection", + description: + "Clears the stored access and refresh tokens as well as the BYO pair — a token minted against a deleted app is orphaned, so the grant goes with the keys. Always answers 200. The audit row and the ledger park are written only when a token was actually present, so tearing down an unconnected credential pair leaves no audit trail. Auth via cookie or Bearer.", + responses: { + "200": { + description: "Credentials and any connection removed.", + content: { + "application/json": { + schema: dataEnvelope(deletedAck, "OuraCredentialsDeleteEnvelope"), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/oura/status": { + get: { + tags: ["Integrations"], + summary: "Read the caller's Oura connection status", + description: + "Reports whether usable credentials resolve at all, whether the user brought their own, and the ledger snapshot plus liveness verdict when a token is stored. Tokens and the client secret are never returned. Pure read. Auth via cookie or Bearer.", + responses: { + "200": { + description: "Connection status.", + content: { + "application/json": { + schema: dataEnvelope( + oauthProviderStatusResponse, + "OuraStatusEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/oura/disconnect": { + post: { + tags: ["Integrations"], + summary: "Disconnect the caller's Oura account", + description: + "Clears the stored access and refresh tokens, audits the teardown and parks the `oura` ledger at `disconnected`. The BYO-key credentials are left in place. Imported readings stay; a reconnect resumes sync. No body. Rate-limited 20 requests / 60 s per user. Auth via cookie or Bearer.", + responses: { + "200": { + description: "The connection was cleared.", + content: { + "application/json": { + schema: dataEnvelope(disconnectedAck, "OuraDisconnectEnvelope"), + }, + }, + }, + "404": { + description: + "No Oura token stored. NOT an idempotent no-op: a second disconnect answers 404 rather than 200.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/oura/test": { + post: { + tags: ["Integrations"], + summary: "Probe the caller's Oura grant", + description: + "Fetches the personal-info record, the cheapest authenticated Oura call, to confirm the stored grant still works. No body, nothing written. Rate-limited 5 requests / 60 s per user. Auth via cookie or Bearer.", + responses: { + "200": { + description: "The probe reached Oura.", + content: { + "application/json": { + schema: dataEnvelope(providerProbeResponse, "OuraTestEnvelope"), + }, + }, + }, + ...probeFailure502, + ...stdResponses, + ...probeNotConfigured422, + ...selfRateLimited429, + }, + }, + }, + "/api/polar/credentials": { + get: { + tags: ["Integrations"], + summary: "Check whether Polar BYO-key credentials are stored", + description: + "Presence only. `false` is not a misconfiguration: with no BYO pair the integration falls back to the operator's shared env app. Auth via cookie or Bearer.", + responses: { + "200": { + description: "Credential presence.", + content: { + "application/json": { + schema: dataEnvelope( + credentialsPresenceResponse, + "PolarCredentialsStatusEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + put: { + tags: ["Integrations"], + summary: "Store the caller's Polar BYO-key credentials", + description: + "Replaces both values; there is no partial update. Audits the write — the only credentials PUT in this group that does. Auth via cookie or Bearer.", + requestBody: { + required: true, + content: { "application/json": { schema: polarCredentialsRequest } }, + }, + responses: { + "200": { + description: "Credentials stored.", + content: { + "application/json": { + schema: dataEnvelope( + updatedAck, + "PolarCredentialsUpdateEnvelope", + ), + }, + }, + }, + ...jsonBody64k, + ...stdResponses, + ...credentialsRejected422, + }, + }, + delete: { + tags: ["Integrations"], + summary: "Delete the caller's Polar credentials and connection", + description: + "Clears the stored access token and member id as well as the BYO pair — a token minted under a deleted AccessLink app is orphaned, so the grant goes with the keys. Always answers 200, and unlike Oura's and Strava's equivalents it audits and parks the ledger unconditionally, whether or not a token was present. Auth via cookie or Bearer.", + responses: { + "200": { + description: "Credentials and any connection removed.", + content: { + "application/json": { + schema: dataEnvelope( + deletedAck, + "PolarCredentialsDeleteEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/polar/status": { + get: { + tags: ["Integrations"], + summary: "Read the caller's Polar connection status", + description: + "Reports whether usable credentials resolve at all, whether the user brought their own, and the ledger snapshot plus liveness verdict when a token is stored. The access token, member id and client secret are never returned. Pure read. Auth via cookie or Bearer.", + responses: { + "200": { + description: "Connection status.", + content: { + "application/json": { + schema: dataEnvelope( + oauthProviderStatusResponse, + "PolarStatusEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/polar/disconnect": { + post: { + tags: ["Integrations"], + summary: "Disconnect the caller's Polar account", + description: + "Clears the stored token and member id, audits the teardown and parks the `polar` ledger at `disconnected`. The BYO-key credentials are left in place. Imported readings stay. No body. Rate-limited 20 requests / 60 s per user. Auth via cookie or Bearer.", + responses: { + "200": { + description: "The connection was cleared.", + content: { + "application/json": { + schema: dataEnvelope(disconnectedAck, "PolarDisconnectEnvelope"), + }, + }, + }, + "404": { + description: + "No Polar token stored. NOT an idempotent no-op: a second disconnect answers 404 rather than 200.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/polar/test": { + post: { + tags: ["Integrations"], + summary: "Probe the caller's Polar grant", + description: + "Fetches the registered AccessLink user record, the cheapest authenticated Polar call, to confirm the stored grant still works. No body, nothing written. Rate-limited 5 requests / 60 s per user. Auth via cookie or Bearer.", + responses: { + "200": { + description: "The probe reached Polar.", + content: { + "application/json": { + schema: dataEnvelope(providerProbeResponse, "PolarTestEnvelope"), + }, + }, + }, + ...probeFailure502, + ...stdResponses, + ...probeNotConfigured422, + ...selfRateLimited429, + }, + }, + }, + "/api/strava/credentials": { + get: { + tags: ["Integrations"], + summary: "Check whether Strava BYO-key credentials are stored", + description: + "Presence only. `false` is not a misconfiguration: with no BYO pair the integration falls back to the operator's shared env app. Auth via cookie or Bearer.", + responses: { + "200": { + description: "Credential presence.", + content: { + "application/json": { + schema: dataEnvelope( + credentialsPresenceResponse, + "StravaCredentialsStatusEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + put: { + tags: ["Integrations"], + summary: "Store the caller's Strava BYO-key credentials", + description: + "Replaces both values; there is no partial update. Auth via cookie or Bearer.", + requestBody: { + required: true, + content: { "application/json": { schema: stravaCredentialsRequest } }, + }, + responses: { + "200": { + description: "Credentials stored.", + content: { + "application/json": { + schema: dataEnvelope( + updatedAck, + "StravaCredentialsUpdateEnvelope", + ), + }, + }, + }, + ...jsonBody64k, + ...stdResponses, + ...credentialsRejected422, + }, + }, + delete: { + tags: ["Integrations"], + summary: "Delete the caller's Strava credentials and connection", + description: + "Clears the stored access token, refresh token and athlete id as well as the BYO pair. Always answers 200. The audit row and the ledger park are written only when a token was actually present. Note that this path does NOT deauthorize at Strava — only `POST /api/strava/disconnect` does, so removing the credentials here leaves the app authorised on the user's Strava account. Auth via cookie or Bearer.", + responses: { + "200": { + description: "Credentials and any connection removed.", + content: { + "application/json": { + schema: dataEnvelope( + deletedAck, + "StravaCredentialsDeleteEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/strava/status": { + get: { + tags: ["Integrations"], + summary: "Read the caller's Strava connection status", + description: + "Reports whether usable credentials resolve at all, whether the user brought their own, and the ledger snapshot plus liveness verdict when a token is stored. Tokens and the client secret are never returned. Pure read. Auth via cookie or Bearer.", + responses: { + "200": { + description: "Connection status.", + content: { + "application/json": { + schema: dataEnvelope( + oauthProviderStatusResponse, + "StravaStatusEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/strava/disconnect": { + post: { + tags: ["Integrations"], + summary: "Disconnect the caller's Strava account", + description: + "Best-effort deauthorizes at Strava, then clears the stored tokens and athlete id, audits the teardown and parks the `strava` ledger at `disconnected`. A Strava-side outage does not block the local teardown, so a 200 here does not prove the app was deauthorised upstream. Imported workouts stay. No body. Rate-limited 20 requests / 60 s per user. Auth via cookie or Bearer.", + responses: { + "200": { + description: "The connection was cleared locally.", + content: { + "application/json": { + schema: dataEnvelope(disconnectedAck, "StravaDisconnectEnvelope"), + }, + }, + }, + "404": { + description: + "No Strava connection. NOT an idempotent no-op: a second disconnect answers 404 rather than 200.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/strava/test": { + post: { + tags: ["Integrations"], + summary: "Probe the caller's Strava grant", + description: + "Fetches the authenticated athlete, the cheapest authenticated Strava call, to confirm the stored grant still works. No body, nothing written. Rate-limited 5 requests / 60 s per user. Auth via cookie or Bearer.", + responses: { + "200": { + description: "The probe reached Strava.", + content: { + "application/json": { + schema: dataEnvelope(providerProbeResponse, "StravaTestEnvelope"), + }, + }, + }, + ...probeFailure502, + ...stdResponses, + ...probeNotConfigured422, + ...selfRateLimited429, + }, + }, + }, + "/api/integrations/status": { + get: { + tags: ["Integrations"], + summary: "Read every integration's status in one call", + description: + "The consolidated envelope behind the integrations settings surface: all eight providers, always, in a fixed order, each with the ledger core, the server-resolved liveness verdict and its own extras. Prefer this over eight per-provider round-trips. Pure read. Auth via cookie or Bearer.", + responses: { + "200": { + description: "All eight provider entries plus the alert threshold.", + content: { + "application/json": { + schema: dataEnvelope( + integrationStatusEnvelope, + "IntegrationStatusEnvelopeResponse", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/integrations/fitbit/resume": { + post: { + tags: ["Integrations"], + summary: "Clear a parked Fitbit integration", + description: + "A provider whose persistent-failure streak has run for more than 24 h is parked, and the sync entry point short-circuits until somebody clears it. This clears it; the next cron tick attempts again. No body, no upstream call. Rate-limited 5 requests / 60 s per user. Auth via cookie or Bearer.", + responses: { + "200": { + description: "Park cleared, or nothing to clear.", + content: { + "application/json": { + schema: dataEnvelope(resumeResponse, "FitbitResumeEnvelope"), + }, + }, + }, + ...stdResponses, + ...selfRateLimited429, + }, + }, + }, + "/api/integrations/fitbit/test": { + post: { + tags: ["Integrations"], + summary: "Probe the caller's Fitbit connection", + description: + "Resolves a valid access token (refreshing if needed) and fetches the profile endpoint. Returns the connection's `lastSyncedAt` alongside the probe result — the shape difference from the `/api//test` family. No body, nothing written beyond whatever the token refresh persists. Rate-limited 5 requests / 60 s per user. Auth via cookie or Bearer.", + responses: { + "200": { + description: "The probe reached Fitbit.", + content: { + "application/json": { + schema: dataEnvelope( + integrationProbeResponse, + "FitbitIntegrationTestEnvelope", + ), + }, + }, + }, + ...probeFailure502, + ...stdResponses, + ...probeNotConfigured422, + ...selfRateLimited429, + }, + }, + }, + "/api/integrations/google-health/resume": { + post: { + tags: ["Integrations"], + summary: "Clear a parked Google Health integration", + description: + "Identical contract to the Fitbit / WHOOP / Withings resume endpoints — same body (none), same response, same limiter. Auth via cookie or Bearer.", + responses: { + "200": { + description: "Park cleared, or nothing to clear.", + content: { + "application/json": { + schema: dataEnvelope( + resumeResponse, + "GoogleHealthResumeEnvelope", + ), + }, + }, + }, + ...stdResponses, + ...selfRateLimited429, + }, + }, + }, + "/api/integrations/google-health/test": { + post: { + tags: ["Integrations"], + summary: "Probe the caller's Google Health connection", + description: + 'Two endpoints in one, selected by the body. With no body (or any body other than the one below) it behaves like its Fitbit / WHOOP / Withings siblings: fetch the profile endpoint and answer `{ ok, lastSyncedAt, latencyMs }`. With `{ "probe": "structure" }` it instead walks one page or window per Google Health data type and answers a per-type diagnostic map — a completely different response shape under the same status code, so a client MUST branch on what it sent. The structure map reduces every leaf to its type name and never carries a value, a timestamp or an identifier, so the output is safe to paste into a public support thread. Rate-limited 5 requests / 60 s per user. Auth via cookie or Bearer.', + requestBody: { + required: false, + content: { + "application/json": { + schema: z + .object({ + probe: z + .literal("structure") + .optional() + .describe( + 'Send `"structure"` for the per-data-type diagnostic. Anything else, including a malformed body, runs the plain connection check — the body is not validated and a typo is silently ignored rather than refused.', + ), + }) + .meta({ id: "GoogleHealthTestRequest" }), + }, + }, + }, + responses: { + "200": { + description: + "Either the plain probe result or the structure map, depending on the body sent.", + content: { + "application/json": { + schema: dataEnvelope( + z.union([ + integrationProbeResponse, + z + .object({ + probe: z.literal("structure"), + probedAt: z.iso.datetime({ offset: true }), + types: z + .record(z.string(), z.unknown()) + .describe( + "One entry per Google Health data type. A successful entry carries `{ ok: true, count, structure }` plus, where the client learned it, `requestShape` (which documented request form Google accepted) and `envelopeKeys` (raw first-page key names, present only when the parse yielded zero points — the one signal separating 'nothing returned' from 'returned under a key this reader does not know'). A failed entry carries `{ ok: false, httpStatus, classification, detail }`. `structure` is the first data point with every leaf replaced by its type name.", + ), + }) + .meta({ id: "GoogleHealthStructureProbeResponse" }), + ]), + "GoogleHealthIntegrationTestEnvelope", + ), + }, + }, + }, + "413": { + description: "Body exceeds 64 KiB.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...probeFailure502, + ...stdResponses, + ...probeNotConfigured422, + ...selfRateLimited429, + }, + }, + }, + "/api/integrations/whoop/resume": { + post: { + tags: ["Integrations"], + summary: "Clear a parked WHOOP integration", + description: + "Identical contract to the Fitbit / Google Health / Withings resume endpoints — same body (none), same response, same limiter. Auth via cookie or Bearer.", + responses: { + "200": { + description: "Park cleared, or nothing to clear.", + content: { + "application/json": { + schema: dataEnvelope(resumeResponse, "WhoopResumeEnvelope"), + }, + }, + }, + ...stdResponses, + ...selfRateLimited429, + }, + }, + }, + "/api/integrations/whoop/test": { + post: { + tags: ["Integrations"], + summary: "Probe the caller's WHOOP connection", + description: + "Resolves a valid access token (refreshing if needed) and fetches the basic-profile endpoint. Returns the connection's `lastSyncedAt` alongside the probe result. No body. Rate-limited 5 requests / 60 s per user. Auth via cookie or Bearer.", + responses: { + "200": { + description: "The probe reached WHOOP.", + content: { + "application/json": { + schema: dataEnvelope( + integrationProbeResponse, + "WhoopIntegrationTestEnvelope", + ), + }, + }, + }, + ...probeFailure502, + ...stdResponses, + ...probeNotConfigured422, + ...selfRateLimited429, + }, + }, + }, + "/api/integrations/withings/resume": { + post: { + tags: ["Integrations"], + summary: "Clear a parked Withings integration", + description: + "Identical contract to the Fitbit / Google Health / WHOOP resume endpoints — same body (none), same response, same limiter. For Withings the park is typically reached through a run of contract-mismatch errors rather than an expired grant. Auth via cookie or Bearer.", + responses: { + "200": { + description: "Park cleared, or nothing to clear.", + content: { + "application/json": { + schema: dataEnvelope(resumeResponse, "WithingsResumeEnvelope"), + }, + }, + }, + ...stdResponses, + ...selfRateLimited429, + }, + }, + }, + "/api/integrations/withings/test": { + post: { + tags: ["Integrations"], + summary: "Probe the caller's Withings connection", + description: + "Resolves a valid access token and asks for one recent measure. Withings signals API-level failure with HTTP 200 and a status code in the JSON body, so this probe inspects the body as well as the status — a 200 from Withings can still be a `credentials_rejected` 502 from here. No body. Rate-limited 5 requests / 60 s per user. Auth via cookie or Bearer.", + responses: { + "200": { + description: "The probe reached Withings and the body reported OK.", + content: { + "application/json": { + schema: dataEnvelope( + integrationProbeResponse, + "WithingsIntegrationTestEnvelope", + ), + }, + }, + }, + ...stdResponses, + ...probeNotConfigured422, + ...selfRateLimited429, + "502": { + description: + "The probe did not complete. The shared codes apply (`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, `connection_failed`) and this path adds one of its own: `upstream_invalid_json`, when Withings answered 200 with a body that would not parse. Withings' in-body status codes are mapped onto the same shared set, so a body-level rejection is indistinguishable from an HTTP-level one by errorCode alone.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, }; diff --git a/src/lib/openapi/routes/record-settings.ts b/src/lib/openapi/routes/record-settings.ts new file mode 100644 index 000000000..acfd2e1b4 --- /dev/null +++ b/src/lib/openapi/routes/record-settings.ts @@ -0,0 +1,453 @@ +/** + * OpenAPI route table — the managed record's own configuration. + * + * A Guardian acting on a managed record reads and writes that record's + * settings here, never through `/api/auth/me`: the actor's identity payload + * stays the actor's while the browser is pointed at somebody else's record, and + * conflating the two is how a Guardian's own locale ends up written onto a + * child's profile. Every response names the record it describes for the same + * reason. + * + * These paths carry `recordRefusal()` rather than the local `sharingRefusal` + * the grant-lifecycle module uses, and the difference is load-bearing: this is + * the one tree in the application fenced by `requireGuardianAuth`, so + * `src/__tests__/openapi-sharing-denial.test.ts` holds every operation here to + * the byte-identical shared refusal sentence. Copying the neighbouring + * module's wording into this one fails that guard. + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. The PATCH + * bodies come from `src/lib/record-settings/configuration.ts` — the same strict + * objects the handler parses — so a published field the route would 422 cannot + * drift into the contract. + */ +import type { ZodOpenApiObject } from "zod-openapi"; +import { z } from "zod/v4"; + +import { METRIC_BOUNDS } from "@/lib/analytics/effective-range"; +import { MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS } from "@/lib/record-settings"; +import { + dataEnvelope, + errorEnvelope, + recordRefusal, + stdResponses, +} from "./shared"; + +const thresholdMetricEnum = z + .enum(Object.keys(METRIC_BOUNDS) as [string, ...string[]]) + .describe("A metric that carries a user-overridable traffic-light range."); + +const settingsFamilyEnum = z + .enum([ + "profile", + "modules", + "notifications", + "thresholds", + "coach", + "insights", + ]) + .describe( + "The closed set of configuration families a Guardian may read and write on a managed record. Identity, credentials, delivery channels, Coach memories and provider connections are absent by construction — no family accepts them, so every future field fails closed until one deliberately does.", + ); + +const recordSettingsResponse = z + .object({ + record: z.object({ + id: z.string(), + displayName: z + .string() + .describe("Empty string when the record has no display name set."), + locale: z.string().nullable(), + timezone: z.string(), + kind: z + .literal("managed") + .describe( + "Always `managed`. The endpoint exists only for managed records; there is no owner-record arm.", + ), + }), + }) + .meta({ + id: "RecordSettingsResponse", + description: + "The active managed record's configuration identity. Deliberately a distinct DTO rather than another actor field on `/api/auth/me`.", + }); + +const recordSettingsIntegrationsResponse = z + .object({ + recordId: z.string(), + integrations: z + .array( + z.object({ + integration: z.enum([ + "withings", + "whoop", + "fitbit", + "nightscout", + "polar", + "oura", + "google-health", + "strava", + ]), + state: z.enum([ + "connected", + "error_transient", + "error_reauth", + "disconnected", + "parked", + ]), + lastSuccessAt: z.iso.datetime({ offset: true }).nullable(), + lastAttemptAt: z.iso.datetime({ offset: true }).nullable(), + }), + ) + .describe( + "All eight providers, always, in the fixed order withings, whoop, fitbit, nightscout, polar, oura, google-health, strava. Note the order differs from the owner's own `/api/integrations/status` envelope.", + ), + }) + .meta({ + id: "RecordSettingsIntegrationsResponse", + description: + "Whether the managed record's integrations are connected, and nothing else. Deliberately no control and no credential projection — OAuth and provider state cannot safely carry a record selector, so a Guardian can see that a pipe exists but cannot connect, disconnect, probe or resume it from here. A provider with no real credential reads `disconnected` even when its ledger row says otherwise, because a synthesised healthy ledger is not proof of a connection.", + }); + +// ── The six family DTOs ────────────────────────────────────────────── + +const profileSettings = z + .object({ + displayName: z.string().nullable(), + heightCm: z.number().nullable(), + dateOfBirth: z.iso + .date() + .nullable() + .describe("Calendar date (`YYYY-MM-DD`), not an instant."), + gender: z.enum(["MALE", "FEMALE", "OTHER"]).nullable(), + locale: z.enum(["de", "en", "es", "fr", "it", "pl"]).nullable(), + timezone: z.string(), + unitPreference: z.enum(["metric", "imperial"]), + timeFormat: z.enum(["AUTO", "H12", "H24"]), + dateFormat: z.enum(["AUTO", "DMY", "MDY", "YMD"]), + }) + .meta({ id: "RecordProfileSettings" }); + +const moduleSettings = z + .object({ + modulePreferences: z + .record(z.string(), z.boolean()) + .describe( + "Every directly-owned module key, always present, with the registry default filled in for keys the record has never persisted — so a Guardian administers the whole supported surface rather than only what happens to be stored.", + ), + }) + .meta({ id: "RecordModuleSettings" }); + +const notificationSettings = z + .object({ + moodReminderEnabled: z.boolean(), + notificationPreferences: z.object({ + medication: z.object({ + lowStockRunwayDays: z.number().int().nullable(), + reorderLeadDays: z.number().int(), + }), + mood: z.object({ reminderHour: z.number().int() }), + }), + }) + .meta({ + id: "RecordNotificationSettings", + description: + "Reminder timing and low-stock thresholds only. Delivery ownership is the actor's personal choice and is not here: a Guardian cannot route a managed record's notifications to their own phone or channel.", + }); + +const trafficRange = z.object({ + greenMin: z.number(), + greenMax: z.number(), + orangeMin: z.number(), + orangeMax: z.number(), +}); + +const thresholdSettings = z + .object({ + overrides: z + .record( + thresholdMetricEnum, + z.object({ min: z.number(), max: z.number() }), + ) + .describe( + "Only the metrics the record has actually overridden. A metric absent here uses the computed default.", + ), + effective: z + .record( + thresholdMetricEnum, + z.object({ + range: trafficRange + .nullable() + .describe("Resolved range: the override if set, else the default."), + isOverride: z.boolean(), + default: trafficRange + .nullable() + .describe( + "The unmodified computed default, so a client can show the diff. Null when no default can be computed — a weight range needs a height, an age-banded range needs a date of birth.", + ), + bounds: z.object({ + min: z.number(), + max: z.number(), + unit: z.string(), + }), + }), + ) + .describe( + "Every threshold metric, always, whether overridden or not — this is the resolved view a client renders without recomputing anything.", + ), + }) + .meta({ id: "RecordThresholdSettings" }); + +const coachSettings = z + .object({ + disableCoach: z.boolean(), + preferences: z.object({ + tone: z.string(), + verbosity: z.string(), + excludeMetrics: z.array(z.string()), + defaultWindow: z.string(), + dataClusters: z + .array(z.string()) + .optional() + .describe("Absent when the record has never set it."), + }), + }) + .meta({ + id: "RecordCoachSettings", + description: + "The Coach kill switch and the prompt-tuning subset a Guardian may set. A write to `preferences` also mirrors `excludeMetrics` onto the record's insights exclusion list, so the two cannot drift apart.", + }); + +const insightsSettings = z + .object({ + layout: z.object({ + version: z.number().int(), + sections: z.array( + z.object({ + id: z.string(), + visible: z.boolean(), + order: z.number().int(), + }), + ), + tiles: z.array( + z.object({ + id: z.string(), + visible: z.boolean(), + order: z.number().int(), + }), + ), + }), + }) + .meta({ + id: "RecordInsightsSettings", + description: + "The resolved insights layout. Unknown and legacy tile ids are normalised or dropped on read, and a newly-shipped tile is merged in default-visible, so the response is always the full current tile set rather than whatever was last persisted.", + }); + +const recordSettingsFamilyResponse = z + .object({ + recordId: z.string(), + family: settingsFamilyEnum, + settings: z + .union([ + profileSettings, + moduleSettings, + notificationSettings, + thresholdSettings, + coachSettings, + insightsSettings, + ]) + .describe( + "The DTO for the family in the path. Which variant arrives is determined entirely by `{family}`, so branch on `family` rather than probing the shape — OpenAPI cannot express the dependency between a path parameter and a response variant.", + ), + }) + .meta({ + id: "RecordSettingsFamilyResponse", + description: + "One family's settings for one managed record. Both the GET and the PATCH answer this same shape; the PATCH echo is the post-write state.", + }); + +const recordSettingsPatchRequest = z + .union([ + MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS.profile.meta({ + id: "RecordProfileSettingsPatch", + description: + "Partial profile edit. Every key is optional and an omitted key is untouched; a `null` on a nullable field clears it. Strict — an unknown key is a 422, which is what keeps the allowlist an allowlist.", + }), + MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS.modules.meta({ + id: "RecordModuleSettingsPatch", + description: + "Partial module toggle. Merged onto the record's current preferences; omitted keys keep their value.", + }), + MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS.notifications.meta({ + id: "RecordNotificationSettingsPatch", + description: + "Reminder timing and low-stock thresholds. No delivery-channel field exists here by design.", + }), + MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS.thresholds.meta({ + id: "RecordThresholdSettingsPatch", + description: + "Per-metric range overrides, merged onto the stored set. Each range must sit inside the metric's physiological bounds with `min` strictly below `max`. There is no clear arm: sending a metric replaces its override, and omitting it keeps the stored one.", + }), + MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS.coach.meta({ + id: "RecordCoachSettingsPatch", + description: + "The Coach kill switch and the prompt-tuning subset. `preferences` is merged onto the stored preferences, not replaced wholesale.", + }), + MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS.insights.meta({ + id: "RecordInsightsSettingsPatch", + description: + "The full insights layout. Unlike the other five families this one is a replacement rather than a merge.", + }), + ]) + .meta({ + id: "RecordSettingsPatchRequest", + description: + "The body for the family named in the path. Only that family's variant is accepted — sending the profile body to `/thresholds` is a 422, because every variant is strict. OpenAPI cannot tie the variant to the path parameter, so the union is the honest publication of six separate contracts sharing one URL shape.", + }); + +const familyParameter = { + name: "family", + in: "path" as const, + required: true, + schema: { + type: "string" as const, + enum: [ + "profile", + "modules", + "notifications", + "thresholds", + "coach", + "insights", + ], + }, + description: + "Which configuration family to read or write. An unrecognised value is a 404 — and note that the family check runs BEFORE authentication, so an unknown family answers 404 to an unauthenticated caller where a known one would answer 401.", +}; + +export const recordSettingsPaths: NonNullable = { + "/api/record-settings": { + get: { + tags: ["Account sharing"], + summary: "Read the active managed record's identity", + description: + "The record the session is currently acting on, named explicitly. This is not `/api/auth/me` with a different scope: that endpoint keeps reporting the actor throughout, and this one reports the record, so a client rendering a managed surface has an unambiguous source for each. Cookie session or Bearer, but only while acting on a managed record the caller guards.", + responses: { + ...recordRefusal(), + "200": { + description: "The active managed record.", + content: { + "application/json": { + schema: dataEnvelope( + recordSettingsResponse, + "RecordSettingsEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/record-settings/integrations": { + get: { + tags: ["Account sharing"], + summary: "Read the managed record's integration connection states", + description: + "Disclosure only: whether each of the record's integrations is connected and when it last succeeded. There is deliberately no control arm and no credential projection anywhere under this path — a Guardian cannot connect, disconnect, probe or resume a managed record's provider, because an OAuth flow cannot safely carry a record selector. Cookie session or Bearer, while acting on a managed record the caller guards.", + responses: { + ...recordRefusal(), + "200": { + description: "Connection state per provider.", + content: { + "application/json": { + schema: dataEnvelope( + recordSettingsIntegrationsResponse, + "RecordSettingsIntegrationsEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/record-settings/{family}": { + get: { + tags: ["Account sharing"], + summary: "Read one configuration family of the managed record", + description: + "Returns the named family's resolved settings for the record the session is acting on. Cookie session or Bearer, while acting on a managed record the caller guards.", + parameters: [familyParameter], + responses: { + ...recordRefusal(), + "200": { + description: "The family's settings.", + content: { + "application/json": { + schema: dataEnvelope( + recordSettingsFamilyResponse, + "GetRecordSettingsFamilyEnvelope", + ), + }, + }, + }, + "404": { + description: + "Either the path named a family outside the closed set, or the record row itself is gone. The two are not distinguished, and the first is answered before the caller is authenticated at all.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + patch: { + tags: ["Account sharing"], + summary: "Update one configuration family of the managed record", + description: + "Applies the family's patch and echoes the post-write state, so a client never needs a follow-up read. Five of the six families MERGE — an omitted key keeps its stored value; `insights` replaces the layout instead. The write is audited against the record with the acting Guardian recorded as the actor, and the changed key names ride the audit row. Body capped at 64 KiB. Cookie session or Bearer, while acting on a managed record the caller guards.", + parameters: [familyParameter], + requestBody: { + required: true, + content: { + "application/json": { schema: recordSettingsPatchRequest }, + }, + }, + responses: { + ...recordRefusal(), + "200": { + description: "The family's settings after the write.", + content: { + "application/json": { + schema: dataEnvelope( + recordSettingsFamilyResponse, + "PatchRecordSettingsFamilyEnvelope", + ), + }, + }, + }, + "400": { + description: "Body is not parseable JSON.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "404": { + description: + "The path named a family outside the closed set. Answered before authentication.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "413": { + description: "Body exceeds 64 KiB.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "415": { + description: "Content-Type is not `application/json`.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + "422": { + description: + "Validation failed against the family's strict schema, with every offending issue in the envelope rather than only the first. An unknown key lands here rather than being ignored — that is what makes the field allowlist enforceable.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, +}; diff --git a/src/lib/record-settings/configuration.ts b/src/lib/record-settings/configuration.ts index d7d2507b2..e2e007132 100644 --- a/src/lib/record-settings/configuration.ts +++ b/src/lib/record-settings/configuration.ts @@ -129,7 +129,13 @@ const managedCoachPreferencesSchema = coachPrefsSchema }) .strict(); -const MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS = { +/** + * One strict patch schema per family. Exported so the OpenAPI registry can + * publish the same objects the handler parses rather than a restatement of + * them — every family is `strict()`, so a published paraphrase that drifted + * would promise a field the route answers 422 for. + */ +export const MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS = { profile: profilePatchSchema, modules: z.object({ modulePreferences: modulePrefsPatchSchema }).strict(), notifications: z diff --git a/src/lib/record-settings/index.ts b/src/lib/record-settings/index.ts index 293842925..f479498fb 100644 --- a/src/lib/record-settings/index.ts +++ b/src/lib/record-settings/index.ts @@ -18,6 +18,7 @@ export { managedModulePreferencesFrom, MANAGED_RECORD_SETTINGS_FIELD_ALLOWLIST, MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS, + MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS, parseManagedRecordSettingsPatch, safeParseManagedRecordSettingsPatch, type ManagedRecordSettingsFamily, diff --git a/src/lib/validations/google-health.ts b/src/lib/validations/google-health.ts new file mode 100644 index 000000000..6d0934a55 --- /dev/null +++ b/src/lib/validations/google-health.ts @@ -0,0 +1,17 @@ +import { z } from "zod/v4"; + +/** + * Per-user Google Health BYO-key credentials. Each self-hoster registers their + * own Google Cloud OAuth client (the Restricted-scope brand verification + CASA + * assessment is per-OAuth-client, so a single shared app is unworkable for a + * multi-operator product) and pastes the client id/secret into Settings. Stored + * encrypted on `User`. + */ +export const googleHealthCredentialsSchema = z.object({ + clientId: z.string().min(1).max(200), + clientSecret: z.string().min(1).max(200), +}); + +export type GoogleHealthCredentialsInput = z.infer< + typeof googleHealthCredentialsSchema +>; From b6e34d12649c188e893b947e985ca92325194245 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 22 Aug 2026 13:13:18 +0200 Subject: [PATCH 3/4] Close five honesty gaps on the integration surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each of these made the server report something that was not so, and each now has a test that fails without the fix. The Withings credential DELETE wrote no audit row and left the ledger at whatever state it last held. Fitbit, Google Health and WHOOP all park theirs, and three of them say in their own comments that they do it for parity with a list that never included Withings. A client reading `state` after the teardown was told `connected` about a connection that no longer existed. Oura's and Strava's credential DELETEs audited only when an access token happened to be present, so removing a stored pair that had never completed OAuth left no trail at all. Polar's twin has always done both unconditionally; they match it now. The old Oura test asserted the skip, which is why the gap survived a rewrite — that expectation is inverted here, with the reason next to it. The Withings and WHOOP manual syncs had no rate limiter, while `fullSync` walks full history against a per-user provider budget. The Fitbit route's own comment records that it was the outlier until it got one; these two were never brought along. Both now carry the same 5/60s baseline and 1/hour full-sync buckets as their siblings. Every sync route parsed its body inside a catch commented "no body provided", which was true for one of the three ways to reach it. An absent body still means an incremental run — that is the documented shape and the web cards send it. A body that is present and unparseable is now 400, and one that fails the schema is 422: `{"fullSync": "true"}` used to be read as false and answered 200, so a client that asked for full history and mistyped the value was told the run it never requested had succeeded. Unknown keys are still ignored. The four copies collapse into one helper. The consolidated integration status swallowed a failed freshness query into an empty array, which is exactly what a record with nothing in it also produces — so the signal built to say "this metric has gone quiet" answered "nothing has gone quiet" when it had failed to look. `metricFreshnessDegraded` separates the two, as one flag rather than one per entry, because it is a single query for all eight providers. The contract follows the behaviour in the same commit. --- src/app/api/fitbit/sync/route.ts | 16 +-- src/app/api/google-health/sync/route.ts | 16 +-- .../status/__tests__/route.test.ts | 73 ++++++++++- src/app/api/integrations/status/route.ts | 22 +++- .../oura/credentials/__tests__/route.test.ts | 17 ++- src/app/api/oura/credentials/route.ts | 25 ++-- .../credentials/__tests__/route.test.ts | 95 ++++++++++++++ src/app/api/strava/credentials/route.ts | 29 +++-- .../api/whoop/sync/__tests__/route.test.ts | 94 +++++++++++--- src/app/api/whoop/sync/route.ts | 40 ++++-- .../credentials/__tests__/route.test.ts | 42 +++++++ src/app/api/withings/credentials/route.ts | 12 ++ .../api/withings/sync/__tests__/route.test.ts | 118 ++++++++++++++++++ src/app/api/withings/sync/route.ts | 40 ++++-- .../__tests__/sync-request.test.ts | 114 +++++++++++++++++ src/lib/integrations/sync-request.ts | 79 ++++++++++++ src/lib/openapi/routes/integrations.ts | 87 ++++++++++--- src/lib/validations/sync-trigger.ts | 15 +++ 18 files changed, 826 insertions(+), 108 deletions(-) create mode 100644 src/app/api/strava/credentials/__tests__/route.test.ts create mode 100644 src/app/api/withings/sync/__tests__/route.test.ts create mode 100644 src/lib/integrations/__tests__/sync-request.test.ts create mode 100644 src/lib/integrations/sync-request.ts create mode 100644 src/lib/validations/sync-trigger.ts diff --git a/src/app/api/fitbit/sync/route.ts b/src/app/api/fitbit/sync/route.ts index 00be8309a..be981267e 100644 --- a/src/app/api/fitbit/sync/route.ts +++ b/src/app/api/fitbit/sync/route.ts @@ -2,6 +2,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiSuccess, apiError } from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; import { annotate } from "@/lib/logging/context"; +import { readSyncTriggerBody } from "@/lib/integrations/sync-request"; import { syncUserFitbit } from "@/lib/fitbit/sync"; import { resolveSyncOutcome } from "@/lib/outcome/written-outcome"; import { NextRequest } from "next/server"; @@ -30,18 +31,9 @@ export const POST = apiHandler(async (request: NextRequest) => { }); } - let fullSync = false; - try { - const raw = await request.text(); - // Flag-only payload — cap the parse cost (mirrors safeJson maxBytes). - if (raw.length > 64 * 1024) { - return apiError(`Request body exceeds ${64 * 1024} bytes`, 413); - } - const body = JSON.parse(raw); - fullSync = body?.fullSync === true; - } catch { - // no body provided -> default incremental sync - } + const body = await readSyncTriggerBody(request); + if (body.error) return body.error; + const { fullSync } = body; // The full-history walk is the expensive path (four paginated resource // walkers); cap it well below the incremental bucket so a re-trigger loop diff --git a/src/app/api/google-health/sync/route.ts b/src/app/api/google-health/sync/route.ts index ee9a068fc..1987a5170 100644 --- a/src/app/api/google-health/sync/route.ts +++ b/src/app/api/google-health/sync/route.ts @@ -2,6 +2,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiSuccess, apiError } from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; import { annotate } from "@/lib/logging/context"; +import { readSyncTriggerBody } from "@/lib/integrations/sync-request"; import { syncUserGoogleHealth } from "@/lib/google-health/sync"; import type { GoogleHealthReasonCode, @@ -83,18 +84,9 @@ export const POST = apiHandler(async (request: NextRequest) => { }); } - let fullSync = false; - try { - const raw = await request.text(); - // Flag-only payload — cap the parse cost (mirrors safeJson maxBytes). - if (raw.length > 64 * 1024) { - return apiError(`Request body exceeds ${64 * 1024} bytes`, 413); - } - const body = JSON.parse(raw); - fullSync = body?.fullSync === true; - } catch { - // no body provided -> default incremental sync - } + const body = await readSyncTriggerBody(request); + if (body.error) return body.error; + const { fullSync } = body; // The full-history walk is the expensive path; cap it well below the // incremental bucket so a re-trigger loop cannot churn the Google quota. diff --git a/src/app/api/integrations/status/__tests__/route.test.ts b/src/app/api/integrations/status/__tests__/route.test.ts index ec5d59e2e..31f546594 100644 --- a/src/app/api/integrations/status/__tests__/route.test.ts +++ b/src/app/api/integrations/status/__tests__/route.test.ts @@ -70,10 +70,19 @@ type Entry = { metricFreshness?: Array<{ type: string; lastSeenAt: string; stale: boolean }>; }; +async function fetchEnvelope(): Promise<{ + integrations: Entry[]; + metricFreshnessDegraded: boolean; +}> { + return (await (GET as unknown as () => Promise<{ data: unknown }>)()) + .data as { + integrations: Entry[]; + metricFreshnessDegraded: boolean; + }; +} + async function fetchEntries(): Promise { - const res = (await (GET as unknown as () => Promise<{ data: unknown }>)()) - .data as { integrations: Entry[] }; - return res.integrations; + return (await fetchEnvelope()).integrations; } describe("/api/integrations/status — Polar/Oura fold (04-M2)", () => { @@ -277,3 +286,61 @@ describe("/api/integrations/status — a provider that has delivered nothing for }); }); }); + +/** + * The freshness read is fail-soft, and it used to be fail-silent with it. + * + * `getSourceMetricFreshness` is one query for all eight providers, wrapped in a + * `.catch(() => ({}))` so a groupBy hiccup never 500s the whole settings + * surface. The fallback makes every entry's `metricFreshness` an empty array — + * which is byte-identical to what a user with nothing recorded sees. So the + * honesty signal built to say "this metric has gone quiet" answered "nothing + * has gone quiet" when it had in fact failed to look. + * + * `metricFreshnessDegraded` separates the two. It stays a flag rather than a + * nullable array per entry because the query fails for all eight providers at + * once or for none of them. + */ +describe("/api/integrations/status — the freshness read failed", () => { + beforeEach(() => { + vi.clearAllMocks(); + for (const key of Object.keys(perIntegrationLedger)) { + delete perIntegrationLedger[key]; + } + userFind.mockResolvedValue({}); + whoopFind.mockResolvedValue(null); + polarAvailable.mockResolvedValue(true); + ouraAvailable.mockResolvedValue(false); + }); + + it("reports the degradation instead of passing an empty list off as an answer", async () => { + (prisma.measurement.groupBy as ReturnType).mockRejectedValue( + new Error("statement timeout"), + ); + + const envelope = await fetchEnvelope(); + + expect(envelope.metricFreshnessDegraded).toBe(true); + // Still fail-soft: the rest of the envelope survives, all eight entries + // present, because a freshness hiccup is never worth the settings surface. + expect(envelope.integrations).toHaveLength(8); + for (const entry of envelope.integrations) { + expect(entry.metricFreshness).toEqual([]); + } + }); + + it("reports no degradation when the read simply found nothing", async () => { + (prisma.measurement.groupBy as ReturnType).mockResolvedValue( + [], + ); + (prisma.workout.groupBy as ReturnType).mockResolvedValue([]); + + const envelope = await fetchEnvelope(); + + // The same empty lists as above, and this is the case they honestly mean. + expect(envelope.metricFreshnessDegraded).toBe(false); + for (const entry of envelope.integrations) { + expect(entry.metricFreshness).toEqual([]); + } + }); +}); diff --git a/src/app/api/integrations/status/route.ts b/src/app/api/integrations/status/route.ts index fc6b657fc..af6c7243c 100644 --- a/src/app/api/integrations/status/route.ts +++ b/src/app/api/integrations/status/route.ts @@ -149,9 +149,20 @@ export const GET = apiHandler(async () => { // single metric type has gone silent even while the integration reads green // ("connected · 5 min ago"). Fail-soft: this is an honesty signal, never worth // 500-ing the whole Settings card, so a groupBy hiccup degrades to no data. - const metricFreshness = await getSourceMetricFreshness(user.id).catch( - () => ({}) as Partial>, - ); + // + // The degradation is REPORTED rather than swallowed. Falling back to `{}` + // makes every entry's `metricFreshness` an empty array, which is exactly what + // a user with nothing recorded also sees — so "no metric has gone quiet" and + // "we could not tell whether a metric has gone quiet" were the same answer on + // the wire, and the honesty signal quietly became the thing it was built to + // prevent. `metricFreshnessDegraded` separates them. One flag rather than a + // nullable array per entry, because this is ONE query for all eight providers + // — it fails for all of them or for none. + let metricFreshnessDegraded = false; + const metricFreshness = await getSourceMetricFreshness(user.id).catch(() => { + metricFreshnessDegraded = true; + return {} as Partial>; + }); const now = Date.now(); const whoopConnected = !!whoopConn?.whoopUserId; @@ -243,8 +254,13 @@ export const GET = apiHandler(async () => { const stravaConnected = !!dbUser?.stravaAccessTokenEncrypted; const nightscoutConfigured = !!dbUser?.nightscoutUrlEncrypted; + if (metricFreshnessDegraded) { + annotate({ meta: { integrations_metric_freshness_degraded: true } }); + } + return apiSuccess({ threshold: getPersistentFailureThreshold(), + metricFreshnessDegraded, integrations: [ { ...publish(withingsStatus), diff --git a/src/app/api/oura/credentials/__tests__/route.test.ts b/src/app/api/oura/credentials/__tests__/route.test.ts index e956cdee8..800be41a7 100644 --- a/src/app/api/oura/credentials/__tests__/route.test.ts +++ b/src/app/api/oura/credentials/__tests__/route.test.ts @@ -111,12 +111,23 @@ describe("DELETE /api/oura/credentials", () => { expect(markDisconnected).toHaveBeenCalledWith("u1", "oura"); }); - it("skips audit + ledger update when nothing was connected", async () => { + /** + * This case used to assert the opposite, and the assertion was the defect. + * + * Auditing only when a token happened to be present meant that tearing down + * a stored credential pair which had never completed OAuth left no trail at + * all — and a credential teardown is exactly the event an operator reads the + * audit trail for. The Polar twin has always done both unconditionally; the + * old expectation pinned the divergence rather than catching it. + */ + it("audits + marks disconnected even when nothing was connected", async () => { userFind.mockResolvedValue({ ouraAccessTokenEncrypted: null }); const res = await del(); expect(res.data).toEqual({ deleted: true }); expect(clearMock).toHaveBeenCalledWith("u1"); - expect(auditLog).not.toHaveBeenCalled(); - expect(markDisconnected).not.toHaveBeenCalled(); + expect(auditLog).toHaveBeenCalledWith("oura.credentials.delete", { + userId: "u1", + }); + expect(markDisconnected).toHaveBeenCalledWith("u1", "oura"); }); }); diff --git a/src/app/api/oura/credentials/route.ts b/src/app/api/oura/credentials/route.ts index 40064ccaf..177718127 100644 --- a/src/app/api/oura/credentials/route.ts +++ b/src/app/api/oura/credentials/route.ts @@ -67,20 +67,21 @@ export const PUT = apiHandler(async (request: NextRequest) => { * Delete Oura credentials and the active connection. * * Deleting the BYO credentials drops a live connection (a token minted against - * the deleted app is now orphaned), so when an access token was present this - * mirrors `/api/oura/disconnect`: audit the event and park the integration - * ledger at `disconnected` rather than leaving it stale at its last state. + * the deleted app is now orphaned), so this mirrors `/api/oura/disconnect`: + * audit the event and park the integration ledger at `disconnected` rather than + * leaving it stale at its last state. + * + * Both are unconditional, matching the Polar twin. They used to run only when + * an access token happened to be present, so tearing down a stored credential + * pair that had never been connected left no audit row at all — and a + * credential teardown is exactly the event an operator reads the trail for. The + * ledger park is idempotent, so running it for an already-disconnected provider + * costs one write and changes nothing. */ export const DELETE = apiHandler(async () => { const { user } = await requireAuth(); annotate({ action: { name: "oura.credentials.delete" } }); - const dbUser = await prisma.user.findUnique({ - where: { id: user.id }, - select: { ouraAccessTokenEncrypted: true }, - }); - const wasConnected = !!dbUser?.ouraAccessTokenEncrypted; - await prisma.user.update({ where: { id: user.id }, data: { @@ -90,10 +91,8 @@ export const DELETE = apiHandler(async () => { }); await clearOuraClientCredentials(user.id); - if (wasConnected) { - await auditLog("oura.credentials.delete", { userId: user.id }); - await markDisconnected(user.id, "oura"); - } + await auditLog("oura.credentials.delete", { userId: user.id }); + await markDisconnected(user.id, "oura"); return apiSuccess({ deleted: true }); }); diff --git a/src/app/api/strava/credentials/__tests__/route.test.ts b/src/app/api/strava/credentials/__tests__/route.test.ts new file mode 100644 index 000000000..f57870da6 --- /dev/null +++ b/src/app/api/strava/credentials/__tests__/route.test.ts @@ -0,0 +1,95 @@ +/** + * `DELETE /api/strava/credentials` — the teardown always leaves a trail. + * + * The audit row and the ledger park used to run only when an access token + * happened to be present, so removing a stored credential pair that had never + * completed OAuth was invisible afterwards: no audit row, and a ledger left at + * whatever state it last held. The Polar twin has always done both + * unconditionally. Strava had no test file at all, which is why this one + * exists. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ user: { id: "u1" } })), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { user: { findUnique: vi.fn(), update: vi.fn() } }, +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); +vi.mock("@/lib/integrations/status", () => ({ markDisconnected: vi.fn() })); + +const { storeMock, clearMock } = vi.hoisted(() => ({ + storeMock: vi.fn(), + clearMock: vi.fn(), +})); +vi.mock("@/lib/strava/credentials", () => ({ + storeStravaClientCredentials: storeMock, + clearStravaClientCredentials: clearMock, +})); + +vi.mock("@/lib/api-response", () => ({ + apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), + apiError: (error: string, status: number) => ({ data: null, error, status }), + safeJson: vi.fn(), +})); + +import { DELETE } from "../route"; +import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; +import { markDisconnected } from "@/lib/integrations/status"; + +const userFind = prisma.user.findUnique as ReturnType; +const userUpdate = prisma.user.update as ReturnType; + +type RouteResult = { data: unknown; error: string | null; status: number }; + +beforeEach(() => { + vi.clearAllMocks(); + userFind.mockResolvedValue(null); + userUpdate.mockResolvedValue({}); + clearMock.mockResolvedValue(undefined); +}); + +describe("DELETE /api/strava/credentials", () => { + const del = DELETE as unknown as () => Promise; + + it("audits + marks disconnected when a live token was present", async () => { + userFind.mockResolvedValue({ stravaAccessTokenEncrypted: "enc:tok" }); + const res = await del(); + expect(res.data).toEqual({ deleted: true }); + expect(clearMock).toHaveBeenCalledWith("u1"); + expect(auditLog).toHaveBeenCalledWith("strava.credentials.delete", { + userId: "u1", + }); + expect(markDisconnected).toHaveBeenCalledWith("u1", "strava"); + }); + + it("audits + marks disconnected even when nothing was connected", async () => { + userFind.mockResolvedValue({ stravaAccessTokenEncrypted: null }); + const res = await del(); + expect(res.data).toEqual({ deleted: true }); + expect(clearMock).toHaveBeenCalledWith("u1"); + expect(auditLog).toHaveBeenCalledWith("strava.credentials.delete", { + userId: "u1", + }); + expect(markDisconnected).toHaveBeenCalledWith("u1", "strava"); + }); + + it("clears the athlete id alongside both tokens", async () => { + await del(); + expect(userUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + data: { + stravaAccessTokenEncrypted: null, + stravaRefreshTokenEncrypted: null, + stravaAthleteId: null, + }, + }), + ); + }); +}); diff --git a/src/app/api/strava/credentials/route.ts b/src/app/api/strava/credentials/route.ts index 5fe23f168..ac53d9c47 100644 --- a/src/app/api/strava/credentials/route.ts +++ b/src/app/api/strava/credentials/route.ts @@ -68,20 +68,25 @@ export const PUT = apiHandler(async (request: NextRequest) => { * Delete Strava credentials and the active connection. * * Deleting the BYO credentials drops a live connection (a token minted against - * the deleted app is now orphaned), so when an access token was present this - * mirrors `/api/strava/disconnect`: audit the event and park the integration - * ledger at `disconnected` rather than leaving it stale at its last state. + * the deleted app is now orphaned), so this mirrors `/api/strava/disconnect`: + * audit the event and park the integration ledger at `disconnected` rather than + * leaving it stale at its last state. + * + * Both are unconditional, matching the Polar twin. They used to run only when + * an access token happened to be present, so tearing down a stored credential + * pair that had never been connected left no audit row at all — and a + * credential teardown is exactly the event an operator reads the trail for. The + * ledger park is idempotent, so running it for an already-disconnected provider + * costs one write and changes nothing. + * + * Note that this route still does NOT deauthorize at Strava; only + * `POST /api/strava/disconnect` does. Removing the keys here therefore leaves + * the app authorised on the user's Strava account. */ export const DELETE = apiHandler(async () => { const { user } = await requireAuth(); annotate({ action: { name: "strava.credentials.delete" } }); - const dbUser = await prisma.user.findUnique({ - where: { id: user.id }, - select: { stravaAccessTokenEncrypted: true }, - }); - const wasConnected = !!dbUser?.stravaAccessTokenEncrypted; - await prisma.user.update({ where: { id: user.id }, data: { @@ -92,10 +97,8 @@ export const DELETE = apiHandler(async () => { }); await clearStravaClientCredentials(user.id); - if (wasConnected) { - await auditLog("strava.credentials.delete", { userId: user.id }); - await markDisconnected(user.id, "strava"); - } + await auditLog("strava.credentials.delete", { userId: user.id }); + await markDisconnected(user.id, "strava"); return apiSuccess({ deleted: true }); }); diff --git a/src/app/api/whoop/sync/__tests__/route.test.ts b/src/app/api/whoop/sync/__tests__/route.test.ts index 6e5f461e7..22b12ea2a 100644 --- a/src/app/api/whoop/sync/__tests__/route.test.ts +++ b/src/app/api/whoop/sync/__tests__/route.test.ts @@ -8,15 +8,14 @@ vi.mock("@/lib/api-handler", () => ({ vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); vi.mock("@/lib/whoop/sync", () => ({ syncUserWhoop: vi.fn() })); - -vi.mock("@/lib/api-response", () => ({ - apiSuccess: (data: unknown) => ({ data, error: null }), -})); +vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() })); import { POST } from "../route"; import { syncUserWhoop } from "@/lib/whoop/sync"; +import { checkRateLimit } from "@/lib/rate-limit"; const sync = syncUserWhoop as ReturnType; +const limit = checkRateLimit as ReturnType; function req(body?: unknown): NextRequest { return new NextRequest("http://localhost/api/whoop/sync", { @@ -26,25 +25,92 @@ function req(body?: unknown): NextRequest { }); } +function raw(body: string): NextRequest { + return new NextRequest("http://localhost/api/whoop/sync", { + method: "POST", + body, + headers: { "Content-Type": "application/json" }, + }); +} + +const post = POST as unknown as (r: NextRequest) => Promise; + describe("POST /api/whoop/sync", () => { - beforeEach(() => vi.clearAllMocks()); + beforeEach(() => { + vi.clearAllMocks(); + limit.mockResolvedValue({ allowed: true }); + }); it("triggers an incremental sync by default", async () => { sync.mockResolvedValue({ imported: 7, failed: false }); - const res = (await ( - POST as unknown as (r: NextRequest) => Promise<{ data: unknown }> - )(req({}))) as { data: { imported: number; fullSync: boolean } }; - expect(res.data.imported).toBe(7); - expect(res.data.fullSync).toBe(false); + const res = await post(req({})); + const json = (await res.json()) as { + data: { imported: number; fullSync: boolean }; + }; + expect(json.data.imported).toBe(7); + expect(json.data.fullSync).toBe(false); + expect(sync).toHaveBeenCalledWith("u1", { fullSync: false }); + }); + + it("treats an absent body as an incremental run", async () => { + sync.mockResolvedValue({ imported: 1, failed: false }); + const res = await post(req()); + expect(res.status).toBe(200); expect(sync).toHaveBeenCalledWith("u1", { fullSync: false }); }); it("honours fullSync: true", async () => { sync.mockResolvedValue({ imported: 99, failed: false }); - const res = (await ( - POST as unknown as (r: NextRequest) => Promise<{ data: unknown }> - )(req({ fullSync: true }))) as { data: { fullSync: boolean } }; - expect(res.data.fullSync).toBe(true); + const res = await post(req({ fullSync: true })); + const json = (await res.json()) as { data: { fullSync: boolean } }; + expect(json.data.fullSync).toBe(true); expect(sync).toHaveBeenCalledWith("u1", { fullSync: true }); }); + + /** + * WHOOP and Withings were the two manual syncs with no limiter at all, while + * `fullSync` drives the full four-resource history walk against WHOOP's + * per-user budget. The Fitbit route's own comment records that it was "the + * outlier" until it got one; these two were never brought along. + */ + it("refuses once the baseline 5/60s bucket is spent", async () => { + limit.mockResolvedValueOnce({ allowed: false }); + const res = await post(req({})); + expect(res.status).toBe(429); + expect(sync).not.toHaveBeenCalled(); + expect(limit).toHaveBeenCalledWith("whoop-sync:u1", 5, 60_000); + }); + + it("caps the full-history walk at one per hour on its own bucket", async () => { + limit + .mockResolvedValueOnce({ allowed: true }) + .mockResolvedValueOnce({ allowed: false }); + const res = await post(req({ fullSync: true })); + expect(res.status).toBe(429); + expect(sync).not.toHaveBeenCalled(); + expect(limit).toHaveBeenNthCalledWith( + 2, + "whoop-sync-full:u1", + 1, + 3_600_000, + ); + }); + + it("does not consult the full-sync bucket for an incremental run", async () => { + sync.mockResolvedValue({ imported: 0, failed: false }); + await post(req({ fullSync: false })); + expect(limit).toHaveBeenCalledTimes(1); + }); + + it("refuses a present-but-unparseable body instead of syncing", async () => { + const res = await post(raw("{ fullSync")); + expect(res.status).toBe(400); + expect(sync).not.toHaveBeenCalled(); + }); + + it("refuses a mistyped fullSync instead of silently going incremental", async () => { + const res = await post(req({ fullSync: "true" })); + expect(res.status).toBe(422); + expect(sync).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/api/whoop/sync/route.ts b/src/app/api/whoop/sync/route.ts index 1a7579cfe..874820131 100644 --- a/src/app/api/whoop/sync/route.ts +++ b/src/app/api/whoop/sync/route.ts @@ -1,6 +1,8 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiSuccess, apiError } from "@/lib/api-response"; +import { checkRateLimit } from "@/lib/rate-limit"; import { annotate } from "@/lib/logging/context"; +import { readSyncTriggerBody } from "@/lib/integrations/sync-request"; import { resolveSyncOutcome } from "@/lib/outcome/written-outcome"; import { syncUserWhoop } from "@/lib/whoop/sync"; import { NextRequest } from "next/server"; @@ -10,22 +12,40 @@ import { NextRequest } from "next/server"; * * Mirrors the Withings manual-sync route: incremental by default, full * history when `{ fullSync: true }` is posted. + * + * Rate-limited on both arms, matching the Fitbit / Google Health siblings. + * This route and the Withings one were the two that never got a limiter, while + * `fullSync` drives the full four-resource history walk against WHOOP's + * per-user budget: a tight loop or a stolen native token could exhaust it and + * pin Prisma. A baseline 5/60s bucket gates the route and the expensive + * `fullSync` path carries a tighter 1/hour bucket of its own. */ export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); annotate({ action: { name: "whoop.sync" } }); - let fullSync = false; - try { - const raw = await request.text(); - // Flag-only payload — cap the parse cost (mirrors safeJson maxBytes). - if (raw.length > 64 * 1024) { - return apiError(`Request body exceeds ${64 * 1024} bytes`, 413); + const rl = await checkRateLimit(`whoop-sync:${user.id}`, 5, 60_000); + if (!rl.allowed) { + return apiError("Too many sync requests", 429, { + errorCode: "rate_limited_self", + }); + } + + const body = await readSyncTriggerBody(request); + if (body.error) return body.error; + const { fullSync } = body; + + if (fullSync) { + const fullRl = await checkRateLimit( + `whoop-sync-full:${user.id}`, + 1, + 60 * 60_000, + ); + if (!fullRl.allowed) { + return apiError("Full sync is limited to once per hour", 429, { + errorCode: "rate_limited_self", + }); } - const body = JSON.parse(raw); - fullSync = body?.fullSync === true; - } catch { - // no body provided -> default incremental sync } // Resolved server-side so the card and the native client cannot disagree on diff --git a/src/app/api/withings/credentials/__tests__/route.test.ts b/src/app/api/withings/credentials/__tests__/route.test.ts index c5603b222..737ce1a9f 100644 --- a/src/app/api/withings/credentials/__tests__/route.test.ts +++ b/src/app/api/withings/credentials/__tests__/route.test.ts @@ -24,6 +24,9 @@ vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), })); +vi.mock("@/lib/integrations/status", () => ({ + markDisconnected: vi.fn().mockResolvedValue(undefined), +})); vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), @@ -40,6 +43,8 @@ vi.mock("next/headers", () => ({ import { DELETE } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; +import { auditLog } from "@/lib/auth/audit"; +import { markDisconnected } from "@/lib/integrations/status"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -93,4 +98,41 @@ describe("DELETE /api/withings/credentials", () => { // the encrypted OAuth tokens survive is the worst of both states. expect(prisma.user.update).not.toHaveBeenCalled(); }); + + /** + * Withings was the one provider whose credential DELETE wrote no audit row + * and left the ledger alone. Fitbit, Google Health and WHOOP all park theirs, + * and three of them say in their own comments that they do it for parity — + * with a list that never included Withings. The visible cost was a client + * reading `state` after the teardown and being told `connected` about a + * connection that no longer existed. + */ + it("audits the teardown and parks the ledger at disconnected", async () => { + vi.mocked(prisma.withingsConnection.delete).mockResolvedValue({} as never); + const res = await DELETE(); + expect(res.status).toBe(200); + expect(auditLog).toHaveBeenCalledWith("withings.credentials.delete", { + userId: "user-1", + }); + expect(markDisconnected).toHaveBeenCalledWith("user-1", "withings"); + }); + + it("still audits and parks when the connection row was already gone", async () => { + vi.mocked(prisma.withingsConnection.delete).mockRejectedValue(p2025()); + const res = await DELETE(); + expect(res.status).toBe(200); + expect(auditLog).toHaveBeenCalledWith("withings.credentials.delete", { + userId: "user-1", + }); + expect(markDisconnected).toHaveBeenCalledWith("user-1", "withings"); + }); + + it("does not audit or park when the teardown itself failed", async () => { + vi.mocked(prisma.withingsConnection.delete).mockRejectedValue( + new Error("connection terminated unexpectedly"), + ); + await DELETE(); + expect(auditLog).not.toHaveBeenCalled(); + expect(markDisconnected).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/api/withings/credentials/route.ts b/src/app/api/withings/credentials/route.ts index 429514f95..3829ed3d6 100644 --- a/src/app/api/withings/credentials/route.ts +++ b/src/app/api/withings/credentials/route.ts @@ -2,7 +2,9 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; import { isP2025 } from "@/lib/prisma-errors"; +import { auditLog } from "@/lib/auth/audit"; import { annotate } from "@/lib/logging/context"; +import { markDisconnected } from "@/lib/integrations/status"; import { encrypt } from "@/lib/crypto"; import { withingsCredentialsSchema } from "@/lib/validations/withings"; import { NextRequest } from "next/server"; @@ -60,6 +62,13 @@ export const PUT = apiHandler(async (request: NextRequest) => { /** * Delete Withings credentials and disconnect. + * + * Audits the teardown and parks the integration ledger at `disconnected`, for + * parity with the Fitbit / Google Health / WHOOP credential-DELETE. Withings + * was the one provider that did neither: the connection row went, the + * credential columns went, and the ledger kept whatever state it last held — + * so a client reading `state` afterwards was told `connected` about a + * connection that no longer existed, and a sensitive teardown left no trail. */ export const DELETE = apiHandler(async () => { const { user } = await requireAuth(); @@ -84,5 +93,8 @@ export const DELETE = apiHandler(async () => { }, }); + await auditLog("withings.credentials.delete", { userId: user.id }); + await markDisconnected(user.id, "withings"); + return apiSuccess({ deleted: true }); }); diff --git a/src/app/api/withings/sync/__tests__/route.test.ts b/src/app/api/withings/sync/__tests__/route.test.ts new file mode 100644 index 000000000..6ca4b3228 --- /dev/null +++ b/src/app/api/withings/sync/__tests__/route.test.ts @@ -0,0 +1,118 @@ +/** + * `POST /api/withings/sync` — the limiter and the honest body parse. + * + * Withings and WHOOP were the two manual syncs that never got a rate limiter, + * while `fullSync` drives the full measure-history walk against the provider's + * per-user budget. Withings had no test file at all, which is why this one + * exists; the WHOOP twin carries the same shape. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ user: { id: "u1" } })), +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/withings/sync", () => ({ syncUserMeasurements: vi.fn() })); +vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() })); + +import { POST } from "../route"; +import { syncUserMeasurements } from "@/lib/withings/sync"; +import { checkRateLimit } from "@/lib/rate-limit"; + +const sync = syncUserMeasurements as ReturnType; +const limit = checkRateLimit as ReturnType; + +function req(body?: unknown): NextRequest { + return new NextRequest("http://localhost/api/withings/sync", { + method: "POST", + body: body === undefined ? undefined : JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }); +} + +function raw(body: string): NextRequest { + return new NextRequest("http://localhost/api/withings/sync", { + method: "POST", + body, + headers: { "Content-Type": "application/json" }, + }); +} + +const post = POST as unknown as (r: NextRequest) => Promise; + +describe("POST /api/withings/sync", () => { + beforeEach(() => { + vi.clearAllMocks(); + limit.mockResolvedValue({ allowed: true }); + }); + + it("triggers an incremental sync by default", async () => { + sync.mockResolvedValue({ imported: 4, failed: false }); + const res = await post(req({})); + const json = (await res.json()) as { + data: { imported: number; fullSync: boolean }; + }; + expect(json.data.imported).toBe(4); + expect(json.data.fullSync).toBe(false); + expect(sync).toHaveBeenCalledWith("u1", { fullSync: false }); + }); + + it("treats an absent body as an incremental run", async () => { + sync.mockResolvedValue({ imported: 0, failed: false }); + const res = await post(req()); + expect(res.status).toBe(200); + expect(sync).toHaveBeenCalledWith("u1", { fullSync: false }); + }); + + it("honours fullSync: true", async () => { + sync.mockResolvedValue({ imported: 120, failed: false }); + const res = await post(req({ fullSync: true })); + const json = (await res.json()) as { data: { fullSync: boolean } }; + expect(json.data.fullSync).toBe(true); + expect(sync).toHaveBeenCalledWith("u1", { fullSync: true }); + }); + + it("refuses once the baseline 5/60s bucket is spent", async () => { + limit.mockResolvedValueOnce({ allowed: false }); + const res = await post(req({})); + expect(res.status).toBe(429); + expect(sync).not.toHaveBeenCalled(); + expect(limit).toHaveBeenCalledWith("withings-sync:u1", 5, 60_000); + }); + + it("caps the full-history walk at one per hour on its own bucket", async () => { + limit + .mockResolvedValueOnce({ allowed: true }) + .mockResolvedValueOnce({ allowed: false }); + const res = await post(req({ fullSync: true })); + expect(res.status).toBe(429); + expect(sync).not.toHaveBeenCalled(); + expect(limit).toHaveBeenNthCalledWith( + 2, + "withings-sync-full:u1", + 1, + 3_600_000, + ); + }); + + it("does not consult the full-sync bucket for an incremental run", async () => { + sync.mockResolvedValue({ imported: 0, failed: false }); + await post(req({ fullSync: false })); + expect(limit).toHaveBeenCalledTimes(1); + }); + + it("refuses a present-but-unparseable body instead of syncing", async () => { + const res = await post(raw("{ fullSync")); + expect(res.status).toBe(400); + expect(sync).not.toHaveBeenCalled(); + }); + + it("refuses a mistyped fullSync instead of silently going incremental", async () => { + const res = await post(req({ fullSync: "true" })); + expect(res.status).toBe(422); + expect(sync).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/withings/sync/route.ts b/src/app/api/withings/sync/route.ts index 508f8cc4a..a35cdba89 100644 --- a/src/app/api/withings/sync/route.ts +++ b/src/app/api/withings/sync/route.ts @@ -1,28 +1,48 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiSuccess, apiError } from "@/lib/api-response"; +import { checkRateLimit } from "@/lib/rate-limit"; import { annotate } from "@/lib/logging/context"; +import { readSyncTriggerBody } from "@/lib/integrations/sync-request"; import { resolveSyncOutcome } from "@/lib/outcome/written-outcome"; import { syncUserMeasurements } from "@/lib/withings/sync"; import { NextRequest } from "next/server"; /** * Manually trigger a Withings sync for the current user. + * + * Rate-limited on both arms, matching the Fitbit / Google Health siblings. + * This route and the WHOOP one were the two that never got a limiter, while + * `fullSync` drives the full measure-history walk against Withings' per-user + * budget: a tight loop or a stolen native token could exhaust it and pin + * Prisma. A baseline 5/60s bucket gates the route and the expensive `fullSync` + * path carries a tighter 1/hour bucket of its own. */ export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); annotate({ action: { name: "withings.sync" } }); - let fullSync = false; - try { - const raw = await request.text(); - // Flag-only payload — cap the parse cost (mirrors safeJson maxBytes). - if (raw.length > 64 * 1024) { - return apiError(`Request body exceeds ${64 * 1024} bytes`, 413); + const rl = await checkRateLimit(`withings-sync:${user.id}`, 5, 60_000); + if (!rl.allowed) { + return apiError("Too many sync requests", 429, { + errorCode: "rate_limited_self", + }); + } + + const body = await readSyncTriggerBody(request); + if (body.error) return body.error; + const { fullSync } = body; + + if (fullSync) { + const fullRl = await checkRateLimit( + `withings-sync-full:${user.id}`, + 1, + 60 * 60_000, + ); + if (!fullRl.allowed) { + return apiError("Full sync is limited to once per hour", 429, { + errorCode: "rate_limited_self", + }); } - const body = JSON.parse(raw); - fullSync = body?.fullSync === true; - } catch { - // no body provided -> default incremental sync } const result = await syncUserMeasurements(user.id, { fullSync }); diff --git a/src/lib/integrations/__tests__/sync-request.test.ts b/src/lib/integrations/__tests__/sync-request.test.ts new file mode 100644 index 000000000..29d6a13cf --- /dev/null +++ b/src/lib/integrations/__tests__/sync-request.test.ts @@ -0,0 +1,114 @@ +/** + * The manual-sync trigger body tells its three cases apart. + * + * Before this helper, all four provider sync routes shared one `try { … } + * catch { /* no body provided -> default incremental sync *\/ }` block, and the + * comment was true for only one of the three ways to reach it. A body that was + * present and unparseable, and a body carrying `"fullSync": "true"` as a + * string, both came out as `fullSync = false` with a 200 — so a client that + * asked for full history and got the type wrong was told the incremental run it + * never requested had succeeded. + * + * The absent-body case is load-bearing in the other direction: it is the + * documented shape and the tests below pin it, so tightening the wrong two + * cases cannot take it with them. + */ +import { describe, it, expect } from "vitest"; + +import { readSyncTriggerBody } from "../sync-request"; + +function req(body?: BodyInit, contentType = "application/json"): Request { + return new Request("http://localhost/api/whoop/sync", { + method: "POST", + headers: { "Content-Type": contentType }, + ...(body === undefined ? {} : { body }), + }); +} + +async function envelope(res: Response) { + return (await res.json()) as { + data: null; + error: string; + details?: { issues?: unknown[] }; + }; +} + +describe("readSyncTriggerBody", () => { + it("reads an absent body as an incremental run", async () => { + const out = await readSyncTriggerBody(req()); + expect(out.error).toBeUndefined(); + expect(out.fullSync).toBe(false); + }); + + it("reads a whitespace-only body as an incremental run", async () => { + const out = await readSyncTriggerBody(req("\n")); + expect(out.error).toBeUndefined(); + expect(out.fullSync).toBe(false); + }); + + it("reads an empty object as an incremental run", async () => { + const out = await readSyncTriggerBody(req("{}")); + expect(out.error).toBeUndefined(); + expect(out.fullSync).toBe(false); + }); + + it("honours an explicit fullSync: true", async () => { + const out = await readSyncTriggerBody( + req(JSON.stringify({ fullSync: true })), + ); + expect(out.error).toBeUndefined(); + expect(out.fullSync).toBe(true); + }); + + it("honours an explicit fullSync: false", async () => { + const out = await readSyncTriggerBody( + req(JSON.stringify({ fullSync: false })), + ); + expect(out.error).toBeUndefined(); + expect(out.fullSync).toBe(false); + }); + + it("ignores unknown keys rather than refusing them", async () => { + const out = await readSyncTriggerBody( + req(JSON.stringify({ fullSync: true, since: "2026-01-01" })), + ); + expect(out.error).toBeUndefined(); + expect(out.fullSync).toBe(true); + }); + + it("refuses a present-but-unparseable body with 400", async () => { + const out = await readSyncTriggerBody(req("{ fullSync: true")); + expect(out.fullSync).toBeUndefined(); + expect(out.error?.status).toBe(400); + expect((await envelope(out.error!)).error).toBe("Invalid JSON body"); + }); + + /** + * The case the old catch-all hid: a string where a boolean belongs used to + * read as "the user asked for an incremental sync", and the 200 said so. + */ + it("refuses a string fullSync with 422 rather than reading it as false", async () => { + const out = await readSyncTriggerBody( + req(JSON.stringify({ fullSync: "true" })), + ); + expect(out.fullSync).toBeUndefined(); + expect(out.error?.status).toBe(422); + const body = await envelope(out.error!); + expect(body.error).toBe("Validation failed"); + expect(body.details?.issues?.length).toBeGreaterThan(0); + }); + + it("refuses a JSON scalar body with 422", async () => { + const out = await readSyncTriggerBody(req("null")); + expect(out.fullSync).toBeUndefined(); + expect(out.error?.status).toBe(422); + }); + + it("refuses a body over the 64 KiB cap with 413", async () => { + const out = await readSyncTriggerBody( + req(JSON.stringify({ fullSync: true, pad: "x".repeat(70 * 1024) })), + ); + expect(out.fullSync).toBeUndefined(); + expect(out.error?.status).toBe(413); + }); +}); diff --git a/src/lib/integrations/sync-request.ts b/src/lib/integrations/sync-request.ts new file mode 100644 index 000000000..d5cb3628e --- /dev/null +++ b/src/lib/integrations/sync-request.ts @@ -0,0 +1,79 @@ +/** + * The manual-sync trigger body, read once for every provider. + * + * Four sync routes each carried their own copy of this: + * + * let fullSync = false; + * try { + * const raw = await request.text(); + * if (raw.length > 64 * 1024) return apiError(…, 413); + * const body = JSON.parse(raw); + * fullSync = body?.fullSync === true; + * } catch { + * // no body provided -> default incremental sync + * } + * + * The comment is only true for one of the three cases that reach the catch. An + * absent body means an incremental run, and that is the documented and used + * shape. But a body that is present and unparseable, and a body that is present + * and carries `"fullSync": "true"` as a string, both landed in the same place: + * `fullSync = false`, a 200, and a response that reads to the client as a + * successful incremental sync. A caller could ask for full history, get a typo + * wrong, and be told it worked. + * + * This separates the three. Absent still means incremental. Present and + * unparseable is a 400, present and failing the schema is a 422 with the + * multi-issue envelope the rest of the surface uses. No caller that sends + * nothing, `{}`, or a well-formed `{ "fullSync": }` sees any change; the + * only requests that now fail are the ones that were already being misread. + */ +import { apiError, returnAllZodIssues } from "@/lib/api-response"; +import { syncTriggerSchema } from "@/lib/validations/sync-trigger"; + +/** Matches the per-route cap the four copies each applied by hand. */ +export const SYNC_TRIGGER_MAX_BYTES = 64 * 1024; + +export type SyncTriggerBody = + { fullSync: boolean; error?: never } | { fullSync?: never; error: Response }; + +/** + * Resolve `fullSync` from the request body, refusing a body that is present + * and wrong instead of silently reading it as `false`. + */ +export async function readSyncTriggerBody( + request: Request, +): Promise { + let raw: string; + try { + raw = await request.text(); + } catch { + return { error: apiError("Invalid request body", 400) }; + } + + if (raw.length > SYNC_TRIGGER_MAX_BYTES) { + return { + error: apiError( + `Request body exceeds ${SYNC_TRIGGER_MAX_BYTES} bytes`, + 413, + ), + }; + } + + // An absent body is the documented way to ask for an incremental run, and + // stays one. Whitespace-only counts as absent: some HTTP clients send a + // newline for a bodyless POST, and refusing that would be a refusal of the + // shape this branch exists to keep working. + if (raw.trim() === "") return { fullSync: false }; + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return { error: apiError("Invalid JSON body", 400) }; + } + + const result = syncTriggerSchema.safeParse(parsed); + if (!result.success) return { error: returnAllZodIssues(result.error, 422) }; + + return { fullSync: result.data.fullSync === true }; +} diff --git a/src/lib/openapi/routes/integrations.ts b/src/lib/openapi/routes/integrations.ts index 7b52b18ff..f30216231 100644 --- a/src/lib/openapi/routes/integrations.ts +++ b/src/lib/openapi/routes/integrations.ts @@ -15,8 +15,10 @@ * descriptions say so per path rather than smoothing it over. Three divergences * are worth knowing before reading further: * - * * A disconnect is 404-on-repeat for six providers and an idempotent 200 for - * two. Which is which is stated on each path. + * * `POST /api//disconnect` is 404-on-repeat for all seven + * providers that have one; `DELETE /api//credentials` is an + * idempotent 200 for all six. The same user-facing gesture, opposite + * contracts, and the split follows the URL rather than the provider. * * `/api//test` answers `{ ok, latencyMs }`; the same-named * `/api/integrations//test` answers `{ ok, lastSyncedAt, * latencyMs }`, and the Google Health one additionally accepts a body that @@ -649,10 +651,10 @@ const selfRateLimited429 = { }; /** - * The manual-sync body. Every sync route parses it the same way and the parse - * is deliberately forgiving: a missing body, an unparseable body and a body - * without the key all mean the same thing — an incremental run. Only a literal - * `true` selects the full-history walk. + * The manual-sync body. Every sync route reads it through the same helper, and + * the helper separates three cases the old per-route copies collapsed into one: + * an ABSENT body is an incremental run, a body that is present and unparseable + * is a 400, and a body that is present and fails this schema is a 422. */ const syncTriggerRequest = z .object({ @@ -660,13 +662,13 @@ const syncTriggerRequest = z .boolean() .optional() .describe( - "`true` walks full history; anything else (including a malformed body, which is swallowed rather than refused) runs the incremental sync.", + "`true` walks full history; `false` or omitted runs the incremental sync. A non-boolean here is refused with 422 — it is NOT read as `false`.", ), }) .meta({ id: "SyncTriggerRequest", description: - "Optional flag-only body for a manual sync. Note that this body is NOT validated: a parse failure silently degrades to `fullSync: false` rather than answering 422, so a client typo reads as a successful incremental run.", + 'Optional flag-only body for a manual sync. Omit the body entirely (or send `{}`) for an incremental run — that is the documented shape and it keeps working. A body that IS present must be valid: unparseable JSON is 400, and a body failing this schema is 422 with the multi-issue envelope. This matters because it used to be neither: `{ "fullSync": "true" }` was silently read as `false` and answered 200, so a client that asked for full history and mistyped the value was told the run it never requested had succeeded. Unknown keys are still ignored.', }); const syncTriggerOutcome = z @@ -862,7 +864,7 @@ const integrationEnvelopeEntry = z metricFreshness: z .array(metricFreshnessEntry) .describe( - "Per-metric-type freshness for this provider's data. Empty when the provider has delivered nothing, and also empty when the freshness read itself failed — the endpoint degrades to no data rather than failing the whole envelope, so an empty list is not proof of no data.", + "Per-metric-type freshness for this provider's data. Empty when the provider has delivered nothing — and also empty when the freshness read failed, because the endpoint degrades to no data rather than failing the whole envelope. Read the top-level `metricFreshnessDegraded` to tell those two apart before rendering an empty list as 'nothing has gone quiet'.", ), connected: z.boolean(), configured: z.boolean(), @@ -906,6 +908,11 @@ const integrationStatusEnvelope = z .describe( "Consecutive persistent failures at which the operator is alerted. Operator-level, configurable via env.", ), + metricFreshnessDegraded: z + .boolean() + .describe( + "The per-metric freshness query failed, so EVERY entry's `metricFreshness` is an empty array regardless of what the record actually holds. One flag rather than one per entry, because it is a single query for all eight providers: it fails for all of them or for none. When true, render the freshness section as unavailable rather than as 'no metric has gone quiet' — the two used to be indistinguishable on the wire, which turned the honesty signal into the thing it was built to prevent. The rest of the envelope is unaffected and still complete.", + ), integrations: z .array(integrationEnvelopeEntry) .describe( @@ -1380,7 +1387,7 @@ export const integrationPaths: NonNullable = { tags: ["Integrations"], summary: "Delete the caller's Withings credentials and connection", description: - "Deletes the connection row (with its encrypted OAuth tokens) and clears both credential columns. Idempotent — a missing connection row is a benign no-op, so a repeat call still answers 200. Unlike every sibling provider's credential DELETE, this one writes NO audit row and does NOT park the `withings` ledger: the ledger keeps whatever state it last held, so a client should not read `state` as proof of a live connection after this call. Auth via cookie or Bearer.", + "Deletes the connection row (with its encrypted OAuth tokens), clears both credential columns, audits the teardown and parks the `withings` ledger at `disconnected`. Idempotent — a missing connection row is a benign no-op, so a repeat call still answers 200. Auth via cookie or Bearer.", responses: { "200": { description: "Credentials and connection removed.", @@ -1402,7 +1409,7 @@ export const integrationPaths: NonNullable = { tags: ["Integrations"], summary: "Trigger a Withings sync", description: - "Incremental by default; `{ fullSync: true }` walks full history. NOT rate-limited — the only manual sync in this group without a limiter, alongside WHOOP's. Auth via cookie or Bearer.", + "Incremental by default; `{ fullSync: true }` walks full history. Two limiters apply, matching the Fitbit / Google Health siblings: 5 requests / 60 s for any run, and additionally 1 per hour for a full one. Auth via cookie or Bearer.", requestBody: { required: false, content: { "application/json": { schema: syncTriggerRequest } }, @@ -1419,11 +1426,26 @@ export const integrationPaths: NonNullable = { }, }, }, + "400": { + description: + "Body is not parseable JSON. An ABSENT body is not this case — omit the body entirely for an incremental run.", + content: { "application/json": { schema: errorEnvelope } }, + }, "413": { description: "Body exceeds 64 KiB.", content: { "application/json": { schema: errorEnvelope } }, }, ...stdResponses, + "422": { + description: + "The body was present and failed the schema — `fullSync` was not a boolean. Nothing was synced. Note that this is a behaviour change: such a body used to be read as `fullSync: false` and answered 200.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "429": { + description: + "Either limiter tripped: more than 5 syncs in 60 s, or a second full sync within the hour. `meta.errorCode` = `rate_limited_self` for both; the message distinguishes them.", + content: { "application/json": { schema: errorEnvelope } }, + }, }, }, }, @@ -1432,7 +1454,7 @@ export const integrationPaths: NonNullable = { tags: ["Integrations"], summary: "Trigger a WHOOP sync", description: - "Incremental by default; `{ fullSync: true }` walks full history. NOT rate-limited — the only manual sync in this group without a limiter, alongside Withings'. Auth via cookie or Bearer.", + "Incremental by default; `{ fullSync: true }` walks full history across the four WHOOP resources. Two limiters apply, matching the Fitbit / Google Health siblings: 5 requests / 60 s for any run, and additionally 1 per hour for a full one. Auth via cookie or Bearer.", requestBody: { required: false, content: { "application/json": { schema: syncTriggerRequest } }, @@ -1449,11 +1471,26 @@ export const integrationPaths: NonNullable = { }, }, }, + "400": { + description: + "Body is not parseable JSON. An ABSENT body is not this case — omit the body entirely for an incremental run.", + content: { "application/json": { schema: errorEnvelope } }, + }, "413": { description: "Body exceeds 64 KiB.", content: { "application/json": { schema: errorEnvelope } }, }, ...stdResponses, + "422": { + description: + "The body was present and failed the schema — `fullSync` was not a boolean. Nothing was synced. Note that this is a behaviour change: such a body used to be read as `fullSync: false` and answered 200.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "429": { + description: + "Either limiter tripped: more than 5 syncs in 60 s, or a second full sync within the hour. `meta.errorCode` = `rate_limited_self` for both; the message distinguishes them.", + content: { "application/json": { schema: errorEnvelope } }, + }, }, }, }, @@ -1630,11 +1667,21 @@ export const integrationPaths: NonNullable = { }, }, }, + "400": { + description: + "Body is not parseable JSON. An ABSENT body is not this case — omit the body entirely for an incremental run.", + content: { "application/json": { schema: errorEnvelope } }, + }, "413": { description: "Body exceeds 64 KiB.", content: { "application/json": { schema: errorEnvelope } }, }, ...stdResponses, + "422": { + description: + "The body was present and failed the schema — `fullSync` was not a boolean. Nothing was synced. Note that this is a behaviour change: such a body used to be read as `fullSync: false` and answered 200.", + content: { "application/json": { schema: errorEnvelope } }, + }, "429": { description: "Either limiter tripped: more than 5 syncs in 60 s, or a second full sync within the hour. `meta.errorCode` = `rate_limited_self` for both; the message distinguishes them.", @@ -1788,12 +1835,22 @@ export const integrationPaths: NonNullable = { description: "Body exceeds 64 KiB.", content: { "application/json": { schema: errorEnvelope } }, }, + "400": { + description: + "Body is not parseable JSON. An ABSENT body is not this case — omit the body entirely for an incremental run.", + content: { "application/json": { schema: errorEnvelope } }, + }, "502": { description: "The run failed and wrote nothing. A run that wrote something before failing answers 200 instead.", content: { "application/json": { schema: errorEnvelope } }, }, ...stdResponses, + "422": { + description: + "The body was present and failed the schema — `fullSync` was not a boolean. Nothing was synced. Note that this is a behaviour change: such a body used to be read as `fullSync: false` and answered 200.", + content: { "application/json": { schema: errorEnvelope } }, + }, "429": { description: "Either limiter tripped: more than 5 syncs in 60 s, or a second full sync within the hour. `meta.errorCode` = `rate_limited_self`.", @@ -1850,7 +1907,7 @@ export const integrationPaths: NonNullable = { tags: ["Integrations"], summary: "Delete the caller's Oura credentials and connection", description: - "Clears the stored access and refresh tokens as well as the BYO pair — a token minted against a deleted app is orphaned, so the grant goes with the keys. Always answers 200. The audit row and the ledger park are written only when a token was actually present, so tearing down an unconnected credential pair leaves no audit trail. Auth via cookie or Bearer.", + "Clears the stored access and refresh tokens as well as the BYO pair — a token minted against a deleted app is orphaned, so the grant goes with the keys. Always answers 200, and always audits the teardown and parks the `oura` ledger at `disconnected`, whether or not a token was present. Auth via cookie or Bearer.", responses: { "200": { description: "Credentials and any connection removed.", @@ -1983,7 +2040,7 @@ export const integrationPaths: NonNullable = { tags: ["Integrations"], summary: "Delete the caller's Polar credentials and connection", description: - "Clears the stored access token and member id as well as the BYO pair — a token minted under a deleted AccessLink app is orphaned, so the grant goes with the keys. Always answers 200, and unlike Oura's and Strava's equivalents it audits and parks the ledger unconditionally, whether or not a token was present. Auth via cookie or Bearer.", + "Clears the stored access token and member id as well as the BYO pair — a token minted under a deleted AccessLink app is orphaned, so the grant goes with the keys. Always answers 200, and always audits the teardown and parks the `polar` ledger at `disconnected`, whether or not a token was present. Auth via cookie or Bearer.", responses: { "200": { description: "Credentials and any connection removed.", @@ -2119,7 +2176,7 @@ export const integrationPaths: NonNullable = { tags: ["Integrations"], summary: "Delete the caller's Strava credentials and connection", description: - "Clears the stored access token, refresh token and athlete id as well as the BYO pair. Always answers 200. The audit row and the ledger park are written only when a token was actually present. Note that this path does NOT deauthorize at Strava — only `POST /api/strava/disconnect` does, so removing the credentials here leaves the app authorised on the user's Strava account. Auth via cookie or Bearer.", + "Clears the stored access token, refresh token and athlete id as well as the BYO pair. Always answers 200, and always audits the teardown and parks the `strava` ledger at `disconnected`, whether or not a token was present. Note that this path does NOT deauthorize at Strava — only `POST /api/strava/disconnect` does, so removing the credentials here leaves the app authorised on the user's Strava account. Auth via cookie or Bearer.", responses: { "200": { description: "Credentials and any connection removed.", diff --git a/src/lib/validations/sync-trigger.ts b/src/lib/validations/sync-trigger.ts new file mode 100644 index 000000000..402f0559b --- /dev/null +++ b/src/lib/validations/sync-trigger.ts @@ -0,0 +1,15 @@ +import { z } from "zod/v4"; + +/** + * The optional flag-only body every manual provider-sync trigger accepts. + * + * Deliberately NOT `strict()`. An unknown key has always been ignored here and + * tightening that would refuse callers this change is not aiming at — the point + * is that a key the server DOES read must not be misread, not that the body + * becomes a closed shape. + */ +export const syncTriggerSchema = z.object({ + fullSync: z.boolean().optional(), +}); + +export type SyncTriggerInput = z.infer; From 6f62f66105af0bae63c09c297a48e6ee60b9cdf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 22 Aug 2026 13:18:25 +0200 Subject: [PATCH 4/4] Describe the WHOOP disconnect in the contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The eighth teardown endpoint, missed when the seven siblings were split across the earlier batches. It has no per-user unsubscribe to make — WHOOP webhook subscriptions are registered once per developer app rather than per category the way Withings does it — and it leaves the BYO-key credentials in place so a reconnect does not force a re-paste. Like every other `POST /api//disconnect`, a repeat call answers 404 rather than an idempotent 200, and the description says so. That completes the tally: all seven disconnect endpoints refuse a repeat, all six credential DELETEs accept one. --- src/lib/openapi/routes/integrations.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/lib/openapi/routes/integrations.ts b/src/lib/openapi/routes/integrations.ts index f30216231..1a711c1b2 100644 --- a/src/lib/openapi/routes/integrations.ts +++ b/src/lib/openapi/routes/integrations.ts @@ -1286,6 +1286,30 @@ export const integrationPaths: NonNullable = { }, }, }, + "/api/whoop/disconnect": { + post: { + tags: ["Integrations"], + summary: "Disconnect the caller's WHOOP account", + description: + "Deletes the connection row, audits the teardown and parks the `whoop` ledger at `disconnected`. There is no per-user unsubscribe to make — WHOOP webhook subscriptions are registered once per developer app, unlike Withings which subscribes per category. The BYO-key credentials are deliberately LEFT IN PLACE so a reconnect does not force the user to re-paste them; use the credentials DELETE to remove those too. No body. Auth via cookie or Bearer.", + responses: { + "200": { + description: "The connection was removed.", + content: { + "application/json": { + schema: dataEnvelope(disconnectedAck, "WhoopDisconnectEnvelope"), + }, + }, + }, + "404": { + description: + "No WHOOP connection to remove. NOT an idempotent no-op: a second disconnect answers 404 rather than 200.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, "/api/withings/status": { get: { tags: ["Integrations"],