Merge pull request #361 from trails-cool/architecture/deepen-connected-services
Deepen connected-services architecture (sync provider seam)
This commit is contained in:
commit
eee5689a75
50 changed files with 3099 additions and 1495 deletions
126
CONTEXT.md
Normal file
126
CONTEXT.md
Normal 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`.
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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.
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
9
apps/journal/app/lib/connected-services/index.ts
Normal file
9
apps/journal/app/lib/connected-services/index.ts
Normal 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";
|
||||||
241
apps/journal/app/lib/connected-services/manager.test.ts
Normal file
241
apps/journal/app/lib/connected-services/manager.test.ts
Normal 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;
|
||||||
241
apps/journal/app/lib/connected-services/manager.ts
Normal file
241
apps/journal/app/lib/connected-services/manager.ts
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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 {};
|
||||||
|
}
|
||||||
|
}
|
||||||
11
apps/journal/app/lib/connected-services/providers/index.ts
Normal file
11
apps/journal/app/lib/connected-services/providers/index.ts
Normal 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 };
|
||||||
|
|
@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
115
apps/journal/app/lib/connected-services/push-action.server.ts
Normal file
115
apps/journal/app/lib/connected-services/push-action.server.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
138
apps/journal/app/lib/connected-services/registry.ts
Normal file
138
apps/journal/app/lib/connected-services/registry.ts
Normal 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);
|
||||||
|
}
|
||||||
82
apps/journal/app/lib/connected-services/types.ts
Normal file
82
apps/journal/app/lib/connected-services/types.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)));
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
|
@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -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({});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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 {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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";
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +1,20 @@
|
||||||
import { redirect, data } from "react-router";
|
import { redirect, data } from "react-router";
|
||||||
import type { Route } from "./+types/api.sync.callback.$provider";
|
import type { Route } from "./+types/api.sync.callback.$provider";
|
||||||
import { getSessionUser } from "~/lib/auth.server";
|
import { getSessionUser } from "~/lib/auth.server";
|
||||||
import { getProvider } from "~/lib/sync/registry";
|
import { getManifest, link } from "~/lib/connected-services";
|
||||||
import { saveConnection } from "~/lib/sync/connections.server";
|
import {
|
||||||
import { decodeOAuthState, pushRouteToProvider } from "~/lib/sync/pushes.server";
|
decodeOAuthState,
|
||||||
import { OAuthError } from "~/lib/sync/types";
|
} from "~/lib/connected-services/oauth-state.server";
|
||||||
|
import { pushRouteToProvider } from "~/lib/connected-services/push-action.server";
|
||||||
|
|
||||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||||
const user = await getSessionUser(request);
|
const user = await getSessionUser(request);
|
||||||
if (!user) return redirect("/auth/login");
|
if (!user) return redirect("/auth/login");
|
||||||
|
|
||||||
const provider = getProvider(params.provider);
|
const manifest = getManifest(params.provider);
|
||||||
if (!provider) return data({ error: "Unknown provider" }, { status: 404 });
|
if (!manifest || !manifest.exchangeCode) {
|
||||||
|
return data({ error: "Unknown provider" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const state = decodeOAuthState(url.searchParams.get("state"));
|
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}`;
|
const redirectUri = `${origin}/api/sync/callback/${params.provider}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tokens = await provider.exchangeCode(code, redirectUri);
|
const exchange = await manifest.exchangeCode(code, redirectUri);
|
||||||
// Wahoo's token endpoint doesn't return a `scope` field and grants
|
await link({
|
||||||
// scopes all-or-nothing, so the requested set is the granted set.
|
userId: user.id,
|
||||||
await saveConnection(user.id, provider.id, tokens, provider.scopes);
|
provider: manifest.id,
|
||||||
|
credentialKind: manifest.credentialKind,
|
||||||
|
credentials: exchange.credentials as Record<string, unknown>,
|
||||||
|
providerUserId: exchange.providerUserId,
|
||||||
|
grantedScopes: exchange.grantedScopes,
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`OAuth callback failed for ${params.provider}:`, e);
|
console.error(`OAuth callback failed for ${params.provider}:`, e);
|
||||||
const code = e instanceof OAuthError ? e.code : "sync_failed";
|
const errCode =
|
||||||
return redirect(`${fallbackReturn}?error=${code}`);
|
typeof (e as { code?: string }).code === "string"
|
||||||
|
? (e as { code: string }).code
|
||||||
|
: "sync_failed";
|
||||||
|
return redirect(`${fallbackReturn}?error=${errCode}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.pushAfter?.routeId) {
|
if (state.pushAfter?.routeId) {
|
||||||
const outcome = await pushRouteToProvider({
|
const outcome = await pushRouteToProvider({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
providerId: provider.id,
|
providerId: manifest.id,
|
||||||
routeId: state.pushAfter.routeId,
|
routeId: state.pushAfter.routeId,
|
||||||
});
|
});
|
||||||
const target = state.returnTo ?? `/routes/${state.pushAfter.routeId}`;
|
const target = state.returnTo ?? `/routes/${state.pushAfter.routeId}`;
|
||||||
if (outcome.status === "success") return redirect(`${target}?push=success`);
|
if (outcome.status === "success") return redirect(`${target}?push=success`);
|
||||||
if (outcome.status === "scope_missing") return redirect(`${target}?push=needs_permission`);
|
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}`);
|
if (outcome.status === "error") return redirect(`${target}?push=error&code=${outcome.code}`);
|
||||||
return redirect(`${target}?push=${outcome.status}`);
|
return redirect(`${target}?push=${outcome.status}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,21 @@
|
||||||
import { redirect, data } from "react-router";
|
import { redirect, data } from "react-router";
|
||||||
import type { Route } from "./+types/api.sync.connect.$provider";
|
import type { Route } from "./+types/api.sync.connect.$provider";
|
||||||
import { getSessionUser } from "~/lib/auth.server";
|
import { getSessionUser } from "~/lib/auth.server";
|
||||||
import { getProvider } from "~/lib/sync/registry";
|
import { getManifest } from "~/lib/connected-services";
|
||||||
import { encodeOAuthState } from "~/lib/sync/pushes.server";
|
import { encodeOAuthState } from "~/lib/connected-services/oauth-state.server";
|
||||||
|
|
||||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||||
const user = await getSessionUser(request);
|
const user = await getSessionUser(request);
|
||||||
if (!user) return redirect("/auth/login");
|
if (!user) return redirect("/auth/login");
|
||||||
|
|
||||||
const provider = getProvider(params.provider);
|
const manifest = getManifest(params.provider);
|
||||||
if (!provider) return data({ error: "Unknown provider" }, { status: 404 });
|
if (!manifest || !manifest.buildAuthUrl) {
|
||||||
|
return data({ error: "Unknown provider" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
const origin = process.env.ORIGIN ?? "http://localhost:3000";
|
const origin = process.env.ORIGIN ?? "http://localhost:3000";
|
||||||
const redirectUri = `${origin}/api/sync/callback/${params.provider}`;
|
const redirectUri = `${origin}/api/sync/callback/${params.provider}`;
|
||||||
const state = encodeOAuthState({ returnTo: "/settings/connections" });
|
const state = encodeOAuthState({ returnTo: "/settings/connections" });
|
||||||
|
|
||||||
return redirect(provider.getAuthUrl(redirectUri, state));
|
return redirect(manifest.buildAuthUrl(redirectUri, state));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,18 @@
|
||||||
import { redirect, data } from "react-router";
|
import { redirect, data } from "react-router";
|
||||||
import type { Route } from "./+types/api.sync.disconnect.$provider";
|
import type { Route } from "./+types/api.sync.disconnect.$provider";
|
||||||
import { getSessionUser } from "~/lib/auth.server";
|
import { getSessionUser } from "~/lib/auth.server";
|
||||||
import { getProvider } from "~/lib/sync/registry";
|
import { getManifest, unlinkByUserProvider } from "~/lib/connected-services";
|
||||||
import { deleteConnection, getConnection } from "~/lib/sync/connections.server";
|
|
||||||
|
|
||||||
export async function action({ params, request }: Route.ActionArgs) {
|
export async function action({ params, request }: Route.ActionArgs) {
|
||||||
const user = await getSessionUser(request);
|
const user = await getSessionUser(request);
|
||||||
if (!user) return redirect("/auth/login");
|
if (!user) return redirect("/auth/login");
|
||||||
|
|
||||||
const provider = getProvider(params.provider);
|
const manifest = getManifest(params.provider);
|
||||||
if (!provider) return data({ error: "Unknown provider" }, { status: 404 });
|
if (!manifest) return data({ error: "Unknown provider" }, { status: 404 });
|
||||||
|
|
||||||
if (provider.revoke) {
|
// unlinkByUserProvider best-effort revokes at the provider, then deletes
|
||||||
const conn = await getConnection(user.id, provider.id);
|
// the local row regardless of revoke outcome. Imported activities are
|
||||||
if (conn) {
|
// retained (FK is set null on imports.activityId, not cascaded).
|
||||||
try {
|
await unlinkByUserProvider(user.id, manifest.id);
|
||||||
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);
|
|
||||||
return redirect("/settings");
|
return redirect("/settings");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,21 @@
|
||||||
import { redirect, data } from "react-router";
|
import { redirect, data } from "react-router";
|
||||||
import type { Route } from "./+types/api.sync.push.$provider.$routeId";
|
import type { Route } from "./+types/api.sync.push.$provider.$routeId";
|
||||||
import { getSessionUser } from "~/lib/auth.server";
|
import { getSessionUser } from "~/lib/auth.server";
|
||||||
import { getProvider } from "~/lib/sync/registry";
|
import { getManifest } from "~/lib/connected-services";
|
||||||
import { pushRouteToProvider, encodeOAuthState } from "~/lib/sync/pushes.server";
|
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) {
|
export async function action({ params, request }: Route.ActionArgs) {
|
||||||
const user = await getSessionUser(request);
|
const user = await getSessionUser(request);
|
||||||
if (!user) return redirect("/auth/login");
|
if (!user) return redirect("/auth/login");
|
||||||
|
|
||||||
const provider = getProvider(params.provider);
|
const manifest = getManifest(params.provider);
|
||||||
if (!provider) return data({ error: "Unknown provider" }, { status: 404 });
|
if (!manifest) return data({ error: "Unknown provider" }, { status: 404 });
|
||||||
|
|
||||||
const returnTo = `/routes/${params.routeId}`;
|
const returnTo = `/routes/${params.routeId}`;
|
||||||
const outcome = await pushRouteToProvider({
|
const outcome = await pushRouteToProvider({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
providerId: provider.id,
|
providerId: manifest.id,
|
||||||
routeId: params.routeId,
|
routeId: params.routeId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -22,14 +23,19 @@ export async function action({ params, request }: Route.ActionArgs) {
|
||||||
case "success":
|
case "success":
|
||||||
return redirect(`${returnTo}?push=success`);
|
return redirect(`${returnTo}?push=success`);
|
||||||
case "scope_missing": {
|
case "scope_missing": {
|
||||||
|
if (!manifest.buildAuthUrl) {
|
||||||
|
return redirect(`${returnTo}?push=needs_permission`);
|
||||||
|
}
|
||||||
const origin = process.env.ORIGIN ?? "http://localhost:3000";
|
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({
|
const state = encodeOAuthState({
|
||||||
pushAfter: { routeId: params.routeId },
|
pushAfter: { routeId: params.routeId },
|
||||||
returnTo,
|
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":
|
case "no_connection":
|
||||||
return redirect(`${returnTo}?push=no_connection`);
|
return redirect(`${returnTo}?push=no_connection`);
|
||||||
case "no_geometry":
|
case "no_geometry":
|
||||||
|
|
|
||||||
|
|
@ -1,68 +1,37 @@
|
||||||
import { data } from "react-router";
|
import { data } from "react-router";
|
||||||
import type { Route } from "./+types/api.sync.webhook.$provider";
|
import type { Route } from "./+types/api.sync.webhook.$provider";
|
||||||
import { getProvider } from "~/lib/sync/registry";
|
import { getManifest } from "~/lib/connected-services";
|
||||||
import { getConnectionByProviderUser, updateTokens } from "~/lib/sync/connections.server";
|
|
||||||
import { isAlreadyImported, recordImport } from "~/lib/sync/imports.server";
|
|
||||||
import { createActivity } from "~/lib/activities.server";
|
|
||||||
|
|
||||||
export async function action({ params, request }: Route.ActionArgs) {
|
export async function action({ params, request }: Route.ActionArgs) {
|
||||||
if (request.method !== "POST") {
|
if (request.method !== "POST") {
|
||||||
return data({ error: "Method not allowed" }, { status: 405 });
|
return data({ error: "Method not allowed" }, { status: 405 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const provider = getProvider(params.provider);
|
const manifest = getManifest(params.provider);
|
||||||
if (!provider) return data({ ok: true }); // Don't reveal provider existence
|
if (!manifest || !manifest.webhookReceiver) {
|
||||||
|
// Don't reveal provider existence — return 200 silently.
|
||||||
|
return data({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
|
|
||||||
// Verify webhook token
|
// Verify webhook token (provider-specific shared secret).
|
||||||
const expectedToken = process.env[`${params.provider.toUpperCase()}_WEBHOOK_TOKEN`];
|
const expectedToken = process.env[`${params.provider.toUpperCase()}_WEBHOOK_TOKEN`];
|
||||||
if (expectedToken && (body as { webhook_token?: string }).webhook_token !== expectedToken) {
|
if (
|
||||||
return data({ ok: true }); // Invalid token — ignore silently
|
expectedToken &&
|
||||||
|
(body as { webhook_token?: string }).webhook_token !== expectedToken
|
||||||
|
) {
|
||||||
|
return data({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const event = provider.parseWebhook(body);
|
const event = manifest.webhookReceiver.parseWebhook(body);
|
||||||
if (!event) return data({ ok: true }); // Unrecognized event — ignore silently
|
if (!event) return data({ ok: true });
|
||||||
|
|
||||||
// Look up user by provider user ID
|
|
||||||
const connection = await getConnectionByProviderUser(provider.id, event.providerUserId);
|
|
||||||
if (!connection) return data({ ok: true }); // Unknown user — ignore
|
|
||||||
|
|
||||||
// Check duplicate
|
|
||||||
const alreadyImported = await isAlreadyImported(connection.userId, provider.id, event.workoutId);
|
|
||||||
if (alreadyImported) return data({ ok: true });
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Refresh token if expired
|
await manifest.webhookReceiver.handle(event);
|
||||||
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);
|
|
||||||
} catch (e) {
|
} 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 });
|
return data({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { canView, getSessionUser } from "~/lib/auth.server";
|
||||||
import { getRoute, getRouteWithVersions, deleteRoute, updateRoute } from "~/lib/routes.server";
|
import { getRoute, getRouteWithVersions, deleteRoute, updateRoute } from "~/lib/routes.server";
|
||||||
import { getDb } from "~/lib/db";
|
import { getDb } from "~/lib/db";
|
||||||
import { syncPushes } from "@trails-cool/db/schema/journal";
|
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 { ClientDate } from "~/components/ClientDate";
|
||||||
import { ClientMap } from "~/components/ClientMap";
|
import { ClientMap } from "~/components/ClientMap";
|
||||||
|
|
||||||
|
|
@ -62,7 +62,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||||
}
|
}
|
||||||
| null = null;
|
| null = null;
|
||||||
if (isOwner && user && !!route.gpx) {
|
if (isOwner && user && !!route.gpx) {
|
||||||
const connection = await getConnection(user.id, "wahoo");
|
const connection = await getService(user.id, "wahoo");
|
||||||
let latest:
|
let latest:
|
||||||
| {
|
| {
|
||||||
pushedAt: string | null;
|
pushedAt: string | null;
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ import { eq } from "drizzle-orm";
|
||||||
import type { Route } from "./+types/settings.connections";
|
import type { Route } from "./+types/settings.connections";
|
||||||
import { getSessionUser } from "~/lib/auth.server";
|
import { getSessionUser } from "~/lib/auth.server";
|
||||||
import { getDb } from "~/lib/db";
|
import { getDb } from "~/lib/db";
|
||||||
import { syncConnections } from "@trails-cool/db/schema/journal";
|
import { connectedServices } from "@trails-cool/db/schema/journal";
|
||||||
import { getAllProviders } from "~/lib/sync/registry";
|
import { getAllManifests } from "~/lib/connected-services";
|
||||||
|
|
||||||
const KNOWN_ERRORS = ["too_many_tokens", "sync_failed", "generic"] as const;
|
const KNOWN_ERRORS = ["too_many_tokens", "sync_failed", "generic"] as const;
|
||||||
type KnownError = (typeof KNOWN_ERRORS)[number];
|
type KnownError = (typeof KNOWN_ERRORS)[number];
|
||||||
|
|
@ -24,15 +24,20 @@ export async function loader({ request }: Route.LoaderArgs) {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const connections = await db
|
const connections = await db
|
||||||
.select({
|
.select({
|
||||||
provider: syncConnections.provider,
|
provider: connectedServices.provider,
|
||||||
providerUserId: syncConnections.providerUserId,
|
providerUserId: connectedServices.providerUserId,
|
||||||
})
|
})
|
||||||
.from(syncConnections)
|
.from(connectedServices)
|
||||||
.where(eq(syncConnections.userId, user.id));
|
.where(eq(connectedServices.userId, user.id));
|
||||||
|
|
||||||
const providers = getAllProviders().map((p) => {
|
const providers = getAllManifests().map((m) => {
|
||||||
const conn = connections.find((c) => c.provider === p.id);
|
const conn = connections.find((c) => c.provider === m.id);
|
||||||
return { id: p.id, name: p.name, connected: !!conn, providerUserId: conn?.providerUserId };
|
return {
|
||||||
|
id: m.id,
|
||||||
|
name: m.displayName,
|
||||||
|
connected: !!conn,
|
||||||
|
providerUserId: conn?.providerUserId,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return data({ providers });
|
return data({ providers });
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,11 @@ import { data, redirect, useFetcher } from "react-router";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { Route } from "./+types/sync.import.$provider";
|
import type { Route } from "./+types/sync.import.$provider";
|
||||||
import { getSessionUser } from "~/lib/auth.server";
|
import { getSessionUser } from "~/lib/auth.server";
|
||||||
import { getProvider } from "~/lib/sync/registry";
|
import {
|
||||||
import { getConnection, updateTokens } from "~/lib/sync/connections.server";
|
getManifest,
|
||||||
|
getService,
|
||||||
|
capabilityContextFor,
|
||||||
|
} from "~/lib/connected-services";
|
||||||
import { getImportedIds, recordImport } from "~/lib/sync/imports.server";
|
import { getImportedIds, recordImport } from "~/lib/sync/imports.server";
|
||||||
import { createActivity } from "~/lib/activities.server";
|
import { createActivity } from "~/lib/activities.server";
|
||||||
import { ClientDate } from "~/components/ClientDate";
|
import { ClientDate } from "~/components/ClientDate";
|
||||||
|
|
@ -13,35 +16,28 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||||
const user = await getSessionUser(request);
|
const user = await getSessionUser(request);
|
||||||
if (!user) return redirect("/auth/login");
|
if (!user) return redirect("/auth/login");
|
||||||
|
|
||||||
const provider = getProvider(params.provider);
|
const manifest = getManifest(params.provider);
|
||||||
if (!provider) throw data({ error: "Unknown provider" }, { status: 404 });
|
if (!manifest || !manifest.importer) {
|
||||||
|
throw data({ error: "Unknown provider" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
const connection = await getConnection(user.id, provider.id);
|
const service = await getService(user.id, manifest.id);
|
||||||
if (!connection) return redirect("/settings");
|
if (!service) return redirect("/settings");
|
||||||
|
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const page = parseInt(url.searchParams.get("page") ?? "1");
|
const page = parseInt(url.searchParams.get("page") ?? "1");
|
||||||
|
|
||||||
// Refresh token if needed
|
const ctx = capabilityContextFor(service.id);
|
||||||
let tokens = {
|
const workoutList = await manifest.importer.listImportable(ctx, page);
|
||||||
accessToken: connection.accessToken,
|
|
||||||
refreshToken: connection.refreshToken,
|
|
||||||
expiresAt: connection.expiresAt,
|
|
||||||
};
|
|
||||||
if (new Date() >= tokens.expiresAt) {
|
|
||||||
tokens = await provider.refreshToken(tokens.refreshToken);
|
|
||||||
await updateTokens(connection.id, tokens);
|
|
||||||
}
|
|
||||||
|
|
||||||
const workoutList = await provider.listWorkouts(tokens, page);
|
|
||||||
const importedIds = await getImportedIds(
|
const importedIds = await getImportedIds(
|
||||||
user.id,
|
user.id,
|
||||||
provider.id,
|
manifest.id,
|
||||||
workoutList.workouts.map((w) => w.id),
|
workoutList.workouts.map((w) => w.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
return data({
|
return data({
|
||||||
provider: { id: provider.id, name: provider.name },
|
provider: { id: manifest.id, name: manifest.displayName },
|
||||||
workouts: workoutList.workouts.map((w) => ({
|
workouts: workoutList.workouts.map((w) => ({
|
||||||
...w,
|
...w,
|
||||||
imported: importedIds.has(w.id),
|
imported: importedIds.has(w.id),
|
||||||
|
|
@ -55,11 +51,13 @@ export async function action({ params, request }: Route.ActionArgs) {
|
||||||
const user = await getSessionUser(request);
|
const user = await getSessionUser(request);
|
||||||
if (!user) return redirect("/auth/login");
|
if (!user) return redirect("/auth/login");
|
||||||
|
|
||||||
const provider = getProvider(params.provider);
|
const manifest = getManifest(params.provider);
|
||||||
if (!provider) throw data({ error: "Unknown provider" }, { status: 404 });
|
if (!manifest || !manifest.importer) {
|
||||||
|
throw data({ error: "Unknown provider" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
const connection = await getConnection(user.id, provider.id);
|
const service = await getService(user.id, manifest.id);
|
||||||
if (!connection) return redirect("/settings");
|
if (!service) return redirect("/settings");
|
||||||
|
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const workoutId = formData.get("workoutId") as string;
|
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 distance = formData.get("distance") as string;
|
||||||
const duration = formData.get("duration") as string;
|
const duration = formData.get("duration") as string;
|
||||||
|
|
||||||
// Refresh token if needed
|
// Inline import here (not via Importer.importOne) so we can pass through
|
||||||
let tokens = {
|
// the form-supplied metadata (started_at, distance, duration) the
|
||||||
accessToken: connection.accessToken,
|
// capability seam doesn't carry. The seam-shape importer is reserved for
|
||||||
refreshToken: connection.refreshToken,
|
// automatic / webhook-driven imports.
|
||||||
expiresAt: connection.expiresAt,
|
|
||||||
};
|
|
||||||
if (new Date() >= tokens.expiresAt) {
|
|
||||||
tokens = await provider.refreshToken(tokens.refreshToken);
|
|
||||||
await updateTokens(connection.id, tokens);
|
|
||||||
}
|
|
||||||
|
|
||||||
let gpx: string | null = null;
|
let gpx: string | null = null;
|
||||||
if (fileUrl) {
|
if (fileUrl) {
|
||||||
const workout = { id: workoutId, name: workoutName, type: "", startedAt: "", duration: null, distance: null, fileUrl };
|
const ctx = capabilityContextFor(service.id);
|
||||||
const fileBuffer = await provider.downloadFile(tokens, workout);
|
// Wahoo CDN URLs are pre-signed; we still go through withFreshCredentials
|
||||||
gpx = await provider.convertToGpx(fileBuffer);
|
// 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, {
|
const activityId = await createActivity(user.id, {
|
||||||
name: workoutName || `${provider.name} workout`,
|
name: workoutName || `${manifest.displayName} workout`,
|
||||||
gpx: gpx ?? undefined,
|
gpx: gpx ?? undefined,
|
||||||
distance: distance ? parseFloat(distance) : null,
|
distance: distance ? parseFloat(distance) : null,
|
||||||
duration: duration ? parseInt(duration) : null,
|
duration: duration ? parseInt(duration) : null,
|
||||||
startedAt: startedAt ? new Date(startedAt) : 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 });
|
return data({ imported: workoutId });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
19
docs/adr/0001-connected-services-credential-discriminator.md
Normal file
19
docs/adr/0001-connected-services-credential-discriminator.md
Normal 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.
|
||||||
19
docs/adr/0002-no-unified-syncprovider-interface.md
Normal file
19
docs/adr/0002-no-unified-syncprovider-interface.md
Normal 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.
|
||||||
18
docs/adr/0003-routepusher-seam-shape.md
Normal file
18
docs/adr/0003-routepusher-seam-shape.md
Normal 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.
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
schema: spec-driven
|
||||||
|
created: 2026-05-07
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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).
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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/`.
|
||||||
|
|
@ -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
|
project had a working Komoot integration using basic auth against Komoot's
|
||||||
undocumented API (`api.komoot.de`).
|
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 / Non-Goals
|
||||||
|
|
||||||
**Goals:**
|
**Goals:**
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# connected-services Specification
|
# connected-services Specification
|
||||||
|
|
||||||
## Purpose
|
## 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
|
## 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** 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
|
- **AND** disconnected state shows a "Connect Wahoo" button that begins the OAuth handshake
|
||||||
|
|
||||||
### Requirement: OAuth token storage in `sync_connections`
|
### Requirement: OAuth token storage in `connected_services`
|
||||||
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.
|
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
|
#### Scenario: Wahoo connect persists tokens
|
||||||
- **WHEN** a user completes the Wahoo OAuth flow
|
- **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
|
#### Scenario: Disconnect removes the row but keeps imports
|
||||||
- **WHEN** a user clicks "Disconnect" on a Wahoo connection
|
- **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
|
#### Scenario: Each user has at most one row per provider
|
||||||
- **WHEN** a user reconnects an already-connected 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
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,14 @@ Provider-agnostic activity sync framework with Wahoo as the first provider, supp
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
### Requirement: Provider-agnostic sync framework
|
### 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
|
#### Scenario: Add new provider
|
||||||
- **WHEN** a developer wants to add a new sync provider (e.g., Garmin)
|
- **WHEN** a developer wants to add a new sync provider (e.g., Garmin)
|
||||||
- **THEN** they implement the `SyncProvider` interface in a single file
|
- **THEN** they create `providers/garmin/` containing a `manifest.ts` declaring `credential_kind` and the implemented capabilities
|
||||||
- **AND** register it in the provider registry
|
- **AND** they implement only the capability interfaces that apply (e.g. `Importer` for an import-only provider)
|
||||||
- **AND** all OAuth, webhook, import, and settings UI works automatically
|
- **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
|
### Requirement: Connect Wahoo account
|
||||||
Users SHALL be able to connect their Wahoo account via OAuth2.
|
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
|
- **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`
|
- **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** after granting permission, redirected back to the journal
|
||||||
- **AND** access and refresh tokens are stored in `sync_connections`
|
- **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 `sync_connections.granted_scopes` so feature gates can detect missing scopes without round-tripping to Wahoo
|
- **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
|
#### Scenario: Disconnect Wahoo
|
||||||
- **WHEN** a user clicks "Disconnect" next to their Wahoo connection
|
- **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
|
#### Scenario: Token refresh
|
||||||
- **WHEN** a Wahoo API call fails with an expired token
|
- **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
|
#### 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)
|
- **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
|
- **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
|
### Requirement: Webhook-based automatic sync
|
||||||
|
|
|
||||||
|
|
@ -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
|
#### 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
|
- **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
|
- **THEN** a "Send to Wahoo" button is visible alongside the existing "Export GPX" action
|
||||||
|
|
||||||
#### Scenario: Owner without a connected Wahoo account
|
#### Scenario: Owner without a connected Wahoo account
|
||||||
- **WHEN** the route owner loads a route detail page
|
- **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
|
- **THEN** the "Send to Wahoo" button is not rendered
|
||||||
|
|
||||||
#### Scenario: Non-owner viewing the route
|
#### 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
|
#### Scenario: Existing connection lacks routes_write
|
||||||
- **WHEN** the route owner clicks "Send to Wahoo"
|
- **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 }`
|
- **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
|
- **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
|
#### 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
|
- **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
|
- **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
|
||||||
|
|
|
||||||
89
packages/db/migrations/0002_connected_services.sql
Normal file
89
packages/db/migrations/0002_connected_services.sql
Normal 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 $$;
|
||||||
|
|
@ -186,27 +186,42 @@ export const oauthTokens = journalSchema.table("oauth_tokens", {
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const syncConnections = journalSchema.table("sync_connections", {
|
// External-service connections (OAuth, web-login, mobile-paired devices).
|
||||||
id: text("id").primaryKey(),
|
// `credential_kind` discriminates the shape of `credentials` JSONB:
|
||||||
userId: text("user_id")
|
// - 'oauth': { access_token, refresh_token, expires_at }
|
||||||
.notNull()
|
// - 'web-login': { email, encrypted_password, session_jar } (Komoot, future)
|
||||||
.references(() => users.id, { onDelete: "cascade" }),
|
// - 'device': {} (Apple Health, future)
|
||||||
provider: text("provider").notNull(),
|
// See docs/adr/0001 and CONTEXT.md (Connected Services).
|
||||||
accessToken: text("access_token").notNull(),
|
export const connectedServices = journalSchema.table(
|
||||||
refreshToken: text("refresh_token").notNull(),
|
"connected_services",
|
||||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
{
|
||||||
providerUserId: text("provider_user_id"),
|
id: text("id").primaryKey(),
|
||||||
// Scopes the user actually granted at OAuth time. Wahoo doesn't return a
|
userId: text("user_id")
|
||||||
// scope field in token responses and grants scopes all-or-nothing, so we
|
.notNull()
|
||||||
// record the requested scope set on exchangeCode. Used to detect when an
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
// existing connection predates a scope upgrade (e.g. routes_write) and
|
provider: text("provider").notNull(),
|
||||||
// needs a re-auth before a new push call can succeed.
|
credentialKind: text("credential_kind").notNull(),
|
||||||
grantedScopes: text("granted_scopes")
|
credentials: jsonb("credentials").notNull(),
|
||||||
.array()
|
// 'active' | 'needs_relink' | 'revoked'. Manager flips to 'needs_relink'
|
||||||
.notNull()
|
// when CredentialAdapter.refresh returns a permanent failure.
|
||||||
.default(sql`ARRAY[]::text[]`),
|
status: text("status").notNull().default("active"),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
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", {
|
export const syncImports = journalSchema.table("sync_imports", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue