diff --git a/apps/journal/app/lib/connected-services/credential-adapters/oauth.test.ts b/apps/journal/app/lib/connected-services/credential-adapters/oauth.test.ts new file mode 100644 index 0000000..ae0b707 --- /dev/null +++ b/apps/journal/app/lib/connected-services/credential-adapters/oauth.test.ts @@ -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).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(); + }); +}); diff --git a/apps/journal/app/lib/connected-services/credential-adapters/oauth.ts b/apps/journal/app/lib/connected-services/credential-adapters/oauth.ts new file mode 100644 index 0000000..28e8aea --- /dev/null +++ b/apps/journal/app/lib/connected-services/credential-adapters/oauth.ts @@ -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 = { + isExpired(creds) { + return parseExpiresAt(creds.expires_at).getTime() - Date.now() < REFRESH_SKEW_MS; + }, + + async refresh(creds, providerConfig): Promise { + 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. + }); + }, +}; diff --git a/apps/journal/app/lib/connected-services/manager.test.ts b/apps/journal/app/lib/connected-services/manager.test.ts new file mode 100644 index 0000000..e8e8d28 --- /dev/null +++ b/apps/journal/app/lib/connected-services/manager.test.ts @@ -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) => ({ + 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 = { + 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("./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; diff --git a/apps/journal/app/lib/connected-services/manager.ts b/apps/journal/app/lib/connected-services/manager.ts new file mode 100644 index 0000000..511e3ae --- /dev/null +++ b/apps/journal/app/lib/connected-services/manager.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const service = await getService(userId, provider); + if (!service) return; + await unlink(service.id); +} + +export async function markNeedsRelink( + serviceId: string, + reason: string, +): Promise { + 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 { + 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( + serviceId: string, + fn: (credentials: unknown) => Promise, +): Promise { + 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), + }; +} diff --git a/apps/journal/app/lib/connected-services/registry.ts b/apps/journal/app/lib/connected-services/registry.ts new file mode 100644 index 0000000..4146589 --- /dev/null +++ b/apps/journal/app/lib/connected-services/registry.ts @@ -0,0 +1,138 @@ +// Provider registry. Each provider lives in providers// 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( + fn: (credentials: unknown) => Promise, + ): Promise; +} + +export interface Importer { + listImportable(ctx: CapabilityContext, page: number): Promise; + importOne(ctx: CapabilityContext, workoutId: string): Promise; +} + +export interface RoutePusher { + pushRoute(ctx: CapabilityContext, input: RoutePushInput): Promise; +} + +export interface WebhookReceiver { + parseWebhook(body: unknown): WebhookEvent | null; + handle(event: WebhookEvent): Promise; +} + +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//index.ts) calls register at import. + +const PROVIDERS: Record = {}; + +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); +} diff --git a/apps/journal/app/lib/connected-services/types.ts b/apps/journal/app/lib/connected-services/types.ts new file mode 100644 index 0000000..4d614d5 --- /dev/null +++ b/apps/journal/app/lib/connected-services/types.ts @@ -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; + +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 { + // 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; + // 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; + // 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; + } +} diff --git a/apps/journal/app/lib/sync/connections.server.ts b/apps/journal/app/lib/sync/connections.server.ts index 4337fcf..0067491 100644 --- a/apps/journal/app/lib/sync/connections.server.ts +++ b/apps/journal/app/lib/sync/connections.server.ts @@ -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 { 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 { + 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)), + ); } diff --git a/apps/journal/app/routes/settings.connections.tsx b/apps/journal/app/routes/settings.connections.tsx index db046df..e11e9ac 100644 --- a/apps/journal/app/routes/settings.connections.tsx +++ b/apps/journal/app/routes/settings.connections.tsx @@ -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); diff --git a/openspec/changes/deepen-connected-services/tasks.md b/openspec/changes/deepen-connected-services/tasks.md index d5a4d38..7558d6e 100644 --- a/openspec/changes/deepen-connected-services/tasks.md +++ b/openspec/changes/deepen-connected-services/tasks.md @@ -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()`. diff --git a/packages/db/migrations/0002_connected_services.sql b/packages/db/migrations/0002_connected_services.sql new file mode 100644 index 0000000..5a02645 --- /dev/null +++ b/packages/db/migrations/0002_connected_services.sql @@ -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 $$; diff --git a/packages/db/src/schema/journal.ts b/packages/db/src/schema/journal.ts index 1462d3f..d6074e4 100644 --- a/packages/db/src/schema/journal.ts +++ b/packages/db/src/schema/journal.ts @@ -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(),