mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
Each of these made the server report something that was not so, and each now
has a test that fails without the fix.
The Withings credential DELETE wrote no audit row and left the ledger at
whatever state it last held. 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. A client reading `state` after the teardown
was told `connected` about a connection that no longer existed.
Oura's and Strava's credential DELETEs audited only when an access token
happened to be present, so removing a stored pair that had never completed
OAuth left no trail at all. Polar's twin has always done both unconditionally;
they match it now. The old Oura test asserted the skip, which is why the gap
survived a rewrite — that expectation is inverted here, with the reason next to
it.
The Withings and WHOOP manual syncs had no rate limiter, while `fullSync` walks
full history against a per-user provider budget. The Fitbit route's own comment
records that it was the outlier until it got one; these two were never brought
along. Both now carry the same 5/60s baseline and 1/hour full-sync buckets as
their siblings.
Every sync route parsed its body inside a catch commented "no body provided",
which was true for one of the three ways to reach it. An absent body still
means an incremental run — that is the documented shape and the web cards send
it. A body that is present and unparseable is now 400, and one that fails the
schema is 422: `{"fullSync": "true"}` used to be 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. The four
copies collapse into one helper.
The consolidated integration status swallowed a failed freshness query into an
empty array, which is exactly what a record with nothing in it also produces —
so the signal built to say "this metric has gone quiet" answered "nothing has
gone quiet" when it had failed to look. `metricFreshnessDegraded` separates the
two, as one flag rather than one per entry, because it is a single query for all
eight providers.
The contract follows the behaviour in the same commit.
105 lines
3.4 KiB
TypeScript
105 lines
3.4 KiB
TypeScript
import { prisma } from "@/lib/db";
|
|
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
|
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
|
import { annotate } from "@/lib/logging/context";
|
|
import { auditLog } from "@/lib/auth/audit";
|
|
import { markDisconnected } from "@/lib/integrations/status";
|
|
import {
|
|
storeStravaClientCredentials,
|
|
clearStravaClientCredentials,
|
|
} from "@/lib/strava/credentials";
|
|
import { stravaCredentialsSchema } from "@/lib/validations/strava";
|
|
import { NextRequest } from "next/server";
|
|
import { z } from "zod/v4";
|
|
|
|
/**
|
|
* v1.28.x — per-user Strava BYO-key credentials.
|
|
*
|
|
* Whether the user has their own Strava client id/secret stored. The
|
|
* connect/callback/status routes resolve credentials DB-first then env, so an
|
|
* unset pair simply falls back to the shared env app.
|
|
*/
|
|
export const GET = apiHandler(async () => {
|
|
const { user } = await requireAuth();
|
|
annotate({ action: { name: "strava.credentials.get" } });
|
|
|
|
const dbUser = await prisma.user.findUnique({
|
|
where: { id: user.id },
|
|
select: {
|
|
stravaClientIdEncrypted: true,
|
|
stravaClientSecretEncrypted: true,
|
|
},
|
|
});
|
|
|
|
return apiSuccess({
|
|
hasCredentials:
|
|
!!dbUser?.stravaClientIdEncrypted &&
|
|
!!dbUser?.stravaClientSecretEncrypted,
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Save Strava OAuth client credentials (encrypted at rest).
|
|
*/
|
|
export const PUT = apiHandler(async (request: NextRequest) => {
|
|
const { user } = await requireAuth();
|
|
annotate({ action: { name: "strava.credentials.update" } });
|
|
|
|
const { data: body, error: jsonError } = await safeJson(request, {
|
|
maxBytes: 64 * 1024,
|
|
});
|
|
if (jsonError) return jsonError;
|
|
|
|
const result = z.safeParse(stravaCredentialsSchema, body);
|
|
if (!result.success) {
|
|
return apiError("Client ID and Client Secret are required", 422);
|
|
}
|
|
|
|
await storeStravaClientCredentials(
|
|
user.id,
|
|
result.data.clientId,
|
|
result.data.clientSecret,
|
|
);
|
|
|
|
return apiSuccess({ updated: true });
|
|
});
|
|
|
|
/**
|
|
* 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 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" } });
|
|
|
|
await prisma.user.update({
|
|
where: { id: user.id },
|
|
data: {
|
|
stravaAccessTokenEncrypted: null,
|
|
stravaRefreshTokenEncrypted: null,
|
|
stravaAthleteId: null,
|
|
},
|
|
});
|
|
await clearStravaClientCredentials(user.id);
|
|
|
|
await auditLog("strava.credentials.delete", { userId: user.id });
|
|
await markDisconnected(user.id, "strava");
|
|
|
|
return apiSuccess({ deleted: true });
|
|
});
|