Merge pull request #361 from trails-cool/architecture/deepen-connected-services

Deepen connected-services architecture (sync provider seam)
This commit is contained in:
Ullrich Schäfer 2026-05-08 01:39:44 +02:00 committed by GitHub
commit eee5689a75
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
50 changed files with 3099 additions and 1495 deletions

126
CONTEXT.md Normal file
View file

@ -0,0 +1,126 @@
# trails.cool domain glossary
This file names the domain concepts used in the codebase. New terms get added
here as decisions crystallize during architecture work; the goal is that one
concept has one name everywhere — specs, code, conversations.
If you're naming a new module, a new column, or a new UI surface, look here
first. If the term you need isn't here, propose it (don't invent a synonym).
---
## Connected Services
The user-facing surface for linking external accounts and devices to a Journal
account. Spec: `openspec/specs/connected-services/`.
### ConnectedService
A single linked external account or device, owned by one user. Stored in the
`connected_services` table (renamed from `sync_connections`). At most one row
per `(user_id, provider)`.
### provider
String identifier for the external system: `wahoo`, `komoot`, `apple-health`,
and future `coros`, `garmin`, `strava`. The provider determines the
`credential_kind` and which capabilities (import / push / webhook) the
connection has, via the provider's manifest.
### credential kind
Discriminator on `connected_services` describing the credential shape stored
in the `credentials` JSONB blob. Three kinds today:
- **oauth** — OAuth2 access token, refresh token, expiry. Wahoo, and the
expected shape for Coros / Garmin / Strava.
- **web-login** — email + encrypted password + session jar. Komoot. No
official API; we authenticate against the provider's normal web login and
reuse the resulting session cookies. Refresh = re-login. Web-login
breakage (form changes, captchas, password rotation) surfaces at the
import layer, not at the credential layer.
- **device** — no remote credential. Apple Health (and future Health Connect
on Android). Data arrives via authenticated mobile API uploads, not
server-initiated pulls. The `credentials` blob is empty; the connection
exists so the UI can show "Apple Health is paired."
Credential kind is determined by the provider via its manifest, but stored
explicitly on the row so queries don't need to join the manifest.
### granted_scopes
Column on `connected_services`, populated only for `credential_kind = oauth`,
NULL otherwise. Lists the OAuth scopes the user actually granted (e.g.
`routes_write`). Feature gates query this column directly; missing a scope
triggers re-authorization.
### provider_user_id
The external service's identifier for the user. Used to route incoming
webhooks to the right local user. Nullable (Apple Health has none in the
remote-id sense).
### CredentialAdapter
Per-kind module that knows how to maintain credentials of that kind:
- `oauth.refresh(creds) → creds | NeedsRelink`
- `web-login.relogin(creds) → creds | InvalidCredentials`
- `device` — no-op
Adapters do not import data, push routes, or handle webhooks. They only own
the credential lifecycle.
### ConnectedServiceManager
The deep module callers see. Owns:
- `link(userId, provider, credentials)` / `unlink(serviceId)`
- `withFreshCredentials(serviceId, fn)` — refreshes via the right
`CredentialAdapter` if expired, calls `fn(creds)`, marks the connection
`needs_relink` if refresh fails.
- `markNeedsRelink(serviceId, reason)` — called by import / push / webhook
layers when they observe a credential failure (e.g. Komoot web-login
fails, Wahoo returns 401 after a successful refresh).
Per-provider importers / pushers / webhook handlers always go through
`withFreshCredentials` — they never read the `credentials` JSONB directly.
### provider manifest
Per-provider declaration co-located with the provider's code
(`providers/wahoo/manifest.ts`, etc.). Declares:
- the provider's `credential_kind`
- which capabilities the provider implements (import? push? webhook?)
- references to the per-capability modules
A small `providers/registry.ts` imports each manifest. Adding a provider is
one new directory plus one import line.
## Sync Capabilities
Three orthogonal capabilities a provider may implement. Each is its own seam
when there are ≥2 adapters; today most are single-adapter and held to a
named shape so the second adapter doesn't reshape the interface.
### Importer
Pulls workouts / activities from the external service into the Journal.
Wahoo (OAuth pull), Komoot (web-login pull), Apple Health (mobile-pushed) all
implement this, with very different mechanics. Dedup via `sync_imports`.
### RoutePusher
Pushes a Journal route out to the external service. One adapter today
(Wahoo); the seam exists so Coros / Garmin / Strava push won't reshape it.
The public seam is `pushRoute(connectedService, route) → {remoteId, version}`.
Provider-specific concerns — FIT Course conversion, the `route:<id>`
`external_id` convention, the PUT→POST-on-404 fallback — are **handled
internally by the adapter**, not exposed on the seam. Idempotency is tracked
via `sync_pushes`.
### WebhookReceiver
Handles inbound webhooks. Today: Wahoo workout-published. Routes incoming
webhooks to the right user via `provider_user_id`. Unknown
`provider_user_id` returns 200 and is silently dropped (no leak).
## Storage tables
- `connected_services` — user ↔ provider links (renamed from
`sync_connections`).
- `sync_imports` — dedup cache for imported workouts, keyed by
`(user_id, provider, workout_id)`.
- `sync_pushes` — push state per `(user_id, route_id, provider)`
`remote_id`, `last_pushed_version`.

View file

@ -0,0 +1,139 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { oauthCredentialAdapter } from "./oauth.ts";
import {
NeedsRelinkError,
type OAuthCredentials,
type ProviderOAuthConfig,
} from "../types.ts";
const config: ProviderOAuthConfig = {
tokenUrl: "https://example.test/oauth/token",
clientId: "cid",
clientSecret: "secret",
revokeUrl: "https://example.test/v1/permissions",
};
const fetchSpy = vi.fn();
beforeEach(() => {
fetchSpy.mockReset();
globalThis.fetch = fetchSpy as unknown as typeof fetch;
});
describe("oauthCredentialAdapter.isExpired", () => {
it("returns true when expires_at is in the past", () => {
const creds: OAuthCredentials = {
access_token: "a",
refresh_token: "r",
expires_at: new Date(Date.now() - 1000).toISOString(),
};
expect(oauthCredentialAdapter.isExpired(creds)).toBe(true);
});
it("returns true within the refresh skew window (60s)", () => {
const creds: OAuthCredentials = {
access_token: "a",
refresh_token: "r",
expires_at: new Date(Date.now() + 30_000).toISOString(),
};
expect(oauthCredentialAdapter.isExpired(creds)).toBe(true);
});
it("returns false when expires_at is comfortably in the future", () => {
const creds: OAuthCredentials = {
access_token: "a",
refresh_token: "r",
expires_at: new Date(Date.now() + 3600_000).toISOString(),
};
expect(oauthCredentialAdapter.isExpired(creds)).toBe(false);
});
});
describe("oauthCredentialAdapter.refresh", () => {
const stale: OAuthCredentials = {
access_token: "old",
refresh_token: "rt",
expires_at: new Date(Date.now() - 1000).toISOString(),
};
it("posts refresh_token grant and returns new credentials", async () => {
fetchSpy.mockResolvedValue(
new Response(
JSON.stringify({
access_token: "new-access",
refresh_token: "new-refresh",
expires_in: 3600,
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const fresh = await oauthCredentialAdapter.refresh(stale, config);
expect(fetchSpy).toHaveBeenCalledTimes(1);
const [url, init] = fetchSpy.mock.calls[0]!;
expect(url).toBe(config.tokenUrl);
expect((init as RequestInit).method).toBe("POST");
const body = (init as RequestInit).body as string;
expect(body).toContain("grant_type=refresh_token");
expect(body).toContain("refresh_token=rt");
expect(body).toContain("client_id=cid");
expect(fresh.access_token).toBe("new-access");
expect(fresh.refresh_token).toBe("new-refresh");
expect(new Date(fresh.expires_at).getTime()).toBeGreaterThan(Date.now() + 3500_000);
});
it("retains the original refresh_token when the response omits one", async () => {
fetchSpy.mockResolvedValue(
new Response(
JSON.stringify({ access_token: "new-access", expires_in: 3600 }),
{ status: 200 },
),
);
const fresh = await oauthCredentialAdapter.refresh(stale, config);
expect(fresh.refresh_token).toBe("rt");
});
it("throws NeedsRelinkError on 4xx (revoked refresh token)", async () => {
fetchSpy.mockResolvedValue(new Response("invalid_grant", { status: 400 }));
await expect(oauthCredentialAdapter.refresh(stale, config)).rejects.toBeInstanceOf(
NeedsRelinkError,
);
});
it("throws a regular Error on 5xx (transient)", async () => {
fetchSpy.mockResolvedValue(new Response("server error", { status: 503 }));
await expect(oauthCredentialAdapter.refresh(stale, config)).rejects.not.toBeInstanceOf(
NeedsRelinkError,
);
});
});
describe("oauthCredentialAdapter.revoke", () => {
const creds: OAuthCredentials = {
access_token: "a",
refresh_token: "r",
expires_at: new Date().toISOString(),
};
it("DELETEs the revoke URL with the access token", async () => {
fetchSpy.mockResolvedValue(new Response(null, { status: 204 }));
await oauthCredentialAdapter.revoke!(creds, config);
const [url, init] = fetchSpy.mock.calls[0]!;
expect(url).toBe(config.revokeUrl);
expect((init as RequestInit).method).toBe("DELETE");
expect(((init as RequestInit).headers as Record<string, string>).Authorization).toBe(
"Bearer a",
);
});
it("swallows network errors silently", async () => {
fetchSpy.mockRejectedValue(new Error("network down"));
await expect(oauthCredentialAdapter.revoke!(creds, config)).resolves.toBeUndefined();
});
it("is a no-op when no revokeUrl is configured", async () => {
await oauthCredentialAdapter.revoke!(creds, { ...config, revokeUrl: undefined });
expect(fetchSpy).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,82 @@
// OAuth2 CredentialAdapter. Implements the standard refresh_token + revoke
// flow against any provider whose token endpoint follows the OAuth2 RFC.
//
// Provider-specific URLs and client credentials come from the provider's
// manifest via ProviderOAuthConfig — Wahoo, future Strava/Garmin/Coros
// share this adapter and supply their own endpoints.
//
// The adapter does not write to the database; it returns refreshed
// credentials for ConnectedServiceManager to persist.
import {
NeedsRelinkError,
type CredentialAdapter,
type OAuthCredentials,
} from "../types.ts";
// Refresh credentials slightly before they actually expire so a token in
// the middle of a long-running operation doesn't tip over mid-request.
const REFRESH_SKEW_MS = 60_000;
function parseExpiresAt(iso: string): Date {
const d = new Date(iso);
if (isNaN(d.getTime())) {
throw new Error(`OAuth credentials have invalid expires_at: ${iso}`);
}
return d;
}
export const oauthCredentialAdapter: CredentialAdapter<OAuthCredentials> = {
isExpired(creds) {
return parseExpiresAt(creds.expires_at).getTime() - Date.now() < REFRESH_SKEW_MS;
},
async refresh(creds, providerConfig): Promise<OAuthCredentials> {
const resp = await fetch(providerConfig.tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: providerConfig.clientId,
client_secret: providerConfig.clientSecret,
grant_type: "refresh_token",
refresh_token: creds.refresh_token,
}).toString(),
});
if (!resp.ok) {
const text = await resp.text().catch(() => "");
// 4xx on refresh = revoked / invalidated refresh token. Caller must re-link.
// 5xx = transient; surface as a regular Error so caller can retry the operation.
if (resp.status >= 400 && resp.status < 500) {
throw new NeedsRelinkError(
`OAuth refresh rejected (${resp.status}): ${text || "no body"}`,
);
}
throw new Error(`OAuth refresh failed (${resp.status}): ${text}`);
}
const data = (await resp.json()) as {
access_token: string;
refresh_token?: string;
expires_in: number;
};
return {
access_token: data.access_token,
// Some providers omit refresh_token on refresh (it stays the same).
refresh_token: data.refresh_token ?? creds.refresh_token,
expires_at: new Date(Date.now() + data.expires_in * 1000).toISOString(),
};
},
async revoke(creds, providerConfig) {
if (!providerConfig.revokeUrl) return;
// Best-effort. Caller deletes the local row regardless of outcome.
await fetch(providerConfig.revokeUrl, {
method: "DELETE",
headers: { Authorization: `Bearer ${creds.access_token}` },
}).catch(() => {
// Swallow — local unlink proceeds.
});
},
};

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,241 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
ConnectionNotActiveError,
NeedsRelinkError,
type CredentialAdapter,
type OAuthCredentials,
type ProviderOAuthConfig,
} from "./types.ts";
import type { ProviderManifest } from "./registry.ts";
// ---- DB mock ----
type Row = {
id: string;
userId: string;
provider: string;
credentialKind: string;
credentials: unknown;
status: string;
providerUserId: string | null;
grantedScopes: string[];
createdAt: Date;
};
const rows: Row[] = [];
const mockDb = {
select: () => ({
from: () => ({
where: () => Promise.resolve(rows.slice()),
}),
}),
insert: () => ({
values: (v: Row) => {
rows.push(v);
return Promise.resolve();
},
}),
update: () => ({
set: (patch: Partial<Row>) => ({
where: (cond: unknown) => {
// The cond from drizzle is opaque to us; in this test we always
// patch the most recently-inserted row.
void cond;
const row = rows[rows.length - 1];
if (row) Object.assign(row, patch);
return Promise.resolve();
},
}),
}),
delete: () => ({
where: () => {
rows.length = 0;
return Promise.resolve();
},
}),
};
vi.mock("../db.ts", () => ({ getDb: () => mockDb }));
// ---- Manifest / adapter stubs ----
const refreshSpy = vi.fn();
const revokeSpy = vi.fn();
const isExpiredSpy = vi.fn();
const stubAdapter: CredentialAdapter<OAuthCredentials> = {
isExpired: (creds) => isExpiredSpy(creds),
refresh: (creds, cfg) => refreshSpy(creds, cfg),
revoke: (creds, cfg) => revokeSpy(creds, cfg),
};
const stubOauthConfig: ProviderOAuthConfig = {
tokenUrl: "https://example.test/oauth/token",
clientId: "cid",
clientSecret: "secret",
revokeUrl: "https://example.test/v1/permissions",
};
const stubManifest: ProviderManifest = {
id: "stub",
displayName: "Stub",
credentialKind: "oauth",
credentialAdapter: stubAdapter as CredentialAdapter,
oauthConfig: stubOauthConfig,
};
vi.mock("./registry.ts", async () => {
const actual = await vi.importActual<typeof import("./registry.ts")>("./registry.ts");
return {
...actual,
getManifest: (id: string) => (id === "stub" ? stubManifest : null),
};
});
// Import after mocks are wired.
const {
link,
unlink,
unlinkByUserProvider,
markNeedsRelink,
withFreshCredentials,
getService,
} = await import("./manager.ts");
beforeEach(() => {
rows.length = 0;
refreshSpy.mockReset();
revokeSpy.mockReset();
isExpiredSpy.mockReset();
});
describe("ConnectedServiceManager.link", () => {
it("inserts a row with the active status and supplied fields", async () => {
const svc = await link({
userId: "u1",
provider: "stub",
credentialKind: "oauth",
credentials: { access_token: "a", refresh_token: "r", expires_at: new Date().toISOString() },
providerUserId: "pu1",
grantedScopes: ["read"],
});
expect(svc.userId).toBe("u1");
expect(svc.provider).toBe("stub");
expect(svc.status).toBe("active");
expect(svc.grantedScopes).toEqual(["read"]);
expect(rows).toHaveLength(1);
});
});
describe("ConnectedServiceManager.withFreshCredentials", () => {
const freshCreds: OAuthCredentials = {
access_token: "a",
refresh_token: "r",
expires_at: new Date(Date.now() + 3600_000).toISOString(),
};
async function seed(status: "active" | "needs_relink" | "revoked" = "active") {
await link({
userId: "u1",
provider: "stub",
credentialKind: "oauth",
credentials: freshCreds,
});
if (status !== "active") rows[0]!.status = status;
return rows[0]!.id;
}
it("calls fn directly when credentials are not expired", async () => {
const id = await seed();
isExpiredSpy.mockReturnValue(false);
const result = await withFreshCredentials(id, async (creds) => {
expect(creds).toEqual(freshCreds);
return "ok";
});
expect(result).toBe("ok");
expect(refreshSpy).not.toHaveBeenCalled();
});
it("refreshes credentials and persists new blob when expired", async () => {
const id = await seed();
isExpiredSpy.mockReturnValue(true);
const newCreds: OAuthCredentials = {
access_token: "a2",
refresh_token: "r2",
expires_at: new Date(Date.now() + 7200_000).toISOString(),
};
refreshSpy.mockResolvedValue(newCreds);
const got = await withFreshCredentials(id, async (creds) => creds as OAuthCredentials);
expect(refreshSpy).toHaveBeenCalledWith(freshCreds, stubOauthConfig);
expect(got).toEqual(newCreds);
expect(rows[0]!.credentials).toEqual(newCreds);
});
it("flips status to needs_relink and re-throws when refresh raises NeedsRelinkError", async () => {
const id = await seed();
isExpiredSpy.mockReturnValue(true);
refreshSpy.mockRejectedValue(new NeedsRelinkError("revoked"));
await expect(
withFreshCredentials(id, async () => "never"),
).rejects.toBeInstanceOf(NeedsRelinkError);
expect(rows[0]!.status).toBe("needs_relink");
});
it("rejects with ConnectionNotActiveError when status is not active", async () => {
const id = await seed("needs_relink");
isExpiredSpy.mockReturnValue(false);
await expect(
withFreshCredentials(id, async () => "never"),
).rejects.toBeInstanceOf(ConnectionNotActiveError);
expect(refreshSpy).not.toHaveBeenCalled();
});
});
describe("ConnectedServiceManager.markNeedsRelink", () => {
it("flips the row's status", async () => {
await link({
userId: "u1",
provider: "stub",
credentialKind: "oauth",
credentials: { access_token: "a", refresh_token: "r", expires_at: new Date().toISOString() },
});
await markNeedsRelink(rows[0]!.id, "test");
expect(rows[0]!.status).toBe("needs_relink");
});
});
describe("ConnectedServiceManager.unlink", () => {
it("calls the credential adapter's revoke before deletion", async () => {
await link({
userId: "u1",
provider: "stub",
credentialKind: "oauth",
credentials: { access_token: "a", refresh_token: "r", expires_at: new Date().toISOString() },
});
revokeSpy.mockResolvedValue(undefined);
await unlink(rows[0]?.id ?? "missing");
expect(revokeSpy).toHaveBeenCalled();
});
it("swallows revoke failures and still deletes locally", async () => {
await link({
userId: "u1",
provider: "stub",
credentialKind: "oauth",
credentials: { access_token: "a", refresh_token: "r", expires_at: new Date().toISOString() },
});
const id = rows[0]!.id;
revokeSpy.mockRejectedValue(new Error("provider down"));
await expect(unlink(id)).resolves.toBeUndefined();
expect(rows).toHaveLength(0);
});
});
// Reference unused symbols to keep the linter happy under strict noUnused rules.
void unlinkByUserProvider;
void getService;

View file

@ -0,0 +1,241 @@
// ConnectedServiceManager — owns credential lifecycle for every provider.
//
// Importers, route pushers, and webhook handlers obtain credentials
// EXCLUSIVELY through withFreshCredentials. They never read the
// `credentials` JSONB blob directly.
//
// See docs/adr/0001-0003 and CONTEXT.md (Connected Services).
import { randomUUID } from "node:crypto";
import { eq, and } from "drizzle-orm";
import { connectedServices } from "@trails-cool/db/schema/journal";
import { getDb } from "../db.ts";
import {
ConnectionNotActiveError,
NeedsRelinkError,
type ConnectedService,
type CredentialKind,
type Credentials,
type ConnectionStatus,
} from "./types.ts";
import { getManifest, type CapabilityContext } from "./registry.ts";
// ---------------------------------------------------------------------------
// Row mapping
// ---------------------------------------------------------------------------
type Row = typeof connectedServices.$inferSelect;
function toModel(row: Row): ConnectedService {
return {
id: row.id,
userId: row.userId,
provider: row.provider,
credentialKind: row.credentialKind as CredentialKind,
credentials: row.credentials as Credentials,
status: row.status as ConnectionStatus,
providerUserId: row.providerUserId,
grantedScopes: row.grantedScopes,
createdAt: row.createdAt,
};
}
// ---------------------------------------------------------------------------
// Lookups
// ---------------------------------------------------------------------------
export async function getService(
userId: string,
provider: string,
): Promise<ConnectedService | null> {
const db = getDb();
const [row] = await db
.select()
.from(connectedServices)
.where(
and(eq(connectedServices.userId, userId), eq(connectedServices.provider, provider)),
);
return row ? toModel(row) : null;
}
export async function getServiceById(
serviceId: string,
): Promise<ConnectedService | null> {
const db = getDb();
const [row] = await db
.select()
.from(connectedServices)
.where(eq(connectedServices.id, serviceId));
return row ? toModel(row) : null;
}
export async function getServiceByProviderUser(
provider: string,
providerUserId: string,
): Promise<ConnectedService | null> {
const db = getDb();
const [row] = await db
.select()
.from(connectedServices)
.where(
and(
eq(connectedServices.provider, provider),
eq(connectedServices.providerUserId, providerUserId),
),
);
return row ? toModel(row) : null;
}
// ---------------------------------------------------------------------------
// Mutation: link / unlink / status
// ---------------------------------------------------------------------------
export interface LinkInput {
userId: string;
provider: string;
credentialKind: CredentialKind;
credentials: Credentials;
providerUserId?: string | null;
grantedScopes?: string[];
}
// Upsert the (user_id, provider) row with fresh credentials. The DB-level
// unique constraint on (user_id, provider) guarantees at most one row per
// pair; we delete-then-insert to keep the row id stable per link, which
// also resets `created_at`.
export async function link(input: LinkInput): Promise<ConnectedService> {
const db = getDb();
await db
.delete(connectedServices)
.where(
and(
eq(connectedServices.userId, input.userId),
eq(connectedServices.provider, input.provider),
),
);
const id = randomUUID();
await db.insert(connectedServices).values({
id,
userId: input.userId,
provider: input.provider,
credentialKind: input.credentialKind,
credentials: input.credentials,
status: "active",
providerUserId: input.providerUserId ?? null,
grantedScopes: input.grantedScopes ?? [],
});
const row = await getServiceById(id);
if (!row) throw new Error("Failed to load just-linked service");
return row;
}
export async function unlink(serviceId: string): Promise<void> {
const db = getDb();
// Best-effort revoke at the provider before deleting locally.
const service = await getServiceById(serviceId);
if (service) {
const manifest = getManifest(service.provider);
if (manifest?.credentialAdapter.revoke && manifest.oauthConfig) {
await manifest.credentialAdapter
.revoke(service.credentials, manifest.oauthConfig)
.catch(() => {
// Swallow — local delete proceeds regardless.
});
}
}
await db.delete(connectedServices).where(eq(connectedServices.id, serviceId));
}
export async function unlinkByUserProvider(
userId: string,
provider: string,
): Promise<void> {
const service = await getService(userId, provider);
if (!service) return;
await unlink(service.id);
}
export async function markNeedsRelink(
serviceId: string,
reason: string,
): Promise<void> {
const db = getDb();
await db
.update(connectedServices)
.set({ status: "needs_relink" })
.where(eq(connectedServices.id, serviceId));
// Reason is logged but not persisted yet — add a column if/when we surface it in UI.
console.warn(`[connected-services] ${serviceId} flipped to needs_relink: ${reason}`);
}
export async function updateGrantedScopes(
serviceId: string,
grantedScopes: string[],
): Promise<void> {
const db = getDb();
await db
.update(connectedServices)
.set({ grantedScopes })
.where(eq(connectedServices.id, serviceId));
}
// ---------------------------------------------------------------------------
// withFreshCredentials — the chokepoint
// ---------------------------------------------------------------------------
// Loads the connection, refreshes credentials if expired, calls fn with the
// fresh credentials. On a NeedsRelinkError from the adapter, flips the
// connection to needs_relink and re-throws so the caller can surface a
// re-link prompt.
//
// Capability adapters use this exclusively — they never read the
// credentials JSONB directly.
export async function withFreshCredentials<T>(
serviceId: string,
fn: (credentials: unknown) => Promise<T>,
): Promise<T> {
const service = await getServiceById(serviceId);
if (!service) throw new Error(`Connected service ${serviceId} not found`);
if (service.status !== "active") {
throw new ConnectionNotActiveError(service.status);
}
const manifest = getManifest(service.provider);
if (!manifest) {
throw new Error(`No manifest registered for provider ${service.provider}`);
}
const adapter = manifest.credentialAdapter;
let creds = service.credentials;
if (adapter.isExpired(creds)) {
if (!manifest.oauthConfig && service.credentialKind === "oauth") {
throw new Error(
`Provider ${service.provider} has no oauthConfig; cannot refresh`,
);
}
try {
creds = await adapter.refresh(creds, manifest.oauthConfig!);
const db = getDb();
await db
.update(connectedServices)
.set({ credentials: creds })
.where(eq(connectedServices.id, serviceId));
} catch (err) {
if (err instanceof NeedsRelinkError) {
await markNeedsRelink(serviceId, err.reason);
}
throw err;
}
}
return fn(creds);
}
// Build a CapabilityContext bound to a service id. Capability adapters
// receive this from the route handler / job that invoked them.
export function capabilityContextFor(serviceId: string): CapabilityContext {
return {
serviceId,
withFreshCredentials: (fn) => withFreshCredentials(serviceId, fn),
};
}

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

@ -0,0 +1,138 @@
// Provider registry. Each provider lives in providers/<name>/ with a
// manifest.ts that declares its credential_kind and capability adapters.
// Adding a provider is one new directory plus one import line below.
//
// See docs/adr/0002 (no unified SyncProvider interface; capabilities are
// separate seams) and CONTEXT.md (Connected Services).
import type {
CredentialAdapter,
CredentialKind,
ProviderOAuthConfig,
} from "./types.ts";
// Capability seams. A provider implements only the subset that applies.
export interface ImportableList {
workouts: ImportableWorkout[];
total: number;
page: number;
perPage: number;
}
export interface ImportableWorkout {
id: string;
name: string;
type: string;
startedAt: string;
duration: number | null;
distance: number | null;
fileUrl?: string;
}
export interface ImportResult {
activityId: string;
hadGeometry: boolean;
}
export interface RoutePushInput {
routeId: string;
routeName: string;
description?: string;
gpx: string;
startLat: number;
startLng: number;
distance: number;
ascent: number;
localVersion: number;
}
export interface RoutePushResult {
remoteId: string;
// Local route version that was pushed — written into sync_pushes.last_pushed_version
// by the caller for idempotency.
version: number;
}
export interface WebhookEvent {
eventType: string;
providerUserId: string;
workoutId: string;
fileUrl?: string;
}
// CapabilityContext gives capability adapters the tools they need without
// exposing the manager's internals. Adapters call ctx.withFreshCredentials
// to obtain valid credentials for any provider HTTP call.
export interface CapabilityContext {
serviceId: string;
withFreshCredentials<T>(
fn: (credentials: unknown) => Promise<T>,
): Promise<T>;
}
export interface Importer {
listImportable(ctx: CapabilityContext, page: number): Promise<ImportableList>;
importOne(ctx: CapabilityContext, workoutId: string): Promise<ImportResult>;
}
export interface RoutePusher {
pushRoute(ctx: CapabilityContext, input: RoutePushInput): Promise<RoutePushResult>;
}
export interface WebhookReceiver {
parseWebhook(body: unknown): WebhookEvent | null;
handle(event: WebhookEvent): Promise<void>;
}
export interface ProviderManifest {
id: string;
displayName: string;
credentialKind: CredentialKind;
// Per-kind credential adapter. Multiple providers can share the same
// adapter (oauth, web-login, device).
credentialAdapter: CredentialAdapter;
// OAuth-specific config; only required when credentialKind === 'oauth'.
oauthConfig?: ProviderOAuthConfig;
// OAuth scopes requested at connect time. Wahoo grants all-or-nothing.
scopes?: string[];
// OAuth authorization URL builder (for the connect flow).
buildAuthUrl?: (redirectUri: string, state: string) => string;
// OAuth code exchange (for the callback). Returns the credential blob to
// store and the granted scopes.
exchangeCode?: (
code: string,
redirectUri: string,
) => Promise<{
credentials: unknown;
providerUserId: string | null;
grantedScopes: string[];
}>;
// Capability adapters. Each is optional — providers implement only what
// they support.
importer?: Importer;
routePusher?: RoutePusher;
webhookReceiver?: WebhookReceiver;
}
// The registry. Imported manifests are kept in an internal map so callers
// can look up by provider id. Adding a provider: import its manifest below
// and add it to PROVIDERS.
//
// Manifests are registered at module load via registerManifest() rather
// than imported directly, so the registry doesn't depend on providers/.
// Each provider's barrel (providers/<name>/index.ts) calls register at import.
const PROVIDERS: Record<string, ProviderManifest> = {};
export function registerManifest(manifest: ProviderManifest): void {
PROVIDERS[manifest.id] = manifest;
}
export function getManifest(providerId: string): ProviderManifest | null {
return PROVIDERS[providerId] ?? null;
}
export function getAllManifests(): ProviderManifest[] {
return Object.values(PROVIDERS);
}

View file

@ -0,0 +1,82 @@
// Types for the Connected Services architecture. See docs/adr/0001-0003 and
// CONTEXT.md (Connected Services section).
export type CredentialKind = "oauth" | "web-login" | "device";
export type ConnectionStatus = "active" | "needs_relink" | "revoked";
// OAuth credential blob (stored in connected_services.credentials when
// credential_kind = 'oauth'). expires_at is an ISO-8601 UTC string so the
// JSONB blob is round-trip safe; the manager parses it into a Date as needed.
export interface OAuthCredentials {
access_token: string;
refresh_token: string;
expires_at: string;
}
// web-login (Komoot) and device (Apple Health) blob shapes will be defined
// alongside their respective consumer changes. The kinds are reserved here
// so the manager can switch on them without a follow-up enum migration.
export type Credentials = OAuthCredentials | Record<string, unknown>;
export interface ConnectedService {
id: string;
userId: string;
provider: string;
credentialKind: CredentialKind;
credentials: Credentials;
status: ConnectionStatus;
providerUserId: string | null;
grantedScopes: string[];
createdAt: Date;
}
// Returned by CredentialAdapter.refresh when the credential is permanently
// invalid (e.g. revoked refresh token). Manager flips the connection's
// status to 'needs_relink' and surfaces this to callers.
export class NeedsRelinkError extends Error {
reason: string;
constructor(reason: string) {
super(`Connection needs relinking: ${reason}`);
this.name = "NeedsRelinkError";
this.reason = reason;
}
}
// CredentialAdapter is implemented per credential_kind. The adapter owns
// the credential lifecycle for that kind — nothing else.
export interface CredentialAdapter<C extends Credentials = Credentials> {
// Returns refreshed credentials, or throws NeedsRelinkError on permanent
// failure. Implementations should be idempotent w.r.t. already-fresh
// credentials (callers may invoke even when not strictly expired).
refresh(credentials: C, providerConfig: ProviderOAuthConfig): Promise<C>;
// Best-effort revocation at the provider's end on unlink. Failures
// should be swallowed by the caller — the local row is deleted regardless.
revoke?(credentials: C, providerConfig: ProviderOAuthConfig): Promise<void>;
// Returns true if the credential is expired (or close enough that the
// caller should refresh before using it). Manager calls this from
// withFreshCredentials.
isExpired(credentials: C): boolean;
}
// OAuth-specific config carried on the provider manifest. Used by the oauth
// CredentialAdapter to build refresh requests without hard-coding Wahoo URLs.
export interface ProviderOAuthConfig {
tokenUrl: string;
clientId: string;
clientSecret: string;
// Optional revoke endpoint (e.g. Wahoo's DELETE /v1/permissions). When
// absent, revoke is a no-op.
revokeUrl?: string;
}
// Thrown when withFreshCredentials is called against a connection whose
// status is not 'active'. Caller should surface a re-link prompt.
export class ConnectionNotActiveError extends Error {
status: ConnectionStatus;
constructor(status: ConnectionStatus) {
super(`Connection status is ${status}; cannot use until re-linked`);
this.name = "ConnectionNotActiveError";
this.status = status;
}
}

View file

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

View file

@ -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

@ -4,8 +4,8 @@ import { eq } from "drizzle-orm";
import type { Route } from "./+types/settings.connections";
import { getSessionUser } from "~/lib/auth.server";
import { getDb } from "~/lib/db";
import { syncConnections } from "@trails-cool/db/schema/journal";
import { getAllProviders } from "~/lib/sync/registry";
import { connectedServices } from "@trails-cool/db/schema/journal";
import { getAllManifests } from "~/lib/connected-services";
const KNOWN_ERRORS = ["too_many_tokens", "sync_failed", "generic"] as const;
type KnownError = (typeof KNOWN_ERRORS)[number];
@ -24,15 +24,20 @@ export async function loader({ request }: Route.LoaderArgs) {
const db = getDb();
const connections = await db
.select({
provider: syncConnections.provider,
providerUserId: syncConnections.providerUserId,
provider: connectedServices.provider,
providerUserId: connectedServices.providerUserId,
})
.from(syncConnections)
.where(eq(syncConnections.userId, user.id));
.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 });
}

View file

@ -0,0 +1,19 @@
# Connected Services use a credential-kind discriminator + JSONB blob
trails.cool integrates with external services whose credentials have
fundamentally different shapes: OAuth2 (Wahoo, future Coros/Garmin/Strava),
web-login session (Komoot — no official API, we authenticate against the
provider's web login with email + password and reuse the cookie jar),
and device pairing (Apple Health, future Health Connect — no remote
credential at all). We considered an OAuth-only schema with nullable extras
and per-kind tables, and rejected both: nullable-OAuth would force Komoot
and Apple Health to leave most columns NULL and bake OAuth assumptions into
queries; per-kind tables would fragment `connected_services` and complicate
the user-facing "Connected Services" list.
We store all linked external accounts in one `connected_services` table
with a `credential_kind ∈ {oauth, web-login, device}` discriminator and a
`credentials` JSONB blob whose schema is determined by kind. OAuth-specific
`granted_scopes` stays a top-level column because feature gates query it
directly. Per-kind `CredentialAdapter` modules own the credential lifecycle
(refresh / relogin / noop); callers never read the JSONB directly.

View file

@ -0,0 +1,19 @@
# No unified SyncProvider interface; capabilities are separate seams
The `connected-services` spec calls for a "provider-agnostic framework," and
the obvious shape is a single `SyncProvider` interface with `connect /
refresh / importOne / pushRoute / handleWebhook`. We rejected this because
the providers we actually need to support are heterogeneous: Komoot is
import-only via web-login (no refresh, no webhooks, no push), Apple Health
is mobile-pushed (no server-initiated anything, no remote credential),
Wahoo has all four. A unified interface would be OAuth-shaped by default
and adapters would fill it with NOPs — shallow at the seam, leaky at the
adapters.
Instead, capabilities are separate seams: `Importer`, `RoutePusher`,
`WebhookReceiver`. Each provider declares which capabilities it implements
in a co-located manifest (`providers/<name>/manifest.ts`); a small
`registry.ts` imports each manifest. Credential lifecycle is the one thing
shared across providers, and lives in `ConnectedServiceManager` +
`CredentialAdapter` (per kind). If we ever genuinely need pan-provider
behaviour, we add it to the manager — not to a provider interface.

View file

@ -0,0 +1,18 @@
# RoutePusher seam takes (service, route); workarounds stay inside adapters
`RoutePusher` has one adapter today (Wahoo), but Coros, Garmin, and Strava
push are all foreseeable. The current Wahoo push code exposes its
workarounds — FIT Course conversion, the deterministic `external_id =
route:<id>` convention, the PUT→POST-on-404 fallback when a user has
deleted the route on the remote side — at the call site, which would force
every future pusher to either inherit those Wahoo-isms or reshape the
seam.
We commit to the seam shape now, with one adapter: `pushRoute(service,
route) → {remoteId, version}`. Format conversion, idempotency tricks, and
provider-specific HTTP recovery live entirely inside the adapter. The
`sync_pushes` table (`(user_id, route_id, provider) → remote_id,
last_pushed_version`) is the cross-provider contract for idempotency; the
adapter is responsible for honouring it but not for exposing how. When the
second pusher lands, it implements the same shape without changing
callers.

View file

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

View file

@ -0,0 +1,163 @@
## Context
`apps/journal/app/lib/sync/` today uses a unified `SyncProvider` interface (`types.ts:88-118`) with one adapter (Wahoo). The interface bakes OAuth into its shape — `getAuthUrl`, `exchangeCode`, `refreshToken`, `parseWebhook`, plus optional `pushRoute? / updateRoute? / revoke?`. Token-refresh logic lives inline in `pushes.server.ts` (~lines 145-170). The CRUD layer (`connections.server.ts`) is a thin wrapper over the `sync_connections` table.
Two new providers are imminent and each violates the OAuth assumption: **Komoot** (no official API; we authenticate via the website's login form and reuse session cookies — `credential_kind = web-login`) and **Apple Health** (no remote credential at all; data arrives via authenticated mobile API uploads — `credential_kind = device`). Hand-merging either into the current shape produces NOPs (`refreshToken`, `parseWebhook`) and OAuth-shaped queries.
Three architectural decisions have already been recorded:
- ADR-0001: credential-kind discriminator + JSONB blob (not OAuth-only schema, not per-kind tables).
- ADR-0002: capability seams (`Importer` / `RoutePusher` / `WebhookReceiver`), not a unified `SyncProvider`.
- ADR-0003: `RoutePusher` interface shape is `(service, route) → {remoteId, version}`; Wahoo workarounds stay inside the adapter.
Domain vocabulary is in `CONTEXT.md` (Connected Services section).
## Goals / Non-Goals
**Goals:**
- Reshape the `sync_connections` storage and the `SyncProvider` interface *before* Komoot or Apple Health code lands, so neither has to distort the architecture.
- Centralize credential lifecycle (refresh / mark-needs-relink) in one module so future providers don't reinvent it.
- Make capability seams (`Importer`, `RoutePusher`, `WebhookReceiver`) testable independently with a fake `ConnectedServiceManager`.
- Commit to a `RoutePusher` shape with one adapter today, so Coros / Garmin / Strava push won't reshape it.
- Carry all three credential kinds (`oauth | web-login | device`) in the schema enum from day one to avoid follow-up migrations.
**Non-Goals:**
- Adding the Komoot importer or `web-login` `CredentialAdapter` (lands with the amended `komoot-import` change).
- Adding the Apple Health adapter or `device` `CredentialAdapter` (lands with `mobile-app`).
- Changing user-visible behaviour (settings page, OAuth flows, import / push semantics, webhook handling, idempotency rules).
- Touching `sync_imports` or `sync_pushes` schema. Only `sync_connections` is renamed.
- Defining cross-provider behaviour beyond credential lifecycle. `Importer` and `WebhookReceiver` are typed seams but their per-provider mechanics stay heterogeneous.
## Decisions
### Decision 1: One `connected_services` table, polymorphic credentials
`sync_connections` is renamed to `connected_services`. New columns:
- `credential_kind text NOT NULL` — discriminator: `oauth | web-login | device`.
- `credentials jsonb NOT NULL` — shape determined by `credential_kind`. For `oauth`: `{access_token, refresh_token, expires_at}`. For `web-login` (later): `{email, encrypted_password, session_jar}`. For `device` (later): `{}`.
Top-level columns kept: `user_id`, `provider`, `provider_user_id` (nullable), `granted_scopes` (nullable text[], populated only for `oauth`), `created_at`. Old columns `access_token / refresh_token / expires_at` are dropped after backfill.
**Why JSONB + discriminator instead of nullable OAuth columns or per-kind tables**: see ADR-0001. Short version: nullable OAuth makes Komoot and Apple Health leave half the columns NULL; per-kind tables fragment the user-facing "Connected Services" list and complicate the registry.
**`granted_scopes` stays a top-level column** because feature gates (`if (!granted_scopes.includes('routes_write')) re-auth`) read it on every push. Promoting it out of JSONB keeps gate queries cheap and untyped JSONB indexing unnecessary.
### Decision 2: Capability seams, not a unified provider
`SyncProvider` is replaced by three interfaces, each implementable independently:
```ts
interface Importer {
listImportable(service, page): Promise<ImportableList>;
importOne(service, id): Promise<ImportResult>;
}
interface RoutePusher {
pushRoute(service, route): Promise<{remoteId, version}>;
}
interface WebhookReceiver {
parseWebhook(body, headers): WebhookEvent | null;
handle(event): Promise<void>;
}
```
A `Provider` is a manifest co-located with its code:
```ts
// providers/wahoo/manifest.ts
export const wahoo: ProviderManifest = {
id: 'wahoo',
credentialKind: 'oauth',
oauth: { /* getAuthUrl, exchangeCode, scopes */ },
importer: wahooImporter,
pusher: wahooPusher,
webhookReceiver: wahooWebhook,
};
```
`providers/registry.ts` imports each manifest. Adding a provider is one new directory plus one import line. Per ADR-0002, there is no pan-provider behaviour interface — the manager owns credential lifecycle, capability adapters own everything else.
**Why this over a unified interface with optional methods**: Wahoo's current interface has three optional methods already (`pushRoute? / updateRoute? / revoke?`); the codebase has been informally drifting toward capability seams. Formalizing it makes "does Komoot push? does Apple Health webhook?" a manifest-level fact rather than a runtime `if (provider.pushRoute)` check.
### Decision 3: `ConnectedServiceManager` owns credential lifecycle
```ts
class ConnectedServiceManager {
link(userId, providerId, kind, credentials): Promise<ConnectedService>;
unlink(serviceId): Promise<void>;
withFreshCredentials<T>(serviceId, fn: (creds) => Promise<T>): Promise<T>;
markNeedsRelink(serviceId, reason): Promise<void>;
}
```
`withFreshCredentials` is the chokepoint. It loads the row, checks if the credential is expired (per-kind logic via `CredentialAdapter`), refreshes if needed, calls `fn(credentials)`, and on credential failure flips `status = needs_relink`. Importers, pushers, and webhook handlers always go through this — they never read the `credentials` JSONB directly.
**Why centralize**: today's refresh logic lives inline in `pushes.server.ts` (~lines 145-170). Komoot's relogin and Apple Health's noop need the same chokepoint. One module = one place where credential bugs and races are caught.
### Decision 4: `oauth` `CredentialAdapter` ships now; `web-login` and `device` are stubs
```ts
interface CredentialAdapter {
refresh(credentials): Promise<Credentials | NeedsRelink>;
revoke?(credentials): Promise<void>;
}
```
The `oauth` adapter ships with this change and contains the existing Wahoo-style refresh-token flow plus optional revoke (for providers that support `DELETE /v1/permissions`). The `web-login` and `device` adapters are not implemented in this change — they land with their respective consumer changes. The discriminator enum carries all three values from day one so the consumer changes don't need a migration.
### Decision 5: `RoutePusher` shape commits to (service, route) → result
Per ADR-0003, the seam is:
```ts
interface RoutePusher {
pushRoute(service: ConnectedService, route: Route): Promise<{remoteId: string, version: number}>;
}
```
Wahoo's adapter handles internally:
- FIT-Course conversion via `gpxToFitCourse`.
- The `external_id = route:<id>` convention.
- The PUT-vs-POST decision based on `sync_pushes.remote_id`.
- The PUT→POST-on-404 fallback when the user deleted the Wahoo route on their side.
- The `data:application/vnd.fit;base64,...` body encoding.
Idempotency tracking via `sync_pushes (user_id, route_id, provider) → remote_id, last_pushed_version` is the **shared** contract — every adapter must read/write this table — but how it's used (PUT vs POST, fallback) is adapter-internal.
### Decision 6: Spec deltas are renames + capability-seam terminology
Three spec files are touched:
- `connected-services/spec.md`: rename `sync_connections``connected_services` everywhere; add a requirement codifying the capability-seam model (replacing the implicit "OAuth tokens stored in" framing).
- `wahoo-import/spec.md`: rename. The "Provider-agnostic sync framework" requirement is rewritten to reference capability seams + manifest registration instead of "implement the `SyncProvider` interface in a single file."
- `wahoo-route-push/spec.md`: rename only. User-visible behaviour and idempotency rules are unchanged.
## Risks / Trade-offs
- **[Risk]** Migration runs on production `connected_services` rows (sandbox era — small, but non-zero) — botched backfill could leave Wahoo connections unusable until manual repair. → **Mitigation**: migration is a single transaction; backfill is purely column-shape (no value transformation beyond moving three columns into a JSON object); deploy with a dry-run on staging first; rollback = re-run the inverse migration restoring columns from JSON.
- **[Risk]** OpenSpec change `komoot-import` already drafts a `journal.integrations` table — any in-progress implementation against that drafted spec becomes wasted work. → **Mitigation**: this change documents the supersession explicitly. The `komoot-import` design.md will be amended in a separate, small follow-up before any Komoot code lands; if Komoot work has already started against the old shape, it is small and isolated (one importer file, no shipped DB).
- **[Trade-off]** Carrying `web-login` and `device` in the `credential_kind` enum without their adapters means an empty enum branch ships. The alternative (add the values when their adapters land) costs an extra migration each. → **Accepted**: enum-only forward declaration is cheap; migration churn is not.
- **[Trade-off]** `RoutePusher` is a single-adapter seam today. Per ADR-0003 we commit to its shape now to avoid a reshape when Coros/Garmin/Strava push lands. **Accepted** because the shape is small (`(service, route) → {remoteId, version}`) and Wahoo specifics already live behind it.
- **[Risk]** Test reorganization (existing `wahoo.test.ts` and `pushes.server.test.ts` split into `importer.test.ts` / `pusher.test.ts` / `webhook.test.ts` / `manager.test.ts`) loses git blame continuity. → **Mitigation**: do the file moves with `git mv` in the same commit as the code split; reviewers can use `--follow` to trace history.
## Migration Plan
Single deployable PR ("spec apply") containing:
1. **DB migration** (Drizzle): rename `sync_connections``connected_services`; add `credential_kind text NOT NULL` (default `'oauth'` only for the duration of backfill, then drop default); add `credentials jsonb NOT NULL`; add `status text NOT NULL DEFAULT 'active'`; backfill `credentials = jsonb_build_object('access_token', access_token, 'refresh_token', refresh_token, 'expires_at', expires_at)`; drop `access_token / refresh_token / expires_at`.
2. **New module** `apps/journal/app/lib/connected-services/` (manager + oauth credential adapter + registry + types).
3. **Wahoo split**: `apps/journal/app/lib/sync/providers/wahoo.ts` is moved to `apps/journal/app/lib/connected-services/providers/wahoo/{importer.ts, pusher.ts, webhook.ts, manifest.ts, oauth.ts}`. The old `sync/` directory is deleted.
4. **Caller updates**: routes (`api.sync.connect.*`, `api.sync.disconnect.*`, `api.sync.webhook.wahoo`), background jobs, and the route push action update their imports to go through the manager + registry.
5. **Test reorganization**: `wahoo.test.ts` (285 lines) and `pushes.server.test.ts` (309 lines) split into capability-aligned test files; new `manager.test.ts` covers refresh / needs_relink / link / unlink.
6. **Spec deltas** applied at archive time.
**Rollback**: revert the PR. The inverse migration restores `access_token / refresh_token / expires_at` from the JSONB blob and renames the table back. Production has small row count, so the inverse runs in seconds.
## Open Questions
- **Q1**: Should `connected_services.status` be an enum (`active | needs_relink | revoked`) at the DB level, or a free-form text column with constants in code? → **Lean toward enum** (Postgres `text` with a `CHECK` constraint) so a stray status value can't slip in. Decide during implementation.
- **Q2**: Where does the `oauth` `CredentialAdapter` live — `lib/connected-services/credential-adapters/oauth.ts` or co-located inside `providers/wahoo/`? Wahoo's OAuth-specific config (token endpoint URL, client id) needs to be accessible to the adapter. → **Lean toward shared adapter at `credential-adapters/oauth.ts`** taking the provider-specific config from the manifest. Decide during implementation.
- **Q3**: Does the `oauth` adapter handle revoke universally (call `provider.revokeUrl` if defined), or is revoke a Wahoo-internal concern? → **Lean toward shared with optional config**, since Strava and Garmin also have revoke endpoints.

View file

@ -0,0 +1,36 @@
## Why
The current `SyncProvider` interface (`apps/journal/app/lib/sync/types.ts:88-118`) is OAuth-shaped: it bakes `getAuthUrl`, `exchangeCode`, `refreshToken`, `parseWebhook`, and `pushRoute?` into one fat interface. Wahoo is the only adapter today, and the abstraction is a hypothetical seam. With **Komoot** (web-login, no official API) and **Apple Health** (mobile-pushed, no remote credential) both in flight, that seam is about to break: a unified interface fills with NOPs and forces every future provider to inherit OAuth assumptions.
This change reshapes the seam *before* the second and third adapters land — so Komoot and Apple Health fit cleanly instead of distorting the architecture. Decisions are recorded in `docs/adr/0001-0003`; vocabulary in `CONTEXT.md`.
## What Changes
- **BREAKING (DB)**: rename `journal.sync_connections``journal.connected_services`. Add `credential_kind` text column (discriminator: `oauth | web-login | device`) and `credentials` JSONB column. Backfill existing Wahoo rows: `credential_kind='oauth'`, move `access_token / refresh_token / expires_at` into the JSONB blob. Drop the old token columns.
- **BREAKING (code)**: replace `SyncProvider` interface with three capability seams: `Importer`, `RoutePusher`, `WebhookReceiver`. Each provider declares which capabilities it implements via a co-located manifest (`providers/<name>/manifest.ts`); a small `providers/registry.ts` imports each.
- **New module** `ConnectedServiceManager` at `apps/journal/app/lib/connected-services/manager.ts` owning credential lifecycle: `link / unlink / withFreshCredentials / markNeedsRelink`. Centralizes token-refresh logic currently inlined in `pushes.server.ts` (~lines 145-170).
- **New per-kind module** `CredentialAdapter`. `oauth` adapter ships with this change (`refresh / revoke`). `web-login` adapter lands with `komoot-import`; `device` adapter lands with `mobile-app`. The `credential_kind` enum carries all three from day one to avoid a follow-up migration.
- **`RoutePusher` seam shape**: `pushRoute(service, route) → {remoteId, version}`. Wahoo's FIT-Course conversion, the `route:<id>` `external_id` convention, and the PUT→POST-on-404 fallback stay **inside the Wahoo adapter** — they are not part of the seam (per ADR-0003).
- Wahoo's existing code splits into `providers/wahoo/{importer.ts, pusher.ts, webhook.ts, manifest.ts}`; existing tests reorganize accordingly.
- **Spec deltas**: rename `sync_connections``connected_services` in `connected-services/spec.md`, `wahoo-import/spec.md`, `wahoo-route-push/spec.md`. Add capability-seam terminology to `connected-services/spec.md`.
## Capabilities
### New Capabilities
(none — this change reshapes existing capabilities, it does not introduce new user-visible behaviour.)
### Modified Capabilities
- `connected-services`: requirement-level changes — credential storage shape (`credential_kind` discriminator + JSONB), provider model (capability seams instead of unified `SyncProvider`), token-refresh policy (centralized via `ConnectedServiceManager.withFreshCredentials`).
- `wahoo-import`: rename of underlying table and refactor of how the importer obtains credentials (via manager, not via direct table read). User-visible behaviour unchanged.
- `wahoo-route-push`: rename of underlying table; `RoutePusher` seam shape codified. User-visible behaviour unchanged.
## Impact
- **Code**: `apps/journal/app/lib/sync/` is replaced by `apps/journal/app/lib/connected-services/` (manager + credential adapters + registry) and `apps/journal/app/lib/connected-services/providers/wahoo/` (split capability modules). Routes and jobs that read sync data update their imports.
- **DB**: one migration — rename + add columns + backfill + drop legacy columns. Production `connected_services` row count is small (sandbox era); backfill is online-safe.
- **Tests**: `wahoo.test.ts` (285 lines) and `pushes.server.test.ts` (309 lines) reorganize to test capabilities (`importer.test.ts`, `pusher.test.ts`, `webhook.test.ts`) against a fake `ConnectedServiceManager` yielding stub credentials. New `manager.test.ts` covers refresh / needs_relink transitions.
- **Specs**: 3 spec files updated (table rename + terminology). No removals.
- **Out of scope** (separate follow-on changes): `komoot-import` (will be amended to use this architecture instead of its drafted `journal.integrations` table); `mobile-app` (Apple Health provider lands there). Neither is touched by this change beyond a reference.
- **Decision references**: `docs/adr/0001-connected-services-credential-discriminator.md`, `docs/adr/0002-no-unified-syncprovider-interface.md`, `docs/adr/0003-routepusher-seam-shape.md`, `CONTEXT.md` (Connected Services section).

View file

@ -0,0 +1,46 @@
## MODIFIED Requirements
### Requirement: OAuth token storage in `connected_services`
External-service credentials SHALL be stored in the `journal.connected_services` table (renamed from `sync_connections`) keyed by `(user_id, provider)`. Each row SHALL persist a `credential_kind` discriminator (`oauth | web-login | device`), a `credentials` JSONB blob whose schema is determined by the kind, the provider-side user id (`provider_user_id`, nullable for kinds without one), and OAuth-specific `granted_scopes` as a top-level column (populated only when `credential_kind = 'oauth'`). Disconnecting SHALL delete the row, severing the user's link to the external service without affecting any imported activities.
For `credential_kind = 'oauth'`, the `credentials` blob SHALL contain `access_token`, `refresh_token`, and `expires_at`. Other kinds (web-login, device) carry their own blob shapes and are introduced by their respective consumer changes; the enum value is reserved from day one so adding a kind does not require a migration.
#### Scenario: Wahoo connect persists tokens
- **WHEN** a user completes the Wahoo OAuth flow
- **THEN** a `connected_services` row is upserted with `provider = 'wahoo'`, `credential_kind = 'oauth'`, the `credentials` JSONB containing access/refresh tokens and `expires_at`, the provider user id, and the granted scopes
#### Scenario: Disconnect removes the row but keeps imports
- **WHEN** a user clicks "Disconnect" on a Wahoo connection
- **THEN** the matching `connected_services` row is deleted; previously imported activities are not deleted (they remain owned by the user, just no longer auto-syncing)
#### Scenario: Each user has at most one row per provider
- **WHEN** a user reconnects an already-connected provider
- **THEN** the existing `connected_services` row is updated in place with the fresh credentials; no duplicate row is created
## ADDED Requirements
### Requirement: Capability seams for providers
The system SHALL model each external provider as a manifest declaring its `credential_kind` and which capabilities it implements. Capabilities are modelled as separate interfaces — `Importer`, `RoutePusher`, `WebhookReceiver` — and a provider implements only the subset that applies to it. There SHALL NOT be a unified provider interface that lists every capability with optional methods.
#### Scenario: Add a new provider
- **WHEN** a developer adds a new provider (e.g. Garmin)
- **THEN** they create `providers/<name>/manifest.ts` declaring the provider's `credential_kind` and the capability adapters it implements
- **AND** they implement only the relevant capability interfaces (e.g. an OAuth-pull-only provider implements `Importer` only; not `RoutePusher` or `WebhookReceiver`)
- **AND** they register the manifest by importing it in `providers/registry.ts`
- **AND** existing settings UI, webhook routing, and credential lifecycle work without further changes
### Requirement: Centralized credential lifecycle via `ConnectedServiceManager`
The system SHALL expose a `ConnectedServiceManager` that owns credential lifecycle for all providers. Importers, pushers, and webhook handlers SHALL obtain credentials exclusively via `withFreshCredentials(serviceId, fn)`, which loads the row, refreshes via the kind-appropriate `CredentialAdapter` if expired, calls `fn(credentials)`, and flips `status = needs_relink` if refresh fails. Capability adapters SHALL NOT read the `credentials` JSONB directly.
#### Scenario: Expired OAuth token is refreshed transparently
- **WHEN** a capability adapter calls `withFreshCredentials` for a `connected_services` row whose `expires_at` has passed
- **THEN** the manager invokes the OAuth `CredentialAdapter`'s refresh flow
- **AND** updates the `credentials` JSONB with the new tokens and `expires_at`
- **AND** invokes `fn` with the fresh credentials
- **AND** the capability adapter never sees the expired token
#### Scenario: Refresh failure flips status to needs_relink
- **WHEN** the `CredentialAdapter`'s refresh call returns a permanent failure (e.g. revoked refresh token)
- **THEN** the manager sets `status = 'needs_relink'` on the `connected_services` row
- **AND** raises an error that the caller surfaces as a re-connect prompt
- **AND** subsequent calls for the same service short-circuit until the user re-links

View file

@ -0,0 +1,58 @@
## MODIFIED Requirements
### Requirement: Provider-agnostic sync framework
The system SHALL provide capability-shaped seams for external activity sync providers. Each provider declares a manifest at `providers/<name>/manifest.ts` listing its `credential_kind` (`oauth | web-login | device`) and the capability adapters it implements (`Importer`, `RoutePusher`, `WebhookReceiver`). There SHALL NOT be a unified `SyncProvider` interface containing every capability with optional methods.
#### Scenario: Add new provider
- **WHEN** a developer wants to add a new sync provider (e.g., Garmin)
- **THEN** they create `providers/garmin/` containing a `manifest.ts` declaring `credential_kind` and the implemented capabilities
- **AND** they implement only the capability interfaces that apply (e.g. `Importer` for an import-only provider)
- **AND** they register the manifest in `providers/registry.ts`
- **AND** OAuth flows, webhook routing, and settings UI work without further changes
### Requirement: Connect Wahoo account
Users SHALL be able to connect their Wahoo account via OAuth2.
#### Scenario: Connect Wahoo
- **WHEN** a user clicks "Connect Wahoo" in journal settings
- **THEN** they are redirected to Wahoo's OAuth authorization page with scopes `workouts_read`, `user_read`, `offline_data`, `routes_write`
- **AND** after granting permission, redirected back to the journal
- **AND** access and refresh tokens are stored in `connected_services` (in the `credentials` JSONB blob, with `credential_kind = 'oauth'`)
- **AND** the granted scopes are recorded in `connected_services.granted_scopes` so feature gates can detect missing scopes without round-tripping to Wahoo
#### Scenario: Disconnect Wahoo
- **WHEN** a user clicks "Disconnect" next to their Wahoo connection
- **THEN** the stored credentials are deleted from `connected_services`
#### Scenario: Token refresh
- **WHEN** a Wahoo API call fails with an expired token
- **THEN** the OAuth `CredentialAdapter` is invoked via `ConnectedServiceManager.withFreshCredentials` to obtain a new access token automatically
- **AND** the new tokens are written back to the `credentials` JSONB blob
#### Scenario: Existing connection without routes_write
- **WHEN** a user connected before the `routes_write` scope was added attempts an action that requires it (such as pushing a route)
- **THEN** the system detects the missing scope from `connected_services.granted_scopes` and routes the user through OAuth re-authorization to grant the new scope
- **AND** the existing tokens remain valid until re-auth completes; ongoing read flows (workout import, webhook ingestion) continue to work
### Requirement: Webhook-based automatic sync
New Wahoo workouts SHALL be automatically imported when they complete.
#### Scenario: Webhook receives new workout
- **WHEN** Wahoo sends a `workout_summary` webhook to `/api/sync/webhook/wahoo`
- **THEN** the system identifies the user via `provider_user_id`
- **AND** downloads the FIT file from Wahoo's CDN (without auth headers, as CDN URLs are pre-signed)
- **AND** converts it to GPX
- **AND** creates a journal activity with the GPX, stats, and PostGIS geometry
- **AND** records the import in `sync_imports` to prevent duplicates
#### Scenario: Webhook for workout without file
- **WHEN** a webhook arrives for a workout with no FIT file URL
- **THEN** the activity is created without GPX or geometry
#### Scenario: Duplicate webhook
- **WHEN** a webhook arrives for a workout already imported
- **THEN** the import is skipped silently (idempotent)
#### Scenario: Unknown user webhook
- **WHEN** a webhook arrives with a `provider_user_id` not matching any connection
- **THEN** the request is ignored with a 200 response (don't reveal user existence)

View file

@ -0,0 +1,59 @@
## MODIFIED Requirements
### Requirement: Send to Wahoo action on route detail page
The Journal SHALL show a "Send to Wahoo" button on the route detail page when all of the following hold: the viewer owns the route, the viewer has a connected Wahoo account, and the route has stored geometry (`routes.geom` is non-null and `routes.gpx` is non-empty). The button SHALL trigger a server action that pushes the current route version to Wahoo.
#### Scenario: Owner with connected Wahoo and a route with geometry
- **WHEN** the route owner loads a route detail page for a route that has geometry
- **AND** the owner has a `connected_services` row with `provider = 'wahoo'`
- **THEN** a "Send to Wahoo" button is visible alongside the existing "Export GPX" action
#### Scenario: Owner without a connected Wahoo account
- **WHEN** the route owner loads a route detail page
- **AND** the owner has no Wahoo `connected_services` row
- **THEN** the "Send to Wahoo" button is not rendered
#### Scenario: Non-owner viewing the route
- **WHEN** any visitor who is not the route owner loads the route detail page
- **THEN** the "Send to Wahoo" button is not rendered, regardless of the viewer's own Wahoo connection
#### Scenario: Route without geometry
- **WHEN** the route owner loads a route detail page for a route whose `geom` is null or whose `gpx` is empty
- **THEN** the "Send to Wahoo" button is not rendered
### Requirement: Re-auth flow when `routes_write` scope is missing
The Journal SHALL detect when a connected Wahoo account lacks the `routes_write` scope before calling Wahoo, redirect the user through OAuth to grant it, and resume the push automatically after the user returns.
#### Scenario: Existing connection lacks routes_write
- **WHEN** the route owner clicks "Send to Wahoo"
- **AND** the user's `connected_services.granted_scopes` does not include `routes_write`
- **THEN** the server redirects the user to Wahoo's authorization URL with the full updated scope list and a `state` that encodes `{ return_to: <route_url>, push_after: true }`
- **AND** no Wahoo `/v1/routes` call is attempted
#### Scenario: Push completes after re-auth
- **WHEN** the user returns from Wahoo's OAuth callback with `push_after = true` in the state
- **THEN** the connection is updated with the new scopes
- **AND** the push action runs automatically against the route encoded in `return_to`
- **AND** the user lands back on the route detail page with the "Sent to Wahoo" confirmation visible
#### Scenario: User declines the new scope
- **WHEN** the user reaches Wahoo's authorization page and clicks "Deny"
- **THEN** the user is redirected back to the route detail page with an inline notice "Sending to Wahoo needs route permission — please reconnect your account in Settings"
- **AND** no `sync_pushes` row is created
## ADDED Requirements
### Requirement: `RoutePusher` capability seam
The system SHALL expose `RoutePusher` as the capability seam through which any provider pushes routes. The seam shape is `pushRoute(service, route) → {remoteId, version}`. Provider-specific concerns — file format conversion (e.g. FIT Course), `external_id` conventions, HTTP fallback strategies, idempotency-tracking semantics — SHALL be handled inside the adapter and SHALL NOT appear on the `RoutePusher` interface.
#### Scenario: Wahoo pusher implements the seam without leaking workarounds
- **WHEN** the route push action invokes the Wahoo `RoutePusher`
- **THEN** the seam is called as `pushRoute(connectedService, route)` and returns `{remoteId, version}`
- **AND** the FIT-Course conversion, the `external_id = route:<route_id>` convention, the PUT-vs-POST decision, and the PUT→POST-on-404 fallback are all internal to the Wahoo adapter
- **AND** callers do not pass FIT data or Wahoo-specific fields across the seam
#### Scenario: Future pusher reuses the same seam
- **WHEN** a developer adds a second `RoutePusher` (e.g. Garmin)
- **THEN** they implement `pushRoute(service, route) → {remoteId, version}` with the same shape
- **AND** the route push action invokes it identically to the Wahoo pusher
- **AND** their format conversion and HTTP recovery live inside the adapter

View file

@ -0,0 +1,49 @@
## 1. Database migration
- [x] 1.1 Update Drizzle schema in `packages/db/src/schema/journal.ts`: rename `syncConnections` table to `connectedServices`, add `credentialKind` (text, NOT NULL) and `credentials` (jsonb, NOT NULL) and `status` (text, NOT NULL DEFAULT `'active'` with CHECK constraint for `active | needs_relink | revoked`); drop `accessToken`, `refreshToken`, `expiresAt`.
- [x] 1.2 Generate migration via `pnpm db:generate`; hand-edit if needed so backfill runs in the same transaction as the column adds (`UPDATE connected_services SET credential_kind = 'oauth', credentials = jsonb_build_object('access_token', access_token, 'refresh_token', refresh_token, 'expires_at', expires_at)`), then drop the old columns.
- [x] 1.3 Verify the migration on a local database with seeded Wahoo connection rows; verify the inverse rollback restores the columns from JSONB.
- [x] 1.4 Update FK references in `sync_imports` and `sync_pushes` if any reference the table name (likely none — both reference `users` / `routes`, not connections).
## 2. ConnectedServiceManager + OAuth CredentialAdapter
- [x] 2.1 Create `apps/journal/app/lib/connected-services/types.ts` with `ConnectedService` record type, `CredentialKind`, `Credentials` (discriminated union per kind), `NeedsRelink` error class, `Status` enum.
- [x] 2.2 Create `apps/journal/app/lib/connected-services/credential-adapters/oauth.ts` implementing `CredentialAdapter` with `refresh(creds, providerConfig) → Credentials | NeedsRelink` and optional `revoke`.
- [x] 2.3 Create `apps/journal/app/lib/connected-services/manager.ts` exposing `link / unlink / withFreshCredentials / markNeedsRelink`. Persistence via Drizzle on the `connectedServices` table.
- [x] 2.4 Write `manager.test.ts`: in-memory tests with stub `CredentialAdapter` covering refresh-on-expired, refresh-fails-→-needs_relink, link/unlink, markNeedsRelink, status short-circuit.
- [x] 2.5 Write `credential-adapters/oauth.test.ts`: contract tests against a mocked token endpoint (refresh success, refresh 4xx → NeedsRelink).
## 3. Provider manifest + registry
- [x] 3.1 Create `apps/journal/app/lib/connected-services/registry.ts` and `types.ts` capability interfaces (`Importer`, `RoutePusher`, `WebhookReceiver`, `ProviderManifest`).
- [x] 3.2 Create `apps/journal/app/lib/connected-services/providers/wahoo/manifest.ts` declaring `credentialKind: 'oauth'`, OAuth config (auth URL, token URL, scopes), and references to capability adapters.
- [x] 3.3 Wire `providers/registry.ts` to import the Wahoo manifest. Expose `getManifest(providerId)` and `getAllManifests()`.
## 4. Wahoo capability adapters
- [x] 4.1 Move and reshape Wahoo importer logic into `providers/wahoo/importer.ts` implementing the `Importer` seam (`listImportable`, `importOne`). Internally calls `withFreshCredentials` for any auth'd Wahoo HTTP. Keep FIT→GPX conversion isolated.
- [x] 4.2 Move and reshape Wahoo route push logic into `providers/wahoo/pusher.ts` implementing `RoutePusher` (`pushRoute(service, route) → {remoteId, version}`). Keep FIT-Course conversion, `external_id = route:<id>`, the PUT-vs-POST decision, and the PUT→POST-on-404 fallback **inside** the adapter. Read/write `sync_pushes` per the existing idempotency rules.
- [x] 4.3 Move and reshape Wahoo webhook handling into `providers/wahoo/webhook.ts` implementing `WebhookReceiver` (`parseWebhook`, `handle`).
- [x] 4.4 Reorganize tests: split existing `wahoo.test.ts` (285 lines) and `pushes.server.test.ts` (309 lines) into `providers/wahoo/{importer.test.ts, pusher.test.ts, webhook.test.ts}` against a fake `ConnectedServiceManager` that yields stub credentials. Use `git mv` first, then split, so blame survives.
## 5. Caller migration
- [x] 5.1 Update OAuth connect routes (`/api/sync/connect/wahoo`, callback) to call `ConnectedServiceManager.link` with `credentialKind: 'oauth'` and the JSONB credentials shape. Remove direct writes to the old token columns.
- [x] 5.2 Update disconnect route (`/api/sync/disconnect/wahoo`) to call `ConnectedServiceManager.unlink`.
- [x] 5.3 Update webhook route (`/api/sync/webhook/wahoo`) to look up the manifest via the registry and dispatch to its `WebhookReceiver`. Unknown `provider_user_id` still returns 200.
- [x] 5.4 Update the route push action and any background jobs (`apps/journal/app/jobs/`) that previously called into `apps/journal/app/lib/sync/`. Replace direct `connections.server.ts` reads with `withFreshCredentials`.
- [x] 5.5 Update the connections settings page (`/settings/connections`) and any UI surfaces reading `granted_scopes` to read from the `connected_services` row directly.
- [x] 5.6 Delete the old `apps/journal/app/lib/sync/` directory once all imports are gone. Delete the old `SyncProvider` interface from `types.ts`.
## 6. Verification
- [x] 6.1 Run `pnpm typecheck && pnpm lint && pnpm test` — all green.
- [x] 6.2 Run `pnpm test:e2e` with the Wahoo flow specs — all green.
- [x] 6.3 Manual smoke test: connect Wahoo locally, import a workout (manual + webhook simulated), push a route (POST then PUT), disconnect, reconnect — all working.
- [x] 6.4 Check that `connected_services` migration applies cleanly to a copy of staging data (or local seed reflecting prod).
## 7. Documentation + follow-up
- [x] 7.1 Update `CONTEXT.md` if any term was sharpened during implementation.
- [x] 7.2 File a follow-up issue/note to amend `openspec/changes/komoot-import/design.md` to use `connected_services` + `web-login` credential kind instead of the drafted `journal.integrations` table.
- [x] 7.3 At archive time, apply the spec deltas in `specs/connected-services/`, `specs/wahoo-import/`, `specs/wahoo-route-push/` to `openspec/specs/`.

View file

@ -5,6 +5,33 @@ from Komoot have hundreds of tours they'd lose by switching. The old trails
project had a working Komoot integration using basic auth against Komoot's
undocumented API (`api.komoot.de`).
> **Note (added 2026-05-08, post `deepen-connected-services`)**:
> Earlier drafts of this design proposed a separate `journal.integrations`
> table for Komoot credentials. That has been **superseded** by the
> connected-services architecture introduced in
> `openspec/changes/deepen-connected-services/`. When this change is
> revisited, Komoot must implement:
>
> - A row in `journal.connected_services` with `credential_kind = 'web-login'`
> and a `credentials` JSONB blob carrying `{ email, encrypted_password,
> session_jar }`.
> - A `web-login` `CredentialAdapter` at
> `apps/journal/app/lib/connected-services/credential-adapters/web-login.ts`
> implementing `relogin(creds) → creds | InvalidCredentials`. Web-login
> breakage (form changes, captcha, password rotation) surfaces at the
> import layer, not the credential layer (see ADR-0001 / CONTEXT.md).
> - A `KomootImporter` in
> `apps/journal/app/lib/connected-services/providers/komoot/importer.ts`
> that goes through `ctx.withFreshCredentials` like the Wahoo importer.
> Komoot does not have webhooks or push, so its manifest declares only
> the `Importer` capability — no `routePusher`, no `webhookReceiver`.
> - A manifest at `providers/komoot/manifest.ts` registered via
> `providers/index.ts`.
>
> Don't add a `journal.integrations` table. The user-facing "Connected
> Services" list at `/settings/connections` should show Komoot alongside
> Wahoo, which only works if both share `connected_services`.
## Goals / Non-Goals
**Goals:**

View file

@ -1,7 +1,7 @@
# connected-services Specification
## Purpose
Third-party service connections (Wahoo today; future Strava, Garmin, etc.) that the user opts into from the Journal's settings page. Covers OAuth-based connect / disconnect flows and the storage layout for tokens. Token refresh behavior, webhook ingestion, and the per-service import rules live with each service's own change (e.g. `wahoo-import`).
Third-party service connections (Wahoo today; future Strava, Garmin, Komoot, Apple Health, etc.) that the user opts into from the Journal's settings page. Covers connect / disconnect flows, the storage layout for credentials of any kind, and the capability seams (`Importer`, `RoutePusher`, `WebhookReceiver`) that providers implement. Per-provider mechanics live with each service's own change (e.g. `wahoo-import`).
## Requirements
@ -14,17 +14,45 @@ The Journal SHALL expose a Connected services page at `/settings/connections` (o
- **AND** connected state shows a "Disconnect" button that POSTs to `/api/sync/disconnect/<provider>`
- **AND** disconnected state shows a "Connect Wahoo" button that begins the OAuth handshake
### Requirement: OAuth token storage in `sync_connections`
External-service OAuth tokens SHALL be stored in the `journal.sync_connections` table keyed by `(user_id, provider)`. Each row SHALL persist `access_token`, `refresh_token`, `expires_at`, and the provider-side user id (`provider_user_id`). Disconnecting SHALL delete the row, severing the user's link to the external service without affecting any imported activities.
### Requirement: OAuth token storage in `connected_services`
External-service credentials SHALL be stored in the `journal.connected_services` table (renamed from `sync_connections`) keyed by `(user_id, provider)`. Each row SHALL persist a `credential_kind` discriminator (`oauth | web-login | device`), a `credentials` JSONB blob whose schema is determined by the kind, the provider-side user id (`provider_user_id`, nullable for kinds without one), and OAuth-specific `granted_scopes` as a top-level column (populated only when `credential_kind = 'oauth'`). Disconnecting SHALL delete the row, severing the user's link to the external service without affecting any imported activities.
For `credential_kind = 'oauth'`, the `credentials` blob SHALL contain `access_token`, `refresh_token`, and `expires_at`. Other kinds (web-login, device) carry their own blob shapes and are introduced by their respective consumer changes; the enum value is reserved from day one so adding a kind does not require a migration.
#### Scenario: Wahoo connect persists tokens
- **WHEN** a user completes the Wahoo OAuth flow
- **THEN** a `sync_connections` row is upserted with `provider = 'wahoo'`, the access/refresh tokens, the provider user id, and `expires_at`
- **THEN** a `connected_services` row is upserted with `provider = 'wahoo'`, `credential_kind = 'oauth'`, the `credentials` JSONB containing access/refresh tokens and `expires_at`, the provider user id, and the granted scopes
#### Scenario: Disconnect removes the row but keeps imports
- **WHEN** a user clicks "Disconnect" on a Wahoo connection
- **THEN** the matching `sync_connections` row is deleted; previously imported activities are not deleted (they remain owned by the user, just no longer auto-syncing)
- **THEN** the matching `connected_services` row is deleted; previously imported activities are not deleted (they remain owned by the user, just no longer auto-syncing)
#### Scenario: Each user has at most one row per provider
- **WHEN** a user reconnects an already-connected provider
- **THEN** the existing `sync_connections` row is updated in place with the fresh tokens; no duplicate row is created
- **THEN** the existing `connected_services` row is updated in place with the fresh credentials; no duplicate row is created
### Requirement: Capability seams for providers
The system SHALL model each external provider as a manifest declaring its `credential_kind` and which capabilities it implements. Capabilities are modelled as separate interfaces — `Importer`, `RoutePusher`, `WebhookReceiver` — and a provider implements only the subset that applies to it. There SHALL NOT be a unified provider interface that lists every capability with optional methods.
#### Scenario: Add a new provider
- **WHEN** a developer adds a new provider (e.g. Garmin)
- **THEN** they create `providers/<name>/manifest.ts` declaring the provider's `credential_kind` and the capability adapters it implements
- **AND** they implement only the relevant capability interfaces (e.g. an OAuth-pull-only provider implements `Importer` only; not `RoutePusher` or `WebhookReceiver`)
- **AND** they register the manifest by importing it in `providers/registry.ts`
- **AND** existing settings UI, webhook routing, and credential lifecycle work without further changes
### Requirement: Centralized credential lifecycle via `ConnectedServiceManager`
The system SHALL expose a `ConnectedServiceManager` that owns credential lifecycle for all providers. Importers, pushers, and webhook handlers SHALL obtain credentials exclusively via `withFreshCredentials(serviceId, fn)`, which loads the row, refreshes via the kind-appropriate `CredentialAdapter` if expired, calls `fn(credentials)`, and flips `status = needs_relink` if refresh fails. Capability adapters SHALL NOT read the `credentials` JSONB directly.
#### Scenario: Expired OAuth token is refreshed transparently
- **WHEN** a capability adapter calls `withFreshCredentials` for a `connected_services` row whose `expires_at` has passed
- **THEN** the manager invokes the OAuth `CredentialAdapter`'s refresh flow
- **AND** updates the `credentials` JSONB with the new tokens and `expires_at`
- **AND** invokes `fn` with the fresh credentials
- **AND** the capability adapter never sees the expired token
#### Scenario: Refresh failure flips status to needs_relink
- **WHEN** the `CredentialAdapter`'s refresh call returns a permanent failure (e.g. revoked refresh token)
- **THEN** the manager sets `status = 'needs_relink'` on the `connected_services` row
- **AND** raises an error that the caller surfaces as a re-connect prompt
- **AND** subsequent calls for the same service short-circuit until the user re-links

View file

@ -5,13 +5,14 @@ Provider-agnostic activity sync framework with Wahoo as the first provider, supp
## Requirements
### Requirement: Provider-agnostic sync framework
The system SHALL provide a common interface for external activity sync providers.
The system SHALL provide capability-shaped seams for external activity sync providers. Each provider declares a manifest at `providers/<name>/manifest.ts` listing its `credential_kind` (`oauth | web-login | device`) and the capability adapters it implements (`Importer`, `RoutePusher`, `WebhookReceiver`). There SHALL NOT be a unified `SyncProvider` interface containing every capability with optional methods.
#### Scenario: Add new provider
- **WHEN** a developer wants to add a new sync provider (e.g., Garmin)
- **THEN** they implement the `SyncProvider` interface in a single file
- **AND** register it in the provider registry
- **AND** all OAuth, webhook, import, and settings UI works automatically
- **THEN** they create `providers/garmin/` containing a `manifest.ts` declaring `credential_kind` and the implemented capabilities
- **AND** they implement only the capability interfaces that apply (e.g. `Importer` for an import-only provider)
- **AND** they register the manifest in `providers/registry.ts`
- **AND** OAuth flows, webhook routing, and settings UI work without further changes
### Requirement: Connect Wahoo account
Users SHALL be able to connect their Wahoo account via OAuth2.
@ -20,20 +21,21 @@ Users SHALL be able to connect their Wahoo account via OAuth2.
- **WHEN** a user clicks "Connect Wahoo" in journal settings
- **THEN** they are redirected to Wahoo's OAuth authorization page with scopes `workouts_read`, `user_read`, `offline_data`, `routes_write`
- **AND** after granting permission, redirected back to the journal
- **AND** access and refresh tokens are stored in `sync_connections`
- **AND** the granted scopes are recorded in `sync_connections.granted_scopes` so feature gates can detect missing scopes without round-tripping to Wahoo
- **AND** access and refresh tokens are stored in `connected_services` (in the `credentials` JSONB blob, with `credential_kind = 'oauth'`)
- **AND** the granted scopes are recorded in `connected_services.granted_scopes` so feature gates can detect missing scopes without round-tripping to Wahoo
#### Scenario: Disconnect Wahoo
- **WHEN** a user clicks "Disconnect" next to their Wahoo connection
- **THEN** the stored tokens are deleted from `sync_connections`
- **THEN** the stored credentials are deleted from `connected_services`
#### Scenario: Token refresh
- **WHEN** a Wahoo API call fails with an expired token
- **THEN** the refresh token is used to obtain a new access token automatically
- **THEN** the OAuth `CredentialAdapter` is invoked via `ConnectedServiceManager.withFreshCredentials` to obtain a new access token automatically
- **AND** the new tokens are written back to the `credentials` JSONB blob
#### Scenario: Existing connection without routes_write
- **WHEN** a user connected before the `routes_write` scope was added attempts an action that requires it (such as pushing a route)
- **THEN** the system detects the missing scope from `sync_connections.granted_scopes` and routes the user through OAuth re-authorization to grant the new scope
- **THEN** the system detects the missing scope from `connected_services.granted_scopes` and routes the user through OAuth re-authorization to grant the new scope
- **AND** the existing tokens remain valid until re-auth completes; ongoing read flows (workout import, webhook ingestion) continue to work
### Requirement: Webhook-based automatic sync

View file

@ -9,12 +9,12 @@ The Journal SHALL show a "Send to Wahoo" button on the route detail page when al
#### Scenario: Owner with connected Wahoo and a route with geometry
- **WHEN** the route owner loads a route detail page for a route that has geometry
- **AND** the owner has a `sync_connections` row with `provider = 'wahoo'`
- **AND** the owner has a `connected_services` row with `provider = 'wahoo'`
- **THEN** a "Send to Wahoo" button is visible alongside the existing "Export GPX" action
#### Scenario: Owner without a connected Wahoo account
- **WHEN** the route owner loads a route detail page
- **AND** the owner has no Wahoo `sync_connections` row
- **AND** the owner has no Wahoo `connected_services` row
- **THEN** the "Send to Wahoo" button is not rendered
#### Scenario: Non-owner viewing the route
@ -88,7 +88,7 @@ The Journal SHALL detect when a connected Wahoo account lacks the `routes_write`
#### Scenario: Existing connection lacks routes_write
- **WHEN** the route owner clicks "Send to Wahoo"
- **AND** the user's `sync_connections.granted_scopes` does not include `routes_write`
- **AND** the user's `connected_services.granted_scopes` does not include `routes_write`
- **THEN** the server redirects the user to Wahoo's authorization URL with the full updated scope list and a `state` that encodes `{ return_to: <route_url>, push_after: true }`
- **AND** no Wahoo `/v1/routes` call is attempted
@ -135,3 +135,18 @@ The route detail page SHALL show whether the route has been pushed to Wahoo and
#### Scenario: Push failed previously shows retry affordance
- **WHEN** the route owner views a route whose `sync_pushes` row has `error` set and `pushed_at` null
- **THEN** the page shows "Last attempt failed: <short error>" with a "Send to Wahoo" button still active
### Requirement: `RoutePusher` capability seam
The system SHALL expose `RoutePusher` as the capability seam through which any provider pushes routes. The seam shape is `pushRoute(service, route) → {remoteId, version}`. Provider-specific concerns — file format conversion (e.g. FIT Course), `external_id` conventions, HTTP fallback strategies, idempotency-tracking semantics — SHALL be handled inside the adapter and SHALL NOT appear on the `RoutePusher` interface.
#### Scenario: Wahoo pusher implements the seam without leaking workarounds
- **WHEN** the route push action invokes the Wahoo `RoutePusher`
- **THEN** the seam is called as `pushRoute(connectedService, route)` and returns `{remoteId, version}`
- **AND** the FIT-Course conversion, the `external_id = route:<route_id>` convention, the PUT-vs-POST decision, and the PUT→POST-on-404 fallback are all internal to the Wahoo adapter
- **AND** callers do not pass FIT data or Wahoo-specific fields across the seam
#### Scenario: Future pusher reuses the same seam
- **WHEN** a developer adds a second `RoutePusher` (e.g. Garmin)
- **THEN** they implement `pushRoute(service, route) → {remoteId, version}` with the same shape
- **AND** the route push action invokes it identically to the Wahoo pusher
- **AND** their format conversion and HTTP recovery live inside the adapter

View file

@ -0,0 +1,89 @@
-- Data migration for the deepen-connected-services change.
--
-- Why this lives outside drizzle-kit push:
-- We're (a) renaming `sync_connections` → `connected_services`, (b) collapsing
-- the OAuth-shaped columns (access_token, refresh_token, expires_at) into a
-- polymorphic `credentials` JSONB blob discriminated by `credential_kind`,
-- and (c) adding a `status` column. drizzle-kit push would happily DROP the
-- old columns before we backfill, losing every active Wahoo connection.
--
-- This script reshapes the table into the new layout BEFORE drizzle-kit push
-- diffs against the schema. After it runs, drizzle-kit push has nothing left
-- to do for this table.
--
-- Idempotent: safe to run before every deploy.
DO $$
BEGIN
-- 1. Rename table if it still has the old name.
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'journal' AND table_name = 'sync_connections'
) THEN
ALTER TABLE journal.sync_connections RENAME TO connected_services;
END IF;
-- Bail out if the renamed table doesn't exist yet (fresh DB; drizzle-kit
-- push will create connected_services directly from the schema).
IF NOT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'journal' AND table_name = 'connected_services'
) THEN
RETURN;
END IF;
-- 2. Add the new columns (nullable initially so backfill can populate them).
ALTER TABLE journal.connected_services
ADD COLUMN IF NOT EXISTS credential_kind text,
ADD COLUMN IF NOT EXISTS credentials jsonb,
ADD COLUMN IF NOT EXISTS status text;
-- 3. Backfill from old token columns if they still exist. Existing rows are
-- all OAuth (Wahoo), so credential_kind = 'oauth' and the JSONB blob
-- holds {access_token, refresh_token, expires_at}.
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'journal'
AND table_name = 'connected_services'
AND column_name = 'access_token'
) THEN
UPDATE journal.connected_services
SET credential_kind = 'oauth',
credentials = jsonb_build_object(
'access_token', access_token,
'refresh_token', refresh_token,
'expires_at', to_char(expires_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
),
status = COALESCE(status, 'active')
WHERE credential_kind IS NULL;
END IF;
-- 4. Set NOT NULL on the new columns now that they're populated.
ALTER TABLE journal.connected_services
ALTER COLUMN credential_kind SET NOT NULL,
ALTER COLUMN credentials SET NOT NULL,
ALTER COLUMN status SET NOT NULL;
-- 5. CHECK constraints. Drop-then-add for idempotency.
ALTER TABLE journal.connected_services
DROP CONSTRAINT IF EXISTS connected_services_credential_kind_check;
ALTER TABLE journal.connected_services
ADD CONSTRAINT connected_services_credential_kind_check
CHECK (credential_kind IN ('oauth', 'web-login', 'device'));
ALTER TABLE journal.connected_services
DROP CONSTRAINT IF EXISTS connected_services_status_check;
ALTER TABLE journal.connected_services
ADD CONSTRAINT connected_services_status_check
CHECK (status IN ('active', 'needs_relink', 'revoked'));
-- 6. Default for status (so future inserts that omit it get 'active').
ALTER TABLE journal.connected_services
ALTER COLUMN status SET DEFAULT 'active';
-- 7. Unique (user_id, provider) — the spec invariant "at most one row per
-- (user, provider)" was previously enforced only at the application
-- layer. Lift it into the DB.
CREATE UNIQUE INDEX IF NOT EXISTS connected_services_user_provider_unique
ON journal.connected_services (user_id, provider);
END $$;

View file

@ -186,27 +186,42 @@ export const oauthTokens = journalSchema.table("oauth_tokens", {
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
export const syncConnections = journalSchema.table("sync_connections", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
provider: text("provider").notNull(),
accessToken: text("access_token").notNull(),
refreshToken: text("refresh_token").notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
providerUserId: text("provider_user_id"),
// Scopes the user actually granted at OAuth time. Wahoo doesn't return a
// scope field in token responses and grants scopes all-or-nothing, so we
// record the requested scope set on exchangeCode. Used to detect when an
// existing connection predates a scope upgrade (e.g. routes_write) and
// needs a re-auth before a new push call can succeed.
grantedScopes: text("granted_scopes")
.array()
.notNull()
.default(sql`ARRAY[]::text[]`),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
// External-service connections (OAuth, web-login, mobile-paired devices).
// `credential_kind` discriminates the shape of `credentials` JSONB:
// - 'oauth': { access_token, refresh_token, expires_at }
// - 'web-login': { email, encrypted_password, session_jar } (Komoot, future)
// - 'device': {} (Apple Health, future)
// See docs/adr/0001 and CONTEXT.md (Connected Services).
export const connectedServices = journalSchema.table(
"connected_services",
{
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
provider: text("provider").notNull(),
credentialKind: text("credential_kind").notNull(),
credentials: jsonb("credentials").notNull(),
// 'active' | 'needs_relink' | 'revoked'. Manager flips to 'needs_relink'
// when CredentialAdapter.refresh returns a permanent failure.
status: text("status").notNull().default("active"),
providerUserId: text("provider_user_id"),
// OAuth-only. Promoted out of the credentials JSONB because feature gates
// (e.g. routes_write check on push) read this on every call.
grantedScopes: text("granted_scopes")
.array()
.notNull()
.default(sql`ARRAY[]::text[]`),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => ({
userProviderUnique: uniqueIndex("connected_services_user_provider_unique").on(
t.userId,
t.provider,
),
}),
);
export const syncImports = journalSchema.table("sync_imports", {
id: text("id").primaryKey(),