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;