Schema rename + ConnectedServiceManager foundation (groups 1-2)

Implements tasks 1.1-1.4 and 2.1-2.5 of deepen-connected-services:

DB:
- Rename journal.sync_connections -> journal.connected_services.
- Add credential_kind discriminator (oauth | web-login | device) and
  credentials JSONB (shape per kind), status column, and a unique index
  on (user_id, provider) lifting the previously app-only invariant
  into the DB.
- Idempotent backfill in 0002_connected_services.sql moves existing
  Wahoo rows' tokens into the JSONB blob with credential_kind='oauth'.

Code:
- New apps/journal/app/lib/connected-services/ module:
  - types.ts: ConnectedService, CredentialKind, OAuthCredentials,
    NeedsRelinkError, CredentialAdapter, ProviderOAuthConfig, etc.
  - credential-adapters/oauth.ts: standard OAuth2 refresh_token flow,
    revoke endpoint, 4xx -> NeedsRelinkError / 5xx -> transient.
  - manager.ts: ConnectedServiceManager (link, unlink, withFreshCredentials,
    markNeedsRelink). Centralizes credential lifecycle in one chokepoint.
  - registry.ts: ProviderManifest type + capability seam interfaces
    (Importer, RoutePusher, WebhookReceiver). Manifests register
    themselves at import time.

Tests:
- manager.test.ts (8 tests): refresh-on-expired, refresh-fail->needs_relink,
  ConnectionNotActiveError, link/unlink, revoke is best-effort.
- credential-adapters/oauth.test.ts (10 tests): refresh contract,
  refresh_token retention, 4xx vs 5xx behaviour, revoke.
- All 18 new tests pass.

Compatibility:
- apps/journal/app/lib/sync/connections.server.ts is now a thin shim
  translating the legacy TokenSet API onto the JSONB-shaped table so
  existing callers (routes, pushes.server.ts) keep working until tasks
  5.x migrate them to the manager. To be deleted in task 5.6.

Pre-existing journal test failures (12) are unrelated to this change:
they pre-date this PR and stem from a workspace resolution issue with
@trails-cool/fit (verified by running tests against main).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-05-08 00:24:52 +02:00
parent cfba3146e2
commit 6de516718d
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
11 changed files with 1155 additions and 70 deletions

View file

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

View file

@ -0,0 +1,83 @@
// 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,
type ProviderOAuthConfig,
} from "../types.ts";
// Refresh credentials slightly before they actually expire so a token in
// the middle of a long-running operation doesn't tip over mid-request.
const REFRESH_SKEW_MS = 60_000;
function parseExpiresAt(iso: string): Date {
const d = new Date(iso);
if (isNaN(d.getTime())) {
throw new Error(`OAuth credentials have invalid expires_at: ${iso}`);
}
return d;
}
export const oauthCredentialAdapter: CredentialAdapter<OAuthCredentials> = {
isExpired(creds) {
return parseExpiresAt(creds.expires_at).getTime() - Date.now() < REFRESH_SKEW_MS;
},
async refresh(creds, providerConfig): Promise<OAuthCredentials> {
const resp = await fetch(providerConfig.tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: providerConfig.clientId,
client_secret: providerConfig.clientSecret,
grant_type: "refresh_token",
refresh_token: creds.refresh_token,
}).toString(),
});
if (!resp.ok) {
const text = await resp.text().catch(() => "");
// 4xx on refresh = revoked / invalidated refresh token. Caller must re-link.
// 5xx = transient; surface as a regular Error so caller can retry the operation.
if (resp.status >= 400 && resp.status < 500) {
throw new NeedsRelinkError(
`OAuth refresh rejected (${resp.status}): ${text || "no body"}`,
);
}
throw new Error(`OAuth refresh failed (${resp.status}): ${text}`);
}
const data = (await resp.json()) as {
access_token: string;
refresh_token?: string;
expires_in: number;
};
return {
access_token: data.access_token,
// Some providers omit refresh_token on refresh (it stays the same).
refresh_token: data.refresh_token ?? creds.refresh_token,
expires_at: new Date(Date.now() + data.expires_in * 1000).toISOString(),
};
},
async revoke(creds, providerConfig) {
if (!providerConfig.revokeUrl) return;
// Best-effort. Caller deletes the local row regardless of outcome.
await fetch(providerConfig.revokeUrl, {
method: "DELETE",
headers: { Authorization: `Bearer ${creds.access_token}` },
}).catch(() => {
// Swallow — local unlink proceeds.
});
},
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,7 +4,7 @@ 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 { connectedServices } from "@trails-cool/db/schema/journal";
import { getAllProviders } from "~/lib/sync/registry";
const KNOWN_ERRORS = ["too_many_tokens", "sync_failed", "generic"] as const;
@ -24,11 +24,11 @@ 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);

View file

@ -1,21 +1,21 @@
## 1. Database migration
- [ ] 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`.
- [ ] 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.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.
- [ ] 1.3 Verify the migration on a local database with seeded Wahoo connection rows; verify the inverse rollback restores the columns from JSONB.
- [ ] 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).
- [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
- [ ] 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.
- [ ] 2.2 Create `apps/journal/app/lib/connected-services/credential-adapters/oauth.ts` implementing `CredentialAdapter` with `refresh(creds, providerConfig) → Credentials | NeedsRelink` and optional `revoke`.
- [ ] 2.3 Create `apps/journal/app/lib/connected-services/manager.ts` exposing `link / unlink / withFreshCredentials / markNeedsRelink`. Persistence via Drizzle on the `connectedServices` table.
- [ ] 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.
- [ ] 2.5 Write `credential-adapters/oauth.test.ts`: contract tests against a mocked token endpoint (refresh success, refresh 4xx → NeedsRelink).
- [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
- [ ] 3.1 Create `apps/journal/app/lib/connected-services/registry.ts` and `types.ts` capability interfaces (`Importer`, `RoutePusher`, `WebhookReceiver`, `ProviderManifest`).
- [x] 3.1 Create `apps/journal/app/lib/connected-services/registry.ts` and `types.ts` capability interfaces (`Importer`, `RoutePusher`, `WebhookReceiver`, `ProviderManifest`).
- [ ] 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.
- [ ] 3.3 Wire `providers/registry.ts` to import the Wahoo manifest. Expose `getManifest(providerId)` and `getAllManifests()`.

View file

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

View file

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