Add Wahoo push action route + OAuth callback resume

Adds /api/sync/push/:provider/:routeId as the user-triggered entry
point for pushing a route. The route action delegates to a new
pushes.server.ts pipeline that:

- resolves the latest route_versions row and uses that GPX (not
  routes.gpx) so the bytes Wahoo gets match the snapshot the user
  sees
- short-circuits on (user, route, version, provider) idempotency:
  a successful prior push returns the existing remote_id without
  re-calling Wahoo
- detects scope_missing before hitting Wahoo and redirects through
  getAuthUrl with a base64url-encoded state carrying pushAfter +
  returnTo
- refreshes tokens once on PushError({ code: "token_expired" }) and
  retries the push, then updates sync_connections in place
- records every outcome in sync_pushes (insert on first attempt,
  update on retry) so the UI can show success/failure state

The OAuth callback handler now decodes the state, resumes a
pushAfter pipeline server-side after exchangeCode, and handles the
?error=access_denied path with a needs_permission notice.

Also flips packages/fit/src/fitsdk.d.ts to a regular .ts side-effect
shim so journal's tsc picks up the @garmin/fitsdk module declaration
when consuming the workspace package via source.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-30 22:41:24 +02:00
parent 5b0bf40b97
commit a9c0093877
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
8 changed files with 526 additions and 12 deletions

View file

@ -0,0 +1,245 @@
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(),
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:v2");
expect(payload.startLat).toBeCloseTo(52.52, 4);
expect(mockInsert).toHaveBeenCalledOnce();
expect(mockUpdate).not.toHaveBeenCalled();
});
it("short-circuits when the 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" }],
);
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();
});
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

@ -0,0 +1,194 @@
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: if this exact (user, route, version) was already pushed, return that result.
const existing = await db
.select()
.from(syncPushes)
.where(
and(
eq(syncPushes.userId, userId),
eq(syncPushes.routeId, routeId),
eq(syncPushes.routeVersion, versionNumber),
eq(syncPushes.provider, providerId),
),
)
.limit(1);
const existingRow = existing[0];
if (existingRow?.pushedAt && existingRow.remoteId) {
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}:v${versionNumber}`;
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,
pushedAt: success ? now : null,
error,
updatedAt: now,
})
.where(eq(syncPushes.id, existingRow.id));
} else {
await db.insert(syncPushes).values({
id: randomUUID(),
userId,
routeId,
routeVersion: versionNumber,
provider: providerId,
externalId,
remoteId,
pushedAt: success ? now : null,
error,
});
}
};
try {
let result;
try {
result = await provider.pushRoute(tokens, payload);
} 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 provider.pushRoute(tokens, payload);
} 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" };
return { status: "error", code: e.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

@ -52,6 +52,7 @@ export default [
route("api/sync/callback/:provider", "routes/api.sync.callback.$provider.ts"),
route("api/sync/disconnect/:provider", "routes/api.sync.disconnect.$provider.ts"),
route("api/sync/webhook/:provider", "routes/api.sync.webhook.$provider.ts"),
route("api/sync/push/:provider/:routeId", "routes/api.sync.push.$provider.$routeId.ts"),
route("privacy", "routes/privacy.tsx"),
route("legal/imprint", "routes/legal.imprint.tsx"),
route("legal/terms", "routes/legal.terms.tsx"),

View file

@ -3,6 +3,7 @@ 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";
export async function loader({ params, request }: Route.LoaderArgs) {
const user = await getSessionUser(request);
@ -12,6 +13,15 @@ export async function loader({ params, request }: Route.LoaderArgs) {
if (!provider) return data({ error: "Unknown provider" }, { status: 404 });
const url = new URL(request.url);
const state = decodeOAuthState(url.searchParams.get("state"));
const fallbackReturn = state.returnTo ?? "/settings";
// User denied the new scope at Wahoo. Send them back to the originating
// page with a notice instead of looping them through OAuth again.
if (url.searchParams.get("error") === "access_denied") {
return redirect(`${fallbackReturn}?push=needs_permission`);
}
const code = url.searchParams.get("code");
if (!code) return data({ error: "Missing authorization code" }, { status: 400 });
@ -25,8 +35,21 @@ export async function loader({ params, request }: Route.LoaderArgs) {
await saveConnection(user.id, provider.id, tokens, provider.scopes);
} catch (e) {
console.error(`OAuth callback failed for ${params.provider}:`, e);
return redirect("/settings?error=sync_failed");
return redirect(`${fallbackReturn}?error=sync_failed`);
}
return redirect("/settings");
if (state.pushAfter?.routeId) {
const outcome = await pushRouteToProvider({
userId: user.id,
providerId: provider.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 === "error") return redirect(`${target}?push=error&code=${outcome.code}`);
return redirect(`${target}?push=${outcome.status}`);
}
return redirect(state.returnTo ?? "/settings");
}

View file

@ -0,0 +1,50 @@
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";
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 returnTo = `/routes/${params.routeId}`;
const outcome = await pushRouteToProvider({
userId: user.id,
providerId: provider.id,
routeId: params.routeId,
});
switch (outcome.status) {
case "success":
return redirect(`${returnTo}?push=success`);
case "scope_missing": {
const origin = process.env.ORIGIN ?? "http://localhost:3000";
const redirectUri = `${origin}/api/sync/callback/${provider.id}`;
const state = encodeOAuthState({
pushAfter: { routeId: params.routeId },
returnTo,
});
return redirect(provider.getAuthUrl(redirectUri, state));
}
case "no_connection":
return redirect(`${returnTo}?push=no_connection`);
case "no_geometry":
return redirect(`${returnTo}?push=no_geometry`);
case "not_owner":
return data({ error: "Forbidden" }, { status: 403 });
case "not_found":
return data({ error: "Not found" }, { status: 404 });
case "unsupported_provider":
return data({ error: "Provider does not support push" }, { status: 400 });
case "error":
return redirect(`${returnTo}?push=error&code=${outcome.code}`);
}
}
export function loader() {
return data({ error: "Method not allowed" }, { status: 405 });
}