From e5fad221a009b35c58ab5ac933e7b9d6df8447f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 21:11:09 +0200 Subject: [PATCH] feat(integrations): add test-connection endpoints for Nightscout, Polar, Oura --- src/app/api/nightscout/test/route.ts | 90 ++++++++++++++++++++++++++++ src/app/api/oura/test/route.ts | 82 +++++++++++++++++++++++++ src/app/api/polar/test/route.ts | 81 +++++++++++++++++++++++++ 3 files changed, 253 insertions(+) create mode 100644 src/app/api/nightscout/test/route.ts create mode 100644 src/app/api/oura/test/route.ts create mode 100644 src/app/api/polar/test/route.ts diff --git a/src/app/api/nightscout/test/route.ts b/src/app/api/nightscout/test/route.ts new file mode 100644 index 000000000..854e5ea32 --- /dev/null +++ b/src/app/api/nightscout/test/route.ts @@ -0,0 +1,90 @@ +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 { getUserNightscoutCredentials } from "@/lib/nightscout/credentials"; +import { fetchSgvEntries, NightscoutApiError } from "@/lib/nightscout/client"; +import { SafeFetchError } from "@/lib/safe-fetch"; + +export const dynamic = "force-dynamic"; + +// Cheap live probe — re-validate the stored URL + token by pulling a single +// SGV entry off the user's instance via the same client the sync uses. No new +// credentials path: it reuses `getUserNightscoutCredentials` + `fetchSgvEntries`. +const TIMEOUT_MS = 5_000; + +function categoriseStatus(status: number | null): { + code: string; + message: string; +} { + if (status === 401 || status === 403) { + return { + code: "credentials_rejected", + message: "Nightscout rejected the token", + }; + } + if (status === 429) { + return { + code: "rate_limited", + message: "Nightscout rate-limited the request", + }; + } + if (status !== null && status >= 500) { + return { code: "upstream_error", message: "Nightscout returned an error" }; + } + return { code: "connection_failed", message: "Nightscout connection failed" }; +} + +export const POST = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "nightscout.test" } }); + + const rl = await checkRateLimit(`nightscout-test:${user.id}`, 5, 60_000); + if (!rl.allowed) { + return apiError("Too many test requests", 429, { + errorCode: "rate_limited_self", + }); + } + + const creds = await getUserNightscoutCredentials(user.id); + if (!creds) { + return apiError("Nightscout not connected", 422, { + errorCode: "not_configured", + }); + } + + const start = performance.now(); + + try { + await fetchSgvEntries({ + baseUrl: creds.baseUrl, + token: creds.token, + count: 1, + allowPrivateHost: creds.allowPrivateHost, + timeoutMs: TIMEOUT_MS, + }); + const latencyMs = Math.round(performance.now() - start); + return apiSuccess({ ok: true, latencyMs }); + } catch (e) { + if (e instanceof NightscoutApiError) { + const cat = categoriseStatus(e.status); + annotate({ + meta: { nightscout_test_status: e.status, nightscout_test_code: cat.code }, + }); + return apiError(cat.message, 502, { errorCode: cat.code }); + } + const isTimeout = e instanceof SafeFetchError && e.kind === "timeout"; + const isPrivate = e instanceof SafeFetchError && e.kind === "private_host"; + const code = isTimeout + ? "timeout" + : isPrivate + ? "url_not_public" + : "connection_failed"; + annotate({ meta: { nightscout_test_code: code } }); + return apiError( + isTimeout ? "Nightscout request timed out" : "Nightscout connection failed", + 502, + { errorCode: code }, + ); + } +}); diff --git a/src/app/api/oura/test/route.ts b/src/app/api/oura/test/route.ts new file mode 100644 index 000000000..7e659115f --- /dev/null +++ b/src/app/api/oura/test/route.ts @@ -0,0 +1,82 @@ +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 { getOuraConnection } from "@/lib/oura/credentials"; +import { safeFetch, SafeFetchError } from "@/lib/safe-fetch"; + +export const dynamic = "force-dynamic"; + +// Lightweight token probe — fetch the personal-info record, the cheapest +// authenticated Oura call that confirms the stored grant is still valid. +// Reuses `getOuraConnection`; no new credential path. +// Base from `src/lib/oura/client.ts` (`OURA_API_BASE`). +const OURA_PERSONAL_INFO_URL = + "https://api.ouraring.com/v2/usercollection/personal_info"; +const TIMEOUT_MS = 5_000; + +function categoriseHttpStatus(status: number): { code: string; message: string } { + if (status === 401 || status === 403) { + return { code: "credentials_rejected", message: "Oura rejected the token" }; + } + if (status === 429) { + return { code: "rate_limited", message: "Oura rate-limited the request" }; + } + if (status >= 500) { + return { code: "upstream_error", message: "Oura returned a server error" }; + } + return { code: "connection_failed", message: "Oura connection failed" }; +} + +export const POST = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "oura.test" } }); + + const rl = await checkRateLimit(`oura-test:${user.id}`, 5, 60_000); + if (!rl.allowed) { + return apiError("Too many test requests", 429, { + errorCode: "rate_limited_self", + }); + } + + const connection = await getOuraConnection(user.id); + if (!connection) { + return apiError("Oura not connected", 422, { errorCode: "not_configured" }); + } + + const start = performance.now(); + + try { + const res = await safeFetch( + OURA_PERSONAL_INFO_URL, + { + method: "GET", + headers: { + Authorization: `Bearer ${connection.accessToken}`, + Accept: "application/json", + }, + }, + { timeoutMs: TIMEOUT_MS }, + ); + const latencyMs = Math.round(performance.now() - start); + + if (!res.ok) { + const cat = categoriseHttpStatus(res.status); + annotate({ + meta: { oura_test_status: res.status, oura_test_code: cat.code }, + }); + return apiError(cat.message, 502, { errorCode: cat.code }); + } + + return apiSuccess({ ok: true, latencyMs }); + } catch (e) { + const isTimeout = e instanceof SafeFetchError && e.kind === "timeout"; + const code = isTimeout ? "timeout" : "connection_failed"; + annotate({ meta: { oura_test_code: code } }); + return apiError( + isTimeout ? "Oura request timed out" : "Oura connection failed", + 502, + { errorCode: code }, + ); + } +}); diff --git a/src/app/api/polar/test/route.ts b/src/app/api/polar/test/route.ts new file mode 100644 index 000000000..ebd809626 --- /dev/null +++ b/src/app/api/polar/test/route.ts @@ -0,0 +1,81 @@ +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 { getPolarConnection } from "@/lib/polar/credentials"; +import { safeFetch, SafeFetchError } from "@/lib/safe-fetch"; + +export const dynamic = "force-dynamic"; + +// Lightweight token probe — fetch the registered AccessLink user record, the +// cheapest authenticated Polar call that confirms the stored grant is still +// valid. Reuses `getPolarConnection`; no new credential path. +// Base from `src/lib/polar/client.ts` (`POLAR_API_BASE`). +const POLAR_USERS_BASE = "https://www.polaraccesslink.com/v3/users"; +const TIMEOUT_MS = 5_000; + +function categoriseHttpStatus(status: number): { code: string; message: string } { + if (status === 401 || status === 403) { + return { code: "credentials_rejected", message: "Polar rejected the token" }; + } + if (status === 429) { + return { code: "rate_limited", message: "Polar rate-limited the request" }; + } + if (status >= 500) { + return { code: "upstream_error", message: "Polar returned a server error" }; + } + return { code: "connection_failed", message: "Polar connection failed" }; +} + +export const POST = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "polar.test" } }); + + const rl = await checkRateLimit(`polar-test:${user.id}`, 5, 60_000); + if (!rl.allowed) { + return apiError("Too many test requests", 429, { + errorCode: "rate_limited_self", + }); + } + + const connection = await getPolarConnection(user.id); + if (!connection) { + return apiError("Polar not connected", 422, { errorCode: "not_configured" }); + } + + const start = performance.now(); + + try { + const res = await safeFetch( + `${POLAR_USERS_BASE}/${encodeURIComponent(connection.polarUserId)}`, + { + method: "GET", + headers: { + Authorization: `Bearer ${connection.accessToken}`, + Accept: "application/json", + }, + }, + { timeoutMs: TIMEOUT_MS }, + ); + const latencyMs = Math.round(performance.now() - start); + + if (!res.ok) { + const cat = categoriseHttpStatus(res.status); + annotate({ + meta: { polar_test_status: res.status, polar_test_code: cat.code }, + }); + return apiError(cat.message, 502, { errorCode: cat.code }); + } + + return apiSuccess({ ok: true, latencyMs }); + } catch (e) { + const isTimeout = e instanceof SafeFetchError && e.kind === "timeout"; + const code = isTimeout ? "timeout" : "connection_failed"; + annotate({ meta: { polar_test_code: code } }); + return apiError( + isTimeout ? "Polar request timed out" : "Polar connection failed", + 502, + { errorCode: code }, + ); + } +});