diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 32acce270..86bc7ede5 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -16640,6 +16640,57 @@ paths: "401": *a1 "422": *a4 "429": *a5 + /api/nightscout/connect: + post: + tags: + - Integrations + summary: Connect (or update) the caller's Nightscout instance + description: Validates the URL and token with a live single-entry probe BEFORE storing anything, so a wrong token, an + unreachable instance or an unapproved private origin surfaces here rather than on the first cron tick. On + success the URL and token are encrypted at rest, the `nightscout` ledger is reset to connected, and a first sync + is enqueued best-effort. Re-sending replaces the stored values; there is no partial update. Body capped at 16 + KiB, rate-limited 10 requests / 60 s per user. Auth via cookie or Bearer. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/NightscoutConnectRequest" + responses: + "200": + description: The instance answered the probe and was stored. + content: + application/json: + schema: + $ref: "#/components/schemas/NightscoutConnectEnvelope" + "400": + description: Body is not parseable JSON. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "401": *a1 + "413": + description: Body exceeds 16 KiB. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": + description: "The URL failed validation, the origin is private and the operator has not approved it (`meta.errorCode` = + `private_origin_not_approved` or `invalid_origin`), Nightscout rejected the token, or the instance could not + be reached. Nothing was written. The message is fixed per class and never the upstream error's own: a + Nightscout base URL carries its API token as a query parameter." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "429": + description: More than 10 connection attempts in 60 s from this user. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" /api/nightscout/status: get: tags: @@ -27453,6 +27504,26 @@ components: - entries description: Merge-by-`id` update of the HealthKit metric config. Unknown ids are silently ignored; omitted fields fall back to the stored (or default) value for that entry. Up to 50 entries per call. + NightscoutConnectRequest: + type: object + properties: + url: + type: string + minLength: 1 + maxLength: 2048 + token: + default: "" + type: string + maxLength: 512 + allowPrivateHost: + default: false + type: boolean + required: + - url + description: "Instance URL and optional API token. Both are trimmed before use — a trailing space or newline from a copy + button reaches the instance verbatim and answers as an opaque DNS failure. `token` may be omitted or empty for a + fully public instance (`AUTH_DEFAULT_ROLES=readable`). `allowPrivateHost` is accepted for older clients and + ignored: only the server-side `NIGHTSCOUT_PRIVATE_ORIGINS` allowlist can authorise a private origin." WhoopCredentialsRequest: type: object properties: @@ -62778,6 +62849,30 @@ components: - truncated - reasonCode additionalProperties: false + NightscoutConnectEnvelope: + type: object + properties: + data: + type: object + properties: + connected: + type: boolean + const: true + required: + - connected + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false NightscoutStatusEnvelope: type: object properties: diff --git a/src/__tests__/openapi-route-coverage-guard.test.ts b/src/__tests__/openapi-route-coverage-guard.test.ts index 7e6e2b0a2..82480859c 100644 --- a/src/__tests__/openapi-route-coverage-guard.test.ts +++ b/src/__tests__/openapi-route-coverage-guard.test.ts @@ -293,7 +293,15 @@ const UNPUBLISHED: Readonly> = { }, "/api/fitbit/connect": { kind: "browserHandoff", methods: ["GET"] }, "/api/google-health/connect": { kind: "browserHandoff", methods: ["GET"] }, - "/api/nightscout/connect": { kind: "browserHandoff", methods: ["POST"] }, + // `/api/nightscout/connect` sat here as a `browserHandoff` and is published + // now. Nightscout has no OAuth step and no authorise page: the route is an + // authenticated JSON POST that Zod-parses a URL and a token, probes the + // instance and encrypts the pair onto the row. It was the only POST among + // eight `connect` entries and the only one whose siblings — status, test, + // disconnect — were all published, which is what makes it read as an entry + // that inherited a neighbour's reason rather than earned its own. The seven + // GET handoffs below keep it: each of those really does end at a provider's + // authorise page. "/api/oura/connect": { kind: "browserHandoff", methods: ["GET"] }, "/api/polar/connect": { kind: "browserHandoff", methods: ["GET"] }, "/api/strava/connect": { kind: "browserHandoff", methods: ["GET"] }, diff --git a/src/lib/openapi/routes/integrations.ts b/src/lib/openapi/routes/integrations.ts index 1a711c1b2..13fcf3981 100644 --- a/src/lib/openapi/routes/integrations.ts +++ b/src/lib/openapi/routes/integrations.ts @@ -38,6 +38,7 @@ 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 { nightscoutConnectSchema } from "@/lib/validations/nightscout"; import { withingsCredentialsSchema } from "@/lib/validations/withings"; import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; @@ -330,6 +331,12 @@ const integrationLedgerState = z "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 nightscoutConnectRequest = nightscoutConnectSchema.meta({ + id: "NightscoutConnectRequest", + description: + "Instance URL and optional API token. Both are trimmed before use — a trailing space or newline from a copy button reaches the instance verbatim and answers as an opaque DNS failure. `token` may be omitted or empty for a fully public instance (`AUTH_DEFAULT_ROLES=readable`). `allowPrivateHost` is accepted for older clients and ignored: only the server-side `NIGHTSCOUT_PRIVATE_ORIGINS` allowlist can authorise a private origin.", +}); + const nightscoutStatusResponse = z .object({ connected: z @@ -1101,6 +1108,52 @@ export const integrationPaths: NonNullable = { }, }, }, + "/api/nightscout/connect": { + post: { + tags: ["Integrations"], + summary: "Connect (or update) the caller's Nightscout instance", + description: + "Validates the URL and token with a live single-entry probe BEFORE storing anything, so a wrong token, an unreachable instance or an unapproved private origin surfaces here rather than on the first cron tick. On success the URL and token are encrypted at rest, the `nightscout` ledger is reset to connected, and a first sync is enqueued best-effort. Re-sending replaces the stored values; there is no partial update. Body capped at 16 KiB, rate-limited 10 requests / 60 s per user. Auth via cookie or Bearer.", + requestBody: { + required: true, + content: { + "application/json": { schema: nightscoutConnectRequest }, + }, + }, + responses: { + "200": { + description: "The instance answered the probe and was stored.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ connected: z.literal(true) }), + "NightscoutConnectEnvelope", + ), + }, + }, + }, + "400": { + description: "Body is not parseable JSON.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "413": { + description: "Body exceeds 16 KiB.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + "422": { + description: + "The URL failed validation, the origin is private and the operator has not approved it (`meta.errorCode` = `private_origin_not_approved` or `invalid_origin`), Nightscout rejected the token, or the instance could not be reached. Nothing was written. The message is fixed per class and never the upstream error's own: a Nightscout base URL carries its API token as a query parameter.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "429": { + description: + "More than 10 connection attempts in 60 s from this user.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, "/api/nightscout/status": { get: { tags: ["Integrations"],