mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Land the OAuth + credentials half of the Fitbit / Google Health provider, cloning the WHOOP provider structure and adapting it to the Google identity flow. - Client OAuth half (src/lib/fitbit/client.ts): Google authorize-URL builder (access_type=offline + prompt=consent), authorization-code exchange, token refresh, and a profile fetch for the connection's external user id. Client credentials ride in an HTTP Basic-auth header on the token endpoint; the four launch Restricted read scopes are pinned in FITBIT_OAUTH_SCOPE. All egress goes through safeFetch. - Non-rotating refresh tokens: getValidToken refreshes at expiry-minus-five- minutes, persists the new access token + expiry, and overwrites the stored refresh token ONLY when the response carries a fresh one — Google keeps the refresh token stable, unlike WHOOP. - BYO credentials route (PUT/GET/DELETE /api/fitbit/credentials): per-user client id/secret stored encrypted on the User columns, built field-by-field under requireAuth. - OAuth state + connect + callback routes: a random base64url nonce backed by a 10-minute FitbitOAuthState ledger row carries the (nonce -> userId) mapping; the callback validates the cookie/URL state with timingSafeEqual, consumes the row atomically (replay/expired/cross-user reason tags), exchanges the code, upserts the encrypted FitbitConnection, clears prior reauth state, and enqueues the self-converging history backfill. - Response classifier mirrors WHOOP's HTTP-status buckets; Google signals a revoked grant with a 401, so there is no invalid_grant body special-case. Tests cover the authorize URL, Basic-auth token exchange, the no-rotation refresh branch (token preserved when omitted, overwritten when present), the classifier status table, and the OAuth-state nonce mint. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
161 lines
6.3 KiB
TypeScript
161 lines
6.3 KiB
TypeScript
/**
|
|
* Off-response classifier for Fitbit / Google Health API replies (v1.12.0).
|
|
*
|
|
* Mirrors the WHOOP classifier (`src/lib/whoop/response-classifier.ts`) but
|
|
* speaks the Google Health wire. Like WHOOP, Google signals failure through the
|
|
* HTTP status code itself (no `status` field embedded in a 200 body), so this
|
|
* classifier is HTTP-status-driven.
|
|
*
|
|
* Classification buckets:
|
|
*
|
|
* - `success` → HTTP 2xx
|
|
* - `transient` → retryable: HTTP 429 (rate-limited — honour
|
|
* `Retry-After` for backoff at the call site), 5xx
|
|
* upstream outages, 3xx CDN/network glitches, and an
|
|
* empty / off-spec body where we never reached the
|
|
* envelope.
|
|
* - `reauth_required` → HTTP 401 / 403 — the access token is rejected or the
|
|
* grant was revoked; the user must redo OAuth. (The
|
|
* per-data-class 403 soft-skip lives in `sync.ts`'s
|
|
* `isCollectionForbidden`, not here, so a Restricted
|
|
* scope granted independently doesn't park the whole
|
|
* connection.)
|
|
* - `persistent` → any other 4xx (400, 404, 422, …) — a malformed
|
|
* request or a contract mismatch that retrying without
|
|
* operator intervention will not fix.
|
|
*
|
|
* The classifier is conservative: anything it doesn't recognise defaults to
|
|
* `transient` so a single unknown response doesn't hard-disable the
|
|
* integration. The 3-strike admin-alert ladder in `recordSyncFailure` already
|
|
* catches the "keeps happening" case for recurring transients.
|
|
*
|
|
* Unlike WHOOP, there is no `invalid_grant` token-body special-case: Google
|
|
* signals a revoked refresh token with a 401 on the token endpoint, which the
|
|
* status-driven classifier already buckets as `reauth_required`.
|
|
*/
|
|
|
|
/** Outcome buckets for a Fitbit response. Same shape as WHOOP. */
|
|
export type FitbitClassification =
|
|
| "success"
|
|
| "transient"
|
|
| "reauth_required"
|
|
| "persistent";
|
|
|
|
export interface ClassifiedFitbitResponse {
|
|
classification: FitbitClassification;
|
|
/** The HTTP status code that drove the verdict (undefined if none seen). */
|
|
httpStatus: number | undefined;
|
|
/** Short human-readable label for logs / audit details. */
|
|
reason: string;
|
|
}
|
|
|
|
/**
|
|
* Classify a single Fitbit / Google Health API response by its HTTP status.
|
|
*
|
|
* @param httpStatus The HTTP status from fetch.
|
|
*/
|
|
export function classifyFitbitResponse(
|
|
httpStatus: number,
|
|
): ClassifiedFitbitResponse {
|
|
if (httpStatus >= 200 && httpStatus < 300) {
|
|
return { classification: "success", httpStatus, reason: "ok" };
|
|
}
|
|
// 429 is rate-limit — honour `Retry-After` for backoff at the call site, but
|
|
// the verdict is a plain transient so the next sync retries.
|
|
if (httpStatus === 429) {
|
|
return { classification: "transient", httpStatus, reason: "http_429" };
|
|
}
|
|
// 401/403 — the bearer token is rejected or the grant revoked. The user has
|
|
// to reconnect; do not retry silently. (A per-data-class 403 soft-skip is
|
|
// applied in sync.ts before this verdict drives a connection-wide reauth.)
|
|
if (httpStatus === 401 || httpStatus === 403) {
|
|
return {
|
|
classification: "reauth_required",
|
|
httpStatus,
|
|
reason: `http_${httpStatus}`,
|
|
};
|
|
}
|
|
// Any other 4xx is a malformed request / contract mismatch — persistent.
|
|
if (httpStatus >= 400 && httpStatus < 500) {
|
|
return {
|
|
classification: "persistent",
|
|
httpStatus,
|
|
reason: `http_${httpStatus}`,
|
|
};
|
|
}
|
|
// 5xx upstream outage — retryable.
|
|
if (httpStatus >= 500 && httpStatus < 600) {
|
|
return {
|
|
classification: "transient",
|
|
httpStatus,
|
|
reason: `http_${httpStatus}`,
|
|
};
|
|
}
|
|
// 3xx (a CDN / redirect glitch — safeFetch uses redirect:"manual", so a
|
|
// surfaced 3xx is unexpected) and everything else defaults to transient.
|
|
return {
|
|
classification: "transient",
|
|
httpStatus,
|
|
reason: `http_${httpStatus}_unknown`,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Typed Error subclass carrying the classification verdict so downstream
|
|
* `try/catch` blocks can branch without re-parsing the message. Mirrors
|
|
* `WhoopApiError`.
|
|
*/
|
|
export class FitbitApiError extends Error {
|
|
readonly classification: FitbitClassification;
|
|
readonly httpStatus: number | undefined;
|
|
readonly reason: string;
|
|
readonly verb: string;
|
|
/**
|
|
* The OAuth `error` code from a token-endpoint failure body (e.g.
|
|
* `invalid_grant`). Captured for the audit trail; the classifier does not
|
|
* branch on it (Google signals a revoked grant with a 401 status).
|
|
*/
|
|
readonly upstreamError: string | undefined;
|
|
|
|
constructor(opts: {
|
|
verb: string;
|
|
classification: FitbitClassification;
|
|
httpStatus: number | undefined;
|
|
reason: string;
|
|
upstreamError?: string;
|
|
}) {
|
|
const statusLabel =
|
|
typeof opts.httpStatus === "number" ? opts.httpStatus : "?";
|
|
const errSegment = opts.upstreamError ? ` - ${opts.upstreamError}` : "";
|
|
// Cap the message at 1024 chars in the constructor (same bound as
|
|
// WhoopApiError) so a misbehaving upstream returning a multi-MB error body
|
|
// can't bloat an AuditLog row downstream.
|
|
const raw = `Fitbit ${opts.verb} error: ${statusLabel}${errSegment}`;
|
|
super(raw.slice(0, 1024));
|
|
this.name = "FitbitApiError";
|
|
this.verb = opts.verb;
|
|
this.classification = opts.classification;
|
|
this.httpStatus = opts.httpStatus;
|
|
this.reason = opts.reason;
|
|
this.upstreamError = opts.upstreamError;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read the classification from any caught error. Returns `transient` for a
|
|
* non-`FitbitApiError` input so a call site surfacing a plain `Error` retries
|
|
* rather than permanently disabling the integration. Falls back to parsing the
|
|
* HTTP status out of the legacy `"Fitbit <verb> error: <status>"` message shape
|
|
* for callers that lose the original prototype across a pg-boss retry.
|
|
*/
|
|
export function classifyFitbitError(err: unknown): FitbitClassification {
|
|
if (err instanceof FitbitApiError) return err.classification;
|
|
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
const m = /Fitbit\s+\w+\s+error:\s*(\d+)/.exec(msg);
|
|
if (!m) return "transient";
|
|
const status = Number.parseInt(m[1]!, 10);
|
|
if (!Number.isFinite(status)) return "transient";
|
|
return classifyFitbitResponse(status).classification;
|
|
}
|