Files
HealthLog/src/lib/strava/response-classifier.ts
T
Marc-André Bombeck 75eb0637e6 feat(strava): add per-user BYO-OAuth workout source + Garmin indirect-path docs
Add Strava as a per-user BYO-OAuth workout source, mirroring the shipped
WHOOP/Oura/Polar pattern. Strava caps every new app at single-player mode, so
each self-hoster registers their own app and pastes the client id/secret into
Settings (falling back to a shared env app). Strava is a workout source only —
it exposes no daily metrics and no training-depth analytics are derived.

- Schema (migration 0233): per-user encrypted client id/secret + access/refresh
  tokens, plaintext athlete id, incremental cursor, and backfill marker on
  `User`; `STRAVA` added to `MeasurementSource`.
- Token model follows Oura's reactive-refresh-on-401 with a compare-and-set on
  the stored refresh ciphertext, because Strava rotates its refresh token on
  every refresh.
- Client over safeFetch: authorization-code + refresh, activity list + budget-
  aware per-activity detail (for calories/HR), honouring the 200/15min cap; each
  activity maps to one `Workout` row. Activity name/description stay in metadata
  as data, never surfaced or interpolated.
- Dedup reuses the existing source-agnostic two-layer picker: `STRAVA` slots
  into the workout ladder below the device-native wearables and above
  MANUAL/IMPORT, so an Apple-Watch + Strava twin collapses to one canonical row.
- Poll-cohort hourly sync (:17) + self-converging boot backfill, wired into the
  integration-sync registrar. Webhook deferred.
- OAuth routes (connect/callback/credentials/disconnect/status/test) with signed
  state, cross-account re-auth rejection, and a surgical `www.strava.com`
  connect-src gated to the Strava surfaces.
- Settings card + consolidated status envelope entry; env + compose whitelist;
  i18n across all six locales.
- Garmin: no direct connector is possible (business-partner-only). Add
  docs/integrations/garmin.md documenting the Apple Health / Google Health
  Connect path and Garmin's withheld proprietary scores, cross-linked from both
  platform docs, plus a non-connector info note in the Connections panel.

Regenerate docs/api/openapi.yaml for the new `STRAVA` MeasurementSource value.
2026-07-09 21:25:25 +02:00

57 lines
1.9 KiB
TypeScript

/**
* v1.28.x — off-response classifier for Strava API v3 replies.
* HTTP-status-driven; the shared core lives in
* `src/lib/integrations/http-status-classifier.ts` and is re-exported here
* under the Strava-specific names callers already use.
*
* - `success` → HTTP 2xx
* - `transient` → 429 (rate-limited — respect X-RateLimit headers),
* 5xx, 3xx, unknown.
* - `reauth_required` → 401 / 403 — token rejected / grant revoked, plus an
* `invalid_grant` on the refresh endpoint.
* - `persistent` → any other 4xx — malformed request / contract drift.
*/
import {
classifyHttpStatus,
classifyIntegrationError,
IntegrationApiError,
isOAuthInvalidGrant,
type ClassifiedHttpResponse,
type IntegrationClassification,
} from "@/lib/integrations/http-status-classifier";
export function classifyStravaResponse(
httpStatus: number,
): ClassifiedHttpResponse {
return classifyHttpStatus(httpStatus);
}
export class StravaApiError extends IntegrationApiError {
constructor(opts: {
verb: string;
classification: IntegrationClassification;
httpStatus: number | undefined;
reason: string;
upstreamError?: string;
}) {
super({ vendor: "Strava", ...opts });
this.name = "StravaApiError";
}
}
/**
* True when a caught error is an OAuth `invalid_grant` on the token endpoint —
* the canonical revoked / rotated-away refresh-token signal. Lifts a 400
* carrying `invalid_grant` from `persistent` to `reauth_required` so the
* connection surfaces the reconnect prompt.
*/
function isInvalidGrant(err: unknown): boolean {
return isOAuthInvalidGrant(err, "Strava");
}
export function classifyStravaError(err: unknown): IntegrationClassification {
if (isInvalidGrant(err)) return "reauth_required";
return classifyIntegrationError(err, "Strava");
}