Files
HealthLog/src/app/api/fitbit/sync/route.ts
T
Marc-André Bombeck e748a91a36 feat(fitbit): wire PKCE through connect/callback and fix the test probe
Connect mints the PKCE pair, stashes the verifier on the OAuth-state row, and
sends the S256 challenge to the authorize endpoint. Callback reads the verifier
back and presents it on the token exchange, refusing cleanly when it is absent.
The connection-test probe hits the classic /1/user/-/profile.json endpoint.
2026-06-22 07:21:06 +02:00

64 lines
2.3 KiB
TypeScript

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 { syncUserFitbit } from "@/lib/fitbit/sync";
import { NextRequest } from "next/server";
/**
* Manually trigger a Fitbit sync for the current user.
*
* Mirrors the WHOOP manual-sync route: incremental by default, full history when
* `{ fullSync: true }` is posted.
*
* Rate-limited: every sibling Fitbit route (connect / test / resume) is limited;
* this one was the outlier, while `{ fullSync: true }` drives four resource syncs
* over the classic Web API's tight 150 req/h-per-user budget. A tight loop or a
* stolen native token could exhaust that budget and pin Prisma. A baseline 5/60s
* bucket (matching the test route) 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: "fitbit.sync" } });
const rl = await checkRateLimit(`fitbit-sync:${user.id}`, 5, 60_000);
if (!rl.allowed) {
return apiError("Too many sync requests", 429, {
errorCode: "rate_limited_self",
});
}
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
}
// The full-history walk is the expensive path (four paginated resource
// walkers); cap it well below the incremental bucket so a re-trigger loop
// cannot churn the Google quota.
if (fullSync) {
const fullRl = await checkRateLimit(
`fitbit-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 imported = await syncUserFitbit(user.id, { fullSync });
return apiSuccess({ imported, fullSync });
});