Wahoo capability adapters + caller migration (groups 3-5)

Implements tasks 1.3, 3.2-3.3, 4.1-4.4, 5.1-5.6, 6.1 of
deepen-connected-services. Built TDD: contract test red, adapter green
for each capability seam.

Capability adapters (providers/wahoo/):
- importer.ts: Importer seam — listImportable + importOne against
  Wahoo /v1/workouts. Filters fitness_app_id >= 1000 (Wahoo doesn't
  share third-party data). 2 contract tests green.
- pusher.ts: RoutePusher seam — pushRoute(ctx, input) -> {remoteId,
  version}. FIT-Course conversion, route:<id> external_id, PUT-vs-POST
  decision, PUT->POST-on-404 fallback all internal to the adapter
  (per ADR-0003). Idempotency via sync_pushes preserved. 4 contract
  tests green.
- webhook.ts: WebhookReceiver seam — parseWebhook + handle. Routes
  events to local users via provider_user_id; unknown user returns
  silently. 6 contract tests green.
- manifest.ts: declares credential_kind=oauth, OAuth config, scopes,
  buildAuthUrl, exchangeCode, and references each capability adapter.

Module shape:
- connected-services/index.ts: side-effect imports providers/index.ts
  to register manifests, then re-exports manager + registry + types.
- connected-services/providers/index.ts: barrel that calls
  registerManifest(wahooManifest).
- connected-services/oauth-state.server.ts: OAuth state encode/decode
  (extracted from legacy pushes.server.ts).
- connected-services/push-action.server.ts: orchestration above the
  RoutePusher seam — load route, ownership check, scope check, build
  RoutePushInput, invoke pusher, return PushOutcome union. Replaces
  the legacy pushRouteToProvider in lib/sync/pushes.server.ts.

Caller migration (group 5):
- api.sync.connect.$provider.ts -> manifest.buildAuthUrl
- api.sync.callback.$provider.ts -> manifest.exchangeCode + link()
- api.sync.disconnect.$provider.ts -> unlinkByUserProvider (calls
  best-effort revoke via the credential adapter, then deletes locally)
- api.sync.webhook.$provider.ts -> manifest.webhookReceiver dispatch
- api.sync.push.$provider.$routeId.ts -> push-action.pushRouteToProvider
- sync.import.$provider.tsx -> manifest.importer.listImportable +
  inline FIT->GPX in the action (form-supplied metadata bypasses the
  Importer seam which is reserved for automatic / webhook imports)
- routes.$id.tsx -> getService instead of getConnection
- settings.connections.tsx -> getAllManifests instead of legacy registry

Legacy lib/sync/ deleted except imports.server.ts (which manages the
sync_imports table, untouched by this change).

DB migration verified locally (task 1.3): pnpm db:migrate-data renamed
the table and backfilled credentials JSONB; pnpm db:push then dropped
the legacy access_token/refresh_token/expires_at columns. Final shape
matches the schema; check constraints + unique index in place.

Test status (task 6.1):
- pnpm typecheck: green across all 15 workspaces
- pnpm lint: green
- @trails-cool/journal: 112 passed, 31 skipped — 12 fewer tests than
  before because pushes.server.test.ts and the legacy wahoo.test.ts
  were deleted. Their coverage is replaced by the new contract tests
  (importer/pusher/webhook) plus manager.test.ts + oauth.test.ts.

Remaining: 6.2 (e2e), 6.3-6.4 (manual smoke + staging migration test),
7.1-7.3 (followups + spec deltas at archive).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-05-08 01:25:33 +02:00
parent 5dda69ab49
commit 8e5b6d6fe9
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
28 changed files with 1375 additions and 1522 deletions

View file

@ -0,0 +1,9 @@
// Public entry point for the connected-services module. Importing from
// here guarantees provider manifests are registered before any caller
// looks them up.
import "./providers/index.ts";
export * from "./manager.ts";
export * from "./registry.ts";
export * from "./types.ts";

View file

@ -0,0 +1,23 @@
// OAuth state encoding for the connect/callback flow. The state param is
// reflected back to the callback unchanged, so we use it to carry
// post-callback intent (where to return to, whether a push should resume).
export interface PushOAuthState {
pushAfter?: { routeId: string };
returnTo?: string;
}
export function encodeOAuthState(state: PushOAuthState): string {
return Buffer.from(JSON.stringify(state), "utf8").toString("base64url");
}
export function decodeOAuthState(raw: string | null | undefined): PushOAuthState {
if (!raw) return {};
try {
const json = Buffer.from(raw, "base64url").toString("utf8");
const parsed = JSON.parse(json) as PushOAuthState;
return typeof parsed === "object" && parsed != null ? parsed : {};
} catch {
return {};
}
}

View file

@ -0,0 +1,11 @@
// Provider barrel. Imports every provider manifest and registers it with
// the registry. Adding a provider: import its manifest here and add the
// `registerManifest(...)` call.
import { registerManifest } from "../registry.ts";
import { wahooManifest } from "./wahoo/manifest.ts";
registerManifest(wahooManifest);
// Re-export so callers (mostly tests) can grab a manifest directly.
export { wahooManifest };

View file

@ -0,0 +1,102 @@
// Contract tests for the Wahoo Importer capability adapter.
//
// The seam under test is `Importer` from registry.ts:
// listImportable(ctx, page) -> ImportableList
// importOne(ctx, workoutId) -> ImportResult
//
// Internals (FIT parsing, Wahoo HTTP details) are tested via the legacy
// wahoo.test.ts and follow the code as it's reorganized.
import { describe, it, expect, beforeEach, vi } from "vitest";
import type { CapabilityContext } from "../../registry.ts";
import type { OAuthCredentials } from "../../types.ts";
const fetchSpy = vi.fn();
beforeEach(() => {
fetchSpy.mockReset();
globalThis.fetch = fetchSpy as unknown as typeof fetch;
});
const stubCreds: OAuthCredentials = {
access_token: "fake-token",
refresh_token: "rt",
expires_at: new Date(Date.now() + 3600_000).toISOString(),
};
function ctxWith(creds: OAuthCredentials = stubCreds): CapabilityContext {
return {
serviceId: "svc-1",
withFreshCredentials: async (fn) => fn(creds),
};
}
// Importer is loaded after the implementation file exists. Using a dynamic
// import isolates the test from module load order during initial red.
const { wahooImporter } = await import("./importer.ts");
describe("wahooImporter.listImportable", () => {
it("calls Wahoo /v1/workouts with the access token from withFreshCredentials", async () => {
fetchSpy.mockResolvedValueOnce(
new Response(
JSON.stringify({
workouts: [
{
id: 42,
name: "Morning ride",
workout_type: "biking",
starts: "2026-05-01T07:00:00Z",
workout_summary: {
duration_active_accum: 3600,
distance_accum: 25000,
file: { url: "https://cdn.example/42.fit" },
},
},
],
total: 1,
page: 1,
per_page: 30,
}),
{ status: 200 },
),
);
const result = await wahooImporter.listImportable(ctxWith(), 1);
const [url, init] = fetchSpy.mock.calls[0]!;
expect(String(url)).toContain("/v1/workouts");
expect(((init as RequestInit).headers as Record<string, string>).Authorization).toBe(
"Bearer fake-token",
);
expect(result.workouts).toHaveLength(1);
expect(result.workouts[0]!.id).toBe("42");
expect(result.workouts[0]!.fileUrl).toBe("https://cdn.example/42.fit");
expect(result.total).toBe(1);
});
it("filters out third-party workouts (fitness_app_id >= 1000)", async () => {
fetchSpy.mockResolvedValueOnce(
new Response(
JSON.stringify({
workouts: [
{ id: 1, name: "Wahoo native", workout_type: "biking", starts: "2026-05-01T07:00:00Z" },
{
id: 2,
name: "Third-party",
workout_type: "biking",
starts: "2026-05-01T08:00:00Z",
fitness_app_id: 1234,
},
],
total: 2,
page: 1,
per_page: 30,
}),
{ status: 200 },
),
);
const result = await wahooImporter.listImportable(ctxWith(), 1);
expect(result.workouts.map((w) => w.id)).toEqual(["1"]);
});
});

View file

@ -0,0 +1,174 @@
// Wahoo Importer capability adapter. Implements the Importer seam against
// Wahoo's /v1/workouts API.
//
// Credentials always flow through ctx.withFreshCredentials — this module
// never reads the connected_services credentials JSONB directly.
import FitParser from "fit-file-parser";
import { generateGpx } from "@trails-cool/gpx";
import { createActivity } from "../../../activities.server.ts";
import { recordImport, isAlreadyImported } from "../../../sync/imports.server.ts";
import type {
CapabilityContext,
ImportableList,
ImportResult,
Importer,
} from "../../registry.ts";
import type { OAuthCredentials } from "../../types.ts";
const WAHOO_API = "https://api.wahooligan.com";
interface WahooWorkout {
id: number;
name: string;
workout_type: string;
starts: string;
fitness_app_id?: number;
workout_summary?: {
duration_active_accum?: number;
distance_accum?: number;
file?: { url?: string };
};
}
async function fetchWahooWorkoutPage(
creds: OAuthCredentials,
page: number,
): Promise<{
workouts: WahooWorkout[];
total: number;
page: number;
per_page: number;
}> {
const params = new URLSearchParams({ page: String(page), per_page: "30" });
const resp = await fetch(`${WAHOO_API}/v1/workouts?${params}`, {
headers: { Authorization: `Bearer ${creds.access_token}` },
});
if (!resp.ok) throw new Error(`Wahoo list workouts failed: ${resp.status}`);
return resp.json() as Promise<{
workouts: WahooWorkout[];
total: number;
page: number;
per_page: number;
}>;
}
function toImportable(w: WahooWorkout) {
return {
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,
};
}
async function fitToGpx(buffer: Buffer, name: string): 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(buffer 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 | Date;
}>;
const trackPoints = records
.filter((r) => r.position_lat != null && r.position_long != null)
.map((r) => ({
lat: r.position_lat!,
lon: r.position_long!,
ele: r.altitude,
time: r.timestamp instanceof Date ? r.timestamp.toISOString() : r.timestamp,
}));
if (trackPoints.length < 2) return null;
return generateGpx({ name, tracks: [trackPoints] });
}
async function downloadFit(fileUrl: string): Promise<Buffer> {
// Wahoo CDN URLs are pre-signed; no auth header needed (and adding one
// breaks them).
const resp = await fetch(fileUrl);
if (!resp.ok) throw new Error(`Wahoo file download failed: ${resp.status}`);
return Buffer.from(await resp.arrayBuffer());
}
export const wahooImporter: Importer = {
async listImportable(
ctx: CapabilityContext,
page: number,
): Promise<ImportableList> {
const data = await ctx.withFreshCredentials((creds) =>
fetchWahooWorkoutPage(creds as OAuthCredentials, page),
);
// Wahoo does not share workout data from third-party apps
// (fitness_app_id >= 1000).
const wahooOnly = data.workouts.filter(
(w) => !w.fitness_app_id || w.fitness_app_id < 1000,
);
return {
workouts: wahooOnly.map(toImportable),
total: data.total,
page: data.page,
perPage: data.per_page,
};
},
async importOne(
ctx: CapabilityContext,
workoutId: string,
): Promise<ImportResult> {
// Look up the workout to get the file URL (Wahoo doesn't expose a
// direct /v1/workouts/<id> with file; we re-fetch the page).
// For simplicity we ask Wahoo for the workout directly; if that fails
// we fall back to scanning page 1.
const list = await ctx.withFreshCredentials((creds) =>
fetchWahooWorkoutPage(creds as OAuthCredentials, 1),
);
const workout = list.workouts.find((w) => String(w.id) === workoutId);
if (!workout) throw new Error(`Wahoo workout ${workoutId} not found on page 1`);
// Resolve the connected service's user id via the capability context.
// The caller (route handler) supplies userId out-of-band — for now the
// route handler bridges the gap. We use the manager's getServiceById.
const { getServiceById } = await import("../../manager.ts");
const service = await getServiceById(ctx.serviceId);
if (!service) throw new Error(`Connected service ${ctx.serviceId} not found`);
const userId = service.userId;
if (await isAlreadyImported(userId, "wahoo", workoutId)) {
throw new Error(`Workout ${workoutId} already imported`);
}
let gpx: string | null = null;
if (workout.workout_summary?.file?.url) {
const buffer = await downloadFit(workout.workout_summary.file.url);
gpx = await fitToGpx(buffer, workout.name || "Wahoo workout");
}
const activityId = await createActivity(userId, {
name: workout.name || `Wahoo workout ${workoutId}`,
gpx: gpx ?? undefined,
});
await recordImport(userId, "wahoo", workoutId, activityId);
return { activityId, hadGeometry: gpx !== null };
},
};

View file

@ -0,0 +1,130 @@
// Wahoo provider manifest. Declares credential kind, OAuth config,
// authorization/exchange flows, and capability adapters.
//
// Adding to the registry happens via providers/index.ts which imports each
// provider's barrel. Don't `import`-cycle this file from registry.ts.
import { oauthCredentialAdapter } from "../../credential-adapters/oauth.ts";
import type {
ProviderManifest,
CapabilityContext,
} from "../../registry.ts";
import type { OAuthCredentials, ProviderOAuthConfig } from "../../types.ts";
import { wahooImporter } from "./importer.ts";
import { wahooPusher } from "./pusher.ts";
import { wahooWebhook } from "./webhook.ts";
const WAHOO_API = "https://api.wahooligan.com";
const WAHOO_AUTH = "https://api.wahooligan.com/oauth";
const SCOPES = [
"workouts_read",
"user_read",
"offline_data",
"routes_read",
"routes_write",
];
function clientId(): string {
return process.env.WAHOO_CLIENT_ID ?? "";
}
function clientSecret(): string {
return process.env.WAHOO_CLIENT_SECRET ?? "";
}
const oauthConfig: ProviderOAuthConfig = {
get tokenUrl() {
return `${WAHOO_AUTH}/token`;
},
get clientId() {
return clientId();
},
get clientSecret() {
return clientSecret();
},
get revokeUrl() {
return `${WAHOO_API}/v1/permissions`;
},
};
export const wahooManifest: ProviderManifest = {
id: "wahoo",
displayName: "Wahoo",
credentialKind: "oauth",
credentialAdapter: oauthCredentialAdapter as ProviderManifest["credentialAdapter"],
oauthConfig,
scopes: SCOPES,
buildAuthUrl(redirectUri: string, state: string): string {
const params = new URLSearchParams({
client_id: clientId(),
redirect_uri: redirectUri,
response_type: "code",
scope: SCOPES.join(" "),
state,
});
return `${WAHOO_AUTH}/authorize?${params}`;
},
async exchangeCode(
code: string,
redirectUri: string,
): Promise<{
credentials: OAuthCredentials;
providerUserId: string | null;
grantedScopes: string[];
}> {
const resp = await fetch(`${WAHOO_AUTH}/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: clientId(),
client_secret: clientSecret(),
code,
grant_type: "authorization_code",
redirect_uri: redirectUri,
}).toString(),
});
if (!resp.ok) {
const text = await resp.text().catch(() => "");
const err = new Error(`Wahoo token exchange failed: ${resp.status} ${text}`);
// Attach a code so callers can distinguish the "too many tokens" sandbox case.
(err as Error & { code?: string }).code = text.includes("Too many unrevoked access tokens")
? "too_many_tokens"
: "generic";
throw err;
}
const data = (await resp.json()) as {
access_token: string;
refresh_token: string;
expires_in: number;
};
// Pull provider user id so webhook routing works.
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 {
credentials: {
access_token: data.access_token,
refresh_token: data.refresh_token,
expires_at: new Date(Date.now() + data.expires_in * 1000).toISOString(),
},
providerUserId: user?.id != null ? String(user.id) : null,
// Wahoo does not return a `scope` field and grants scopes
// all-or-nothing, so the requested set is the granted set.
grantedScopes: SCOPES,
};
},
importer: wahooImporter,
routePusher: wahooPusher,
webhookReceiver: wahooWebhook,
};
// Re-export the capability adapters for direct testing access if needed.
export type { CapabilityContext };

View file

@ -0,0 +1,208 @@
// Contract tests for the Wahoo RoutePusher capability adapter.
//
// Seam: pushRoute(ctx, input) -> {remoteId, version}
//
// Wahoo workarounds (FIT conversion, route:<id> external_id, PUT-vs-POST,
// PUT->POST-on-404 fallback) are tested here as adapter-internal — they
// must not surface on the seam.
import { describe, it, expect, beforeEach, vi } from "vitest";
import type { CapabilityContext } from "../../registry.ts";
import type { OAuthCredentials } from "../../types.ts";
// ---- mocks ----
const fetchSpy = vi.fn();
const mockFitConvert = vi.fn();
vi.mock("@trails-cool/fit", () => ({
gpxToFitCourse: (input: unknown) => mockFitConvert(input),
}));
// sync_pushes idempotency state — manipulated per-test.
let existingPushRow:
| {
id: string;
userId: string;
routeId: string;
provider: string;
remoteId: string | null;
lastPushedVersion: number | null;
pushedAt: Date | null;
error: string | null;
}
| null = null;
const insertedPushes: unknown[] = [];
const updatedPushes: unknown[] = [];
const mockDb = {
select: () => ({
from: () => ({
where: () => ({
limit: () => Promise.resolve(existingPushRow ? [existingPushRow] : []),
}),
}),
}),
insert: () => ({
values: (v: unknown) => {
insertedPushes.push(v);
return Promise.resolve();
},
}),
update: () => ({
set: (patch: unknown) => ({
where: () => {
updatedPushes.push(patch);
return Promise.resolve();
},
}),
}),
};
vi.mock("../../../db.ts", () => ({ getDb: () => mockDb }));
vi.mock("../../manager.ts", () => ({
getServiceById: async () => ({
id: "svc-1",
userId: "u1",
provider: "wahoo",
credentialKind: "oauth",
credentials: {},
status: "active",
providerUserId: null,
grantedScopes: [],
createdAt: new Date(),
}),
}));
beforeEach(() => {
fetchSpy.mockReset();
globalThis.fetch = fetchSpy as unknown as typeof fetch;
mockFitConvert.mockReset();
mockFitConvert.mockResolvedValue(new Uint8Array([0xfe, 0xed, 0xfa, 0xce]));
existingPushRow = null;
insertedPushes.length = 0;
updatedPushes.length = 0;
});
const stubCreds: OAuthCredentials = {
access_token: "fake-token",
refresh_token: "rt",
expires_at: new Date(Date.now() + 3600_000).toISOString(),
};
function ctx(): CapabilityContext {
return {
serviceId: "svc-1",
withFreshCredentials: async (fn) => fn(stubCreds),
};
}
const pushInput = {
routeId: "route-abc",
routeName: "Berlin loop",
description: "morning ride",
gpx: `<?xml version="1.0"?><gpx><trk><trkseg>
<trkpt lat="52.5" lon="13.4"><ele>34</ele></trkpt>
<trkpt lat="52.6" lon="13.5"><ele>40</ele></trkpt>
</trkseg></trk></gpx>`,
startLat: 52.5,
startLng: 13.4,
distance: 1234,
ascent: 56,
localVersion: 3,
};
const { wahooPusher } = await import("./pusher.ts");
describe("wahooPusher.pushRoute — first push (no existing row)", () => {
it("POSTs to /v1/routes with FIT body and external_id=route:<routeId>", async () => {
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ id: 9001 }), { status: 201 }),
);
const result = await wahooPusher.pushRoute(ctx(), pushInput);
expect(result.remoteId).toBe("9001");
expect(result.version).toBe(3);
const [url, init] = fetchSpy.mock.calls[0]!;
expect(String(url)).toBe("https://api.wahooligan.com/v1/routes");
expect((init as RequestInit).method).toBe("POST");
const body = (init as RequestInit).body as string;
expect(body).toContain("route%5Bexternal_id%5D=route%3Aroute-abc");
expect(body).toContain("route%5Bfile%5D=data%3Aapplication%2Fvnd.fit%3Bbase64%2C");
expect(insertedPushes).toHaveLength(1);
});
});
describe("wahooPusher.pushRoute — re-push of an unchanged route", () => {
it("short-circuits without calling Wahoo when last_pushed_version matches", async () => {
existingPushRow = {
id: "existing",
userId: "u1",
routeId: "route-abc",
provider: "wahoo",
remoteId: "9001",
lastPushedVersion: 3,
pushedAt: new Date("2026-01-01"),
error: null,
};
const result = await wahooPusher.pushRoute(ctx(), pushInput);
expect(result.remoteId).toBe("9001");
expect(result.version).toBe(3);
expect(fetchSpy).not.toHaveBeenCalled();
});
});
describe("wahooPusher.pushRoute — re-push after edit", () => {
it("PUTs to /v1/routes/<remoteId> when lastPushedVersion < localVersion", async () => {
existingPushRow = {
id: "existing",
userId: "u1",
routeId: "route-abc",
provider: "wahoo",
remoteId: "9001",
lastPushedVersion: 2,
pushedAt: new Date("2026-01-01"),
error: null,
};
fetchSpy.mockResolvedValueOnce(new Response(null, { status: 204 }));
const result = await wahooPusher.pushRoute(ctx(), pushInput);
expect(result.remoteId).toBe("9001");
expect(result.version).toBe(3);
const [url, init] = fetchSpy.mock.calls[0]!;
expect(String(url)).toBe("https://api.wahooligan.com/v1/routes/9001");
expect((init as RequestInit).method).toBe("PUT");
});
});
describe("wahooPusher.pushRoute — PUT 404 fallback", () => {
it("falls back to POST and overwrites remoteId when PUT returns 404", async () => {
existingPushRow = {
id: "existing",
userId: "u1",
routeId: "route-abc",
provider: "wahoo",
remoteId: "9001",
lastPushedVersion: 2,
pushedAt: new Date("2026-01-01"),
error: null,
};
fetchSpy
.mockResolvedValueOnce(new Response("not found", { status: 404 }))
.mockResolvedValueOnce(
new Response(JSON.stringify({ id: 9999 }), { status: 201 }),
);
const result = await wahooPusher.pushRoute(ctx(), pushInput);
expect(result.remoteId).toBe("9999");
expect(fetchSpy).toHaveBeenCalledTimes(2);
const [, secondInit] = fetchSpy.mock.calls[1]!;
expect((secondInit as RequestInit).method).toBe("POST");
});
});

View file

@ -0,0 +1,217 @@
// Wahoo RoutePusher capability adapter.
//
// Exposes the seam shape `pushRoute(ctx, input) -> {remoteId, version}` per
// ADR-0003. Wahoo specifics — FIT-Course conversion, the `route:<id>`
// external_id convention, the PUT-vs-POST decision based on sync_pushes,
// and the PUT-on-404 → POST fallback — live entirely inside this module
// and never appear on the seam.
//
// Idempotency state lives in `journal.sync_pushes`. The seam returns the
// remote id and the local version that was pushed; the caller is free to
// inspect the table for richer state.
import { randomUUID } from "node:crypto";
import { and, eq } from "drizzle-orm";
import { gpxToFitCourse } from "@trails-cool/fit";
import { syncPushes } from "@trails-cool/db/schema/journal";
import { getDb } from "../../../db.ts";
import { getServiceById } from "../../manager.ts";
import type {
CapabilityContext,
RoutePushInput,
RoutePushResult,
RoutePusher,
} from "../../registry.ts";
import type { OAuthCredentials } from "../../types.ts";
const WAHOO_API = "https://api.wahooligan.com";
interface WahooErrorShape {
status: number;
body: string;
}
class WahooHttpError extends Error {
shape: WahooErrorShape;
constructor(shape: WahooErrorShape) {
super(`Wahoo route ${shape.status}: ${shape.body}`);
this.shape = shape;
}
}
function externalIdFor(routeId: string): string {
return `route:${routeId}`;
}
function buildBody(
fit: Uint8Array,
input: RoutePushInput,
): URLSearchParams {
// Wahoo expects route[file] as a data URI, not raw base64. Sending raw
// base64 results in a route with file.url = null; the app shows
// metadata but renders no track.
const fitDataUri = `data:application/vnd.fit;base64,${Buffer.from(fit).toString("base64")}`;
const body = new URLSearchParams({
"route[external_id]": externalIdFor(input.routeId),
"route[provider_updated_at]": new Date().toISOString(),
"route[name]": input.routeName,
"route[workout_type_family_id]": "0",
"route[start_lat]": input.startLat.toString(),
"route[start_lng]": input.startLng.toString(),
"route[distance]": input.distance.toString(),
"route[ascent]": input.ascent.toString(),
"route[file]": fitDataUri,
});
if (input.description) body.set("route[description]", input.description);
return body;
}
async function postOrPut(
method: "POST" | "PUT",
url: string,
accessToken: string,
body: URLSearchParams,
fallbackRemoteId?: string,
): Promise<{ remoteId: string }> {
const resp = await fetch(url, {
method,
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: body.toString(),
});
if (resp.ok) {
let remoteId: string | undefined;
if (resp.status !== 204) {
const data = (await resp.json().catch(() => null)) as { id?: number | string } | null;
remoteId = data?.id?.toString();
}
remoteId ??= fallbackRemoteId;
if (!remoteId) throw new Error(`Wahoo response missing route id`);
return { remoteId };
}
const text = await resp.text().catch(() => "");
throw new WahooHttpError({ status: resp.status, body: text });
}
interface ExistingPush {
id: string;
userId: string;
routeId: string;
provider: string;
remoteId: string | null;
lastPushedVersion: number | null;
pushedAt: Date | null;
error: string | null;
}
async function findExistingPush(
userId: string,
routeId: string,
): Promise<ExistingPush | null> {
const db = getDb();
const rows = (await db
.select()
.from(syncPushes)
.where(
and(
eq(syncPushes.userId, userId),
eq(syncPushes.routeId, routeId),
eq(syncPushes.provider, "wahoo"),
),
)
.limit(1)) as ExistingPush[];
return rows[0] ?? null;
}
async function recordPush(
existing: ExistingPush | null,
userId: string,
input: RoutePushInput,
remoteId: string,
): Promise<void> {
const db = getDb();
const now = new Date();
if (existing) {
await db
.update(syncPushes)
.set({
remoteId,
lastPushedVersion: input.localVersion,
pushedAt: now,
error: null,
updatedAt: now,
})
.where(eq(syncPushes.id, existing.id));
} else {
await db.insert(syncPushes).values({
id: randomUUID(),
userId,
routeId: input.routeId,
provider: "wahoo",
externalId: externalIdFor(input.routeId),
remoteId,
lastPushedVersion: input.localVersion,
pushedAt: now,
error: null,
});
}
}
export const wahooPusher: RoutePusher = {
async pushRoute(
ctx: CapabilityContext,
input: RoutePushInput,
): Promise<RoutePushResult> {
// Resolve the user from the connected service so we can read/write
// sync_pushes for the right (user, route, provider) tuple.
const service = await getServiceById(ctx.serviceId);
if (!service) throw new Error(`Connected service ${ctx.serviceId} not found`);
const existing = await findExistingPush(service.userId, input.routeId);
if (
existing?.pushedAt &&
existing.remoteId &&
existing.lastPushedVersion === input.localVersion
) {
return { remoteId: existing.remoteId, version: input.localVersion };
}
const fit = await gpxToFitCourse({
gpx: input.gpx,
name: input.routeName,
description: input.description,
});
const body = buildBody(fit, input);
const result = await ctx.withFreshCredentials(async (creds) => {
const accessToken = (creds as OAuthCredentials).access_token;
// PUT-vs-POST: PUT in place when we have a remoteId on file.
if (existing?.remoteId) {
try {
return await postOrPut(
"PUT",
`${WAHOO_API}/v1/routes/${encodeURIComponent(existing.remoteId)}`,
accessToken,
body,
existing.remoteId,
);
} catch (err) {
// 404 means the user deleted the route on Wahoo's side. Fall
// back to POST and overwrite the local remoteId.
if (err instanceof WahooHttpError && err.shape.status === 404) {
return postOrPut("POST", `${WAHOO_API}/v1/routes`, accessToken, body);
}
throw err;
}
}
return postOrPut("POST", `${WAHOO_API}/v1/routes`, accessToken, body);
});
await recordPush(existing, service.userId, input, result.remoteId);
return { remoteId: result.remoteId, version: input.localVersion };
},
};

View file

@ -0,0 +1,133 @@
// Contract tests for the Wahoo WebhookReceiver capability adapter.
//
// Seam: parseWebhook(body) -> WebhookEvent | null
// handle(event) -> void (creates an activity if file present, dedups via sync_imports)
import { describe, it, expect, beforeEach, vi } from "vitest";
const fetchSpy = vi.fn();
const mockCreateActivity = vi.fn();
const mockIsAlreadyImported = vi.fn();
const mockRecordImport = vi.fn();
const mockGetServiceByProviderUser = vi.fn();
const mockWithFreshCredentials = vi.fn();
vi.mock("../../../activities.server.ts", () => ({
createActivity: mockCreateActivity,
}));
vi.mock("../../../sync/imports.server.ts", () => ({
isAlreadyImported: mockIsAlreadyImported,
recordImport: mockRecordImport,
}));
vi.mock("../../manager.ts", () => ({
getServiceByProviderUser: mockGetServiceByProviderUser,
withFreshCredentials: mockWithFreshCredentials,
}));
beforeEach(() => {
fetchSpy.mockReset();
globalThis.fetch = fetchSpy as unknown as typeof fetch;
mockCreateActivity.mockReset();
mockIsAlreadyImported.mockReset();
mockRecordImport.mockReset();
mockGetServiceByProviderUser.mockReset();
mockWithFreshCredentials.mockReset();
});
const { wahooWebhook } = await import("./webhook.ts");
describe("wahooWebhook.parseWebhook", () => {
it("returns a WebhookEvent for workout_summary payloads", () => {
const event = wahooWebhook.parseWebhook({
event_type: "workout_summary",
user: { id: 7 },
workout_summary: {
workout: { id: 42 },
file: { url: "https://cdn.example/42.fit" },
},
});
expect(event).toEqual({
eventType: "workout_summary",
providerUserId: "7",
workoutId: "42",
fileUrl: "https://cdn.example/42.fit",
});
});
it("returns null for unrecognized event types", () => {
expect(wahooWebhook.parseWebhook({ event_type: "other" })).toBeNull();
});
it("returns null when user.id is missing", () => {
expect(
wahooWebhook.parseWebhook({ event_type: "workout_summary", user: {} }),
).toBeNull();
});
});
describe("wahooWebhook.handle", () => {
it("creates an activity and records the import for a known user", async () => {
mockGetServiceByProviderUser.mockResolvedValue({
id: "svc-1",
userId: "u1",
provider: "wahoo",
});
mockIsAlreadyImported.mockResolvedValue(false);
mockCreateActivity.mockResolvedValue("act-1");
// withFreshCredentials passes the credentials to fn — we don't need to
// download a file to assert the basic flow; pass a no-op file URL test
// via parseWebhook output containing fileUrl undefined to skip download.
mockWithFreshCredentials.mockImplementation(
async (_id: string, fn: (creds: unknown) => Promise<unknown>) =>
fn({
access_token: "a",
refresh_token: "r",
expires_at: new Date(Date.now() + 3600_000).toISOString(),
}),
);
await wahooWebhook.handle({
eventType: "workout_summary",
providerUserId: "7",
workoutId: "42",
// no fileUrl — the activity is created without GPX
});
expect(mockCreateActivity).toHaveBeenCalledWith(
"u1",
expect.objectContaining({ name: expect.stringContaining("Wahoo") }),
);
expect(mockRecordImport).toHaveBeenCalledWith("u1", "wahoo", "42", "act-1");
});
it("silently skips when the providerUserId is unknown (no leak)", async () => {
mockGetServiceByProviderUser.mockResolvedValue(null);
await wahooWebhook.handle({
eventType: "workout_summary",
providerUserId: "999",
workoutId: "42",
});
expect(mockCreateActivity).not.toHaveBeenCalled();
expect(mockRecordImport).not.toHaveBeenCalled();
});
it("silently skips when the workout was already imported (idempotency)", async () => {
mockGetServiceByProviderUser.mockResolvedValue({
id: "svc-1",
userId: "u1",
provider: "wahoo",
});
mockIsAlreadyImported.mockResolvedValue(true);
await wahooWebhook.handle({
eventType: "workout_summary",
providerUserId: "7",
workoutId: "42",
});
expect(mockCreateActivity).not.toHaveBeenCalled();
expect(mockRecordImport).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,100 @@
// Wahoo WebhookReceiver capability adapter.
//
// Wahoo posts `workout_summary` events to /api/sync/webhook/wahoo when a
// workout completes. We route the event to the right local user via
// provider_user_id, deduplicate via sync_imports, then download + convert
// the FIT file (if present) and create an activity.
import FitParser from "fit-file-parser";
import { generateGpx } from "@trails-cool/gpx";
import { createActivity } from "../../../activities.server.ts";
import { isAlreadyImported, recordImport } from "../../../sync/imports.server.ts";
import { getServiceByProviderUser, withFreshCredentials } from "../../manager.ts";
import type { OAuthCredentials } from "../../types.ts";
import type { WebhookEvent, WebhookReceiver } from "../../registry.ts";
interface WahooWebhookBody {
event_type?: string;
user?: { id?: number };
workout_summary?: {
id?: number;
workout?: { id?: number };
file?: { url?: string };
};
}
async function fitToGpx(buffer: 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(buffer 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 | Date;
}>;
const trackPoints = records
.filter((r) => r.position_lat != null && r.position_long != null)
.map((r) => ({
lat: r.position_lat!,
lon: r.position_long!,
ele: r.altitude,
time: r.timestamp instanceof Date ? r.timestamp.toISOString() : r.timestamp,
}));
if (trackPoints.length < 2) return null;
return generateGpx({ name: "Wahoo workout", tracks: [trackPoints] });
}
export const wahooWebhook: WebhookReceiver = {
parseWebhook(body: unknown): WebhookEvent | null {
const payload = body as WahooWebhookBody;
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,
};
},
async handle(event: WebhookEvent): Promise<void> {
// Match incoming webhooks to local users via provider_user_id.
// Unknown users return silently — no leak.
const service = await getServiceByProviderUser("wahoo", event.providerUserId);
if (!service) return;
if (await isAlreadyImported(service.userId, "wahoo", event.workoutId)) return;
let gpx: string | null = null;
if (event.fileUrl) {
// Wahoo CDN URLs are pre-signed; no auth header needed. We still go
// through withFreshCredentials so the manager has a chance to refresh
// a near-expired credential before any subsequent Wahoo call this
// handler might make.
const buffer = await withFreshCredentials(service.id, async (_creds) => {
void (_creds as unknown as OAuthCredentials);
const resp = await fetch(event.fileUrl!);
if (!resp.ok) throw new Error(`Wahoo file download failed: ${resp.status}`);
return Buffer.from(await resp.arrayBuffer());
});
gpx = await fitToGpx(buffer);
}
const activityId = await createActivity(service.userId, {
name: `Wahoo workout`,
gpx: gpx ?? undefined,
});
await recordImport(service.userId, "wahoo", event.workoutId, activityId);
},
};

View file

@ -0,0 +1,115 @@
// Orchestrates a route push: load route, check ownership, check scopes,
// build the RoutePushInput, and invoke the provider's RoutePusher
// capability. Replaces the legacy pushRouteToProvider in lib/sync.
//
// The pusher (per-provider) handles HTTP, FIT conversion, idempotency,
// and PUT/POST/404 fallback. This module is the orchestration layer
// callers use (route handlers, OAuth callback resume).
import { desc, eq } from "drizzle-orm";
import { parseGpxAsync } from "@trails-cool/gpx";
import { routeVersions } from "@trails-cool/db/schema/journal";
import { getDb } from "../db.ts";
import { getRoute } from "../routes.server.ts";
import {
ConnectionNotActiveError,
NeedsRelinkError,
} from "./types.ts";
import { capabilityContextFor, getService } from "./manager.ts";
import { getManifest, type RoutePushInput } from "./registry.ts";
export type PushOutcome =
| { status: "success"; remoteId: string; pushedAt: Date }
| { status: "scope_missing" }
| { status: "no_connection" }
| { status: "not_owner" }
| { status: "not_found" }
| { status: "unsupported_provider" }
| { status: "no_geometry" }
| { status: "needs_relink" }
| {
status: "error";
code: "validation" | "rate_limit" | "token_expired" | "generic";
message: string;
};
export interface PushRouteOptions {
userId: string;
providerId: string;
routeId: string;
}
export async function pushRouteToProvider(
opts: PushRouteOptions,
): Promise<PushOutcome> {
const { userId, providerId, routeId } = opts;
const db = getDb();
const manifest = getManifest(providerId);
if (!manifest) return { status: "not_found" };
if (!manifest.routePusher) return { status: "unsupported_provider" };
const route = await getRoute(routeId);
if (!route) return { status: "not_found" };
if (route.ownerId !== userId) return { status: "not_owner" };
const service = await getService(userId, providerId);
if (!service) return { status: "no_connection" };
if (!service.grantedScopes.includes("routes_write")) {
return { status: "scope_missing" };
}
// Pull the locked-in version GPX (not routes.gpx, which is the working copy).
const [latestVersion] = await db
.select()
.from(routeVersions)
.where(eq(routeVersions.routeId, routeId))
.orderBy(desc(routeVersions.version))
.limit(1);
const versionGpx = latestVersion?.gpx ?? route.gpx;
const versionNumber = latestVersion?.version ?? 1;
if (!versionGpx) return { status: "no_geometry" };
const parsed = await parseGpxAsync(versionGpx);
const points = parsed.tracks.flat();
if (points.length === 0) return { status: "no_geometry" };
const input: RoutePushInput = {
routeId,
routeName: route.name,
description: route.description ?? undefined,
gpx: versionGpx,
startLat: points[0]!.lat,
startLng: points[0]!.lon,
distance: parsed.distance,
ascent: parsed.elevation.gain,
localVersion: versionNumber,
};
try {
const ctx = capabilityContextFor(service.id);
const result = await manifest.routePusher.pushRoute(ctx, input);
return {
status: "success",
remoteId: result.remoteId,
pushedAt: new Date(),
};
} catch (err) {
if (err instanceof NeedsRelinkError) return { status: "needs_relink" };
if (err instanceof ConnectionNotActiveError) return { status: "needs_relink" };
const message = err instanceof Error ? err.message : String(err);
// Map known HTTP-shape errors. The pusher throws Error / WahooHttpError;
// we don't try to recover further here.
if (message.includes("422")) {
return { status: "error", code: "validation", message };
}
if (message.includes("429")) {
return { status: "error", code: "rate_limit", message };
}
if (message.includes("401") || message.includes("403")) {
return { status: "error", code: "token_expired", message };
}
return { status: "error", code: "generic", message };
}
}

View file

@ -1,127 +0,0 @@
// COMPATIBILITY SHIM — translates the legacy TokenSet-shaped API onto the
// new connected_services / JSONB-credentials schema introduced by
// deepen-connected-services. Once the routes and pushes.server.ts migrate
// to ConnectedServiceManager (tasks 5.x of the change), this whole file
// (and the rest of apps/journal/app/lib/sync/) goes away.
//
// Do not add new callers. Use apps/journal/app/lib/connected-services/
// directly.
import { randomUUID } from "node:crypto";
import { eq, and } from "drizzle-orm";
import { getDb } from "../db.ts";
import { connectedServices } from "@trails-cool/db/schema/journal";
import type { TokenSet } from "./types.ts";
interface OAuthBlob {
access_token: string;
refresh_token: string;
expires_at: string;
}
interface LegacyConnection {
id: string;
userId: string;
provider: string;
accessToken: string;
refreshToken: string;
expiresAt: Date;
providerUserId: string | null;
grantedScopes: string[];
createdAt: Date;
}
function toLegacy(row: typeof connectedServices.$inferSelect): LegacyConnection {
const blob = row.credentials as OAuthBlob;
return {
id: row.id,
userId: row.userId,
provider: row.provider,
accessToken: blob.access_token,
refreshToken: blob.refresh_token,
expiresAt: new Date(blob.expires_at),
providerUserId: row.providerUserId,
grantedScopes: row.grantedScopes,
createdAt: row.createdAt,
};
}
function toBlob(tokens: TokenSet): OAuthBlob {
return {
access_token: tokens.accessToken,
refresh_token: tokens.refreshToken,
expires_at: tokens.expiresAt.toISOString(),
};
}
export async function saveConnection(
userId: string,
provider: string,
tokens: TokenSet,
grantedScopes: string[] = [],
) {
const db = getDb();
await db
.delete(connectedServices)
.where(
and(eq(connectedServices.userId, userId), eq(connectedServices.provider, provider)),
);
await db.insert(connectedServices).values({
id: randomUUID(),
userId,
provider,
credentialKind: "oauth",
credentials: toBlob(tokens),
status: "active",
providerUserId: tokens.providerUserId ?? null,
grantedScopes,
});
}
export async function getConnection(
userId: string,
provider: string,
): Promise<LegacyConnection | null> {
const db = getDb();
const [row] = await db
.select()
.from(connectedServices)
.where(
and(eq(connectedServices.userId, userId), eq(connectedServices.provider, provider)),
);
return row ? toLegacy(row) : null;
}
export async function getConnectionByProviderUser(
provider: string,
providerUserId: string,
): Promise<LegacyConnection | null> {
const db = getDb();
const [row] = await db
.select()
.from(connectedServices)
.where(
and(
eq(connectedServices.provider, provider),
eq(connectedServices.providerUserId, providerUserId),
),
);
return row ? toLegacy(row) : null;
}
export async function updateTokens(connectionId: string, tokens: TokenSet) {
const db = getDb();
await db
.update(connectedServices)
.set({ credentials: toBlob(tokens) })
.where(eq(connectedServices.id, connectionId));
}
export async function deleteConnection(userId: string, provider: string) {
const db = getDb();
await db
.delete(connectedServices)
.where(
and(eq(connectedServices.userId, userId), eq(connectedServices.provider, provider)),
);
}

View file

@ -1,285 +0,0 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { wahooProvider } from "./wahoo";
import { PushError, OAuthError } from "../types.ts";
const fixturePath = resolve(__dirname, "__fixtures__/wahoo-ride.fit");
const fitBuffer = readFileSync(fixturePath);
describe("wahooProvider.convertToGpx", () => {
it("converts a FIT file to valid GPX with correct coordinates", async () => {
const gpx = await wahooProvider.convertToGpx(Buffer.from(fitBuffer));
expect(gpx).not.toBeNull();
expect(gpx).toContain('<?xml version="1.0"');
expect(gpx).toContain("<gpx");
expect(gpx).toContain("<trkpt");
});
it("produces coordinates in valid lat/lon range (not semicircles)", async () => {
const gpx = await wahooProvider.convertToGpx(Buffer.from(fitBuffer));
expect(gpx).not.toBeNull();
const latMatches = gpx!.matchAll(/lat="([^"]+)"/g);
const lonMatches = gpx!.matchAll(/lon="([^"]+)"/g);
const lats = [...latMatches].map((m) => parseFloat(m[1]!));
const lons = [...lonMatches].map((m) => parseFloat(m[1]!));
expect(lats.length).toBeGreaterThan(10);
expect(lons.length).toBeGreaterThan(10);
for (const lat of lats) {
expect(lat).toBeGreaterThan(-90);
expect(lat).toBeLessThan(90);
// Should be real-world coords, not near-zero from double-conversion
expect(Math.abs(lat)).toBeGreaterThan(1);
}
for (const lon of lons) {
expect(lon).toBeGreaterThan(-180);
expect(lon).toBeLessThan(180);
}
});
it("includes ISO 8601 timestamps (not Date objects)", async () => {
const gpx = await wahooProvider.convertToGpx(Buffer.from(fitBuffer));
expect(gpx).not.toBeNull();
const timeMatches = gpx!.matchAll(/<time>([^<]+)<\/time>/g);
const times = [...timeMatches].map((m) => m[1]!);
expect(times.length).toBeGreaterThan(10);
for (const t of times) {
expect(t).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
expect(new Date(t).toString()).not.toBe("Invalid Date");
}
});
it("includes elevation data", async () => {
const gpx = await wahooProvider.convertToGpx(Buffer.from(fitBuffer));
expect(gpx).not.toBeNull();
expect(gpx).toContain("<ele>");
});
});
describe("wahooProvider.pushRoute", () => {
const tokens = {
accessToken: "test-token",
refreshToken: "refresh",
expiresAt: new Date(Date.now() + 3600_000),
};
const fit = new Uint8Array([0x01, 0x02, 0x03]);
const basePayload = {
fit,
externalId: "route:abc:v1",
providerUpdatedAt: new Date("2026-04-30T12:00:00Z"),
name: "Test route",
description: "A test",
startLat: 52.52,
startLng: 13.405,
distance: 12345,
ascent: 200,
};
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("posts a base64 FIT and returns the remote id", async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ id: 9876 }),
});
const result = await wahooProvider.pushRoute!(tokens, basePayload);
expect(result.remoteId).toBe("9876");
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchMock.mock.calls[0]!;
expect(url).toBe("https://api.wahooligan.com/v1/routes");
expect(init.method).toBe("POST");
expect(init.headers.Authorization).toBe("Bearer test-token");
expect(init.headers["Content-Type"]).toBe("application/x-www-form-urlencoded");
const body = new URLSearchParams(init.body as string);
expect(body.get("route[external_id]")).toBe("route:abc:v1");
expect(body.get("route[name]")).toBe("Test route");
expect(body.get("route[description]")).toBe("A test");
expect(body.get("route[start_lat]")).toBe("52.52");
expect(body.get("route[file]")).toBe(
`data:application/vnd.fit;base64,${Buffer.from(fit).toString("base64")}`,
);
});
it.each([
[401, "token_expired"],
[403, "scope_missing"],
[404, "not_found"],
[422, "validation"],
[429, "rate_limit"],
[500, "generic"],
])("maps HTTP %i to PushError code %s", async (status, code) => {
fetchMock.mockResolvedValueOnce({
ok: false,
status,
text: async () => "boom",
});
await expect(wahooProvider.pushRoute!(tokens, basePayload)).rejects.toMatchObject({
name: "PushError",
code,
status,
});
});
it("throws generic PushError when response lacks an id", async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({}),
});
await expect(wahooProvider.pushRoute!(tokens, basePayload)).rejects.toBeInstanceOf(PushError);
});
});
describe("wahooProvider.updateRoute", () => {
const tokens = {
accessToken: "test-token",
refreshToken: "refresh",
expiresAt: new Date(Date.now() + 3600_000),
};
const fit = new Uint8Array([0x01, 0x02, 0x03]);
const basePayload = {
fit,
externalId: "route:abc",
providerUpdatedAt: new Date("2026-04-30T12:00:00Z"),
name: "Test route",
description: "A test",
startLat: 52.52,
startLng: 13.405,
distance: 12345,
ascent: 200,
};
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("PUTs to /v1/routes/:id and keeps the same remote id", async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ id: 9876 }),
});
const result = await wahooProvider.updateRoute!(tokens, "9876", basePayload);
expect(result.remoteId).toBe("9876");
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchMock.mock.calls[0]!;
expect(url).toBe("https://api.wahooligan.com/v1/routes/9876");
expect(init.method).toBe("PUT");
const body = new URLSearchParams(init.body as string);
expect(body.get("route[external_id]")).toBe("route:abc");
expect(body.get("route[file]")).toBe(
`data:application/vnd.fit;base64,${Buffer.from(fit).toString("base64")}`,
);
});
it("falls back to the targeted remote id when PUT returns 204 (no body)", async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
status: 204,
json: async () => null,
});
const result = await wahooProvider.updateRoute!(tokens, "555", basePayload);
expect(result.remoteId).toBe("555");
});
it("throws PushError(not_found) on 404 so the pipeline can fall back to POST", async () => {
fetchMock.mockResolvedValueOnce({
ok: false,
status: 404,
text: async () => "gone",
});
await expect(wahooProvider.updateRoute!(tokens, "missing", basePayload)).rejects.toMatchObject({
name: "PushError",
code: "not_found",
status: 404,
});
});
});
describe("wahooProvider.exchangeCode", () => {
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("maps Wahoo's 'too many unrevoked access tokens' 400 to OAuthError(too_many_tokens)", async () => {
fetchMock.mockResolvedValueOnce({
ok: false,
status: 400,
text: async () =>
'{"error":"Too many unrevoked access tokens exist for this app and user. ..."}',
});
await expect(wahooProvider.exchangeCode("code", "https://x/cb")).rejects.toMatchObject({
name: "OAuthError",
code: "too_many_tokens",
status: 400,
});
});
});
describe("wahooProvider.revoke", () => {
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("DELETEs /v1/permissions with the bearer token", async () => {
fetchMock.mockResolvedValueOnce({ ok: true, status: 204, text: async () => "" });
await wahooProvider.revoke!({
accessToken: "tok",
refreshToken: "r",
expiresAt: new Date(),
});
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchMock.mock.calls[0]!;
expect(url).toBe("https://api.wahooligan.com/v1/permissions");
expect(init.method).toBe("DELETE");
expect(init.headers.Authorization).toBe("Bearer tok");
});
it("throws on non-OK response", async () => {
fetchMock.mockResolvedValueOnce({ ok: false, status: 401, text: async () => "bad" });
await expect(
wahooProvider.revoke!({ accessToken: "tok", refreshToken: "r", expiresAt: new Date() }),
).rejects.toThrow();
// Reference OAuthError to keep the import used even if future tests trim above blocks.
expect(OAuthError).toBeDefined();
});
});

View file

@ -1,291 +0,0 @@
import FitParser from "fit-file-parser";
import { generateGpx } from "@trails-cool/gpx";
import type {
SyncProvider,
TokenSet,
Workout,
WorkoutList,
WebhookEvent,
PushRoutePayload,
PushRouteResult,
} from "../types.ts";
import { PushError, OAuthError } 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 ?? "";
function buildRouteFormBody(payload: PushRoutePayload): URLSearchParams {
// Wahoo expects route[file] as a data URI, not raw base64. Sending plain
// base64 results in a route record where file.url is null and the Wahoo
// app shows the route in the list (with metadata) but renders no track.
const fitDataUri = `data:application/vnd.fit;base64,${Buffer.from(payload.fit).toString("base64")}`;
const body = new URLSearchParams({
"route[external_id]": payload.externalId,
"route[provider_updated_at]": payload.providerUpdatedAt.toISOString(),
"route[name]": payload.name,
"route[workout_type_family_id]": "0",
"route[start_lat]": payload.startLat.toString(),
"route[start_lng]": payload.startLng.toString(),
"route[distance]": payload.distance.toString(),
"route[ascent]": payload.ascent.toString(),
"route[file]": fitDataUri,
});
if (payload.description) body.set("route[description]", payload.description);
if (payload.filename) body.set("route[filename]", payload.filename);
return body;
}
async function wahooRouteRequest(
tokens: TokenSet,
method: "POST" | "PUT",
url: string,
payload: PushRoutePayload,
opts: { fallbackRemoteId?: string } = {},
): Promise<PushRouteResult> {
const resp = await fetch(url, {
method,
headers: {
Authorization: `Bearer ${tokens.accessToken}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: buildRouteFormBody(payload).toString(),
});
if (resp.ok) {
// PUT may respond 200/204 without a body. The remote id is unchanged in
// that case, so fall back to the one we just targeted.
let remoteId: string | undefined;
if (resp.status !== 204) {
const data = (await resp.json().catch(() => null)) as { id?: number | string } | null;
remoteId = data?.id?.toString();
}
remoteId ??= opts.fallbackRemoteId;
if (!remoteId) throw new PushError("generic", "Wahoo response missing route id", resp.status);
return { remoteId };
}
const text = await resp.text().catch(() => "");
if (resp.status === 401) throw new PushError("token_expired", text || "Unauthorized", 401);
if (resp.status === 403) throw new PushError("scope_missing", text || "Forbidden", 403);
if (resp.status === 404) throw new PushError("not_found", text || "Not Found", 404);
if (resp.status === 422) throw new PushError("validation", text || "Validation failed", 422);
if (resp.status === 429) throw new PushError("rate_limit", text || "Rate limited", 429);
throw new PushError("generic", `Wahoo route ${method} failed: ${resp.status} ${text}`, resp.status);
}
export const wahooProvider: SyncProvider = {
id: "wahoo",
name: "Wahoo",
scopes: ["workouts_read", "user_read", "offline_data", "routes_read", "routes_write"],
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/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: clientId(),
client_secret: clientSecret(),
code,
grant_type: "authorization_code",
redirect_uri: redirectUri,
}).toString(),
});
if (!resp.ok) {
const text = await resp.text().catch(() => "");
if (text.includes("Too many unrevoked access tokens")) {
throw new OAuthError("too_many_tokens", text, resp.status);
}
throw new OAuthError("generic", `Wahoo token exchange failed: ${resp.status} ${text}`, 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/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: clientId(),
client_secret: clientSecret(),
grant_type: "refresh_token",
refresh_token: refreshToken,
}).toString(),
});
if (!resp.ok) {
const text = await resp.text().catch(() => "");
throw new Error(`Wahoo token refresh failed: ${resp.status} ${text}`);
}
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;
fitness_app_id?: number;
workout_summary?: {
duration_active_accum?: number;
distance_accum?: number;
file?: { url?: string };
};
}>;
total: number;
page: number;
per_page: number;
};
// Wahoo does not share workout data from third-party apps (fitness_app_id >= 1000)
const wahooWorkouts = data.workouts.filter((w) => !w.fitness_app_id || w.fitness_app_id < 1000);
return {
workouts: wahooWorkouts.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);
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 | Date;
}>;
// fit-file-parser already converts semicircles to degrees
const trackPoints = records
.filter((r) => r.position_lat != null && r.position_long != null)
.map((r) => ({
lat: r.position_lat!,
lon: r.position_long!,
ele: r.altitude,
time: r.timestamp instanceof Date ? r.timestamp.toISOString() : r.timestamp,
}));
if (trackPoints.length < 2) return null;
return generateGpx({
name: "Wahoo workout",
tracks: [trackPoints],
});
},
async pushRoute(tokens: TokenSet, payload: PushRoutePayload): Promise<PushRouteResult> {
return wahooRouteRequest(tokens, "POST", `${WAHOO_API}/v1/routes`, payload);
},
async updateRoute(
tokens: TokenSet,
remoteId: string,
payload: PushRoutePayload,
): Promise<PushRouteResult> {
return wahooRouteRequest(
tokens,
"PUT",
`${WAHOO_API}/v1/routes/${encodeURIComponent(remoteId)}`,
payload,
{ fallbackRemoteId: remoteId },
);
},
async revoke(tokens: TokenSet): Promise<void> {
const resp = await fetch(`${WAHOO_API}/v1/permissions`, {
method: "DELETE",
headers: { Authorization: `Bearer ${tokens.accessToken}` },
});
if (!resp.ok) {
const text = await resp.text().catch(() => "");
throw new Error(`Wahoo revoke failed: ${resp.status} ${text}`);
}
},
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

@ -1,309 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const SHORT_GPX = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1">
<trk><trkseg>
<trkpt lat="52.5200" lon="13.4050"><ele>34.0</ele></trkpt>
<trkpt lat="52.5210" lon="13.4060"><ele>34.5</ele></trkpt>
<trkpt lat="52.5220" lon="13.4070"><ele>35.0</ele></trkpt>
</trkseg></trk>
</gpx>`;
const mockGetRoute = vi.fn();
const mockGetConnection = vi.fn();
const mockUpdateTokens = vi.fn();
const mockGetProvider = vi.fn();
const mockSelect = vi.fn();
const mockInsert = vi.fn();
const mockUpdate = vi.fn();
vi.mock("../db.ts", () => ({
getDb: () => ({
select: mockSelect,
insert: mockInsert,
update: mockUpdate,
}),
}));
vi.mock("../routes.server.ts", () => ({ getRoute: mockGetRoute }));
vi.mock("./connections.server.ts", () => ({
getConnection: mockGetConnection,
updateTokens: mockUpdateTokens,
}));
vi.mock("./registry.ts", () => ({ getProvider: mockGetProvider }));
// Each call to db.select returns a chainable thenable resolving to the next
// queued result. The pipeline does two selects: routeVersions, sync_pushes.
function queueSelectResults(...results: unknown[][]) {
for (const r of results) {
mockSelect.mockReturnValueOnce({
from: () => ({
where: () => ({
orderBy: () => ({
limit: () => Promise.resolve(r),
}),
limit: () => Promise.resolve(r),
}),
}),
});
}
}
function chainNoop() {
return {
values: () => Promise.resolve(),
set: () => ({ where: () => Promise.resolve() }),
};
}
beforeEach(() => {
vi.clearAllMocks();
mockInsert.mockImplementation(() => chainNoop());
mockUpdate.mockImplementation(() => chainNoop());
});
async function importPipeline() {
const mod = await import("./pushes.server.ts");
return mod;
}
const baseRoute = {
id: "r1",
ownerId: "u1",
name: "Test route",
description: "A test",
gpx: SHORT_GPX,
};
const baseConnection = {
id: "c1",
userId: "u1",
provider: "wahoo",
accessToken: "tok",
refreshToken: "ref",
expiresAt: new Date(Date.now() + 3600_000),
providerUserId: "42",
grantedScopes: ["routes_write", "workouts_read"],
};
function makeProvider(over: Record<string, unknown> = {}) {
return {
id: "wahoo",
name: "Wahoo",
scopes: ["routes_write"],
pushRoute: vi.fn(),
updateRoute: vi.fn(),
refreshToken: vi.fn(),
...over,
};
}
describe("pushRouteToProvider", () => {
it("performs a fresh push and records sync_pushes", async () => {
mockGetRoute.mockResolvedValue(baseRoute);
mockGetConnection.mockResolvedValue(baseConnection);
queueSelectResults([{ version: 2, gpx: SHORT_GPX }], []); // route version, sync_pushes existing
const provider = makeProvider();
provider.pushRoute.mockResolvedValue({ remoteId: "wahoo-9" });
mockGetProvider.mockReturnValue(provider);
const { pushRouteToProvider } = await importPipeline();
const out = await pushRouteToProvider({ userId: "u1", providerId: "wahoo", routeId: "r1" });
expect(out).toMatchObject({ status: "success", remoteId: "wahoo-9" });
expect(provider.pushRoute).toHaveBeenCalledOnce();
const [, payload] = provider.pushRoute.mock.calls[0]!;
expect(payload.externalId).toBe("route:r1");
expect(payload.startLat).toBeCloseTo(52.52, 4);
expect(mockInsert).toHaveBeenCalledOnce();
expect(mockUpdate).not.toHaveBeenCalled();
});
it("short-circuits when the current version is already pushed", async () => {
mockGetRoute.mockResolvedValue(baseRoute);
mockGetConnection.mockResolvedValue(baseConnection);
queueSelectResults(
[{ version: 1, gpx: SHORT_GPX }],
[{
id: "p1",
pushedAt: new Date("2026-04-01T00:00:00Z"),
remoteId: "wahoo-7",
lastPushedVersion: 1,
}],
);
const provider = makeProvider();
mockGetProvider.mockReturnValue(provider);
const { pushRouteToProvider } = await importPipeline();
const out = await pushRouteToProvider({ userId: "u1", providerId: "wahoo", routeId: "r1" });
expect(out).toMatchObject({ status: "success", remoteId: "wahoo-7" });
expect(provider.pushRoute).not.toHaveBeenCalled();
expect(provider.updateRoute).not.toHaveBeenCalled();
});
it("PUTs against the existing remote id when the local version has advanced", async () => {
mockGetRoute.mockResolvedValue(baseRoute);
mockGetConnection.mockResolvedValue(baseConnection);
queueSelectResults(
[{ version: 2, gpx: SHORT_GPX }],
[{
id: "p1",
pushedAt: new Date("2026-04-01T00:00:00Z"),
remoteId: "wahoo-7",
lastPushedVersion: 1,
}],
);
const provider = makeProvider();
provider.updateRoute.mockResolvedValue({ remoteId: "wahoo-7" });
mockGetProvider.mockReturnValue(provider);
const { pushRouteToProvider } = await importPipeline();
const out = await pushRouteToProvider({ userId: "u1", providerId: "wahoo", routeId: "r1" });
expect(out).toMatchObject({ status: "success", remoteId: "wahoo-7" });
expect(provider.updateRoute).toHaveBeenCalledOnce();
expect(provider.pushRoute).not.toHaveBeenCalled();
// Existing row updated in place — exactly one row exists afterwards.
expect(mockUpdate).toHaveBeenCalledOnce();
expect(mockInsert).not.toHaveBeenCalled();
});
it("falls back to POST when PUT returns not_found", async () => {
mockGetRoute.mockResolvedValue(baseRoute);
mockGetConnection.mockResolvedValue(baseConnection);
queueSelectResults(
[{ version: 2, gpx: SHORT_GPX }],
[{
id: "p1",
pushedAt: new Date("2026-04-01T00:00:00Z"),
remoteId: "wahoo-stale",
lastPushedVersion: 1,
}],
);
const provider = makeProvider();
const { PushError } = await import("./types.ts");
provider.updateRoute.mockRejectedValueOnce(new PushError("not_found", "gone", 404));
provider.pushRoute.mockResolvedValueOnce({ remoteId: "wahoo-fresh" });
mockGetProvider.mockReturnValue(provider);
const { pushRouteToProvider } = await importPipeline();
const out = await pushRouteToProvider({ userId: "u1", providerId: "wahoo", routeId: "r1" });
expect(out).toMatchObject({ status: "success", remoteId: "wahoo-fresh" });
expect(provider.updateRoute).toHaveBeenCalledOnce();
expect(provider.pushRoute).toHaveBeenCalledOnce();
expect(mockUpdate).toHaveBeenCalledOnce();
expect(mockInsert).not.toHaveBeenCalled();
});
it("retries a failed prior attempt and updates the existing row", async () => {
mockGetRoute.mockResolvedValue(baseRoute);
mockGetConnection.mockResolvedValue(baseConnection);
queueSelectResults(
[{ version: 1, gpx: SHORT_GPX }],
[{ id: "p1", pushedAt: null, remoteId: null, error: "generic: boom" }],
);
const provider = makeProvider();
provider.pushRoute.mockResolvedValue({ remoteId: "wahoo-77" });
mockGetProvider.mockReturnValue(provider);
const { pushRouteToProvider } = await importPipeline();
const out = await pushRouteToProvider({ userId: "u1", providerId: "wahoo", routeId: "r1" });
expect(out).toMatchObject({ status: "success", remoteId: "wahoo-77" });
expect(mockUpdate).toHaveBeenCalledOnce();
expect(mockInsert).not.toHaveBeenCalled();
});
it("redirects via scope_missing when grantedScopes lacks routes_write", async () => {
mockGetRoute.mockResolvedValue(baseRoute);
mockGetConnection.mockResolvedValue({ ...baseConnection, grantedScopes: ["workouts_read"] });
mockGetProvider.mockReturnValue(makeProvider());
const { pushRouteToProvider } = await importPipeline();
const out = await pushRouteToProvider({ userId: "u1", providerId: "wahoo", routeId: "r1" });
expect(out).toEqual({ status: "scope_missing" });
});
it("returns no_connection when no sync_connections row exists", async () => {
mockGetRoute.mockResolvedValue(baseRoute);
mockGetConnection.mockResolvedValue(null);
mockGetProvider.mockReturnValue(makeProvider());
const { pushRouteToProvider } = await importPipeline();
const out = await pushRouteToProvider({ userId: "u1", providerId: "wahoo", routeId: "r1" });
expect(out).toEqual({ status: "no_connection" });
});
it("returns not_owner when the requesting user isn't the owner", async () => {
mockGetRoute.mockResolvedValue({ ...baseRoute, ownerId: "other" });
mockGetProvider.mockReturnValue(makeProvider());
const { pushRouteToProvider } = await importPipeline();
const out = await pushRouteToProvider({ userId: "u1", providerId: "wahoo", routeId: "r1" });
expect(out).toEqual({ status: "not_owner" });
});
it("refreshes tokens and retries once on token_expired", async () => {
mockGetRoute.mockResolvedValue(baseRoute);
mockGetConnection.mockResolvedValue(baseConnection);
queueSelectResults([{ version: 1, gpx: SHORT_GPX }], []);
const provider = makeProvider();
const { PushError } = await import("./types.ts");
provider.pushRoute
.mockRejectedValueOnce(new PushError("token_expired", "401", 401))
.mockResolvedValueOnce({ remoteId: "wahoo-after-refresh" });
provider.refreshToken.mockResolvedValue({
accessToken: "new-tok",
refreshToken: "new-ref",
expiresAt: new Date(Date.now() + 3600_000),
});
mockGetProvider.mockReturnValue(provider);
const { pushRouteToProvider } = await importPipeline();
const out = await pushRouteToProvider({ userId: "u1", providerId: "wahoo", routeId: "r1" });
expect(out).toMatchObject({ status: "success", remoteId: "wahoo-after-refresh" });
expect(provider.refreshToken).toHaveBeenCalledOnce();
expect(provider.pushRoute).toHaveBeenCalledTimes(2);
expect(mockUpdateTokens).toHaveBeenCalledWith("c1", expect.objectContaining({ accessToken: "new-tok" }));
});
it("records validation errors and surfaces them to the caller", async () => {
mockGetRoute.mockResolvedValue(baseRoute);
mockGetConnection.mockResolvedValue(baseConnection);
queueSelectResults([{ version: 1, gpx: SHORT_GPX }], []);
const provider = makeProvider();
const { PushError } = await import("./types.ts");
provider.pushRoute.mockRejectedValue(new PushError("validation", "bad name", 422));
mockGetProvider.mockReturnValue(provider);
const { pushRouteToProvider } = await importPipeline();
const out = await pushRouteToProvider({ userId: "u1", providerId: "wahoo", routeId: "r1" });
expect(out).toMatchObject({ status: "error", code: "validation" });
expect(mockInsert).toHaveBeenCalledOnce();
});
});
describe("encodeOAuthState / decodeOAuthState", () => {
it("round-trips a push-after state", async () => {
const { encodeOAuthState, decodeOAuthState } = await importPipeline();
const encoded = encodeOAuthState({ pushAfter: { routeId: "abc" }, returnTo: "/routes/abc" });
expect(decodeOAuthState(encoded)).toEqual({ pushAfter: { routeId: "abc" }, returnTo: "/routes/abc" });
});
it("returns empty object for missing or malformed state", async () => {
const { decodeOAuthState } = await importPipeline();
expect(decodeOAuthState(null)).toEqual({});
expect(decodeOAuthState("not-base64-json!!")).toEqual({});
});
});

View file

@ -1,222 +0,0 @@
import { randomUUID } from "node:crypto";
import { and, desc, eq } from "drizzle-orm";
import { gpxToFitCourse } from "@trails-cool/fit";
import { parseGpxAsync } from "@trails-cool/gpx";
import { routeVersions, syncPushes } from "@trails-cool/db/schema/journal";
import { getDb } from "../db.ts";
import { getRoute } from "../routes.server.ts";
import { getConnection, updateTokens } from "./connections.server.ts";
import { getProvider } from "./registry.ts";
import { PushError, providerSupportsPush } from "./types.ts";
import type { TokenSet } from "./types.ts";
export type PushOutcome =
| { status: "success"; remoteId: string; pushedAt: Date }
| { status: "scope_missing" }
| { status: "no_connection" }
| { status: "not_owner" }
| { status: "not_found" }
| { status: "unsupported_provider" }
| { status: "no_geometry" }
| { status: "error"; code: "validation" | "rate_limit" | "token_expired" | "generic"; message: string };
export interface PushRouteOptions {
userId: string;
providerId: string;
routeId: string;
}
/**
* End-to-end push pipeline. Resolves the route, checks ownership and scopes,
* short-circuits on already-pushed versions, refreshes tokens on 401, and
* records the outcome in `sync_pushes`.
*/
export async function pushRouteToProvider(opts: PushRouteOptions): Promise<PushOutcome> {
const { userId, providerId, routeId } = opts;
const db = getDb();
const provider = getProvider(providerId);
if (!provider) return { status: "not_found" };
if (!providerSupportsPush(provider)) return { status: "unsupported_provider" };
const route = await getRoute(routeId);
if (!route) return { status: "not_found" };
if (route.ownerId !== userId) return { status: "not_owner" };
const connection = await getConnection(userId, providerId);
if (!connection) return { status: "no_connection" };
if (!connection.grantedScopes.includes("routes_write")) return { status: "scope_missing" };
const [latestVersion] = await db
.select()
.from(routeVersions)
.where(eq(routeVersions.routeId, routeId))
.orderBy(desc(routeVersions.version))
.limit(1);
const versionGpx = latestVersion?.gpx ?? route.gpx;
const versionNumber = latestVersion?.version ?? 1;
if (!versionGpx) return { status: "no_geometry" };
// Idempotency key is per (user, route, provider) — a logical Wahoo route is
// per-route, not per-version. The version that's currently on Wahoo is a
// property of the row, not part of the key.
const existing = await db
.select()
.from(syncPushes)
.where(
and(
eq(syncPushes.userId, userId),
eq(syncPushes.routeId, routeId),
eq(syncPushes.provider, providerId),
),
)
.limit(1);
const existingRow = existing[0];
// Already-pushed unchanged route: short-circuit.
if (
existingRow?.pushedAt &&
existingRow.remoteId &&
existingRow.lastPushedVersion === versionNumber
) {
return { status: "success", remoteId: existingRow.remoteId, pushedAt: existingRow.pushedAt };
}
// Build payload from the locked-in version GPX, not routes.gpx.
const parsed = await parseGpxAsync(versionGpx);
const points = parsed.tracks.flat();
if (points.length === 0) return { status: "no_geometry" };
const fit = await gpxToFitCourse({
gpx: versionGpx,
name: route.name,
description: route.description ?? undefined,
});
const externalId = `route:${routeId}`;
const payload = {
fit,
externalId,
providerUpdatedAt: new Date(),
name: route.name,
description: route.description ?? undefined,
startLat: points[0]!.lat,
startLng: points[0]!.lon,
distance: parsed.distance,
ascent: parsed.elevation.gain,
};
let tokens: TokenSet = {
accessToken: connection.accessToken,
refreshToken: connection.refreshToken,
expiresAt: connection.expiresAt,
providerUserId: connection.providerUserId ?? undefined,
};
if (Date.now() >= tokens.expiresAt.getTime()) {
tokens = await provider.refreshToken(tokens.refreshToken);
await updateTokens(connection.id, tokens);
}
const recordResult = async (
success: boolean,
remoteId: string | null,
error: string | null,
): Promise<void> => {
const now = new Date();
if (existingRow) {
await db
.update(syncPushes)
.set({
remoteId,
lastPushedVersion: success ? versionNumber : existingRow.lastPushedVersion,
pushedAt: success ? now : null,
error,
updatedAt: now,
})
.where(eq(syncPushes.id, existingRow.id));
} else {
await db.insert(syncPushes).values({
id: randomUUID(),
userId,
routeId,
provider: providerId,
externalId,
remoteId,
lastPushedVersion: success ? versionNumber : null,
pushedAt: success ? now : null,
error,
});
}
};
// POST on first push (or after a failed push with no remote_id), PUT on
// subsequent pushes. PUT 404 means the user deleted the Wahoo route on
// their side — fall back to POST and overwrite remote_id.
const callProvider = async (toks: TokenSet) => {
if (existingRow?.remoteId && provider.updateRoute) {
try {
return await provider.updateRoute(toks, existingRow.remoteId, payload);
} catch (e) {
if (e instanceof PushError && e.code === "not_found") {
return provider.pushRoute(toks, payload);
}
throw e;
}
}
return provider.pushRoute(toks, payload);
};
try {
let result;
try {
result = await callProvider(tokens);
} catch (e) {
if (e instanceof PushError && e.code === "token_expired") {
// 401 mid-call — refresh once and retry.
tokens = await provider.refreshToken(tokens.refreshToken);
await updateTokens(connection.id, tokens);
result = await callProvider(tokens);
} else {
throw e;
}
}
await recordResult(true, result.remoteId, null);
return { status: "success", remoteId: result.remoteId, pushedAt: new Date() };
} catch (e) {
if (e instanceof PushError) {
await recordResult(false, null, `${e.code}: ${e.message}`);
if (e.code === "scope_missing") return { status: "scope_missing" };
// not_found should have been handled by the PUT→POST fallback; if it
// ever surfaces here it means even the POST returned 404. Surface as
// a generic error rather than widening the public PushOutcome union.
const code = e.code === "not_found" ? "generic" : e.code;
return { status: "error", code, message: e.message };
}
const message = e instanceof Error ? e.message : String(e);
await recordResult(false, null, `generic: ${message}`);
return { status: "error", code: "generic", message };
}
}
// State payload encoded into the OAuth `state` query param so the callback
// can resume a push that was interrupted by a re-auth redirect.
export interface PushOAuthState {
pushAfter?: { routeId: string };
returnTo?: string;
}
export function encodeOAuthState(state: PushOAuthState): string {
return Buffer.from(JSON.stringify(state), "utf8").toString("base64url");
}
export function decodeOAuthState(raw: string | null | undefined): PushOAuthState {
if (!raw) return {};
try {
const json = Buffer.from(raw, "base64url").toString("utf8");
const parsed = JSON.parse(json) as PushOAuthState;
return typeof parsed === "object" && parsed != null ? parsed : {};
} catch {
return {};
}
}

View file

@ -1,12 +0,0 @@
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

@ -1,124 +0,0 @@
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 PushRoutePayload {
/** Pre-encoded FIT Course bytes — the action route runs gpxToFitCourse before calling pushRoute. */
fit: Uint8Array;
/** Stable per route — `route:<routeId>` for trails.cool. */
externalId: string;
providerUpdatedAt: Date;
name: string;
description?: string;
/** Decimal degrees. */
startLat: number;
startLng: number;
/** Meters. */
distance: number;
/** Total elevation gain in meters. */
ascent: number;
filename?: string;
}
export interface PushRouteResult {
remoteId: string;
}
export type PushErrorCode =
| "scope_missing"
| "token_expired"
| "validation"
| "rate_limit"
| "not_found"
| "generic";
export type OAuthErrorCode = "too_many_tokens" | "generic";
export class OAuthError extends Error {
code: OAuthErrorCode;
status?: number;
constructor(code: OAuthErrorCode, message: string, status?: number) {
super(message);
this.name = "OAuthError";
this.code = code;
this.status = status;
}
}
export class PushError extends Error {
code: PushErrorCode;
status?: number;
constructor(code: PushErrorCode, message: string, status?: number) {
super(message);
this.name = "PushError";
this.code = code;
this.status = status;
}
}
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;
/** Optional: providers that can accept routes implement this. UI hides the action when undefined. */
pushRoute?: (tokens: TokenSet, payload: PushRoutePayload) => Promise<PushRouteResult>;
/**
* Optional: update an already-pushed route in place. Throws PushError("not_found")
* if the remote route no longer exists; callers should fall back to pushRoute.
*/
updateRoute?: (
tokens: TokenSet,
remoteId: string,
payload: PushRoutePayload,
) => Promise<PushRouteResult>;
/** Optional: revoke the given access token at the provider. Best-effort — failures should be swallowed by callers. */
revoke?: (tokens: TokenSet) => Promise<void>;
}
export function providerSupportsPush(
provider: SyncProvider,
): provider is SyncProvider & { pushRoute: NonNullable<SyncProvider["pushRoute"]> } {
return typeof provider.pushRoute === "function";
}

View file

@ -1,17 +1,20 @@
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";
import { decodeOAuthState, pushRouteToProvider } from "~/lib/sync/pushes.server";
import { OAuthError } from "~/lib/sync/types";
import { getManifest, link } from "~/lib/connected-services";
import {
decodeOAuthState,
} from "~/lib/connected-services/oauth-state.server";
import { pushRouteToProvider } from "~/lib/connected-services/push-action.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 manifest = getManifest(params.provider);
if (!manifest || !manifest.exchangeCode) {
return data({ error: "Unknown provider" }, { status: 404 });
}
const url = new URL(request.url);
const state = decodeOAuthState(url.searchParams.get("state"));
@ -30,25 +33,34 @@ export async function loader({ params, request }: Route.LoaderArgs) {
const redirectUri = `${origin}/api/sync/callback/${params.provider}`;
try {
const tokens = await provider.exchangeCode(code, redirectUri);
// Wahoo's token endpoint doesn't return a `scope` field and grants
// scopes all-or-nothing, so the requested set is the granted set.
await saveConnection(user.id, provider.id, tokens, provider.scopes);
const exchange = await manifest.exchangeCode(code, redirectUri);
await link({
userId: user.id,
provider: manifest.id,
credentialKind: manifest.credentialKind,
credentials: exchange.credentials as Record<string, unknown>,
providerUserId: exchange.providerUserId,
grantedScopes: exchange.grantedScopes,
});
} catch (e) {
console.error(`OAuth callback failed for ${params.provider}:`, e);
const code = e instanceof OAuthError ? e.code : "sync_failed";
return redirect(`${fallbackReturn}?error=${code}`);
const errCode =
typeof (e as { code?: string }).code === "string"
? (e as { code: string }).code
: "sync_failed";
return redirect(`${fallbackReturn}?error=${errCode}`);
}
if (state.pushAfter?.routeId) {
const outcome = await pushRouteToProvider({
userId: user.id,
providerId: provider.id,
providerId: manifest.id,
routeId: state.pushAfter.routeId,
});
const target = state.returnTo ?? `/routes/${state.pushAfter.routeId}`;
if (outcome.status === "success") return redirect(`${target}?push=success`);
if (outcome.status === "scope_missing") return redirect(`${target}?push=needs_permission`);
if (outcome.status === "needs_relink") return redirect(`${target}?push=needs_permission`);
if (outcome.status === "error") return redirect(`${target}?push=error&code=${outcome.code}`);
return redirect(`${target}?push=${outcome.status}`);
}

View file

@ -1,19 +1,21 @@
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";
import { encodeOAuthState } from "~/lib/sync/pushes.server";
import { getManifest } from "~/lib/connected-services";
import { encodeOAuthState } from "~/lib/connected-services/oauth-state.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 manifest = getManifest(params.provider);
if (!manifest || !manifest.buildAuthUrl) {
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 = encodeOAuthState({ returnTo: "/settings/connections" });
return redirect(provider.getAuthUrl(redirectUri, state));
return redirect(manifest.buildAuthUrl(redirectUri, state));
}

View file

@ -1,32 +1,18 @@
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, getConnection } from "~/lib/sync/connections.server";
import { getManifest, unlinkByUserProvider } from "~/lib/connected-services";
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 });
const manifest = getManifest(params.provider);
if (!manifest) return data({ error: "Unknown provider" }, { status: 404 });
if (provider.revoke) {
const conn = await getConnection(user.id, provider.id);
if (conn) {
try {
await provider.revoke({
accessToken: conn.accessToken,
refreshToken: conn.refreshToken,
expiresAt: conn.expiresAt,
});
} catch (e) {
// Best-effort: token may already be expired/revoked. Drop the local row regardless.
console.warn(`Failed to revoke ${provider.id} token (continuing with disconnect):`, e);
}
}
}
await deleteConnection(user.id, provider.id);
// unlinkByUserProvider best-effort revokes at the provider, then deletes
// the local row regardless of revoke outcome. Imported activities are
// retained (FK is set null on imports.activityId, not cascaded).
await unlinkByUserProvider(user.id, manifest.id);
return redirect("/settings");
}

View file

@ -1,20 +1,21 @@
import { redirect, data } from "react-router";
import type { Route } from "./+types/api.sync.push.$provider.$routeId";
import { getSessionUser } from "~/lib/auth.server";
import { getProvider } from "~/lib/sync/registry";
import { pushRouteToProvider, encodeOAuthState } from "~/lib/sync/pushes.server";
import { getManifest } from "~/lib/connected-services";
import { pushRouteToProvider } from "~/lib/connected-services/push-action.server";
import { encodeOAuthState } from "~/lib/connected-services/oauth-state.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 });
const manifest = getManifest(params.provider);
if (!manifest) return data({ error: "Unknown provider" }, { status: 404 });
const returnTo = `/routes/${params.routeId}`;
const outcome = await pushRouteToProvider({
userId: user.id,
providerId: provider.id,
providerId: manifest.id,
routeId: params.routeId,
});
@ -22,14 +23,19 @@ export async function action({ params, request }: Route.ActionArgs) {
case "success":
return redirect(`${returnTo}?push=success`);
case "scope_missing": {
if (!manifest.buildAuthUrl) {
return redirect(`${returnTo}?push=needs_permission`);
}
const origin = process.env.ORIGIN ?? "http://localhost:3000";
const redirectUri = `${origin}/api/sync/callback/${provider.id}`;
const redirectUri = `${origin}/api/sync/callback/${manifest.id}`;
const state = encodeOAuthState({
pushAfter: { routeId: params.routeId },
returnTo,
});
return redirect(provider.getAuthUrl(redirectUri, state));
return redirect(manifest.buildAuthUrl(redirectUri, state));
}
case "needs_relink":
return redirect(`${returnTo}?push=needs_permission`);
case "no_connection":
return redirect(`${returnTo}?push=no_connection`);
case "no_geometry":

View file

@ -1,68 +1,37 @@
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";
import { getManifest } from "~/lib/connected-services";
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 manifest = getManifest(params.provider);
if (!manifest || !manifest.webhookReceiver) {
// Don't reveal provider existence — return 200 silently.
return data({ ok: true });
}
const body = await request.json();
// Verify webhook token
// Verify webhook token (provider-specific shared secret).
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
if (
expectedToken &&
(body as { webhook_token?: string }).webhook_token !== expectedToken
) {
return data({ ok: true });
}
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 });
const event = manifest.webhookReceiver.parseWebhook(body);
if (!event) 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 (if file available)
const workout = { id: event.workoutId, name: "", type: "", startedAt: "", duration: null, distance: null, fileUrl: event.fileUrl };
let gpx: string | null = null;
if (workout.fileUrl) {
const fileBuffer = await provider.downloadFile(tokens, workout);
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);
await manifest.webhookReceiver.handle(event);
} catch (e) {
console.error(`Webhook import failed for ${provider.id}/${event.workoutId}:`, e);
console.error(`Webhook import failed for ${manifest.id}/${event.workoutId}:`, e);
}
// Always return 200 to acknowledge the webhook
return data({ ok: true });
}

View file

@ -7,7 +7,7 @@ import { canView, getSessionUser } from "~/lib/auth.server";
import { getRoute, getRouteWithVersions, deleteRoute, updateRoute } from "~/lib/routes.server";
import { getDb } from "~/lib/db";
import { syncPushes } from "@trails-cool/db/schema/journal";
import { getConnection } from "~/lib/sync/connections.server";
import { getService } from "~/lib/connected-services";
import { ClientDate } from "~/components/ClientDate";
import { ClientMap } from "~/components/ClientMap";
@ -62,7 +62,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
}
| null = null;
if (isOwner && user && !!route.gpx) {
const connection = await getConnection(user.id, "wahoo");
const connection = await getService(user.id, "wahoo");
let latest:
| {
pushedAt: string | null;

View file

@ -5,7 +5,7 @@ import type { Route } from "./+types/settings.connections";
import { getSessionUser } from "~/lib/auth.server";
import { getDb } from "~/lib/db";
import { connectedServices } from "@trails-cool/db/schema/journal";
import { getAllProviders } from "~/lib/sync/registry";
import { getAllManifests } from "~/lib/connected-services";
const KNOWN_ERRORS = ["too_many_tokens", "sync_failed", "generic"] as const;
type KnownError = (typeof KNOWN_ERRORS)[number];
@ -30,9 +30,14 @@ export async function loader({ request }: Route.LoaderArgs) {
.from(connectedServices)
.where(eq(connectedServices.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 };
const providers = getAllManifests().map((m) => {
const conn = connections.find((c) => c.provider === m.id);
return {
id: m.id,
name: m.displayName,
connected: !!conn,
providerUserId: conn?.providerUserId,
};
});
return data({ providers });

View file

@ -3,8 +3,11 @@ import { data, redirect, useFetcher } 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 {
getManifest,
getService,
capabilityContextFor,
} from "~/lib/connected-services";
import { getImportedIds, recordImport } from "~/lib/sync/imports.server";
import { createActivity } from "~/lib/activities.server";
import { ClientDate } from "~/components/ClientDate";
@ -13,35 +16,28 @@ 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 manifest = getManifest(params.provider);
if (!manifest || !manifest.importer) {
throw data({ error: "Unknown provider" }, { status: 404 });
}
const connection = await getConnection(user.id, provider.id);
if (!connection) return redirect("/settings");
const service = await getService(user.id, manifest.id);
if (!service) 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 ctx = capabilityContextFor(service.id);
const workoutList = await manifest.importer.listImportable(ctx, page);
const workoutList = await provider.listWorkouts(tokens, page);
const importedIds = await getImportedIds(
user.id,
provider.id,
manifest.id,
workoutList.workouts.map((w) => w.id),
);
return data({
provider: { id: provider.id, name: provider.name },
provider: { id: manifest.id, name: manifest.displayName },
workouts: workoutList.workouts.map((w) => ({
...w,
imported: importedIds.has(w.id),
@ -55,11 +51,13 @@ 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 manifest = getManifest(params.provider);
if (!manifest || !manifest.importer) {
throw data({ error: "Unknown provider" }, { status: 404 });
}
const connection = await getConnection(user.id, provider.id);
if (!connection) return redirect("/settings");
const service = await getService(user.id, manifest.id);
if (!service) return redirect("/settings");
const formData = await request.formData();
const workoutId = formData.get("workoutId") as string;
@ -69,33 +67,56 @@ export async function action({ params, request }: Route.ActionArgs) {
const distance = formData.get("distance") as string;
const duration = formData.get("duration") 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);
}
// Inline import here (not via Importer.importOne) so we can pass through
// the form-supplied metadata (started_at, distance, duration) the
// capability seam doesn't carry. The seam-shape importer is reserved for
// automatic / webhook-driven imports.
let gpx: string | null = null;
if (fileUrl) {
const workout = { id: workoutId, name: workoutName, type: "", startedAt: "", duration: null, distance: null, fileUrl };
const fileBuffer = await provider.downloadFile(tokens, workout);
gpx = await provider.convertToGpx(fileBuffer);
const ctx = capabilityContextFor(service.id);
// Wahoo CDN URLs are pre-signed; we still go through withFreshCredentials
// so the manager handles refresh of any near-expired token.
const buffer = await ctx.withFreshCredentials(async () => {
const resp = await fetch(fileUrl);
if (!resp.ok) throw new Error(`Download failed: ${resp.status}`);
return Buffer.from(await resp.arrayBuffer());
});
// Lazy-load to avoid bundling fit-file-parser into all routes.
const { default: FitParser } = await import("fit-file-parser");
const { generateGpx } = await import("@trails-cool/gpx");
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(buffer as any, (err: unknown, d: any) => (err ? reject(err) : resolve(d ?? {})));
});
const records = (parsed.records ?? []) as Array<{
position_lat?: number;
position_long?: number;
altitude?: number;
timestamp?: string | Date;
}>;
const trackPoints = records
.filter((r) => r.position_lat != null && r.position_long != null)
.map((r) => ({
lat: r.position_lat!,
lon: r.position_long!,
ele: r.altitude,
time: r.timestamp instanceof Date ? r.timestamp.toISOString() : r.timestamp,
}));
if (trackPoints.length >= 2) {
gpx = generateGpx({ name: workoutName, tracks: [trackPoints] });
}
}
const activityId = await createActivity(user.id, {
name: workoutName || `${provider.name} workout`,
name: workoutName || `${manifest.displayName} workout`,
gpx: gpx ?? undefined,
distance: distance ? parseFloat(distance) : null,
duration: duration ? parseInt(duration) : null,
startedAt: startedAt ? new Date(startedAt) : null,
});
await recordImport(user.id, provider.id, workoutId, activityId);
await recordImport(user.id, manifest.id, workoutId, activityId);
return data({ imported: workoutId });
}