Merge pull request #162 from trails-cool/feat/wahoo-import

Add Wahoo activity sync with provider-agnostic framework
This commit is contained in:
Ullrich Schäfer 2026-04-04 11:24:24 +01:00 committed by GitHub
commit 4c5aacf223
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1124 additions and 5 deletions

View file

@ -0,0 +1,68 @@
import { randomUUID } from "node:crypto";
import { eq, and } from "drizzle-orm";
import { getDb } from "../db.ts";
import { syncConnections } from "@trails-cool/db/schema/journal";
import type { TokenSet } from "./types.ts";
export async function saveConnection(
userId: string,
provider: string,
tokens: TokenSet,
) {
const db = getDb();
// Upsert: delete existing connection for this user+provider, then insert
await db
.delete(syncConnections)
.where(and(eq(syncConnections.userId, userId), eq(syncConnections.provider, provider)));
await db.insert(syncConnections).values({
id: randomUUID(),
userId,
provider,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: tokens.expiresAt,
providerUserId: tokens.providerUserId ?? null,
});
}
export async function getConnection(userId: string, provider: string) {
const db = getDb();
const [conn] = await db
.select()
.from(syncConnections)
.where(and(eq(syncConnections.userId, userId), eq(syncConnections.provider, provider)));
return conn ?? null;
}
export async function getConnectionByProviderUser(provider: string, providerUserId: string) {
const db = getDb();
const [conn] = await db
.select()
.from(syncConnections)
.where(
and(eq(syncConnections.provider, provider), eq(syncConnections.providerUserId, providerUserId)),
);
return conn ?? null;
}
export async function updateTokens(
connectionId: string,
tokens: TokenSet,
) {
const db = getDb();
await db
.update(syncConnections)
.set({
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: tokens.expiresAt,
})
.where(eq(syncConnections.id, connectionId));
}
export async function deleteConnection(userId: string, provider: string) {
const db = getDb();
await db
.delete(syncConnections)
.where(and(eq(syncConnections.userId, userId), eq(syncConnections.provider, provider)));
}

View file

@ -0,0 +1,59 @@
import { randomUUID } from "node:crypto";
import { eq, and, inArray } from "drizzle-orm";
import { getDb } from "../db.ts";
import { syncImports } from "@trails-cool/db/schema/journal";
export async function recordImport(
userId: string,
provider: string,
externalWorkoutId: string,
activityId: string,
) {
const db = getDb();
await db.insert(syncImports).values({
id: randomUUID(),
userId,
provider,
externalWorkoutId,
activityId,
});
}
export async function isAlreadyImported(
userId: string,
provider: string,
externalWorkoutId: string,
): Promise<boolean> {
const db = getDb();
const [row] = await db
.select({ id: syncImports.id })
.from(syncImports)
.where(
and(
eq(syncImports.userId, userId),
eq(syncImports.provider, provider),
eq(syncImports.externalWorkoutId, externalWorkoutId),
),
);
return !!row;
}
export async function getImportedIds(
userId: string,
provider: string,
externalWorkoutIds: string[],
): Promise<Set<string>> {
if (externalWorkoutIds.length === 0) return new Set();
const db = getDb();
const rows = await db
.select({ externalWorkoutId: syncImports.externalWorkoutId })
.from(syncImports)
.where(
and(
eq(syncImports.userId, userId),
eq(syncImports.provider, provider),
inArray(syncImports.externalWorkoutId, externalWorkoutIds),
),
);
return new Set(rows.map((r) => r.externalWorkoutId));
}

View file

@ -0,0 +1,184 @@
import FitParser from "fit-file-parser";
import { generateGpx } from "@trails-cool/gpx";
import type { SyncProvider, TokenSet, Workout, WorkoutList, WebhookEvent } from "../types.ts";
const WAHOO_API = "https://api.wahooligan.com";
const WAHOO_AUTH = "https://api.wahooligan.com/oauth";
const clientId = () => process.env.WAHOO_CLIENT_ID ?? "";
const clientSecret = () => process.env.WAHOO_CLIENT_SECRET ?? "";
export const wahooProvider: SyncProvider = {
id: "wahoo",
name: "Wahoo",
scopes: ["workouts_read", "user_read", "offline_data"],
getAuthUrl(redirectUri: string, state: string): string {
const params = new URLSearchParams({
client_id: clientId(),
redirect_uri: redirectUri,
response_type: "code",
scope: this.scopes.join(" "),
state,
});
return `${WAHOO_AUTH}/authorize?${params}`;
},
async exchangeCode(code: string, redirectUri: string): Promise<TokenSet> {
const resp = await fetch(`${WAHOO_AUTH}/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
client_id: clientId(),
client_secret: clientSecret(),
code,
grant_type: "authorization_code",
redirect_uri: redirectUri,
}),
});
if (!resp.ok) throw new Error(`Wahoo token exchange failed: ${resp.status}`);
const data = await resp.json() as { access_token: string; refresh_token: string; expires_in: number };
// Fetch user info to get provider user ID
const userResp = await fetch(`${WAHOO_API}/v1/user`, {
headers: { Authorization: `Bearer ${data.access_token}` },
});
const user = userResp.ok ? (await userResp.json() as { id: number }) : null;
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresAt: new Date(Date.now() + data.expires_in * 1000),
providerUserId: user?.id?.toString(),
};
},
async refreshToken(refreshToken: string): Promise<TokenSet> {
const resp = await fetch(`${WAHOO_AUTH}/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
client_id: clientId(),
client_secret: clientSecret(),
grant_type: "refresh_token",
refresh_token: refreshToken,
}),
});
if (!resp.ok) throw new Error(`Wahoo token refresh failed: ${resp.status}`);
const data = await resp.json() as { access_token: string; refresh_token: string; expires_in: number };
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresAt: new Date(Date.now() + data.expires_in * 1000),
};
},
async listWorkouts(tokens: TokenSet, page: number): Promise<WorkoutList> {
const params = new URLSearchParams({ page: String(page), per_page: "30" });
const resp = await fetch(`${WAHOO_API}/v1/workouts?${params}`, {
headers: { Authorization: `Bearer ${tokens.accessToken}` },
});
if (!resp.ok) throw new Error(`Wahoo list workouts failed: ${resp.status}`);
const data = await resp.json() as {
workouts: Array<{
id: number;
name: string;
workout_type: string;
starts: string;
workout_summary?: {
duration_active_accum?: number;
distance_accum?: number;
file?: { url?: string };
};
}>;
total: number;
page: number;
per_page: number;
};
return {
workouts: data.workouts.map((w) => ({
id: String(w.id),
name: w.name || `Workout ${w.id}`,
type: w.workout_type ?? "unknown",
startedAt: w.starts,
duration: w.workout_summary?.duration_active_accum
? Math.round(w.workout_summary.duration_active_accum)
: null,
distance: w.workout_summary?.distance_accum
? Math.round(w.workout_summary.distance_accum)
: null,
fileUrl: w.workout_summary?.file?.url,
})),
total: data.total,
page: data.page,
perPage: data.per_page,
};
},
async downloadFile(tokens: TokenSet, workout: Workout): Promise<Buffer> {
if (!workout.fileUrl) throw new Error("No file URL for workout");
const resp = await fetch(workout.fileUrl, {
headers: { Authorization: `Bearer ${tokens.accessToken}` },
});
if (!resp.ok) throw new Error(`Wahoo file download failed: ${resp.status}`);
return Buffer.from(await resp.arrayBuffer());
},
async convertToGpx(fileBuffer: Buffer): Promise<string | null> {
const parsed = await new Promise<Record<string, unknown>>((resolve, reject) => {
const parser = new FitParser({ force: true });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
parser.parse(fileBuffer as any, (error: unknown, data: any) => {
if (error) reject(error);
else resolve(data ?? {});
});
});
const records = (parsed.records ?? []) as Array<{
position_lat?: number;
position_long?: number;
altitude?: number;
timestamp?: string;
}>;
// FIT uses semicircles — convert to degrees
const SEMICIRCLE_TO_DEG = 180 / Math.pow(2, 31);
const trackPoints = records
.filter((r) => r.position_lat != null && r.position_long != null)
.map((r) => ({
lat: r.position_lat! * SEMICIRCLE_TO_DEG,
lon: r.position_long! * SEMICIRCLE_TO_DEG,
ele: r.altitude,
time: r.timestamp,
}));
if (trackPoints.length < 2) return null;
return generateGpx({
name: "Wahoo workout",
tracks: [trackPoints],
});
},
parseWebhook(body: unknown): WebhookEvent | null {
const payload = body as {
event_type?: string;
user?: { id?: number };
workout_summary?: {
id?: number;
workout?: { id?: number };
file?: { url?: string };
};
};
if (payload.event_type !== "workout_summary" || !payload.user?.id) return null;
return {
eventType: payload.event_type,
providerUserId: String(payload.user.id),
workoutId: String(payload.workout_summary?.workout?.id ?? payload.workout_summary?.id ?? ""),
fileUrl: payload.workout_summary?.file?.url,
};
},
};

View file

@ -0,0 +1,12 @@
import type { SyncProvider } from "./types.ts";
import { wahooProvider } from "./providers/wahoo.ts";
const providers: SyncProvider[] = [wahooProvider];
export function getProvider(id: string): SyncProvider | undefined {
return providers.find((p) => p.id === id);
}
export function getAllProviders(): SyncProvider[] {
return providers;
}

View file

@ -0,0 +1,46 @@
export interface TokenSet {
accessToken: string;
refreshToken: string;
expiresAt: Date;
providerUserId?: string;
}
export interface Workout {
id: string;
name: string;
type: string;
startedAt: string;
duration: number | null; // seconds
distance: number | null; // meters
fileUrl?: string;
}
export interface WorkoutList {
workouts: Workout[];
total: number;
page: number;
perPage: number;
}
export interface WebhookEvent {
eventType: string;
providerUserId: string;
workoutId: string;
fileUrl?: string;
}
export interface SyncProvider {
id: string;
name: string;
scopes: string[];
getAuthUrl(redirectUri: string, state: string): string;
exchangeCode(code: string, redirectUri: string): Promise<TokenSet>;
refreshToken(refreshToken: string): Promise<TokenSet>;
listWorkouts(tokens: TokenSet, page: number): Promise<WorkoutList>;
downloadFile(tokens: TokenSet, workout: Workout): Promise<Buffer>;
convertToGpx(fileBuffer: Buffer): Promise<string | null>;
parseWebhook(body: unknown): WebhookEvent | null;
}

View file

@ -24,6 +24,11 @@ export default [
route("api/settings/email", "routes/api.settings.email.ts"),
route("api/settings/passkey/delete", "routes/api.settings.passkey.delete.ts"),
route("api/settings/delete-account", "routes/api.settings.delete-account.ts"),
route("sync/import/:provider", "routes/sync.import.$provider.tsx"),
route("api/sync/connect/:provider", "routes/api.sync.connect.$provider.ts"),
route("api/sync/callback/:provider", "routes/api.sync.callback.$provider.ts"),
route("api/sync/disconnect/:provider", "routes/api.sync.disconnect.$provider.ts"),
route("api/sync/webhook/:provider", "routes/api.sync.webhook.$provider.ts"),
route("privacy", "routes/privacy.tsx"),
route("api/health", "routes/api.health.ts"),
route("api/metrics", "routes/api.metrics.ts"),

View file

@ -0,0 +1,30 @@
import { redirect, data } from "react-router";
import type { Route } from "./+types/api.sync.callback.$provider";
import { getSessionUser } from "~/lib/auth.server";
import { getProvider } from "~/lib/sync/registry";
import { saveConnection } from "~/lib/sync/connections.server";
export async function loader({ params, request }: Route.LoaderArgs) {
const user = await getSessionUser(request);
if (!user) return redirect("/auth/login");
const provider = getProvider(params.provider);
if (!provider) return data({ error: "Unknown provider" }, { status: 404 });
const url = new URL(request.url);
const code = url.searchParams.get("code");
if (!code) return data({ error: "Missing authorization code" }, { status: 400 });
const origin = process.env.ORIGIN ?? "http://localhost:3000";
const redirectUri = `${origin}/api/sync/callback/${params.provider}`;
try {
const tokens = await provider.exchangeCode(code, redirectUri);
await saveConnection(user.id, provider.id, tokens);
} catch (e) {
console.error(`OAuth callback failed for ${params.provider}:`, e);
return redirect("/settings?error=sync_failed");
}
return redirect("/settings");
}

View file

@ -0,0 +1,18 @@
import { redirect, data } from "react-router";
import type { Route } from "./+types/api.sync.connect.$provider";
import { getSessionUser } from "~/lib/auth.server";
import { getProvider } from "~/lib/sync/registry";
export async function loader({ params, request }: Route.LoaderArgs) {
const user = await getSessionUser(request);
if (!user) return redirect("/auth/login");
const provider = getProvider(params.provider);
if (!provider) return data({ error: "Unknown provider" }, { status: 404 });
const origin = process.env.ORIGIN ?? "http://localhost:3000";
const redirectUri = `${origin}/api/sync/callback/${params.provider}`;
const state = user.id; // Simple state — could use a CSRF token for production
return redirect(provider.getAuthUrl(redirectUri, state));
}

View file

@ -0,0 +1,16 @@
import { redirect, data } from "react-router";
import type { Route } from "./+types/api.sync.disconnect.$provider";
import { getSessionUser } from "~/lib/auth.server";
import { getProvider } from "~/lib/sync/registry";
import { deleteConnection } from "~/lib/sync/connections.server";
export async function action({ params, request }: Route.ActionArgs) {
const user = await getSessionUser(request);
if (!user) return redirect("/auth/login");
const provider = getProvider(params.provider);
if (!provider) return data({ error: "Unknown provider" }, { status: 404 });
await deleteConnection(user.id, provider.id);
return redirect("/settings");
}

View file

@ -0,0 +1,65 @@
import { data } from "react-router";
import type { Route } from "./+types/api.sync.webhook.$provider";
import { getProvider } from "~/lib/sync/registry";
import { getConnectionByProviderUser, updateTokens } from "~/lib/sync/connections.server";
import { isAlreadyImported, recordImport } from "~/lib/sync/imports.server";
import { createActivity } from "~/lib/activities.server";
export async function action({ params, request }: Route.ActionArgs) {
if (request.method !== "POST") {
return data({ error: "Method not allowed" }, { status: 405 });
}
const provider = getProvider(params.provider);
if (!provider) return data({ ok: true }); // Don't reveal provider existence
const body = await request.json();
// Verify webhook token
const expectedToken = process.env[`${params.provider.toUpperCase()}_WEBHOOK_TOKEN`];
if (expectedToken && (body as { webhook_token?: string }).webhook_token !== expectedToken) {
return data({ ok: true }); // Invalid token — ignore silently
}
const event = provider.parseWebhook(body);
if (!event) return data({ ok: true }); // Unrecognized event — ignore silently
// Look up user by provider user ID
const connection = await getConnectionByProviderUser(provider.id, event.providerUserId);
if (!connection) return data({ ok: true }); // Unknown user — ignore
// Check duplicate
const alreadyImported = await isAlreadyImported(connection.userId, provider.id, event.workoutId);
if (alreadyImported) return data({ ok: true });
try {
// Refresh token if expired
let tokens = {
accessToken: connection.accessToken,
refreshToken: connection.refreshToken,
expiresAt: connection.expiresAt,
};
if (new Date() >= tokens.expiresAt) {
tokens = await provider.refreshToken(tokens.refreshToken);
await updateTokens(connection.id, tokens);
}
// Download and convert
const workout = { id: event.workoutId, name: "", type: "", startedAt: "", duration: null, distance: null, fileUrl: event.fileUrl };
const fileBuffer = await provider.downloadFile(tokens, workout);
const gpx = await provider.convertToGpx(fileBuffer);
// Create activity
const activityId = await createActivity(connection.userId, {
name: `${provider.name} workout`,
gpx: gpx ?? undefined,
});
await recordImport(connection.userId, provider.id, event.workoutId, activityId);
} catch (e) {
console.error(`Webhook import failed for ${provider.id}/${event.workoutId}:`, e);
}
// Always return 200 to acknowledge the webhook
return data({ ok: true });
}

View file

@ -6,7 +6,8 @@ import { eq } from "drizzle-orm";
import type { Route } from "./+types/settings";
import { getSessionUser } from "~/lib/auth.server";
import { getDb } from "~/lib/db";
import { credentials } from "@trails-cool/db/schema/journal";
import { credentials, syncConnections } from "@trails-cool/db/schema/journal";
import { getAllProviders } from "~/lib/sync/registry";
export function meta() {
return [{ title: "Settings — trails.cool" }];
@ -27,6 +28,19 @@ export async function loader({ request }: Route.LoaderArgs) {
.from(credentials)
.where(eq(credentials.userId, user.id));
const connections = await db
.select({
provider: syncConnections.provider,
providerUserId: syncConnections.providerUserId,
})
.from(syncConnections)
.where(eq(syncConnections.userId, user.id));
const providers = getAllProviders().map((p) => {
const conn = connections.find((c) => c.provider === p.id);
return { id: p.id, name: p.name, connected: !!conn, providerUserId: conn?.providerUserId };
});
return data({
user: {
id: user.id,
@ -41,6 +55,7 @@ export async function loader({ request }: Route.LoaderArgs) {
transports: p.transports as string[] | null,
createdAt: p.createdAt.toISOString(),
})),
providers,
});
}
@ -54,7 +69,7 @@ function transportLabel(transports: string[] | null, t: (key: string) => string)
}
export default function Settings({ loaderData }: Route.ComponentProps) {
const { user, passkeys } = loaderData;
const { user, passkeys, providers } = loaderData;
const { t } = useTranslation(["journal", "common"]);
const profileFetcher = useFetcher();
const emailFetcher = useFetcher();
@ -268,6 +283,48 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
)}
</section>
{/* Connected Services */}
<section id="services" className="mt-12">
<h2 className="text-lg font-semibold text-gray-900">{t("settings.services.title")}</h2>
<div className="mt-4 space-y-3">
{providers.map((p) => (
<div key={p.id} className="flex items-center justify-between rounded-md border border-gray-200 px-4 py-3">
<div>
<p className="font-medium text-gray-900">{p.name}</p>
{p.connected && p.providerUserId && (
<p className="text-xs text-gray-500">{t("settings.services.connectedAs", { id: p.providerUserId })}</p>
)}
</div>
{p.connected ? (
<div className="flex items-center gap-3">
<a
href={`/sync/import/${p.id}`}
className="text-sm text-blue-600 hover:underline"
>
{t("sync.import")}
</a>
<form method="post" action={`/api/sync/disconnect/${p.id}`}>
<button
type="submit"
className="text-sm text-red-600 hover:underline"
>
{t("settings.services.disconnect")}
</button>
</form>
</div>
) : (
<a
href={`/api/sync/connect/${p.id}`}
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white hover:bg-blue-700"
>
{t("settings.services.connect")}
</a>
)}
</div>
))}
</div>
</section>
{/* Account Section */}
<section id="account" className="mt-12">
<h2 className="text-lg font-semibold text-gray-900">{t("settings.account.title")}</h2>

View file

@ -0,0 +1,180 @@
import { data, redirect } from "react-router";
import { useTranslation } from "react-i18next";
import type { Route } from "./+types/sync.import.$provider";
import { getSessionUser } from "~/lib/auth.server";
import { getProvider } from "~/lib/sync/registry";
import { getConnection, updateTokens } from "~/lib/sync/connections.server";
import { getImportedIds, recordImport } from "~/lib/sync/imports.server";
import { createActivity } from "~/lib/activities.server";
import { ClientDate } from "~/components/ClientDate";
export async function loader({ params, request }: Route.LoaderArgs) {
const user = await getSessionUser(request);
if (!user) return redirect("/auth/login");
const provider = getProvider(params.provider);
if (!provider) throw data({ error: "Unknown provider" }, { status: 404 });
const connection = await getConnection(user.id, provider.id);
if (!connection) return redirect("/settings");
const url = new URL(request.url);
const page = parseInt(url.searchParams.get("page") ?? "1");
// Refresh token if needed
let tokens = {
accessToken: connection.accessToken,
refreshToken: connection.refreshToken,
expiresAt: connection.expiresAt,
};
if (new Date() >= tokens.expiresAt) {
tokens = await provider.refreshToken(tokens.refreshToken);
await updateTokens(connection.id, tokens);
}
const workoutList = await provider.listWorkouts(tokens, page);
const importedIds = await getImportedIds(
user.id,
provider.id,
workoutList.workouts.map((w) => w.id),
);
return data({
provider: { id: provider.id, name: provider.name },
workouts: workoutList.workouts.map((w) => ({
...w,
imported: importedIds.has(w.id),
})),
page: workoutList.page,
totalPages: Math.ceil(workoutList.total / workoutList.perPage),
});
}
export async function action({ params, request }: Route.ActionArgs) {
const user = await getSessionUser(request);
if (!user) return redirect("/auth/login");
const provider = getProvider(params.provider);
if (!provider) throw data({ error: "Unknown provider" }, { status: 404 });
const connection = await getConnection(user.id, provider.id);
if (!connection) return redirect("/settings");
const formData = await request.formData();
const workoutId = formData.get("workoutId") as string;
const workoutName = formData.get("workoutName") as string;
const fileUrl = formData.get("fileUrl") as string;
// Refresh token if needed
let tokens = {
accessToken: connection.accessToken,
refreshToken: connection.refreshToken,
expiresAt: connection.expiresAt,
};
if (new Date() >= tokens.expiresAt) {
tokens = await provider.refreshToken(tokens.refreshToken);
await updateTokens(connection.id, tokens);
}
const workout = {
id: workoutId,
name: workoutName,
type: "",
startedAt: "",
duration: null,
distance: null,
fileUrl: fileUrl || undefined,
};
const fileBuffer = await provider.downloadFile(tokens, workout);
const gpx = await provider.convertToGpx(fileBuffer);
const activityId = await createActivity(user.id, {
name: workoutName || `${provider.name} workout`,
gpx: gpx ?? undefined,
});
await recordImport(user.id, provider.id, workoutId, activityId);
return data({ imported: workoutId });
}
export function meta({ data: loaderData }: Route.MetaArgs) {
const name = (loaderData as { provider: { name: string } })?.provider?.name ?? "Import";
return [{ title: `Import from ${name} — trails.cool` }];
}
export default function SyncImportPage({ loaderData, actionData }: Route.ComponentProps) {
const { provider, workouts, page, totalPages } = loaderData;
const { t } = useTranslation("journal");
const justImported = (actionData as { imported?: string })?.imported;
return (
<div className="mx-auto max-w-4xl px-4 py-8">
<h1 className="text-2xl font-bold text-gray-900">
{t("sync.importFrom", { provider: provider.name })}
</h1>
{workouts.length === 0 ? (
<p className="mt-8 text-center text-gray-500">{t("sync.noWorkouts")}</p>
) : (
<ul className="mt-6 space-y-3">
{workouts.map((w) => (
<li
key={w.id}
className="flex items-center justify-between rounded-lg border border-gray-200 p-4"
>
<div>
<p className="font-medium text-gray-900">{w.name}</p>
<div className="mt-1 flex gap-3 text-sm text-gray-500">
{w.startedAt && <ClientDate iso={w.startedAt} />}
{w.distance != null && <span>{(w.distance / 1000).toFixed(1)} km</span>}
{w.duration != null && <span>{Math.round(w.duration / 60)} min</span>}
</div>
</div>
{w.imported || justImported === w.id ? (
<span className="text-sm text-green-600">{t("sync.imported")}</span>
) : (
<form method="post">
<input type="hidden" name="workoutId" value={w.id} />
<input type="hidden" name="workoutName" value={w.name} />
<input type="hidden" name="fileUrl" value={w.fileUrl ?? ""} />
<button
type="submit"
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white hover:bg-blue-700"
>
{t("sync.import")}
</button>
</form>
)}
</li>
))}
</ul>
)}
{totalPages > 1 && (
<div className="mt-6 flex justify-center gap-2">
{page > 1 && (
<a
href={`/sync/import/${provider.id}?page=${page - 1}`}
className="rounded border border-gray-300 px-3 py-1 text-sm hover:bg-gray-50"
>
{t("sync.previous")}
</a>
)}
<span className="px-3 py-1 text-sm text-gray-500">
{page} / {totalPages}
</span>
{page < totalPages && (
<a
href={`/sync/import/${provider.id}?page=${page + 1}`}
className="rounded border border-gray-300 px-3 py-1 text-sm hover:bg-gray-50"
>
{t("sync.next")}
</a>
)}
</div>
)}
</div>
);
}

View file

@ -7,9 +7,13 @@ SMTP_URL=ENC[AES256_GCM,data:o4kgOpqNnbtoAfZMM3+cnk0pHIQ/xfQ7Z2wg003qkx+7aU0JdNW
SMTP_FROM=ENC[AES256_GCM,data:pS4B7UxNc9DIyhveRV0LP3wchAFtCY1TN7NhTK9YsM92,iv:jnz4zEjU++PVC1OOyQTvr4mnDNe1hmx4oqZMLJ/7jcQ=,tag:ifclwnVpmcD8x0SLMK+ZPw==,type:str]
SENTRY_AUTH_TOKEN=ENC[AES256_GCM,data:Bz+XkGYAJDhCN5iSSPdTkd+21BO1zc7S2rU2NvxaBzmskYve6gHt1KR+OzQ9YLuy7VkhpFlBeYKS592HrpdkCA7Sp3DZvl8=,iv:E3cyWEhDX8WCpvROJdhGhdgkKjV+mhmrj4kMpBz9hWM=,tag:BNb7rsdPQQ4pBS3UX2G4EQ==,type:str]
DEPLOY_GHCR_TOKEN=ENC[AES256_GCM,data:cmyUDf24Mt5iS8rRwjpCMPfGBjKDXW4vXQjEqlO1KWNNxO0fW5SFlg==,iv:rhKAgWZ48JGfq2vS6FJ58E9pU41s6zHYe0xo7+YapoM=,tag:LKl86akqu7E32Lp0ScDWYg==,type:str]
#ENC[AES256_GCM,data:PhxKhxmL30iKEyvTTmmmVZw=,iv:dTLvkB4muDNdBT98EtQdfDnBcpBb7MiBBwDfTkLc2UI=,tag:Qa3jCS4UYHi1kQsXpFwCiw==,type:comment]
WAHOO_CLIENT_ID=ENC[AES256_GCM,data:RWyd1ocUcIeNk0vIqpb21IAJuIEc6M3g2IC8RmHeOozBpUWppOSB2ItLFg==,iv:CKKy/L9+VZSBEcPoOTEMZa6hWet0qA9kwD+byu2M3WE=,tag:3/qloZMHq3StT0KgtLgxvg==,type:str]
WAHOO_CLIENT_SECRET=ENC[AES256_GCM,data:1DGPQqjTZVoJmrgXcMav9y3NMe6+FPNvIqSYM1Q6cQDLLkrsGKzFLCYV6w==,iv:aHcjMvQaXln/5R30etnotdV1Yezpdlc2+bqpXT0ab2U=,tag:ZIzvRFecfU9XuetwCPrngQ==,type:str]
WAHOO_WEBHOOK_TOKEN=ENC[AES256_GCM,data:KQScuqVvDPMyv0rgUC0J225QH3VyL2i3R23PtiC9Ny2ceaD9,iv:jwFRtYLFqlEdbDaJ3WwD+puHMDUpv+8BWPpwnL6iDfQ=,tag:3CxS7Co3p5dfH1JQGpr8FQ==,type:str]
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBWblo1Rm9rUlFCQkVVRUVR\nSCtPR1kwMlFvRXplNmdxWVVSOEJzUVB3VlZBCk9Hb1FJSCs1R2llMkk4eGVydnlh\nUmJmU0Joa1pneEZSWFJ5L1VZRnNiZ0UKLS0tIG9aaUkzT0o5dERSekM0cjNxOXBM\nRVhnU1prNjFOa0RQdEVsSlB5QnZzMGcKFFPBeSgQozLR2zohwr/lYBRFzeAK8Hzd\nV635q8YQrXBt9ZUSYvy/b7Gse61+ynaqI7ukLfbc/cRNszQFSuMJyg==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_0__map_recipient=age1vukt8py0s2mm47fx6qh6vykckfkdqslf9w68ekytfzvc8xp7e3rqn6p64n
sops_lastmodified=2026-03-27T15:54:10Z
sops_mac=ENC[AES256_GCM,data:ESmrI1rbshwEKB3ttElBFD0vpbRQjydPBi5zlP5DNhc9ldBPFiIIFCA/v5MBquPsA4VG0C3RyKJBXrryPiADcvj/bhOWWllVlJ0OPr9GSoNFRpYRsWSTJfu7NG3wP9B24Cm/QpW2p2wYi+Z4nm0KHdHWTshxgUBBFOAOiVJPWIU=,iv:/IzX+/MP+3eTeePcmgfwRmVMAHe/WmRYBfrEFp7JOoA=,tag:nhNbuk8GlbJJMghvx8CnKw==,type:str]
sops_lastmodified=2026-04-04T10:17:34Z
sops_mac=ENC[AES256_GCM,data:MMghvTcbpNT2xgw70shxEVi4nj52ycBiFISCzUBHXBTLYF0K5Hnez6uJaVNPcVTuwcqNW+9MfFbqijKviUyjBVGR1U8sjthORzyLGUyaxmN9nxjBheF8QWiCouyEFsWs0CBW4YIOu+zGfzZa0U/yvN3mORGfJNA1nTgIwiUDDjQ=,iv:G1cC+NgCouTj8o5E4z8je7z+lRK/IsALiSr2M/N3jbc=,tag:LHfh2sZ94nuH+KKEXmRpxg==,type:str]
sops_unencrypted_suffix=_unencrypted
sops_version=3.9.4

View file

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-04

View file

@ -0,0 +1,103 @@
## Context
Wahoo's Cloud API (https://cloud-api.wahooligan.com/) uses OAuth2 for authentication, provides workout data in FIT format, and supports webhooks for the `workout_summary` event. This is the first device integration — Garmin, Strava, and others will follow. The architecture must be provider-agnostic.
## Goals / Non-Goals
**Goals:**
- Provider-agnostic sync framework: common interface for OAuth2, webhooks, activity import
- Wahoo as first provider implementation
- Webhook-based automatic sync (new activities arrive without user action)
- Manual import for historical workouts
- FIT→GPX conversion for Wahoo's binary format
- Extensible to Garmin, Strava, Coros, etc.
**Non-Goals:**
- Two-way sync (no pushing data to providers)
- Importing structured workout plans or power zones
- Real-time streaming of in-progress activities
## Decisions
### D1: Provider interface
```typescript
interface SyncProvider {
id: string; // "wahoo", "garmin", etc.
name: string; // "Wahoo"
scopes: string[]; // OAuth scopes needed
getAuthUrl(state: string): string;
exchangeCode(code: string): Promise<TokenSet>;
refreshToken(refreshToken: string): Promise<TokenSet>;
listWorkouts(tokens: TokenSet, page: number): Promise<WorkoutList>;
downloadFile(tokens: TokenSet, workout: Workout): Promise<Buffer>;
convertToGpx(fileBuffer: Buffer): Promise<string>;
parseWebhook(body: unknown): WebhookEvent | null;
}
```
Each provider implements this interface. The framework handles OAuth flow, token storage, webhook routing, and activity creation. Adding a new provider means implementing one file.
### D2: Database schema
**`sync_connections`** — one row per user-provider pair:
- `id`, `user_id` (FK), `provider` (e.g., "wahoo"), `access_token`, `refresh_token`, `expires_at`, `provider_user_id`, `created_at`
**`sync_imports`** — tracks which external workouts have been imported:
- `id`, `user_id` (FK), `provider`, `external_workout_id`, `activity_id` (FK to activities), `imported_at`
This replaces the earlier `wahoo_tokens` table with a generic schema. No `wahoo_workout_id` column on activities — the `sync_imports` junction table handles duplicate detection for all providers.
### D3: Webhook sync
Wahoo sends `workout_summary` webhooks to a registered URL when a workout completes. Requires the `offline_data` OAuth scope.
**Webhook endpoint:** `POST /api/sync/webhook/wahoo`
- Verifies the request is from Wahoo (check payload structure)
- Looks up the user's `sync_connection` by `provider_user_id`
- Downloads the FIT file, converts to GPX, creates activity
- Stores import record in `sync_imports`
**Webhook registration:** Done via Wahoo's app settings dashboard (not API). The URL is `{ORIGIN}/api/sync/webhook/wahoo`.
### D4: OAuth2 flow
Standard authorization code flow. Scopes: `workouts_read`, `user_read`, `offline_data` (for webhooks).
**Routes:**
- `GET /api/sync/connect/wahoo` → redirect to Wahoo auth
- `GET /api/sync/callback/wahoo` → exchange code, store tokens, redirect to settings
- `POST /api/sync/disconnect/wahoo` → delete tokens
The route pattern `/api/sync/{action}/{provider}` is provider-agnostic — adding Garmin means the same routes with `garmin` instead of `wahoo`.
### D5: FIT→GPX conversion
Use `fit-file-parser` to parse FIT binary data. Extract records with `record_type === 'record'` containing `position_lat`, `position_long`, `altitude`, `timestamp`. FIT uses semicircles for coordinates — convert to degrees by dividing by `2^31 / 180`.
Convert to GPX using the existing `generateGpx` function, then feed through `setGeomFromGpx` for PostGIS geometry.
### D6: Manual import page
`/sync/import/wahoo` — lists workouts from Wahoo API with date, type, duration, distance. Marks already-imported ones via `sync_imports` lookup. Import button downloads FIT, converts, creates activity. Paginated (Wahoo returns 30 per page).
### D7: Settings integration
"Connected Services" section in settings. Shows each configured provider with connect/disconnect. When connected, shows provider user info and link to import page.
Provider registry:
```typescript
const providers = [wahooProvider]; // Add garminProvider, stravaProvider later
```
Settings iterates over registered providers to render the UI.
## Risks / Trade-offs
- **Webhook reliability:** If the webhook fails, the workout is missed. Mitigated by the manual import page as fallback, and idempotent import (duplicate detection via `sync_imports`).
- **FIT parsing:** Binary format parsing via npm package. If it fails for edge cases, the webhook silently drops that workout. Logging + manual import as fallback.
- **Provider-specific quirks:** Each provider has different OAuth flows, data formats, and webhook schemas. The interface abstracts the common pattern, but implementation details will vary. Accept some provider-specific code in each implementation file.
- **Webhook security:** Wahoo doesn't sign webhooks with HMAC. Verify by checking the `provider_user_id` in the payload matches a known `sync_connection`. This prevents arbitrary POST requests from creating activities.

View file

@ -0,0 +1,32 @@
## Why
Users with Wahoo cycling computers (ELEMNT, KICKR) record activities that are synced to Wahoo's cloud. Currently there's no way to get those activities into trails.cool without manually exporting GPX files. A direct Wahoo integration lets users connect their account once and have activities sync automatically.
This is the first of several planned device integrations (Garmin, Strava, Coros, etc.). The architecture should be provider-agnostic so adding new integrations is straightforward.
## What Changes
- **Provider abstraction**: A common interface for activity sync providers (OAuth2, webhook handling, activity listing, file download, format conversion). Wahoo is the first implementation.
- **OAuth2 flow**: "Connect Wahoo" button on the journal settings page. Redirects to Wahoo's authorization endpoint, handles callback, stores tokens.
- **Webhook sync**: Register for Wahoo's `workout_summary` webhook. When a new workout completes, automatically download the FIT file, convert to GPX, and create an activity.
- **Manual import**: Fallback import page for browsing and selectively importing older workouts.
- **FIT to GPX conversion**: Wahoo provides FIT format files. Convert to GPX server-side for storage.
- **Token management**: Store and refresh OAuth tokens per provider. Handle the 2-hour expiry with automatic refresh.
## Capabilities
### New Capabilities
- `activity-sync`: Provider-agnostic activity sync framework (OAuth2, webhooks, import, format conversion)
- `wahoo-import`: Wahoo-specific provider implementation (OAuth2 scopes, FIT files, webhook payload)
### Modified Capabilities
- `account-settings`: Add "Connected Services" section for managing provider connections
- `journal-auth`: Store OAuth tokens for external providers
## Impact
- `apps/journal/app/lib/sync/` — new directory for sync framework + provider implementations
- `apps/journal/app/routes/settings.tsx` — Connected Services section
- `apps/journal/app/routes/sync.*` — OAuth callback, webhook endpoint, import page
- `packages/db/src/schema/journal.ts``sync_connections` and `sync_imports` tables
- `infrastructure/secrets.app.env` — WAHOO_CLIENT_ID, WAHOO_CLIENT_SECRET

View file

@ -0,0 +1,10 @@
## MODIFIED Requirements
### Requirement: Connected Services section
The settings page SHALL include a "Connected Services" section for managing external integrations.
#### Scenario: Wahoo connection status
- **WHEN** a user views the settings page
- **THEN** a "Connected Services" section shows Wahoo as connected or disconnected
- **AND** connected state shows "Disconnect" button
- **AND** disconnected state shows "Connect Wahoo" button

View file

@ -0,0 +1,9 @@
## MODIFIED Requirements
### Requirement: Store external service tokens
The journal auth system SHALL store OAuth tokens for external services alongside user credentials.
#### Scenario: Wahoo token storage
- **WHEN** a user connects their Wahoo account
- **THEN** access token, refresh token, expiry time, and Wahoo user ID are stored in the `wahoo_tokens` table
- **AND** tokens are associated with the journal user ID

View file

@ -0,0 +1,70 @@
## ADDED Requirements
### Requirement: Provider-agnostic sync framework
The system SHALL provide a common interface for external activity sync providers.
#### Scenario: Add new provider
- **WHEN** a developer wants to add a new sync provider (e.g., Garmin)
- **THEN** they implement the `SyncProvider` interface in a single file
- **AND** register it in the provider registry
- **AND** all OAuth, webhook, import, and settings UI works automatically
### Requirement: Connect Wahoo account
Users SHALL be able to connect their Wahoo account via OAuth2.
#### Scenario: Connect Wahoo
- **WHEN** a user clicks "Connect Wahoo" in journal settings
- **THEN** they are redirected to Wahoo's OAuth authorization page with scopes `workouts_read`, `user_read`, `offline_data`
- **AND** after granting permission, redirected back to the journal
- **AND** access and refresh tokens are stored in `sync_connections`
#### Scenario: Disconnect Wahoo
- **WHEN** a user clicks "Disconnect" next to their Wahoo connection
- **THEN** the stored tokens are deleted from `sync_connections`
#### Scenario: Token refresh
- **WHEN** a Wahoo API call fails with an expired token
- **THEN** the refresh token is used to obtain a new access token automatically
### Requirement: Webhook-based automatic sync
New Wahoo workouts SHALL be automatically imported when they complete.
#### Scenario: Webhook receives new workout
- **WHEN** Wahoo sends a `workout_summary` webhook to `/api/sync/webhook/wahoo`
- **THEN** the system identifies the user via `provider_user_id`
- **AND** downloads the FIT file from Wahoo's CDN
- **AND** converts it to GPX
- **AND** creates a journal activity with the GPX, stats, and PostGIS geometry
- **AND** records the import in `sync_imports` to prevent duplicates
#### Scenario: Duplicate webhook
- **WHEN** a webhook arrives for a workout already imported
- **THEN** the import is skipped silently (idempotent)
#### Scenario: Unknown user webhook
- **WHEN** a webhook arrives with a `provider_user_id` not matching any connection
- **THEN** the request is ignored with a 200 response (don't reveal user existence)
### Requirement: Manual import
Users SHALL be able to browse and selectively import older Wahoo workouts.
#### Scenario: View workout list
- **WHEN** a user visits the Wahoo import page
- **THEN** their Wahoo workouts are listed with date, type, duration, and distance
- **AND** already-imported workouts are marked
#### Scenario: Import workout
- **WHEN** a user clicks "Import" on a Wahoo workout
- **THEN** the FIT file is downloaded, converted to GPX, and a new activity is created
### Requirement: FIT to GPX conversion
The system SHALL convert Wahoo's FIT binary files to GPX format.
#### Scenario: Convert FIT with GPS data
- **WHEN** a FIT file contains GPS track records
- **THEN** track points with lat, lon, elevation, and timestamp are extracted
- **AND** a valid GPX string is produced using `generateGpx`
#### Scenario: FIT without GPS data
- **WHEN** a FIT file has no GPS records (e.g., indoor trainer workout)
- **THEN** the activity is created without GPX or geometry (stats only)

View file

@ -0,0 +1,65 @@
## 1. Sync Framework & Database
- [x] 1.1 Add `sync_connections` table to `packages/db/src/schema/journal.ts` (user_id FK, provider, access_token, refresh_token, expires_at, provider_user_id, created_at)
- [x] 1.2 Add `sync_imports` table (user_id FK, provider, external_workout_id, activity_id FK, imported_at)
- [x] 1.3 Define `SyncProvider` TypeScript interface in `apps/journal/app/lib/sync/types.ts`
- [x] 1.4 Create provider registry in `apps/journal/app/lib/sync/registry.ts` (array of providers, lookup by id)
- [x] 1.5 Create token storage helpers: `saveConnection()`, `getConnection()`, `deleteConnection()`, `updateTokens()` in `apps/journal/app/lib/sync/connections.server.ts`
- [x] 1.6 Create import tracking helpers: `recordImport()`, `isAlreadyImported()`, `getImportedIds()` in `apps/journal/app/lib/sync/imports.server.ts`
- [x] 1.7 Add WAHOO_CLIENT_ID and WAHOO_CLIENT_SECRET to `infrastructure/secrets.app.env` via SOPS
- [x] 1.8 Run `pnpm db:push` to apply schema changes
## 2. Wahoo Provider Implementation
- [x] 2.1 Create `apps/journal/app/lib/sync/providers/wahoo.ts` implementing `SyncProvider`
- [x] 2.2 Implement `getAuthUrl()` with scopes `workouts_read`, `user_read`, `offline_data`
- [x] 2.3 Implement `exchangeCode()` and `refreshToken()` using Wahoo's OAuth endpoints
- [x] 2.4 Implement `listWorkouts()` with pagination (Wahoo's `GET /v1/workouts`)
- [x] 2.5 Implement `downloadFile()` to fetch FIT file from Wahoo CDN
- [x] 2.6 Implement `parseWebhook()` to extract workout info from `workout_summary` payload
## 3. FIT → GPX Conversion
- [x] 3.1 Add `fit-file-parser` dependency
- [x] 3.2 Implement `convertToGpx()` in Wahoo provider: parse FIT, extract track records, convert semicircles to degrees, generate GPX via `generateGpx`
- [x] 3.3 Handle indoor workouts (no GPS) — return null GPX, create activity with stats only
## 4. OAuth Routes
- [x] 4.1 Create `apps/journal/app/routes/api.sync.connect.$provider.ts` — generates auth URL from provider, redirects
- [x] 4.2 Create `apps/journal/app/routes/api.sync.callback.$provider.ts` — exchanges code, stores tokens, redirects to settings
- [x] 4.3 Create `apps/journal/app/routes/api.sync.disconnect.$provider.ts` — deletes connection
- [x] 4.4 Register routes in `apps/journal/app/routes.ts`
## 5. Webhook Route
- [x] 5.1 Create `apps/journal/app/routes/api.sync.webhook.$provider.ts` — receives webhook, looks up user, downloads file, converts, creates activity
- [x] 5.2 Verify webhook by matching `provider_user_id` to a `sync_connection`
- [x] 5.3 Handle duplicate detection via `sync_imports`
- [x] 5.4 Register route in `routes.ts`
## 6. Import Page
- [x] 6.1 Create `apps/journal/app/routes/sync.import.$provider.tsx` — lists workouts with import buttons
- [x] 6.2 Show workout date, type, duration, distance per row
- [x] 6.3 Mark already-imported workouts via `sync_imports` lookup
- [x] 6.4 Import action: download file, convert, create activity, record import
- [x] 6.5 Pagination for workout list
- [x] 6.6 Register route in `routes.ts`
## 7. Settings Integration
- [x] 7.1 Add "Connected Services" section to settings page
- [x] 7.2 Iterate over registered providers to show connect/disconnect per provider
- [x] 7.3 Show connection status and link to import page when connected
## 8. i18n
- [x] 8.1 Add translation keys for sync UI (en + de): connect/disconnect, import page, webhook status, provider names
## 9. Testing
- [x] 9.1 Unit test: FIT→GPX conversion with sample FIT data
- [x] 9.2 Unit test: duplicate detection via sync_imports
- [x] 9.3 Unit test: OAuth URL generation and token exchange (mock fetch)
- [x] 9.4 E2E test: settings page shows Connected Services section

View file

@ -28,7 +28,10 @@
"brace-expansion@>=4.0.0 <5.0.5": "5.0.5",
"path-to-regexp@<0.1.13": "0.1.13"
},
"onlyBuiltDependencies": ["@sentry/cli", "esbuild"]
"onlyBuiltDependencies": [
"@sentry/cli",
"esbuild"
]
},
"devDependencies": {
"@eslint/js": "^10.0.1",
@ -49,6 +52,7 @@
"drizzle-postgis": "^1.1.1",
"eslint": "^10.1.0",
"eslint-config-prettier": "^10.1.8",
"fit-file-parser": "^2.3.3",
"i18next": "^26.0.1",
"i18next-browser-languagedetector": "^8.2.1",
"jsdom": "^29.0.1",

View file

@ -106,3 +106,27 @@ export const activities = journalSchema.table("activities", {
participants: jsonb("participants").$type<string[]>(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
export const syncConnections = journalSchema.table("sync_connections", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
provider: text("provider").notNull(),
accessToken: text("access_token").notNull(),
refreshToken: text("refresh_token").notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
providerUserId: text("provider_user_id"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
export const syncImports = journalSchema.table("sync_imports", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
provider: text("provider").notNull(),
externalWorkoutId: text("external_workout_id").notNull(),
activityId: text("activity_id").references(() => activities.id, { onDelete: "set null" }),
importedAt: timestamp("imported_at", { withTimezone: true }).notNull().defaultNow(),
});

View file

@ -169,6 +169,20 @@ export default {
deleteConfirmPrompt: "Gib \"{{username}}\" ein, um die Löschung zu bestätigen:",
confirmDelete: "Endgültig löschen",
},
services: {
title: "Verbundene Dienste",
connect: "Verbinden",
disconnect: "Trennen",
connectedAs: "Verbunden (ID: {{id}})",
},
},
sync: {
import: "Importieren",
importFrom: "Import von {{provider}}",
imported: "Importiert",
noWorkouts: "Keine Workouts gefunden.",
previous: "Zurück",
next: "Weiter",
},
nav: {
routes: "Routen",

View file

@ -169,6 +169,20 @@ export default {
deleteConfirmPrompt: "Type \"{{username}}\" to confirm deletion:",
confirmDelete: "Permanently Delete",
},
services: {
title: "Connected Services",
connect: "Connect",
disconnect: "Disconnect",
connectedAs: "Connected (ID: {{id}})",
},
},
sync: {
import: "Import",
importFrom: "Import from {{provider}}",
imported: "Imported",
noWorkouts: "No workouts found.",
previous: "Previous",
next: "Next",
},
nav: {
routes: "Routes",

28
pnpm-lock.yaml generated
View file

@ -112,6 +112,9 @@ importers:
eslint-config-prettier:
specifier: ^10.1.8
version: 10.1.8(eslint@10.1.0(jiti@2.6.1))
fit-file-parser:
specifier: ^2.3.3
version: 2.3.3
i18next:
specifier: ^26.0.1
version: 26.0.1(typescript@5.9.3)
@ -2158,6 +2161,9 @@ packages:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
baseline-browser-mapping@2.10.12:
resolution: {integrity: sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ==}
engines: {node: '>=6.0.0'}
@ -2195,6 +2201,9 @@ packages:
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'}
@ -2644,6 +2653,9 @@ packages:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
fit-file-parser@2.3.3:
resolution: {integrity: sha512-TZPFfjkEev5TTd9RnZ4xn4k5ZSx2VZiKNjoZsHIkmQDK0S0XA7ebfdMLj76BK7kStsHh5WbK8Fmn/w85jgd0dA==}
flat-cache@4.0.1:
resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
engines: {node: '>=16'}
@ -2756,6 +2768,9 @@ packages:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'}
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
@ -5577,6 +5592,8 @@ snapshots:
balanced-match@4.0.4: {}
base64-js@1.5.1: {}
baseline-browser-mapping@2.10.12: {}
basic-auth@2.0.1:
@ -5624,6 +5641,11 @@ snapshots:
buffer-from@1.1.2: {}
buffer@6.0.3:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
bytes@3.1.2: {}
cac@6.7.14: {}
@ -6062,6 +6084,10 @@ snapshots:
locate-path: 6.0.0
path-exists: 4.0.0
fit-file-parser@2.3.3:
dependencies:
buffer: 6.0.3
flat-cache@4.0.1:
dependencies:
flatted: 3.4.2
@ -6179,6 +6205,8 @@ snapshots:
dependencies:
safer-buffer: 2.1.2
ieee754@1.2.1: {}
ignore@5.3.2: {}
ignore@7.0.5: {}