trails/apps/journal/app/lib/connected-services/registry.ts
Ullrich Schäfer 6de516718d
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>
2026-05-08 01:14:38 +02:00

138 lines
4 KiB
TypeScript

// 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);
}