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>
241 lines
7.4 KiB
TypeScript
241 lines
7.4 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}`);
|
|
}
|
|
|
|
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),
|
|
};
|
|
}
|