mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
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.
This commit is contained in:
1 parent
89634e20ee
commit
75eb0637e6
49 files changed
+2683
-15
No files matched your search
@@ -30,6 +30,7 @@ import {
|
||||
} from "@/lib/integrations/metric-freshness";
|
||||
import { getOuraClientCredentials } from "@/lib/oura/credentials";
|
||||
import { getPolarClientCredentials } from "@/lib/polar/credentials";
|
||||
import { getStravaClientCredentials } from "@/lib/strava/credentials";
|
||||
import { hasActivityScope } from "@/lib/withings/client";
|
||||
import { readMoodLogSecret } from "@/lib/moodlog-secret";
|
||||
|
||||
@@ -47,6 +48,7 @@ export const GET = apiHandler(async () => {
|
||||
googleHealthStatus,
|
||||
polarStatus,
|
||||
ouraStatus,
|
||||
stravaStatus,
|
||||
dbUser,
|
||||
withingsConn,
|
||||
whoopConn,
|
||||
@@ -58,6 +60,7 @@ export const GET = apiHandler(async () => {
|
||||
// /api/<provider>/status the consolidated envelope now replaces.
|
||||
polarAvailable,
|
||||
ouraAvailable,
|
||||
stravaAvailable,
|
||||
] = await Promise.all([
|
||||
getIntegrationStatus(user.id, "withings"),
|
||||
getIntegrationStatus(user.id, "moodlog"),
|
||||
@@ -66,6 +69,7 @@ export const GET = apiHandler(async () => {
|
||||
getIntegrationStatus(user.id, "google-health"),
|
||||
getIntegrationStatus(user.id, "polar"),
|
||||
getIntegrationStatus(user.id, "oura"),
|
||||
getIntegrationStatus(user.id, "strava"),
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
@@ -83,6 +87,9 @@ export const GET = apiHandler(async () => {
|
||||
ouraAccessTokenEncrypted: true,
|
||||
ouraClientIdEncrypted: true,
|
||||
ouraClientSecretEncrypted: true,
|
||||
stravaAccessTokenEncrypted: true,
|
||||
stravaClientIdEncrypted: true,
|
||||
stravaClientSecretEncrypted: true,
|
||||
moodLogUrlEncrypted: true,
|
||||
moodLogApiKeyEncrypted: true,
|
||||
moodLogEnabled: true,
|
||||
@@ -133,6 +140,7 @@ export const GET = apiHandler(async () => {
|
||||
}),
|
||||
getPolarClientCredentials(user.id).then((c) => !!c),
|
||||
getOuraClientCredentials(user.id).then((c) => !!c),
|
||||
getStravaClientCredentials(user.id).then((c) => !!c),
|
||||
]);
|
||||
|
||||
// F-SYNC-1 — per-metric-type last-value timestamps so the card can show that a
|
||||
@@ -259,6 +267,18 @@ export const GET = apiHandler(async () => {
|
||||
!!dbUser?.ouraClientSecretEncrypted,
|
||||
metricFreshness: metricFreshness.oura ?? [],
|
||||
} satisfies IntegrationViewModel & OAuthProviderExtras,
|
||||
{
|
||||
...stravaStatus,
|
||||
connected: !!dbUser?.stravaAccessTokenEncrypted,
|
||||
configured: !!dbUser?.stravaAccessTokenEncrypted,
|
||||
available: stravaAvailable,
|
||||
hasOwnCredentials:
|
||||
!!dbUser?.stravaClientIdEncrypted &&
|
||||
!!dbUser?.stravaClientSecretEncrypted,
|
||||
// Strava writes Workout rows, not Measurements, so it has no
|
||||
// per-metric measurement freshness (like moodLog).
|
||||
metricFreshness: metricFreshness.strava ?? [],
|
||||
} satisfies IntegrationViewModel & OAuthProviderExtras,
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { exchangeCode } from "@/lib/strava/client";
|
||||
import { getStravaClientCredentials } from "@/lib/strava/credentials";
|
||||
import {
|
||||
oauthStateCookieName,
|
||||
stateMatchesCookie,
|
||||
verifySignedState,
|
||||
} from "@/lib/oauth/signed-state";
|
||||
import { markReconnected } from "@/lib/integrations/status";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* v1.28.x — OAuth callback from Strava.
|
||||
*
|
||||
* Identity + CSRF mirror the Polar / Oura callbacks: the `state` param is a
|
||||
* stateless HMAC-signed token, enforced as a byte-exact double-submit against
|
||||
* the httpOnly cookie AND verified for signature / expiry / `strava` provider
|
||||
* binding. The `userId` is read from inside the verified token, cross-checked
|
||||
* against the web session when present.
|
||||
*
|
||||
* On success: exchange the code, persist the encrypted access + rotated refresh
|
||||
* token + the numeric athlete id on `User`, clear any prior reauth state, and
|
||||
* redirect back to Settings. A re-auth that returns a DIFFERENT athlete id than
|
||||
* the one already stored is rejected (`cross_user`) so a second Strava account
|
||||
* cannot silently overwrite the first's grant.
|
||||
*/
|
||||
const ERR = (reason: string) =>
|
||||
NextResponse.redirect(
|
||||
new URL(
|
||||
`/settings/integrations?strava=error&reason=${reason}`,
|
||||
process.env.NEXT_PUBLIC_APP_URL!,
|
||||
),
|
||||
);
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
annotate({ action: { name: "strava.callback" } });
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const code = searchParams.get("code");
|
||||
const state = searchParams.get("state");
|
||||
const cookieState = request.cookies.get(
|
||||
oauthStateCookieName("strava"),
|
||||
)?.value;
|
||||
|
||||
if (!stateMatchesCookie(state, cookieState)) {
|
||||
annotate({ meta: { reason: "csrf1" } });
|
||||
return ERR("csrf1");
|
||||
}
|
||||
|
||||
const verified = verifySignedState("strava", state);
|
||||
if (!verified) {
|
||||
annotate({ meta: { reason: "state" } });
|
||||
return ERR("state");
|
||||
}
|
||||
const userId = verified.userId;
|
||||
|
||||
const evt = getEvent();
|
||||
if (evt) evt.setAuth({ user_id: userId, auth_method: "session" });
|
||||
|
||||
const sessionData = await getSession();
|
||||
if (sessionData && sessionData.user.id !== userId) {
|
||||
annotate({ meta: { reason: "cross_user" } });
|
||||
return ERR("cross_user");
|
||||
}
|
||||
|
||||
if (!code) return ERR("nocode");
|
||||
|
||||
try {
|
||||
const creds = await getStravaClientCredentials(userId);
|
||||
if (!creds) return ERR("nocreds");
|
||||
|
||||
const tokens = await exchangeCode(code, creds);
|
||||
|
||||
const athleteId =
|
||||
typeof tokens.athlete?.id === "number" ? String(tokens.athlete.id) : null;
|
||||
|
||||
// Reject a re-auth that swaps the connected Strava account. A different
|
||||
// athlete id than the one already pinned means a second account is trying
|
||||
// to overwrite the first's grant.
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { stravaAthleteId: true },
|
||||
});
|
||||
if (
|
||||
existing?.stravaAthleteId &&
|
||||
athleteId &&
|
||||
existing.stravaAthleteId !== athleteId
|
||||
) {
|
||||
annotate({ meta: { reason: "cross_user" } });
|
||||
return ERR("cross_user");
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
stravaAccessTokenEncrypted: encrypt(tokens.access_token),
|
||||
stravaRefreshTokenEncrypted: encrypt(tokens.refresh_token),
|
||||
stravaAthleteId: athleteId,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("strava.connect", { userId });
|
||||
await markReconnected(userId, "strava");
|
||||
|
||||
const response = NextResponse.redirect(
|
||||
new URL(
|
||||
"/settings/integrations?strava=connected",
|
||||
process.env.NEXT_PUBLIC_APP_URL!,
|
||||
),
|
||||
);
|
||||
response.cookies.delete(oauthStateCookieName("strava"));
|
||||
return response;
|
||||
} catch (err) {
|
||||
getEvent()?.setError(err);
|
||||
return ERR("token");
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getAuthorizationUrl } from "@/lib/strava/client";
|
||||
import { getStravaClientCredentials } from "@/lib/strava/credentials";
|
||||
import {
|
||||
OAUTH_STATE_TTL_MS,
|
||||
mintSignedState,
|
||||
oauthStateCookieName,
|
||||
} from "@/lib/oauth/signed-state";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* v1.28.x — redirect the user to the Strava consent screen.
|
||||
*
|
||||
* Credentials resolve DB-first then env (`getStravaClientCredentials`): a
|
||||
* user's BYO Strava client id/secret wins, falling back to the shared env app
|
||||
* (`STRAVA_CLIENT_ID` / `STRAVA_CLIENT_SECRET`) when none is stored. CSRF via
|
||||
* the stateless signed state (`src/lib/oauth/signed-state.ts`): the same token
|
||||
* is the `state` URL param AND an httpOnly cookie. Rate-limited per user.
|
||||
*/
|
||||
const CONNECT_RATE_LIMIT = 10;
|
||||
const CONNECT_WINDOW_MS = 60_000;
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "strava.connect" } });
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`strava:connect:${user.id}`,
|
||||
CONNECT_RATE_LIMIT,
|
||||
CONNECT_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
annotate({ action: { name: "strava.connect.rate_limited" } });
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
"/settings/integrations?strava=error&reason=rate_limited",
|
||||
process.env.NEXT_PUBLIC_APP_URL!,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const creds = await getStravaClientCredentials(user.id);
|
||||
if (!creds) {
|
||||
return apiError(
|
||||
"Strava integration is not configured on this server.",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const state = mintSignedState("strava", user.id);
|
||||
const url = getAuthorizationUrl(state, creds);
|
||||
|
||||
const response = NextResponse.redirect(url);
|
||||
response.cookies.set(oauthStateCookieName("strava"), state, {
|
||||
httpOnly: true,
|
||||
secure: shouldEmitSecureCookie(),
|
||||
sameSite: "lax",
|
||||
maxAge: Math.floor(OAUTH_STATE_TTL_MS / 1000),
|
||||
path: "/",
|
||||
});
|
||||
return response;
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
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 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.
|
||||
*/
|
||||
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: {
|
||||
stravaAccessTokenEncrypted: null,
|
||||
stravaRefreshTokenEncrypted: null,
|
||||
stravaAthleteId: null,
|
||||
},
|
||||
});
|
||||
await clearStravaClientCredentials(user.id);
|
||||
|
||||
if (wasConnected) {
|
||||
await auditLog("strava.credentials.delete", { userId: user.id });
|
||||
await markDisconnected(user.id, "strava");
|
||||
}
|
||||
|
||||
return apiSuccess({ deleted: true });
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { markDisconnected } from "@/lib/integrations/status";
|
||||
import { deauthorize } from "@/lib/strava/client";
|
||||
import { getStravaConnection } from "@/lib/strava/credentials";
|
||||
|
||||
/**
|
||||
* v1.28.x — disconnect Strava for the current user.
|
||||
*
|
||||
* Best-effort deauthorize at Strava, then clear the stored access + refresh
|
||||
* token + athlete id on `User` and park the integration status at
|
||||
* `disconnected`. Imported `source = STRAVA` workouts are left intact; a
|
||||
* reconnect resumes sync.
|
||||
*/
|
||||
export const POST = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "strava.disconnect" } });
|
||||
|
||||
const rl = await checkRateLimit(`strava-disconnect:${user.id}`, 20, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many requests", 429, {
|
||||
headers: rateLimitHeaders(rl),
|
||||
});
|
||||
}
|
||||
|
||||
const connection = await getStravaConnection(user.id);
|
||||
if (!connection) {
|
||||
return apiError("No Strava connection", 404);
|
||||
}
|
||||
|
||||
// Best-effort revoke at Strava; a Strava-side outage must never block the
|
||||
// local disconnect.
|
||||
try {
|
||||
await deauthorize(connection.accessToken);
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(`strava: deauthorize failed on disconnect: ${err}`);
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
stravaAccessTokenEncrypted: null,
|
||||
stravaRefreshTokenEncrypted: null,
|
||||
stravaAthleteId: null,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("strava.disconnect", { userId: user.id });
|
||||
await markDisconnected(user.id, "strava");
|
||||
|
||||
return apiSuccess({ disconnected: true });
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { getStravaClientCredentials } from "@/lib/strava/credentials";
|
||||
import { getIntegrationStatus } from "@/lib/integrations/status";
|
||||
|
||||
/**
|
||||
* v1.28.x — Strava connection status for the current user.
|
||||
*
|
||||
* `available` reports whether usable OAuth credentials resolve for this user —
|
||||
* per-user BYO keys first, then the shared env app. `hasOwnCredentials` reports
|
||||
* whether the user has stored their own BYO pair. `connected` is whether a
|
||||
* token is stored. The ledger snapshot comes off the shared `strava`
|
||||
* IntegrationKey. The access / refresh tokens + client secret are NEVER
|
||||
* returned.
|
||||
*/
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "strava.status" } });
|
||||
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
stravaAccessTokenEncrypted: true,
|
||||
stravaClientIdEncrypted: true,
|
||||
stravaClientSecretEncrypted: true,
|
||||
},
|
||||
});
|
||||
|
||||
const available = !!(await getStravaClientCredentials(user.id));
|
||||
const connected = !!dbUser?.stravaAccessTokenEncrypted;
|
||||
const hasOwnCredentials =
|
||||
!!dbUser?.stravaClientIdEncrypted && !!dbUser?.stravaClientSecretEncrypted;
|
||||
|
||||
if (!connected) {
|
||||
return apiSuccess({
|
||||
connected: false,
|
||||
configured: false,
|
||||
available,
|
||||
hasOwnCredentials,
|
||||
});
|
||||
}
|
||||
|
||||
const status = await getIntegrationStatus(user.id, "strava");
|
||||
|
||||
return apiSuccess({
|
||||
connected: true,
|
||||
configured: true,
|
||||
available,
|
||||
hasOwnCredentials,
|
||||
state: status.state,
|
||||
lastSuccessAt: status.lastSuccessAt,
|
||||
lastAttemptAt: status.lastAttemptAt,
|
||||
lastError: status.lastError,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
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 { getStravaConnection } from "@/lib/strava/credentials";
|
||||
import { safeFetch, SafeFetchError } from "@/lib/safe-fetch";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Lightweight token probe — fetch the authenticated athlete, the cheapest
|
||||
// authenticated Strava call that confirms the stored grant is still valid.
|
||||
// Reuses `getStravaConnection`; no new credential path.
|
||||
const STRAVA_ATHLETE_URL = "https://www.strava.com/api/v3/athlete";
|
||||
const TIMEOUT_MS = 5_000;
|
||||
|
||||
function categoriseHttpStatus(status: number): {
|
||||
code: string;
|
||||
message: string;
|
||||
} {
|
||||
if (status === 401 || status === 403) {
|
||||
return {
|
||||
code: "credentials_rejected",
|
||||
message: "Strava rejected the token",
|
||||
};
|
||||
}
|
||||
if (status === 429) {
|
||||
return { code: "rate_limited", message: "Strava rate-limited the request" };
|
||||
}
|
||||
if (status >= 500) {
|
||||
return {
|
||||
code: "upstream_error",
|
||||
message: "Strava returned a server error",
|
||||
};
|
||||
}
|
||||
return { code: "connection_failed", message: "Strava connection failed" };
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "strava.test" } });
|
||||
|
||||
const rl = await checkRateLimit(`strava-test:${user.id}`, 5, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many test requests", 429, {
|
||||
errorCode: "rate_limited_self",
|
||||
});
|
||||
}
|
||||
|
||||
const connection = await getStravaConnection(user.id);
|
||||
if (!connection) {
|
||||
return apiError("Strava not connected", 422, {
|
||||
errorCode: "not_configured",
|
||||
});
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
|
||||
try {
|
||||
const res = await safeFetch(
|
||||
STRAVA_ATHLETE_URL,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${connection.accessToken}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
},
|
||||
{ timeoutMs: TIMEOUT_MS },
|
||||
);
|
||||
const latencyMs = Math.round(performance.now() - start);
|
||||
|
||||
if (!res.ok) {
|
||||
const cat = categoriseHttpStatus(res.status);
|
||||
annotate({
|
||||
meta: { strava_test_status: res.status, strava_test_code: cat.code },
|
||||
});
|
||||
return apiError(cat.message, 502, { errorCode: cat.code });
|
||||
}
|
||||
|
||||
return apiSuccess({ ok: true, latencyMs });
|
||||
} catch (e) {
|
||||
const isTimeout = e instanceof SafeFetchError && e.kind === "timeout";
|
||||
const code = isTimeout ? "timeout" : "connection_failed";
|
||||
annotate({ meta: { strava_test_code: code } });
|
||||
return apiError(
|
||||
isTimeout ? "Strava request timed out" : "Strava connection failed",
|
||||
502,
|
||||
{ errorCode: code },
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user