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>
127 lines
3.4 KiB
TypeScript
127 lines
3.4 KiB
TypeScript
// 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 { 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,
|
|
tokens: TokenSet,
|
|
grantedScopes: string[] = [],
|
|
) {
|
|
const db = getDb();
|
|
await db
|
|
.delete(connectedServices)
|
|
.where(
|
|
and(eq(connectedServices.userId, userId), eq(connectedServices.provider, provider)),
|
|
);
|
|
await db.insert(connectedServices).values({
|
|
id: randomUUID(),
|
|
userId,
|
|
provider,
|
|
credentialKind: "oauth",
|
|
credentials: toBlob(tokens),
|
|
status: "active",
|
|
providerUserId: tokens.providerUserId ?? null,
|
|
grantedScopes,
|
|
});
|
|
}
|
|
|
|
export async function getConnection(
|
|
userId: string,
|
|
provider: string,
|
|
): Promise<LegacyConnection | null> {
|
|
const db = getDb();
|
|
const [row] = await db
|
|
.select()
|
|
.from(connectedServices)
|
|
.where(
|
|
and(eq(connectedServices.userId, userId), eq(connectedServices.provider, provider)),
|
|
);
|
|
return row ? toLegacy(row) : null;
|
|
}
|
|
|
|
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(connectedServices)
|
|
.set({ credentials: toBlob(tokens) })
|
|
.where(eq(connectedServices.id, connectionId));
|
|
}
|
|
|
|
export async function deleteConnection(userId: string, provider: string) {
|
|
const db = getDb();
|
|
await db
|
|
.delete(connectedServices)
|
|
.where(
|
|
and(eq(connectedServices.userId, userId), eq(connectedServices.provider, provider)),
|
|
);
|
|
}
|