trails/apps/journal/app/lib/connected-services/manager.ts
Ullrich Schäfer 0360757ae8 feat(journal): Garmin activity import — provider, webhook pipeline, backfill (§1–5)
Garmin Connect as the third connected-services provider (spec:
garmin-import). The interesting parts:

- Push-first ingestion: Garmin has no list endpoint. The webhook
  normalizes ping (callbackURL) and push (inline) notification batches
  into events; the slow work (authorized FIT download, FIT→GPX via the
  shared converter, activity creation) runs in a garmin-import-activity
  pg-boss job so the webhook answers fast. Callback URLs are validated
  against Garmin's API host before any fetch (SSRF guard).
- History via backfill requests: /sync/import/garmin is a date-range
  requester with honest async progress (no pick list — the concept
  doesn't exist in a push model). Ranges chunk to Garmin's 90-day cap;
  overlaps are free via sync_imports dedupe. Requests persist in
  import_batches via two new nullable columns (range_start/range_end).
- OAuth2 + PKCE on the existing oauth credential kind. Design
  correction from apply: the verifier rides a short-lived httpOnly
  cookie scoped to the callback path — the state param is visible in
  redirect URLs and must never carry it. Manifests opt in via pkce:true.
- Deregistration notifications flip the connection to 'revoked'
  (row kept for audit, imports retained, re-connect prompt shown).
- Framework evolutions, all additive: parseWebhook returns
  WebhookEvent[] (Garmin batches; Wahoo adapted), manifest gains
  configured()/importUrl/pkce, importActivity accepts summary stats
  for FIT-less imports, manager gains markRevoked.
- Env-gated: no GARMIN_CLIENT_ID → provider hidden on
  /settings/connections. Privacy manifest entry (DE+EN). i18n en+de.

Rollout (§6) stays gated on the Garmin Developer Program application
(submitted 2026-06-07). Fixtures are doc-shaped; the staging soak
swaps in recorded payloads if shapes differ.

Gate: typecheck ✓ lint ✓ unit+integration ✓ e2e 70/72 + both known
flakes green isolated ✓ openspec validate ✓

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:47:22 +02:00

253 lines
7.8 KiB
TypeScript

// 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}`);
}
// Provider-side revocation (e.g. a Garmin deregistration notification):
// keep the row for audit, flip to 'revoked' so every subsequent
// withFreshCredentials short-circuits and the UI shows a re-connect
// prompt. Imported activities are untouched.
export async function markRevoked(serviceId: string): Promise<void> {
const db = getDb();
await db
.update(connectedServices)
.set({ status: "revoked" })
.where(eq(connectedServices.id, serviceId));
}
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),
};
}