diff --git a/src/app/api/integrations/whoop/resume/route.ts b/src/app/api/integrations/whoop/resume/route.ts new file mode 100644 index 000000000..54be52fed --- /dev/null +++ b/src/app/api/integrations/whoop/resume/route.ts @@ -0,0 +1,50 @@ +/** + * Manual resume endpoint for a parked WHOOP integration. + * + * Background: the `parked` state lives in + * `src/lib/integrations/status.ts`. Once a WHOOP persistent-failure + * streak has been running for more than 24h, the status writer flips + * the row to `parked` and the sync entry-point short-circuits via + * `isReauthRequired`. The user (or an operator) calls this endpoint to + * clear the park and let the next cron tick attempt the sync again. + * + * Rate-limited (5/min per user) to match the existing + * `/api/integrations/whoop/test` endpoint — there's no upstream call + * here, but a misbehaving client tab loop would still rack up + * audit-log rows if the limit was absent. + * + * Returns 200 with `{ resumed: true, wasParked: boolean }`. The + * `wasParked` flag lets the caller decide whether to surface a "you + * resumed it" toast or treat the action as a no-op (the row was + * already connected / disconnected). Either way the endpoint is + * idempotent — a second click in the same second sees the same + * post-state and writes no extra audit row. + */ +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 { resumeIntegrationFromPark } from "@/lib/integrations/status"; + +export const dynamic = "force-dynamic"; + +export const POST = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "integrations.whoop.resume" } }); + + const rl = await checkRateLimit(`whoop-resume:${user.id}`, 5, 60_000); + if (!rl.allowed) { + return apiError("Too many resume requests", 429, { + errorCode: "rate_limited_self", + }); + } + + const result = await resumeIntegrationFromPark(user.id, "whoop"); + annotate({ + meta: { + whoop_resume_was_parked: result.wasParked, + }, + }); + + return apiSuccess({ resumed: true, wasParked: result.wasParked }); +}); diff --git a/src/app/api/integrations/whoop/test/__tests__/route.test.ts b/src/app/api/integrations/whoop/test/__tests__/route.test.ts new file mode 100644 index 000000000..70b2d923a --- /dev/null +++ b/src/app/api/integrations/whoop/test/__tests__/route.test.ts @@ -0,0 +1,201 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ + user: { id: "u-1" }, + session: { id: "s-1" }, + })), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + whoopConnection: { + findUnique: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(async () => ({ allowed: true })), +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), +})); + +vi.mock("@/lib/crypto", () => ({ + decrypt: vi.fn((x: string) => x), +})); + +vi.mock("@/lib/whoop/sync", () => ({ + getValidToken: vi.fn(), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { getValidToken } from "@/lib/whoop/sync"; + +interface ApiErrorEnvelope { + data: null; + error: string; +} +interface ApiSuccessEnvelope { + data: T; + error: null; +} + +const originalFetch = global.fetch; + +beforeEach(() => { + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 5, + resetAt: Date.now() + 60_000, + } as never); + vi.mocked(prisma.whoopConnection.findUnique).mockReset(); + vi.mocked(getValidToken).mockReset(); + global.fetch = vi.fn() as never; +}); + +afterEach(() => { + global.fetch = originalFetch; +}); + +function emptyRequest(): Request { + return new Request("http://localhost/api/integrations/whoop/test", { + method: "POST", + headers: { "content-length": "0" }, + }); +} + +describe("POST /api/integrations/whoop/test", () => { + it("happy path returns ok with shape", async () => { + const lastSyncedAt = new Date("2026-05-01T10:00:00.000Z"); + vi.mocked(getValidToken).mockResolvedValueOnce({ + accessToken: "plain-access", + connection: { id: "wc-1", whoopUserId: "wu-1" }, + }); + vi.mocked(prisma.whoopConnection.findUnique).mockResolvedValueOnce({ + lastSyncedAt, + } as never); + (global.fetch as unknown as ReturnType).mockResolvedValueOnce( + new Response(JSON.stringify({ user_id: 1, first_name: "x" }), { + status: 200, + }), + ); + + const response = await POST(emptyRequest() as never); + expect(response.status).toBe(200); + const body = (await response.json()) as ApiSuccessEnvelope<{ + ok: boolean; + lastSyncedAt: string; + latencyMs: number; + }>; + expect(body.data.ok).toBe(true); + expect(typeof body.data.latencyMs).toBe("number"); + expect(body.data.lastSyncedAt).toBe(lastSyncedAt.toISOString()); + }); + + it("rate-limit denial returns 429 with no upstream call", async () => { + vi.mocked(checkRateLimit).mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 60_000, + } as never); + const response = await POST(emptyRequest() as never); + expect(response.status).toBe(429); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("returns 422 when no WHOOP connection exists", async () => { + vi.mocked(getValidToken).mockResolvedValueOnce(null); + const response = await POST(emptyRequest() as never); + expect(response.status).toBe(422); + const body = (await response.json()) as ApiErrorEnvelope; + expect(body.error).toMatch(/not connected/i); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("classifies 401 upstream as credentials_rejected", async () => { + vi.mocked(getValidToken).mockResolvedValueOnce({ + accessToken: "sk-secret", + connection: { id: "wc-1", whoopUserId: "wu-1" }, + }); + vi.mocked(prisma.whoopConnection.findUnique).mockResolvedValueOnce({ + lastSyncedAt: null, + } as never); + (global.fetch as unknown as ReturnType).mockResolvedValueOnce( + new Response(JSON.stringify({ error: "unauthorized" }), { status: 401 }), + ); + const response = await POST(emptyRequest() as never); + expect(response.status).toBe(502); + const body = (await response.json()) as ApiErrorEnvelope & { + meta?: { errorCode?: string }; + }; + expect(body.meta?.errorCode).toBe("credentials_rejected"); + }); + + it("classifies 429 upstream as rate_limited", async () => { + vi.mocked(getValidToken).mockResolvedValueOnce({ + accessToken: "plain-access", + connection: { id: "wc-1", whoopUserId: "wu-1" }, + }); + vi.mocked(prisma.whoopConnection.findUnique).mockResolvedValueOnce({ + lastSyncedAt: null, + } as never); + (global.fetch as unknown as ReturnType).mockResolvedValueOnce( + new Response(JSON.stringify({ error: "rate limited" }), { status: 429 }), + ); + const response = await POST(emptyRequest() as never); + expect(response.status).toBe(502); + const body = (await response.json()) as ApiErrorEnvelope & { + meta?: { errorCode?: string }; + }; + expect(body.meta?.errorCode).toBe("rate_limited"); + }); + + it("classifies 500 upstream as upstream_error", async () => { + vi.mocked(getValidToken).mockResolvedValueOnce({ + accessToken: "plain-access", + connection: { id: "wc-1", whoopUserId: "wu-1" }, + }); + vi.mocked(prisma.whoopConnection.findUnique).mockResolvedValueOnce({ + lastSyncedAt: null, + } as never); + (global.fetch as unknown as ReturnType).mockResolvedValueOnce( + new Response("oops", { status: 503 }), + ); + const response = await POST(emptyRequest() as never); + expect(response.status).toBe(502); + const body = (await response.json()) as ApiErrorEnvelope & { + meta?: { errorCode?: string }; + }; + expect(body.meta?.errorCode).toBe("upstream_error"); + }); + + it("does not leak Bearer token or upstream URL on failure", async () => { + vi.mocked(getValidToken).mockResolvedValueOnce({ + accessToken: "sk-secret", + connection: { id: "wc-1", whoopUserId: "wu-1" }, + }); + vi.mocked(prisma.whoopConnection.findUnique).mockResolvedValueOnce({ + lastSyncedAt: null, + } as never); + (global.fetch as unknown as ReturnType).mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: "Authorization: Bearer sk-secret echoed", + url: "https://api.prod.whoop.com/developer/v2/user/profile/basic", + }), + { status: 401 }, + ), + ); + const response = await POST(emptyRequest() as never); + const text = await response.text(); + expect(response.status).toBe(502); + expect(text).not.toMatch(/sk-/); + expect(text).not.toMatch(/api\.prod\.whoop\.com/); + }); +}); diff --git a/src/app/api/integrations/whoop/test/route.ts b/src/app/api/integrations/whoop/test/route.ts new file mode 100644 index 000000000..10bd6c1b2 --- /dev/null +++ b/src/app/api/integrations/whoop/test/route.ts @@ -0,0 +1,127 @@ +import type { NextRequest } from "next/server"; +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 { prisma } from "@/lib/db"; +import { getValidToken } from "@/lib/whoop/sync"; +import { safeFetch, SafeFetchError } from "@/lib/safe-fetch"; + +export const dynamic = "force-dynamic"; + +// Cheap authenticated probe — the single (non-paginated) basic-profile +// endpoint the WHOOP client already calls via `fetchProfile`. +// Base from `src/lib/whoop/client.ts` (`WHOOP_API_BASE`). +const WHOOP_PROFILE_URL = + "https://api.prod.whoop.com/developer/v2/user/profile/basic"; +const TIMEOUT_MS = 5_000; + +type CategorisedError = { code: string; message: string }; + +function categoriseHttpStatus(status: number): CategorisedError { + if (status === 401 || status === 403) { + return { + code: "credentials_rejected", + message: "WHOOP rejected the credentials", + }; + } + if (status === 429) { + return { + code: "rate_limited", + message: "WHOOP rate-limited the request", + }; + } + if (status >= 500) { + return { + code: "upstream_error", + message: "WHOOP returned a server error", + }; + } + return { code: "connection_failed", message: "WHOOP connection failed" }; +} + +export const POST = apiHandler(async (request: NextRequest) => { + void request; + const { user } = await requireAuth(); + annotate({ action: { name: "integrations.whoop.test" } }); + + const rl = await checkRateLimit(`whoop-test:${user.id}`, 5, 60_000); + if (!rl.allowed) { + return apiError("Too many test requests", 429, { + errorCode: "rate_limited_self", + }); + } + + const tokenInfo = await getValidToken(user.id); + if (!tokenInfo) { + return apiError("WHOOP not connected", 422, { + errorCode: "not_configured", + }); + } + + const connection = await prisma.whoopConnection.findUnique({ + where: { userId: user.id }, + select: { lastSyncedAt: true }, + }); + + const start = performance.now(); + + try { + const res = await safeFetch( + WHOOP_PROFILE_URL, + { + method: "GET", + headers: { + Authorization: `Bearer ${tokenInfo.accessToken}`, + }, + }, + { timeoutMs: TIMEOUT_MS }, + ); + const latencyMs = Math.round(performance.now() - start); + + if (!res.ok) { + const cat = categoriseHttpStatus(res.status); + annotate({ + meta: { + whoop_test_status: res.status, + whoop_test_code: cat.code, + whoop_test_latency_ms: latencyMs, + }, + }); + return apiError(cat.message, 502, { errorCode: cat.code }); + } + + return apiSuccess({ + ok: true, + lastSyncedAt: connection?.lastSyncedAt ?? null, + latencyMs, + }); + } catch (e) { + const err = e as Error; + // safeFetch wraps timeouts into SafeFetchError{kind:"timeout"}; + // legacy AbortError name is preserved for compatibility with any + // future direct-fetch path that still reaches this catch. + const isAbort = + (e instanceof SafeFetchError && e.kind === "timeout") || + err.name === "AbortError"; + const code = isAbort ? "timeout" : "connection_failed"; + annotate({ + meta: { + whoop_test_code: code, + whoop_test_error: redact(err.message).slice(0, 300), + }, + }); + return apiError( + isAbort ? "WHOOP request timed out" : "WHOOP connection failed", + 502, + { errorCode: code }, + ); + } +}); + +// Strip any `Bearer ` substring before annotating; the catch path can +// see DNS / TLS errors that include the request init via err.cause in some +// runtimes, and we never want a token in Loki. +function redact(text: string): string { + return text.replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]"); +} diff --git a/src/components/settings/integrations-section.tsx b/src/components/settings/integrations-section.tsx index ad2c66f3a..df5452da3 100644 --- a/src/components/settings/integrations-section.tsx +++ b/src/components/settings/integrations-section.tsx @@ -749,6 +749,30 @@ function WhoopCard({ }, }); + // Clear a parked integration via the resume endpoint. The CTA is + // rendered inside the parked banner below; success invalidates both + // the per-card status (so the pill flips back to connected + // immediately) and the cross-integration envelope (so any other view + // picks up the change on its next focus). + const resume = useMutation({ + mutationFn: async () => { + const res = await fetch("/api/integrations/whoop/resume", { + method: "POST", + }); + if (!res.ok) throw new Error("Failed"); + return (await res.json()).data as { + resumed: boolean; + wasParked: boolean; + }; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.whoop() }); + queryClient.invalidateQueries({ + queryKey: queryKeys.integrationsStatus(), + }); + }, + }); + async function handleSync(fullSync = false) { setSyncing(true); setSyncMsg(null); @@ -851,6 +875,57 @@ function WhoopCard({
{errorMessage && } + {/* Parked-integration resume CTA. Surfaces only when the row + state is `parked` (>24h of persistent failures). The button + POSTs to /api/integrations/whoop/resume which calls + `resumeIntegrationFromPark`; on success the per-card status + invalidates and the pill flips back to connected without a + page refresh. Wider tap target than the inline action row so + it stays reachable on a Pixel 5 viewport. */} + {pillState === "parked" && ( +
+ + {t("settings.integrationPill.parkedReconnect")} + + +
+ )} + {resume.isError && ( +

+ {t("settings.integrationPill.resumeError")} +

+ )} + {resume.isSuccess && resume.data?.wasParked && ( +

+ {t("settings.integrationPill.resumeSuccess")} +

+ )}

@@ -952,7 +1027,11 @@ function WhoopCard({ @@ -973,6 +1052,10 @@ function WhoopCard({ +