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 type { Route } from "./+types/api.sync.callback.$provider";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { getProvider } from "~/lib/sync/registry";
|
||||
import { saveConnection } from "~/lib/sync/connections.server";
|
||||
import { decodeOAuthState, pushRouteToProvider } from "~/lib/sync/pushes.server";
|
||||
import { OAuthError } from "~/lib/sync/types";
|
||||
import { getManifest, link } from "~/lib/connected-services";
|
||||
import {
|
||||
decodeOAuthState,
|
||||
} from "~/lib/connected-services/oauth-state.server";
|
||||
import { pushRouteToProvider } from "~/lib/connected-services/push-action.server";
|
||||
|
||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
const user = await getSessionUser(request);
|
||||
if (!user) return redirect("/auth/login");
|
||||
|
||||
const provider = getProvider(params.provider);
|
||||
if (!provider) return data({ error: "Unknown provider" }, { status: 404 });
|
||||
const manifest = getManifest(params.provider);
|
||||
if (!manifest || !manifest.exchangeCode) {
|
||||
return data({ error: "Unknown provider" }, { status: 404 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const state = decodeOAuthState(url.searchParams.get("state"));
|
||||
|
|
@ -30,25 +33,34 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||
const redirectUri = `${origin}/api/sync/callback/${params.provider}`;
|
||||
|
||||
try {
|
||||
const tokens = await provider.exchangeCode(code, redirectUri);
|
||||
// Wahoo's token endpoint doesn't return a `scope` field and grants
|
||||
// scopes all-or-nothing, so the requested set is the granted set.
|
||||
await saveConnection(user.id, provider.id, tokens, provider.scopes);
|
||||
const exchange = await manifest.exchangeCode(code, redirectUri);
|
||||
await link({
|
||||
userId: user.id,
|
||||
provider: manifest.id,
|
||||
credentialKind: manifest.credentialKind,
|
||||
credentials: exchange.credentials as Record<string, unknown>,
|
||||
providerUserId: exchange.providerUserId,
|
||||
grantedScopes: exchange.grantedScopes,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`OAuth callback failed for ${params.provider}:`, e);
|
||||
const code = e instanceof OAuthError ? e.code : "sync_failed";
|
||||
return redirect(`${fallbackReturn}?error=${code}`);
|
||||
const errCode =
|
||||
typeof (e as { code?: string }).code === "string"
|
||||
? (e as { code: string }).code
|
||||
: "sync_failed";
|
||||
return redirect(`${fallbackReturn}?error=${errCode}`);
|
||||
}
|
||||
|
||||
if (state.pushAfter?.routeId) {
|
||||
const outcome = await pushRouteToProvider({
|
||||
userId: user.id,
|
||||
providerId: provider.id,
|
||||
providerId: manifest.id,
|
||||
routeId: state.pushAfter.routeId,
|
||||
});
|
||||
const target = state.returnTo ?? `/routes/${state.pushAfter.routeId}`;
|
||||
if (outcome.status === "success") return redirect(`${target}?push=success`);
|
||||
if (outcome.status === "scope_missing") return redirect(`${target}?push=needs_permission`);
|
||||
if (outcome.status === "needs_relink") return redirect(`${target}?push=needs_permission`);
|
||||
if (outcome.status === "error") return redirect(`${target}?push=error&code=${outcome.code}`);
|
||||
return redirect(`${target}?push=${outcome.status}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,21 @@
|
|||
import { redirect, data } from "react-router";
|
||||
import type { Route } from "./+types/api.sync.connect.$provider";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { getProvider } from "~/lib/sync/registry";
|
||||
import { encodeOAuthState } from "~/lib/sync/pushes.server";
|
||||
import { getManifest } from "~/lib/connected-services";
|
||||
import { encodeOAuthState } from "~/lib/connected-services/oauth-state.server";
|
||||
|
||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
const user = await getSessionUser(request);
|
||||
if (!user) return redirect("/auth/login");
|
||||
|
||||
const provider = getProvider(params.provider);
|
||||
if (!provider) return data({ error: "Unknown provider" }, { status: 404 });
|
||||
const manifest = getManifest(params.provider);
|
||||
if (!manifest || !manifest.buildAuthUrl) {
|
||||
return data({ error: "Unknown provider" }, { status: 404 });
|
||||
}
|
||||
|
||||
const origin = process.env.ORIGIN ?? "http://localhost:3000";
|
||||
const redirectUri = `${origin}/api/sync/callback/${params.provider}`;
|
||||
const state = encodeOAuthState({ returnTo: "/settings/connections" });
|
||||
|
||||
return redirect(provider.getAuthUrl(redirectUri, state));
|
||||
return redirect(manifest.buildAuthUrl(redirectUri, state));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,32 +1,18 @@
|
|||
import { redirect, data } from "react-router";
|
||||
import type { Route } from "./+types/api.sync.disconnect.$provider";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { getProvider } from "~/lib/sync/registry";
|
||||
import { deleteConnection, getConnection } from "~/lib/sync/connections.server";
|
||||
import { getManifest, unlinkByUserProvider } from "~/lib/connected-services";
|
||||
|
||||
export async function action({ params, request }: Route.ActionArgs) {
|
||||
const user = await getSessionUser(request);
|
||||
if (!user) return redirect("/auth/login");
|
||||
|
||||
const provider = getProvider(params.provider);
|
||||
if (!provider) return data({ error: "Unknown provider" }, { status: 404 });
|
||||
const manifest = getManifest(params.provider);
|
||||
if (!manifest) return data({ error: "Unknown provider" }, { status: 404 });
|
||||
|
||||
if (provider.revoke) {
|
||||
const conn = await getConnection(user.id, provider.id);
|
||||
if (conn) {
|
||||
try {
|
||||
await provider.revoke({
|
||||
accessToken: conn.accessToken,
|
||||
refreshToken: conn.refreshToken,
|
||||
expiresAt: conn.expiresAt,
|
||||
});
|
||||
} catch (e) {
|
||||
// Best-effort: token may already be expired/revoked. Drop the local row regardless.
|
||||
console.warn(`Failed to revoke ${provider.id} token (continuing with disconnect):`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await deleteConnection(user.id, provider.id);
|
||||
// unlinkByUserProvider best-effort revokes at the provider, then deletes
|
||||
// the local row regardless of revoke outcome. Imported activities are
|
||||
// retained (FK is set null on imports.activityId, not cascaded).
|
||||
await unlinkByUserProvider(user.id, manifest.id);
|
||||
return redirect("/settings");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,21 @@
|
|||
import { redirect, data } from "react-router";
|
||||
import type { Route } from "./+types/api.sync.push.$provider.$routeId";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { getProvider } from "~/lib/sync/registry";
|
||||
import { pushRouteToProvider, encodeOAuthState } from "~/lib/sync/pushes.server";
|
||||
import { getManifest } from "~/lib/connected-services";
|
||||
import { pushRouteToProvider } from "~/lib/connected-services/push-action.server";
|
||||
import { encodeOAuthState } from "~/lib/connected-services/oauth-state.server";
|
||||
|
||||
export async function action({ params, request }: Route.ActionArgs) {
|
||||
const user = await getSessionUser(request);
|
||||
if (!user) return redirect("/auth/login");
|
||||
|
||||
const provider = getProvider(params.provider);
|
||||
if (!provider) return data({ error: "Unknown provider" }, { status: 404 });
|
||||
const manifest = getManifest(params.provider);
|
||||
if (!manifest) return data({ error: "Unknown provider" }, { status: 404 });
|
||||
|
||||
const returnTo = `/routes/${params.routeId}`;
|
||||
const outcome = await pushRouteToProvider({
|
||||
userId: user.id,
|
||||
providerId: provider.id,
|
||||
providerId: manifest.id,
|
||||
routeId: params.routeId,
|
||||
});
|
||||
|
||||
|
|
@ -22,14 +23,19 @@ export async function action({ params, request }: Route.ActionArgs) {
|
|||
case "success":
|
||||
return redirect(`${returnTo}?push=success`);
|
||||
case "scope_missing": {
|
||||
if (!manifest.buildAuthUrl) {
|
||||
return redirect(`${returnTo}?push=needs_permission`);
|
||||
}
|
||||
const origin = process.env.ORIGIN ?? "http://localhost:3000";
|
||||
const redirectUri = `${origin}/api/sync/callback/${provider.id}`;
|
||||
const redirectUri = `${origin}/api/sync/callback/${manifest.id}`;
|
||||
const state = encodeOAuthState({
|
||||
pushAfter: { routeId: params.routeId },
|
||||
returnTo,
|
||||
});
|
||||
return redirect(provider.getAuthUrl(redirectUri, state));
|
||||
return redirect(manifest.buildAuthUrl(redirectUri, state));
|
||||
}
|
||||
case "needs_relink":
|
||||
return redirect(`${returnTo}?push=needs_permission`);
|
||||
case "no_connection":
|
||||
return redirect(`${returnTo}?push=no_connection`);
|
||||
case "no_geometry":
|
||||
|
|
|
|||
|
|
@ -1,68 +1,37 @@
|
|||
import { data } from "react-router";
|
||||
import type { Route } from "./+types/api.sync.webhook.$provider";
|
||||
import { getProvider } from "~/lib/sync/registry";
|
||||
import { getConnectionByProviderUser, updateTokens } from "~/lib/sync/connections.server";
|
||||
import { isAlreadyImported, recordImport } from "~/lib/sync/imports.server";
|
||||
import { createActivity } from "~/lib/activities.server";
|
||||
import { getManifest } from "~/lib/connected-services";
|
||||
|
||||
export async function action({ params, request }: Route.ActionArgs) {
|
||||
if (request.method !== "POST") {
|
||||
return data({ error: "Method not allowed" }, { status: 405 });
|
||||
}
|
||||
|
||||
const provider = getProvider(params.provider);
|
||||
if (!provider) return data({ ok: true }); // Don't reveal provider existence
|
||||
const manifest = getManifest(params.provider);
|
||||
if (!manifest || !manifest.webhookReceiver) {
|
||||
// Don't reveal provider existence — return 200 silently.
|
||||
return data({ ok: true });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
// Verify webhook token
|
||||
// Verify webhook token (provider-specific shared secret).
|
||||
const expectedToken = process.env[`${params.provider.toUpperCase()}_WEBHOOK_TOKEN`];
|
||||
if (expectedToken && (body as { webhook_token?: string }).webhook_token !== expectedToken) {
|
||||
return data({ ok: true }); // Invalid token — ignore silently
|
||||
if (
|
||||
expectedToken &&
|
||||
(body as { webhook_token?: string }).webhook_token !== expectedToken
|
||||
) {
|
||||
return data({ ok: true });
|
||||
}
|
||||
|
||||
const event = provider.parseWebhook(body);
|
||||
if (!event) return data({ ok: true }); // Unrecognized event — ignore silently
|
||||
|
||||
// Look up user by provider user ID
|
||||
const connection = await getConnectionByProviderUser(provider.id, event.providerUserId);
|
||||
if (!connection) return data({ ok: true }); // Unknown user — ignore
|
||||
|
||||
// Check duplicate
|
||||
const alreadyImported = await isAlreadyImported(connection.userId, provider.id, event.workoutId);
|
||||
if (alreadyImported) return data({ ok: true });
|
||||
const event = manifest.webhookReceiver.parseWebhook(body);
|
||||
if (!event) return data({ ok: true });
|
||||
|
||||
try {
|
||||
// Refresh token if expired
|
||||
let tokens = {
|
||||
accessToken: connection.accessToken,
|
||||
refreshToken: connection.refreshToken,
|
||||
expiresAt: connection.expiresAt,
|
||||
};
|
||||
if (new Date() >= tokens.expiresAt) {
|
||||
tokens = await provider.refreshToken(tokens.refreshToken);
|
||||
await updateTokens(connection.id, tokens);
|
||||
}
|
||||
|
||||
// Download and convert (if file available)
|
||||
const workout = { id: event.workoutId, name: "", type: "", startedAt: "", duration: null, distance: null, fileUrl: event.fileUrl };
|
||||
let gpx: string | null = null;
|
||||
if (workout.fileUrl) {
|
||||
const fileBuffer = await provider.downloadFile(tokens, workout);
|
||||
gpx = await provider.convertToGpx(fileBuffer);
|
||||
}
|
||||
|
||||
// Create activity
|
||||
const activityId = await createActivity(connection.userId, {
|
||||
name: `${provider.name} workout`,
|
||||
gpx: gpx ?? undefined,
|
||||
});
|
||||
|
||||
await recordImport(connection.userId, provider.id, event.workoutId, activityId);
|
||||
await manifest.webhookReceiver.handle(event);
|
||||
} catch (e) {
|
||||
console.error(`Webhook import failed for ${provider.id}/${event.workoutId}:`, e);
|
||||
console.error(`Webhook import failed for ${manifest.id}/${event.workoutId}:`, e);
|
||||
}
|
||||
|
||||
// Always return 200 to acknowledge the webhook
|
||||
return data({ ok: true });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { canView, getSessionUser } from "~/lib/auth.server";
|
|||
import { getRoute, getRouteWithVersions, deleteRoute, updateRoute } from "~/lib/routes.server";
|
||||
import { getDb } from "~/lib/db";
|
||||
import { syncPushes } from "@trails-cool/db/schema/journal";
|
||||
import { getConnection } from "~/lib/sync/connections.server";
|
||||
import { getService } from "~/lib/connected-services";
|
||||
import { ClientDate } from "~/components/ClientDate";
|
||||
import { ClientMap } from "~/components/ClientMap";
|
||||
|
||||
|
|
@ -62,7 +62,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||
}
|
||||
| null = null;
|
||||
if (isOwner && user && !!route.gpx) {
|
||||
const connection = await getConnection(user.id, "wahoo");
|
||||
const connection = await getService(user.id, "wahoo");
|
||||
let latest:
|
||||
| {
|
||||
pushedAt: string | null;
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ import { eq } from "drizzle-orm";
|
|||
import type { Route } from "./+types/settings.connections";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { getDb } from "~/lib/db";
|
||||
import { syncConnections } from "@trails-cool/db/schema/journal";
|
||||
import { getAllProviders } from "~/lib/sync/registry";
|
||||
import { connectedServices } from "@trails-cool/db/schema/journal";
|
||||
import { getAllManifests } from "~/lib/connected-services";
|
||||
|
||||
const KNOWN_ERRORS = ["too_many_tokens", "sync_failed", "generic"] as const;
|
||||
type KnownError = (typeof KNOWN_ERRORS)[number];
|
||||
|
|
@ -24,15 +24,20 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||
const db = getDb();
|
||||
const connections = await db
|
||||
.select({
|
||||
provider: syncConnections.provider,
|
||||
providerUserId: syncConnections.providerUserId,
|
||||
provider: connectedServices.provider,
|
||||
providerUserId: connectedServices.providerUserId,
|
||||
})
|
||||
.from(syncConnections)
|
||||
.where(eq(syncConnections.userId, user.id));
|
||||
.from(connectedServices)
|
||||
.where(eq(connectedServices.userId, user.id));
|
||||
|
||||
const providers = getAllProviders().map((p) => {
|
||||
const conn = connections.find((c) => c.provider === p.id);
|
||||
return { id: p.id, name: p.name, connected: !!conn, providerUserId: conn?.providerUserId };
|
||||
const providers = getAllManifests().map((m) => {
|
||||
const conn = connections.find((c) => c.provider === m.id);
|
||||
return {
|
||||
id: m.id,
|
||||
name: m.displayName,
|
||||
connected: !!conn,
|
||||
providerUserId: conn?.providerUserId,
|
||||
};
|
||||
});
|
||||
|
||||
return data({ providers });
|
||||
|
|
|
|||
|
|
@ -3,8 +3,11 @@ import { data, redirect, useFetcher } from "react-router";
|
|||
import { useTranslation } from "react-i18next";
|
||||
import type { Route } from "./+types/sync.import.$provider";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { getProvider } from "~/lib/sync/registry";
|
||||
import { getConnection, updateTokens } from "~/lib/sync/connections.server";
|
||||
import {
|
||||
getManifest,
|
||||
getService,
|
||||
capabilityContextFor,
|
||||
} from "~/lib/connected-services";
|
||||
import { getImportedIds, recordImport } from "~/lib/sync/imports.server";
|
||||
import { createActivity } from "~/lib/activities.server";
|
||||
import { ClientDate } from "~/components/ClientDate";
|
||||
|
|
@ -13,35 +16,28 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||
const user = await getSessionUser(request);
|
||||
if (!user) return redirect("/auth/login");
|
||||
|
||||
const provider = getProvider(params.provider);
|
||||
if (!provider) throw data({ error: "Unknown provider" }, { status: 404 });
|
||||
const manifest = getManifest(params.provider);
|
||||
if (!manifest || !manifest.importer) {
|
||||
throw data({ error: "Unknown provider" }, { status: 404 });
|
||||
}
|
||||
|
||||
const connection = await getConnection(user.id, provider.id);
|
||||
if (!connection) return redirect("/settings");
|
||||
const service = await getService(user.id, manifest.id);
|
||||
if (!service) return redirect("/settings");
|
||||
|
||||
const url = new URL(request.url);
|
||||
const page = parseInt(url.searchParams.get("page") ?? "1");
|
||||
|
||||
// Refresh token if needed
|
||||
let tokens = {
|
||||
accessToken: connection.accessToken,
|
||||
refreshToken: connection.refreshToken,
|
||||
expiresAt: connection.expiresAt,
|
||||
};
|
||||
if (new Date() >= tokens.expiresAt) {
|
||||
tokens = await provider.refreshToken(tokens.refreshToken);
|
||||
await updateTokens(connection.id, tokens);
|
||||
}
|
||||
const ctx = capabilityContextFor(service.id);
|
||||
const workoutList = await manifest.importer.listImportable(ctx, page);
|
||||
|
||||
const workoutList = await provider.listWorkouts(tokens, page);
|
||||
const importedIds = await getImportedIds(
|
||||
user.id,
|
||||
provider.id,
|
||||
manifest.id,
|
||||
workoutList.workouts.map((w) => w.id),
|
||||
);
|
||||
|
||||
return data({
|
||||
provider: { id: provider.id, name: provider.name },
|
||||
provider: { id: manifest.id, name: manifest.displayName },
|
||||
workouts: workoutList.workouts.map((w) => ({
|
||||
...w,
|
||||
imported: importedIds.has(w.id),
|
||||
|
|
@ -55,11 +51,13 @@ export async function action({ params, request }: Route.ActionArgs) {
|
|||
const user = await getSessionUser(request);
|
||||
if (!user) return redirect("/auth/login");
|
||||
|
||||
const provider = getProvider(params.provider);
|
||||
if (!provider) throw data({ error: "Unknown provider" }, { status: 404 });
|
||||
const manifest = getManifest(params.provider);
|
||||
if (!manifest || !manifest.importer) {
|
||||
throw data({ error: "Unknown provider" }, { status: 404 });
|
||||
}
|
||||
|
||||
const connection = await getConnection(user.id, provider.id);
|
||||
if (!connection) return redirect("/settings");
|
||||
const service = await getService(user.id, manifest.id);
|
||||
if (!service) return redirect("/settings");
|
||||
|
||||
const formData = await request.formData();
|
||||
const workoutId = formData.get("workoutId") as string;
|
||||
|
|
@ -69,33 +67,56 @@ export async function action({ params, request }: Route.ActionArgs) {
|
|||
const distance = formData.get("distance") as string;
|
||||
const duration = formData.get("duration") as string;
|
||||
|
||||
// Refresh token if needed
|
||||
let tokens = {
|
||||
accessToken: connection.accessToken,
|
||||
refreshToken: connection.refreshToken,
|
||||
expiresAt: connection.expiresAt,
|
||||
};
|
||||
if (new Date() >= tokens.expiresAt) {
|
||||
tokens = await provider.refreshToken(tokens.refreshToken);
|
||||
await updateTokens(connection.id, tokens);
|
||||
}
|
||||
|
||||
// Inline import here (not via Importer.importOne) so we can pass through
|
||||
// the form-supplied metadata (started_at, distance, duration) the
|
||||
// capability seam doesn't carry. The seam-shape importer is reserved for
|
||||
// automatic / webhook-driven imports.
|
||||
let gpx: string | null = null;
|
||||
if (fileUrl) {
|
||||
const workout = { id: workoutId, name: workoutName, type: "", startedAt: "", duration: null, distance: null, fileUrl };
|
||||
const fileBuffer = await provider.downloadFile(tokens, workout);
|
||||
gpx = await provider.convertToGpx(fileBuffer);
|
||||
const ctx = capabilityContextFor(service.id);
|
||||
// Wahoo CDN URLs are pre-signed; we still go through withFreshCredentials
|
||||
// so the manager handles refresh of any near-expired token.
|
||||
const buffer = await ctx.withFreshCredentials(async () => {
|
||||
const resp = await fetch(fileUrl);
|
||||
if (!resp.ok) throw new Error(`Download failed: ${resp.status}`);
|
||||
return Buffer.from(await resp.arrayBuffer());
|
||||
});
|
||||
// Lazy-load to avoid bundling fit-file-parser into all routes.
|
||||
const { default: FitParser } = await import("fit-file-parser");
|
||||
const { generateGpx } = await import("@trails-cool/gpx");
|
||||
const parsed = await new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||
const parser = new FitParser({ force: true });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
parser.parse(buffer as any, (err: unknown, d: any) => (err ? reject(err) : resolve(d ?? {})));
|
||||
});
|
||||
const records = (parsed.records ?? []) as Array<{
|
||||
position_lat?: number;
|
||||
position_long?: number;
|
||||
altitude?: number;
|
||||
timestamp?: string | Date;
|
||||
}>;
|
||||
const trackPoints = records
|
||||
.filter((r) => r.position_lat != null && r.position_long != null)
|
||||
.map((r) => ({
|
||||
lat: r.position_lat!,
|
||||
lon: r.position_long!,
|
||||
ele: r.altitude,
|
||||
time: r.timestamp instanceof Date ? r.timestamp.toISOString() : r.timestamp,
|
||||
}));
|
||||
if (trackPoints.length >= 2) {
|
||||
gpx = generateGpx({ name: workoutName, tracks: [trackPoints] });
|
||||
}
|
||||
}
|
||||
|
||||
const activityId = await createActivity(user.id, {
|
||||
name: workoutName || `${provider.name} workout`,
|
||||
name: workoutName || `${manifest.displayName} workout`,
|
||||
gpx: gpx ?? undefined,
|
||||
distance: distance ? parseFloat(distance) : null,
|
||||
duration: duration ? parseInt(duration) : null,
|
||||
startedAt: startedAt ? new Date(startedAt) : null,
|
||||
});
|
||||
|
||||
await recordImport(user.id, provider.id, workoutId, activityId);
|
||||
await recordImport(user.id, manifest.id, workoutId, activityId);
|
||||
|
||||
return data({ imported: workoutId });
|
||||
}
|
||||
|
|
|
|||
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
|
||||
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:**
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# connected-services Specification
|
||||
|
||||
## Purpose
|
||||
Third-party service connections (Wahoo today; future Strava, Garmin, etc.) that the user opts into from the Journal's settings page. Covers OAuth-based connect / disconnect flows and the storage layout for tokens. Token refresh behavior, webhook ingestion, and the per-service import rules live with each service's own change (e.g. `wahoo-import`).
|
||||
Third-party service connections (Wahoo today; future Strava, Garmin, Komoot, Apple Health, etc.) that the user opts into from the Journal's settings page. Covers connect / disconnect flows, the storage layout for credentials of any kind, and the capability seams (`Importer`, `RoutePusher`, `WebhookReceiver`) that providers implement. Per-provider mechanics live with each service's own change (e.g. `wahoo-import`).
|
||||
|
||||
## Requirements
|
||||
|
||||
|
|
@ -14,17 +14,45 @@ The Journal SHALL expose a Connected services page at `/settings/connections` (o
|
|||
- **AND** connected state shows a "Disconnect" button that POSTs to `/api/sync/disconnect/<provider>`
|
||||
- **AND** disconnected state shows a "Connect Wahoo" button that begins the OAuth handshake
|
||||
|
||||
### Requirement: OAuth token storage in `sync_connections`
|
||||
External-service OAuth tokens SHALL be stored in the `journal.sync_connections` table keyed by `(user_id, provider)`. Each row SHALL persist `access_token`, `refresh_token`, `expires_at`, and the provider-side user id (`provider_user_id`). Disconnecting SHALL delete the row, severing the user's link to the external service without affecting any imported activities.
|
||||
### Requirement: OAuth token storage in `connected_services`
|
||||
External-service credentials SHALL be stored in the `journal.connected_services` table (renamed from `sync_connections`) keyed by `(user_id, provider)`. Each row SHALL persist a `credential_kind` discriminator (`oauth | web-login | device`), a `credentials` JSONB blob whose schema is determined by the kind, the provider-side user id (`provider_user_id`, nullable for kinds without one), and OAuth-specific `granted_scopes` as a top-level column (populated only when `credential_kind = 'oauth'`). Disconnecting SHALL delete the row, severing the user's link to the external service without affecting any imported activities.
|
||||
|
||||
For `credential_kind = 'oauth'`, the `credentials` blob SHALL contain `access_token`, `refresh_token`, and `expires_at`. Other kinds (web-login, device) carry their own blob shapes and are introduced by their respective consumer changes; the enum value is reserved from day one so adding a kind does not require a migration.
|
||||
|
||||
#### Scenario: Wahoo connect persists tokens
|
||||
- **WHEN** a user completes the Wahoo OAuth flow
|
||||
- **THEN** a `sync_connections` row is upserted with `provider = 'wahoo'`, the access/refresh tokens, the provider user id, and `expires_at`
|
||||
- **THEN** a `connected_services` row is upserted with `provider = 'wahoo'`, `credential_kind = 'oauth'`, the `credentials` JSONB containing access/refresh tokens and `expires_at`, the provider user id, and the granted scopes
|
||||
|
||||
#### Scenario: Disconnect removes the row but keeps imports
|
||||
- **WHEN** a user clicks "Disconnect" on a Wahoo connection
|
||||
- **THEN** the matching `sync_connections` row is deleted; previously imported activities are not deleted (they remain owned by the user, just no longer auto-syncing)
|
||||
- **THEN** the matching `connected_services` row is deleted; previously imported activities are not deleted (they remain owned by the user, just no longer auto-syncing)
|
||||
|
||||
#### Scenario: Each user has at most one row per provider
|
||||
- **WHEN** a user reconnects an already-connected provider
|
||||
- **THEN** the existing `sync_connections` row is updated in place with the fresh tokens; no duplicate row is created
|
||||
- **THEN** the existing `connected_services` row is updated in place with the fresh credentials; no duplicate row is created
|
||||
|
||||
### Requirement: Capability seams for providers
|
||||
The system SHALL model each external provider as a manifest declaring its `credential_kind` and which capabilities it implements. Capabilities are modelled as separate interfaces — `Importer`, `RoutePusher`, `WebhookReceiver` — and a provider implements only the subset that applies to it. There SHALL NOT be a unified provider interface that lists every capability with optional methods.
|
||||
|
||||
#### Scenario: Add a new provider
|
||||
- **WHEN** a developer adds a new provider (e.g. Garmin)
|
||||
- **THEN** they create `providers/<name>/manifest.ts` declaring the provider's `credential_kind` and the capability adapters it implements
|
||||
- **AND** they implement only the relevant capability interfaces (e.g. an OAuth-pull-only provider implements `Importer` only; not `RoutePusher` or `WebhookReceiver`)
|
||||
- **AND** they register the manifest by importing it in `providers/registry.ts`
|
||||
- **AND** existing settings UI, webhook routing, and credential lifecycle work without further changes
|
||||
|
||||
### Requirement: Centralized credential lifecycle via `ConnectedServiceManager`
|
||||
The system SHALL expose a `ConnectedServiceManager` that owns credential lifecycle for all providers. Importers, pushers, and webhook handlers SHALL obtain credentials exclusively via `withFreshCredentials(serviceId, fn)`, which loads the row, refreshes via the kind-appropriate `CredentialAdapter` if expired, calls `fn(credentials)`, and flips `status = needs_relink` if refresh fails. Capability adapters SHALL NOT read the `credentials` JSONB directly.
|
||||
|
||||
#### Scenario: Expired OAuth token is refreshed transparently
|
||||
- **WHEN** a capability adapter calls `withFreshCredentials` for a `connected_services` row whose `expires_at` has passed
|
||||
- **THEN** the manager invokes the OAuth `CredentialAdapter`'s refresh flow
|
||||
- **AND** updates the `credentials` JSONB with the new tokens and `expires_at`
|
||||
- **AND** invokes `fn` with the fresh credentials
|
||||
- **AND** the capability adapter never sees the expired token
|
||||
|
||||
#### Scenario: Refresh failure flips status to needs_relink
|
||||
- **WHEN** the `CredentialAdapter`'s refresh call returns a permanent failure (e.g. revoked refresh token)
|
||||
- **THEN** the manager sets `status = 'needs_relink'` on the `connected_services` row
|
||||
- **AND** raises an error that the caller surfaces as a re-connect prompt
|
||||
- **AND** subsequent calls for the same service short-circuit until the user re-links
|
||||
|
|
|
|||
|
|
@ -5,13 +5,14 @@ Provider-agnostic activity sync framework with Wahoo as the first provider, supp
|
|||
## Requirements
|
||||
|
||||
### Requirement: Provider-agnostic sync framework
|
||||
The system SHALL provide a common interface for external activity sync providers.
|
||||
The system SHALL provide capability-shaped seams for external activity sync providers. Each provider declares a manifest at `providers/<name>/manifest.ts` listing its `credential_kind` (`oauth | web-login | device`) and the capability adapters it implements (`Importer`, `RoutePusher`, `WebhookReceiver`). There SHALL NOT be a unified `SyncProvider` interface containing every capability with optional methods.
|
||||
|
||||
#### Scenario: Add new provider
|
||||
- **WHEN** a developer wants to add a new sync provider (e.g., Garmin)
|
||||
- **THEN** they implement the `SyncProvider` interface in a single file
|
||||
- **AND** register it in the provider registry
|
||||
- **AND** all OAuth, webhook, import, and settings UI works automatically
|
||||
- **THEN** they create `providers/garmin/` containing a `manifest.ts` declaring `credential_kind` and the implemented capabilities
|
||||
- **AND** they implement only the capability interfaces that apply (e.g. `Importer` for an import-only provider)
|
||||
- **AND** they register the manifest in `providers/registry.ts`
|
||||
- **AND** OAuth flows, webhook routing, and settings UI work without further changes
|
||||
|
||||
### Requirement: Connect Wahoo account
|
||||
Users SHALL be able to connect their Wahoo account via OAuth2.
|
||||
|
|
@ -20,20 +21,21 @@ Users SHALL be able to connect their Wahoo account via OAuth2.
|
|||
- **WHEN** a user clicks "Connect Wahoo" in journal settings
|
||||
- **THEN** they are redirected to Wahoo's OAuth authorization page with scopes `workouts_read`, `user_read`, `offline_data`, `routes_write`
|
||||
- **AND** after granting permission, redirected back to the journal
|
||||
- **AND** access and refresh tokens are stored in `sync_connections`
|
||||
- **AND** the granted scopes are recorded in `sync_connections.granted_scopes` so feature gates can detect missing scopes without round-tripping to Wahoo
|
||||
- **AND** access and refresh tokens are stored in `connected_services` (in the `credentials` JSONB blob, with `credential_kind = 'oauth'`)
|
||||
- **AND** the granted scopes are recorded in `connected_services.granted_scopes` so feature gates can detect missing scopes without round-tripping to Wahoo
|
||||
|
||||
#### Scenario: Disconnect Wahoo
|
||||
- **WHEN** a user clicks "Disconnect" next to their Wahoo connection
|
||||
- **THEN** the stored tokens are deleted from `sync_connections`
|
||||
- **THEN** the stored credentials are deleted from `connected_services`
|
||||
|
||||
#### Scenario: Token refresh
|
||||
- **WHEN** a Wahoo API call fails with an expired token
|
||||
- **THEN** the refresh token is used to obtain a new access token automatically
|
||||
- **THEN** the OAuth `CredentialAdapter` is invoked via `ConnectedServiceManager.withFreshCredentials` to obtain a new access token automatically
|
||||
- **AND** the new tokens are written back to the `credentials` JSONB blob
|
||||
|
||||
#### Scenario: Existing connection without routes_write
|
||||
- **WHEN** a user connected before the `routes_write` scope was added attempts an action that requires it (such as pushing a route)
|
||||
- **THEN** the system detects the missing scope from `sync_connections.granted_scopes` and routes the user through OAuth re-authorization to grant the new scope
|
||||
- **THEN** the system detects the missing scope from `connected_services.granted_scopes` and routes the user through OAuth re-authorization to grant the new scope
|
||||
- **AND** the existing tokens remain valid until re-auth completes; ongoing read flows (workout import, webhook ingestion) continue to work
|
||||
|
||||
### Requirement: Webhook-based automatic sync
|
||||
|
|
|
|||
|
|
@ -9,12 +9,12 @@ The Journal SHALL show a "Send to Wahoo" button on the route detail page when al
|
|||
|
||||
#### Scenario: Owner with connected Wahoo and a route with geometry
|
||||
- **WHEN** the route owner loads a route detail page for a route that has geometry
|
||||
- **AND** the owner has a `sync_connections` row with `provider = 'wahoo'`
|
||||
- **AND** the owner has a `connected_services` row with `provider = 'wahoo'`
|
||||
- **THEN** a "Send to Wahoo" button is visible alongside the existing "Export GPX" action
|
||||
|
||||
#### Scenario: Owner without a connected Wahoo account
|
||||
- **WHEN** the route owner loads a route detail page
|
||||
- **AND** the owner has no Wahoo `sync_connections` row
|
||||
- **AND** the owner has no Wahoo `connected_services` row
|
||||
- **THEN** the "Send to Wahoo" button is not rendered
|
||||
|
||||
#### Scenario: Non-owner viewing the route
|
||||
|
|
@ -88,7 +88,7 @@ The Journal SHALL detect when a connected Wahoo account lacks the `routes_write`
|
|||
|
||||
#### Scenario: Existing connection lacks routes_write
|
||||
- **WHEN** the route owner clicks "Send to Wahoo"
|
||||
- **AND** the user's `sync_connections.granted_scopes` does not include `routes_write`
|
||||
- **AND** the user's `connected_services.granted_scopes` does not include `routes_write`
|
||||
- **THEN** the server redirects the user to Wahoo's authorization URL with the full updated scope list and a `state` that encodes `{ return_to: <route_url>, push_after: true }`
|
||||
- **AND** no Wahoo `/v1/routes` call is attempted
|
||||
|
||||
|
|
@ -135,3 +135,18 @@ The route detail page SHALL show whether the route has been pushed to Wahoo and
|
|||
#### Scenario: Push failed previously shows retry affordance
|
||||
- **WHEN** the route owner views a route whose `sync_pushes` row has `error` set and `pushed_at` null
|
||||
- **THEN** the page shows "Last attempt failed: <short error>" with a "Send to Wahoo" button still active
|
||||
|
||||
### Requirement: `RoutePusher` capability seam
|
||||
The system SHALL expose `RoutePusher` as the capability seam through which any provider pushes routes. The seam shape is `pushRoute(service, route) → {remoteId, version}`. Provider-specific concerns — file format conversion (e.g. FIT Course), `external_id` conventions, HTTP fallback strategies, idempotency-tracking semantics — SHALL be handled inside the adapter and SHALL NOT appear on the `RoutePusher` interface.
|
||||
|
||||
#### Scenario: Wahoo pusher implements the seam without leaking workarounds
|
||||
- **WHEN** the route push action invokes the Wahoo `RoutePusher`
|
||||
- **THEN** the seam is called as `pushRoute(connectedService, route)` and returns `{remoteId, version}`
|
||||
- **AND** the FIT-Course conversion, the `external_id = route:<route_id>` convention, the PUT-vs-POST decision, and the PUT→POST-on-404 fallback are all internal to the Wahoo adapter
|
||||
- **AND** callers do not pass FIT data or Wahoo-specific fields across the seam
|
||||
|
||||
#### Scenario: Future pusher reuses the same seam
|
||||
- **WHEN** a developer adds a second `RoutePusher` (e.g. Garmin)
|
||||
- **THEN** they implement `pushRoute(service, route) → {remoteId, version}` with the same shape
|
||||
- **AND** the route push action invokes it identically to the Wahoo pusher
|
||||
- **AND** their format conversion and HTTP recovery live inside the adapter
|
||||
|
|
|
|||
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(),
|
||||
});
|
||||
|
||||
export const syncConnections = journalSchema.table("sync_connections", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
provider: text("provider").notNull(),
|
||||
accessToken: text("access_token").notNull(),
|
||||
refreshToken: text("refresh_token").notNull(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
providerUserId: text("provider_user_id"),
|
||||
// Scopes the user actually granted at OAuth time. Wahoo doesn't return a
|
||||
// scope field in token responses and grants scopes all-or-nothing, so we
|
||||
// record the requested scope set on exchangeCode. Used to detect when an
|
||||
// existing connection predates a scope upgrade (e.g. routes_write) and
|
||||
// needs a re-auth before a new push call can succeed.
|
||||
grantedScopes: text("granted_scopes")
|
||||
.array()
|
||||
.notNull()
|
||||
.default(sql`ARRAY[]::text[]`),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
// External-service connections (OAuth, web-login, mobile-paired devices).
|
||||
// `credential_kind` discriminates the shape of `credentials` JSONB:
|
||||
// - 'oauth': { access_token, refresh_token, expires_at }
|
||||
// - 'web-login': { email, encrypted_password, session_jar } (Komoot, future)
|
||||
// - 'device': {} (Apple Health, future)
|
||||
// See docs/adr/0001 and CONTEXT.md (Connected Services).
|
||||
export const connectedServices = journalSchema.table(
|
||||
"connected_services",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
provider: text("provider").notNull(),
|
||||
credentialKind: text("credential_kind").notNull(),
|
||||
credentials: jsonb("credentials").notNull(),
|
||||
// 'active' | 'needs_relink' | 'revoked'. Manager flips to 'needs_relink'
|
||||
// when CredentialAdapter.refresh returns a permanent failure.
|
||||
status: text("status").notNull().default("active"),
|
||||
providerUserId: text("provider_user_id"),
|
||||
// OAuth-only. Promoted out of the credentials JSONB because feature gates
|
||||
// (e.g. routes_write check on push) read this on every call.
|
||||
grantedScopes: text("granted_scopes")
|
||||
.array()
|
||||
.notNull()
|
||||
.default(sql`ARRAY[]::text[]`),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
userProviderUnique: uniqueIndex("connected_services_user_provider_unique").on(
|
||||
t.userId,
|
||||
t.provider,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
export const syncImports = journalSchema.table("sync_imports", {
|
||||
id: text("id").primaryKey(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue