Merge the auth and integration route groups

Two component-id collisions, both from the same cause: two paths that
are two doors onto one action, documented independently.

DELETE /api/devices/{id} and DELETE /api/auth/me/devices/{id} both call
revokeDeviceCascade and answer with the same shape. Each grew its own
DeviceRevokeResponse, which the emitter refuses outright. The response
moves to shared.ts so neither module owns it and the next door onto the
same call finds it; the two envelopes keep separate names because an
envelope names an operation.

The settings module took both halves by hand rather than by keep-both:
one side added nine paths, the other three, with no overlap but a shared
import block and a shared reminder-thresholds entry that a mechanical
union duplicated.
This commit is contained in:
Marc-André Bombeck committed 2026-08-22 13:26:45 +02:00
commit 30930b46d0
34 files changed
+4292 -143

No files matched your search

+4 -12
View File
@@ -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
+1 -12
View File
@@ -6,21 +6,10 @@ import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { encrypt } from "@/lib/crypto";
import { markDisconnected } from "@/lib/integrations/status";
import { googleHealthCredentialsSchema } from "@/lib/validations/google-health";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
/**
* Per-user Google Health BYO-key credentials. Each self-hoster registers their
* own Google Cloud OAuth client (the Restricted-scope brand verification + CASA
* assessment is per-OAuth-client, so a single shared app is unworkable for a
* multi-operator product) and pastes the client id/secret into Settings. Stored
* encrypted on `User`.
*/
const googleHealthCredentialsSchema = z.object({
clientId: z.string().min(1).max(200),
clientSecret: z.string().min(1).max(200),
});
/**
* Check whether the user has Google Health BYO-key credentials stored
* (v1.26.0).
+4 -12
View File
@@ -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.
@@ -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<Entry[]> {
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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue(
[],
);
(prisma.workout.groupBy as ReturnType<typeof vi.fn>).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([]);
}
});
});
+19 -3
View File
@@ -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<Record<IntegrationKey, MetricFreshnessSample[]>>,
);
//
// 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<Record<IntegrationKey, MetricFreshnessSample[]>>;
});
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),
+6 -12
View File
@@ -18,7 +18,6 @@
* every REST write) — it only admits the confirmed in-process write tools.
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
@@ -33,16 +32,11 @@ import { issueApiToken } from "@/lib/auth/issue-token";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { prisma } from "@/lib/db";
import { SCOPE_HEALTH_READ, SCOPE_HEALTH_WRITE } from "@/lib/mcp/oauth/config";
const createSchema = z.object({
name: z.string().min(1, "Name required").max(100),
expiresInDays: z.number().int().min(1).max(365).optional(),
// The ONLY two scope shapes this endpoint will mint. `read` (default) is the
// least-privilege read token; `read_write` adds `health:write` so the
// confirmed `/mcp` write tools become available. Anything else is rejected
// by the enum — the endpoint can never mint a wildcard or arbitrary grant.
scope: z.enum(["read", "read_write"]).optional().default("read"),
});
// The ONLY two scope shapes this endpoint will mint. `read` (default) is the
// least-privilege read token; `read_write` adds `health:write` so the confirmed
// `/mcp` write tools become available. Anything else is rejected by the enum —
// the endpoint can never mint a wildcard or arbitrary grant.
import { createMcpTokenSchema } from "@/lib/validations/mcp";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
@@ -89,7 +83,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
if (jsonError) return jsonError;
const parsed = createSchema.safeParse(body);
const parsed = createMcpTokenSchema.safeParse(body);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422);
}
+2 -6
View File
@@ -1,5 +1,4 @@
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
@@ -7,6 +6,7 @@ import { apiSuccess, apiError } from "@/lib/api-response";
import { CHANNEL_TYPE_LABELS, EVENT_TYPES } from "@/lib/notifications/types";
import type { ChannelType, EventType } from "@/lib/notifications/types";
import { reEnableChannel } from "@/lib/notifications/channel-state";
import { reEnableChannelSchema } from "@/lib/validations/notifications";
export const dynamic = "force-dynamic";
@@ -131,10 +131,6 @@ export const GET = apiHandler(async () => {
return apiSuccess({ channels: data, events });
});
const reEnableSchema = z.object({
channelId: z.string().min(1).max(64),
});
/**
* POST /api/notifications/status
*
@@ -159,7 +155,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("Invalid JSON data", 422);
}
const parsed = reEnableSchema.safeParse(body);
const parsed = reEnableChannelSchema.safeParse(body);
if (!parsed.success) return apiError("Invalid data", 422);
const channel = await prisma.notificationChannel.findFirst({
@@ -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");
});
});
+12 -13
View File
@@ -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 });
});
@@ -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: <T extends (...args: unknown[]) => 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<typeof vi.fn>;
const userUpdate = prisma.user.update as ReturnType<typeof vi.fn>;
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<RouteResult>;
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,
},
}),
);
});
});
+16 -13
View File
@@ -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 });
});
+80 -14
View File
@@ -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<typeof vi.fn>;
const limit = checkRateLimit as ReturnType<typeof vi.fn>;
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<Response>;
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();
});
});
+30 -10
View File
@@ -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
@@ -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();
});
});
+12
View File
@@ -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 });
});
@@ -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: <T extends (...args: unknown[]) => 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<typeof vi.fn>;
const limit = checkRateLimit as ReturnType<typeof vi.fn>;
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<Response>;
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();
});
});
+30 -10
View File
@@ -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 });