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>
139 lines
4.6 KiB
TypeScript
139 lines
4.6 KiB
TypeScript
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<string, string>).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();
|
|
});
|
|
});
|