From 38d3a6d71d3c354dacbc473bc592a4a1b667bd37 Mon Sep 17 00:00:00 2001 From: Steve Kaliski Date: Mon, 24 Aug 2026 10:36:40 -0400 Subject: [PATCH] Move authentication ownership into CLI Committed-By-Agent: codex Co-authored-by: codex --- packages/cli/package.json | 1 + packages/cli/src/__tests__/cli.test.ts | 40 +- .../src/auth/__tests__/auth-resource.test.ts | 7 +- .../cli/src/auth/__tests__/session.test.ts | 54 +- .../cli/src/auth/__tests__/storage.test.ts | 106 ++++ packages/cli/src/auth/auth-resource.ts | 11 +- .../cli/src/auth/authorization-details.ts | 3 +- packages/cli/src/auth/errors.ts | 25 + packages/cli/src/auth/session.ts | 15 +- .../src/utils => cli/src/auth}/storage.ts | 96 +-- packages/cli/src/auth/types.ts | 32 +- packages/cli/src/cli.tsx | 4 +- packages/cli/src/commands/auth/index.tsx | 41 +- packages/cli/src/commands/auth/login.tsx | 21 +- packages/cli/src/commands/auth/logout.tsx | 11 +- packages/cli/src/commands/auth/schema.ts | 2 +- packages/cli/src/commands/auth/status.tsx | 7 +- packages/cli/src/commands/auth/utils.ts | 7 +- packages/cli/src/commands/balances/index.tsx | 9 +- .../cli/src/commands/demo/demo-runner.tsx | 8 +- packages/cli/src/commands/demo/index.tsx | 4 +- packages/cli/src/commands/mpp/index.tsx | 4 +- packages/cli/src/commands/onboard/index.tsx | 4 +- .../src/commands/onboard/onboard-runner.tsx | 8 +- .../src/commands/payment-methods/index.tsx | 5 +- packages/cli/src/commands/report/index.tsx | 5 +- .../src/commands/shipping-address/index.tsx | 5 +- packages/cli/src/commands/sources/index.tsx | 9 +- .../cli/src/commands/spend-request/index.tsx | 4 +- .../cli/src/commands/transactions/index.tsx | 4 +- packages/cli/src/commands/user-info/index.tsx | 5 +- .../cli/src/commands/web-bot-auth/index.tsx | 9 +- .../src/utils/__tests__/require-auth.test.ts | 12 +- .../utils/__tests__/resource-factory.test.ts | 22 +- packages/cli/src/utils/require-auth.ts | 10 +- packages/cli/src/utils/resource-factory.ts | 161 +++--- packages/sdk/package.json | 5 +- packages/sdk/src/__tests__/config.test.ts | 42 +- packages/sdk/src/__tests__/errors.test.ts | 15 - packages/sdk/src/client.ts | 11 +- packages/sdk/src/config.ts | 125 ++-- packages/sdk/src/errors.ts | 28 +- packages/sdk/src/index.ts | 30 +- .../sdk/src/resources/__tests__/auth.test.ts | 545 ------------------ .../src/resources/__tests__/factory.test.ts | 10 +- .../__tests__/payment-methods.test.ts | 10 + packages/sdk/src/resources/auth.ts | 372 ------------ packages/sdk/src/resources/base.ts | 4 +- packages/sdk/src/resources/interfaces.ts | 28 - packages/sdk/src/types/index.ts | 22 - .../sdk/src/utils/__tests__/storage.test.ts | 175 ------ packages/sdk/tsup.config.ts | 2 +- pnpm-lock.yaml | 6 +- 53 files changed, 559 insertions(+), 1642 deletions(-) create mode 100644 packages/cli/src/auth/__tests__/storage.test.ts create mode 100644 packages/cli/src/auth/errors.ts rename packages/{sdk/src/utils => cli/src/auth}/storage.ts (50%) delete mode 100644 packages/sdk/src/resources/__tests__/auth.test.ts delete mode 100644 packages/sdk/src/resources/auth.ts delete mode 100644 packages/sdk/src/utils/__tests__/storage.test.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index 9e8d5d9b..a015f6cb 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -32,6 +32,7 @@ "dev": "tsx src/cli.tsx" }, "dependencies": { + "conf": "^15.1.0", "incur": "^0.4.26", "ink": "^5.2.1", "ink-spinner": "^5.0.0", diff --git a/packages/cli/src/__tests__/cli.test.ts b/packages/cli/src/__tests__/cli.test.ts index 4776d758..f6601148 100644 --- a/packages/cli/src/__tests__/cli.test.ts +++ b/packages/cli/src/__tests__/cli.test.ts @@ -1,8 +1,8 @@ import { execFile } from 'node:child_process'; import http from 'node:http'; import { promisify } from 'node:util'; -import { storage } from '@stripe/link-sdk'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { storage } from '../auth/storage'; const execFileAsync = promisify(execFile); @@ -27,7 +27,7 @@ function parseJson(raw: string): unknown { beforeEach(() => { storage.clearAll(); - storage.setAuth(AUTH_TOKENS); + storage.setTokens(AUTH_TOKENS); }); afterAll(() => { @@ -212,7 +212,7 @@ describe('production mode', () => { responsesByUrl = {}; merchantRequests = []; merchantResponses = []; - storage.setAuth(PROD_AUTH_TOKENS); + storage.setTokens(PROD_AUTH_TOKENS); setNextResponse(200, BASE_REQUEST); }); @@ -1302,7 +1302,7 @@ describe('production mode', () => { }); it('rejects unauthenticated requests before hitting the API', async () => { - storage.clearAuth(); + storage.clearTokens(); const result = await runProdCli('shipping-address', 'list', '--json'); @@ -1413,7 +1413,7 @@ describe('production mode', () => { }); it('rejects unauthenticated requests before hitting the API', async () => { - storage.clearAuth(); + storage.clearTokens(); const result = await runProdCli('transactions', 'list', '--json'); @@ -1494,7 +1494,7 @@ describe('production mode', () => { }); it('rejects unauthenticated requests before hitting the API', async () => { - storage.clearAuth(); + storage.clearTokens(); const result = await runProdCli('sources', 'list', '--json'); @@ -1574,7 +1574,7 @@ describe('production mode', () => { }); it('rejects unauthenticated requests before hitting the API', async () => { - storage.clearAuth(); + storage.clearTokens(); const result = await runProdCli('balances', 'list', '--json'); @@ -1670,7 +1670,7 @@ describe('production mode', () => { }); it('passes a normalized custom --scope to /device/code', async () => { - storage.clearAuth(); + storage.clearTokens(); setResponseForUrl('/device/code', 200, DEVICE_CODE_RESPONSE); const result = await runProdCli( @@ -1694,7 +1694,7 @@ describe('production mode', () => { }); it('does not translate source-related --scope values into authorization_details', async () => { - storage.clearAuth(); + storage.clearTokens(); setResponseForUrl('/device/code', 200, DEVICE_CODE_RESPONSE); const result = await runProdCli( @@ -1721,7 +1721,7 @@ describe('production mode', () => { }); it('passes source actions via authorization_details', async () => { - storage.clearAuth(); + storage.clearTokens(); setResponseForUrl('/device/code', 200, DEVICE_CODE_RESPONSE); const result = await runProdCli( @@ -1754,7 +1754,7 @@ describe('production mode', () => { }); it('passes freeform authorization_details entries after source actions', async () => { - storage.clearAuth(); + storage.clearTokens(); setResponseForUrl('/device/code', 200, DEVICE_CODE_RESPONSE); const result = await runProdCli( @@ -1886,7 +1886,7 @@ describe('production mode', () => { }); it('skips revoke when not previously authenticated', async () => { - storage.clearAuth(); + storage.clearTokens(); setResponseForUrl('/device/code', 200, DEVICE_CODE_RESPONSE); const result = await runProdCli( @@ -1905,7 +1905,7 @@ describe('production mode', () => { }); it('with --interval, yields code first then polls until authenticated', async () => { - storage.clearAuth(); + storage.clearTokens(); setResponseForUrl('/device/revoke', 200, 'ok'); setResponseForUrl('/device/code', 200, DEVICE_CODE_RESPONSE); setResponseForUrl('/device/token', 200, TOKEN_RESPONSE); @@ -1935,7 +1935,7 @@ describe('production mode', () => { }); it('with --interval, yields unauthenticated status on timeout (exit 0)', async () => { - storage.clearAuth(); + storage.clearTokens(); setResponseForUrl('/device/code', 200, DEVICE_CODE_RESPONSE); setResponseForUrl('/device/token', 400, { error: 'authorization_pending', @@ -1960,7 +1960,7 @@ describe('production mode', () => { }); it('with --interval, exits with error on access_denied', async () => { - storage.clearAuth(); + storage.clearTokens(); setResponseForUrl('/device/revoke', 200, 'ok'); setResponseForUrl('/device/code', 200, DEVICE_CODE_RESPONSE); setResponseForUrl('/device/token', 400, { error: 'access_denied' }); @@ -2025,7 +2025,7 @@ describe('production mode', () => { // Deferred lifecycle: the existing session is preserved (NOT cleared) and // the pending is flagged so the poll completes the new approval and // revokes the old grant only once the widened tokens land. - expect(storage.getAuth()).not.toBeNull(); + expect(storage.getTokens()).not.toBeNull(); expect(storage.getPendingDeviceAuth()?.replaces_existing_session).toBe( true, ); @@ -2139,7 +2139,7 @@ describe('production mode', () => { }); it('warns and continues when there is no active session', async () => { - storage.clearAuth(); + storage.clearTokens(); setResponseForUrl('/device/code', 200, DEVICE_CODE_RESPONSE); const result = await runProdCli( @@ -2255,7 +2255,7 @@ describe('production mode', () => { }); it('succeeds when no auth tokens are stored', async () => { - storage.clearAuth(); + storage.clearTokens(); const result = await runProdCli('auth', 'logout', '--format', 'json'); @@ -2271,7 +2271,7 @@ describe('production mode', () => { describe('auth guard', () => { it('rejects unauthenticated requests before hitting the API', async () => { - storage.clearAuth(); + storage.clearTokens(); const result = await runProdCli( 'spend-request', @@ -2300,7 +2300,7 @@ describe('production mode', () => { const ENV_TOKEN = 'env_access_token_abc123'; beforeEach(() => { - storage.clearAuth(); + storage.clearTokens(); }); it('allows user-info retrieve with no stored auth', async () => { diff --git a/packages/cli/src/auth/__tests__/auth-resource.test.ts b/packages/cli/src/auth/__tests__/auth-resource.test.ts index dac91cf1..4628e2c4 100644 --- a/packages/cli/src/auth/__tests__/auth-resource.test.ts +++ b/packages/cli/src/auth/__tests__/auth-resource.test.ts @@ -1,11 +1,8 @@ import { hostname } from 'node:os'; -import { - LinkApiError, - LinkAuthorizationDeclinedError, - LinkTransportError, -} from '@stripe/link-sdk'; +import { LinkApiError, LinkTransportError } from '@stripe/link-sdk'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { LinkAuthResource } from '../auth-resource'; +import { LinkAuthorizationDeclinedError } from '../errors'; const mockFetch = vi.fn(); diff --git a/packages/cli/src/auth/__tests__/session.test.ts b/packages/cli/src/auth/__tests__/session.test.ts index 77d7f8f0..5c0e15b9 100644 --- a/packages/cli/src/auth/__tests__/session.test.ts +++ b/packages/cli/src/auth/__tests__/session.test.ts @@ -1,7 +1,27 @@ -import { LinkAuthenticationError, MemoryStorage } from '@stripe/link-sdk'; import { describe, expect, it, vi } from 'vitest'; +import { LinkAuthenticationError } from '../errors'; import { createAccessTokenProvider } from '../session'; -import type { IAuthResource } from '../types'; +import type { AuthStorage, AuthTokens, IAuthResource } from '../types'; + +class MemoryAuthStorage implements AuthStorage { + private tokens: AuthTokens | null; + + constructor(tokens: AuthTokens | null = null) { + this.tokens = tokens; + } + + getTokens(): AuthTokens | null { + return this.tokens; + } + + setTokens(tokens: AuthTokens): void { + this.tokens = tokens; + } + + clearTokens(): void { + this.tokens = null; + } +} function createMockAuthRepo( refreshResult = { @@ -21,7 +41,7 @@ function createMockAuthRepo( describe('createAccessTokenProvider', () => { it('throws LinkAuthenticationError with not_authenticated code when no auth stored', async () => { - const storage = new MemoryStorage(null); + const storage = new MemoryAuthStorage(null); const repo = createMockAuthRepo(); const provider = createAccessTokenProvider(repo, storage); @@ -34,11 +54,12 @@ describe('createAccessTokenProvider', () => { }); it('returns cached token when not expired', async () => { - const storage = new MemoryStorage({ + const storage = new MemoryAuthStorage({ access_token: 'at_cached', refresh_token: 'rt_123', expires_in: 3600, token_type: 'Bearer', + expires_at: Date.now() + 3_600_000, }); const repo = createMockAuthRepo(); const provider = createAccessTokenProvider(repo, storage); @@ -48,17 +69,10 @@ describe('createAccessTokenProvider', () => { }); it('refreshes token when expired (within 60s buffer)', async () => { - const storage = new MemoryStorage({ - access_token: 'at_old', - refresh_token: 'rt_123', - expires_in: 30, // 30s, will be within 60s buffer after MemoryStorage computes expires_at - token_type: 'Bearer', - }); - // Override expires_at to be within the buffer - storage.setAuth({ + const storage = new MemoryAuthStorage({ access_token: 'at_old', refresh_token: 'rt_123', - expires_in: 0, + expires_in: 30, token_type: 'Bearer', expires_at: Date.now() + 30_000, }); @@ -72,11 +86,12 @@ describe('createAccessTokenProvider', () => { }); it('refreshes token when forceRefresh is true', async () => { - const storage = new MemoryStorage({ + const storage = new MemoryAuthStorage({ access_token: 'at_cached', refresh_token: 'rt_123', expires_in: 3600, token_type: 'Bearer', + expires_at: Date.now() + 3_600_000, }); const repo = createMockAuthRepo(); const provider = createAccessTokenProvider(repo, storage); @@ -88,13 +103,7 @@ describe('createAccessTokenProvider', () => { }); it('throws when noRefresh is true and token is expired', async () => { - const storage = new MemoryStorage({ - access_token: 'at_old', - refresh_token: 'rt_123', - expires_in: 0, - token_type: 'Bearer', - }); - storage.setAuth({ + const storage = new MemoryAuthStorage({ access_token: 'at_old', refresh_token: 'rt_123', expires_in: 0, @@ -111,11 +120,12 @@ describe('createAccessTokenProvider', () => { }); it('throws when noRefresh is true and forceRefresh is requested', async () => { - const storage = new MemoryStorage({ + const storage = new MemoryAuthStorage({ access_token: 'at_cached', refresh_token: 'rt_123', expires_in: 3600, token_type: 'Bearer', + expires_at: Date.now() + 3_600_000, }); const repo = createMockAuthRepo(); const provider = createAccessTokenProvider(repo, storage, { diff --git a/packages/cli/src/auth/__tests__/storage.test.ts b/packages/cli/src/auth/__tests__/storage.test.ts new file mode 100644 index 00000000..8a325e40 --- /dev/null +++ b/packages/cli/src/auth/__tests__/storage.test.ts @@ -0,0 +1,106 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { Storage } from '../storage'; + +const describePosix = process.platform === 'win32' ? describe.skip : describe; + +describePosix('CLI auth storage', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'link-cli-storage-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('implements the SDK token-storage contract', () => { + const storage = new Storage({ cwd: tmpDir }); + + storage.setTokens({ + access_token: 'at_test', + refresh_token: 'rt_test', + expires_in: 3600, + token_type: 'Bearer', + }); + + expect(storage.getTokens()?.access_token).toBe('at_test'); + expect(storage.getTokens()?.expires_at).toBeTypeOf('number'); + storage.clearTokens(); + expect(storage.getTokens()).toBeNull(); + }); + + it('writes credentials with mode 0o600', () => { + const storage = new Storage({ cwd: tmpDir }); + + storage.setTokens({ + access_token: 'at_test', + refresh_token: 'rt_test', + expires_in: 3600, + token_type: 'Bearer', + }); + + expect(fs.statSync(storage.getPath()).mode & 0o777).toBe(0o600); + }); + + it('repairs an existing config file with broader permissions', () => { + const seedStorage = new Storage({ cwd: tmpDir }); + seedStorage.setTokens({ + access_token: 'at_seed', + refresh_token: 'rt_seed', + expires_in: 3600, + token_type: 'Bearer', + }); + const configPath = seedStorage.getPath(); + fs.chmodSync(configPath, 0o644); + + const upgradedStorage = new Storage({ cwd: tmpDir }); + upgradedStorage.setTokens({ + access_token: 'at_after_upgrade', + refresh_token: 'rt_after_upgrade', + expires_in: 3600, + token_type: 'Bearer', + }); + + expect(fs.statSync(configPath).mode & 0o777).toBe(0o600); + }); + + it('uses an explicit credential path in preference to cwd', () => { + const customPath = path.join(tmpDir, 'custom-creds.json'); + const otherDir = fs.mkdtempSync(path.join(os.tmpdir(), 'link-cli-other-')); + const storage = new Storage({ configPath: customPath, cwd: otherDir }); + + storage.setTokens({ + access_token: 'at_custom', + refresh_token: 'rt_custom', + expires_in: 3600, + token_type: 'Bearer', + }); + + expect(storage.getPath()).toBe(customPath); + expect(storage.getTokens()?.access_token).toBe('at_custom'); + expect(fs.statSync(customPath).mode & 0o777).toBe(0o600); + fs.rmSync(otherDir, { recursive: true, force: true }); + }); + + it('keeps pending CLI workflow state out of the SDK token contract', () => { + const storage = new Storage({ cwd: tmpDir }); + + storage.setPendingDeviceAuth({ + device_code: 'dc_test_must_not_leak', + interval: 5, + expires_at: Date.now() + 60_000, + verification_url: 'https://login.link.com/device', + phrase: 'test-phrase', + replaces_existing_session: true, + }); + + expect(storage.getPendingDeviceAuth()?.replaces_existing_session).toBe( + true, + ); + expect(fs.statSync(storage.getPath()).mode & 0o777).toBe(0o600); + }); +}); diff --git a/packages/cli/src/auth/auth-resource.ts b/packages/cli/src/auth/auth-resource.ts index e50ac322..70fd6a05 100644 --- a/packages/cli/src/auth/auth-resource.ts +++ b/packages/cli/src/auth/auth-resource.ts @@ -1,10 +1,5 @@ import { hostname } from 'node:os'; -import { - LinkApiError, - LinkAuthorizationDeclinedError, - LinkTransportError, - type ScopeEligibility, -} from '@stripe/link-sdk'; +import { LinkApiError, LinkTransportError } from '@stripe/link-sdk'; import { buildAuthorizationDetails } from './authorization-details'; import { type AuthResourceOptions, @@ -12,6 +7,10 @@ import { requireFetchImplementation, resolveAuthResourceConfig, } from './config'; +import { + LinkAuthorizationDeclinedError, + type ScopeEligibility, +} from './errors'; import { DEFAULT_SCOPE } from './scopes'; import type { DeviceAuthRequest, diff --git a/packages/cli/src/auth/authorization-details.ts b/packages/cli/src/auth/authorization-details.ts index 9766f15f..fc997a59 100644 --- a/packages/cli/src/auth/authorization-details.ts +++ b/packages/cli/src/auth/authorization-details.ts @@ -1,5 +1,4 @@ -import type { SourceAction } from '@stripe/link-sdk'; -import type { JsonValue } from './types'; +import type { JsonValue, SourceAction } from './types'; export const INVALID_AUTHORIZATION_DETAIL_MESSAGE = 'authorization-detail must be valid JSON'; diff --git a/packages/cli/src/auth/errors.ts b/packages/cli/src/auth/errors.ts new file mode 100644 index 00000000..fa7a3c32 --- /dev/null +++ b/packages/cli/src/auth/errors.ts @@ -0,0 +1,25 @@ +import { LinkSdkError } from '@stripe/link-sdk'; + +export class LinkAuthenticationError extends LinkSdkError { + constructor(message: string, options?: { cause?: unknown }) { + super(message, { code: 'not_authenticated', ...options }); + } +} + +export interface ScopeEligibility { + eligible: boolean; + ineligibility_reasons: string[]; + description?: string; +} + +export class LinkAuthorizationDeclinedError extends LinkSdkError { + readonly scopeEligibility: Record; + + constructor(scopeEligibility: Record) { + super( + 'Authorization declined: account is not eligible for requested scopes', + { code: 'authorization_declined' }, + ); + this.scopeEligibility = scopeEligibility; + } +} diff --git a/packages/cli/src/auth/session.ts b/packages/cli/src/auth/session.ts index 266abf3b..a825ac07 100644 --- a/packages/cli/src/auth/session.ts +++ b/packages/cli/src/auth/session.ts @@ -1,10 +1,7 @@ -import { - type AccessTokenProvider, - type AuthStorage, - LinkAuthenticationError, - storage, -} from '@stripe/link-sdk'; -import type { IAuthResource } from './types'; +import type { AccessTokenProvider } from '@stripe/link-sdk'; +import { LinkAuthenticationError } from './errors'; +import { storage } from './storage'; +import type { AuthStorage, IAuthResource } from './types'; const EXPIRY_BUFFER_MS = 60_000; @@ -14,7 +11,7 @@ export function createAccessTokenProvider( options: { noRefresh?: boolean } = {}, ): AccessTokenProvider { return async ({ forceRefresh } = {}) => { - const auth = authStorage.getAuth(); + const auth = await authStorage.getTokens(); if (!auth) { throw new LinkAuthenticationError( 'Not authenticated. Run "link-cli auth login" first.', @@ -36,7 +33,7 @@ export function createAccessTokenProvider( } const refreshed = await authResource.refreshToken(auth.refresh_token); - authStorage.setAuth(refreshed); + await authStorage.setTokens(refreshed); return refreshed.access_token; }; } diff --git a/packages/sdk/src/utils/storage.ts b/packages/cli/src/auth/storage.ts similarity index 50% rename from packages/sdk/src/utils/storage.ts rename to packages/cli/src/auth/storage.ts index 6924040b..e6d42ebb 100644 --- a/packages/sdk/src/utils/storage.ts +++ b/packages/cli/src/auth/storage.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; -import type { AuthTokens } from '@/types/index'; import Conf from 'conf'; +import type { AuthStorage, AuthTokens } from './types'; export interface PendingDeviceAuth { device_code: string; @@ -9,9 +9,7 @@ export interface PendingDeviceAuth { expires_at: number; verification_url: string; phrase: string; - // Set by `auth upgrade`: this device authorization replaces an existing, - // still-valid session. The poll must complete it (and revoke the old grant - // on success) even though `isAuthenticated()` is currently true. + // `auth upgrade` keeps the old session valid until this authorization wins. replaces_existing_session?: boolean; } @@ -20,10 +18,11 @@ interface StorageSchema { pendingDeviceAuth: PendingDeviceAuth | null; } -export interface AuthStorage { - getAuth(): AuthTokens | null; - setAuth(auth: AuthTokens): void; - clearAuth(): void; +/** Storage for CLI credentials plus CLI-specific login workflow state. */ +export interface CliAuthStorage extends AuthStorage { + getTokens(): AuthTokens | null; + setTokens(tokens: AuthTokens): void; + clearTokens(): void; isAuthenticated(): boolean; getPendingDeviceAuth(): PendingDeviceAuth | null; setPendingDeviceAuth(pending: PendingDeviceAuth): void; @@ -40,25 +39,14 @@ function withComputedExpiry(auth: AuthTokens): AuthTokens { }; } -// Restricts the on-disk config to the owning user only. The file holds -// OAuth access + refresh tokens and, during the device-auth window, a -// device_code. `conf` defaults to 0o666 (masked by umask to 0o644 on most -// systems), which would let any other local user read the credentials and, -// during a pending login, race the legitimate poll loop to /device/token. -// Owner-only matches the convention used by gh, aws, and similar CLIs. const CONFIG_FILE_MODE = 0o600; export interface StorageOptions { - // Override the conf storage directory. Production callers pass nothing — - // conf resolves to the platform user-config directory. Tests pass a temp - // dir so they don't touch the real location. cwd?: string; - // Full file path for the credential file. When set, takes precedence over - // cwd. The file is split into directory + config name for conf. configPath?: string; } -export class Storage implements AuthStorage { +export class Storage implements CliAuthStorage { private config?: Conf; private readonly options: StorageOptions; @@ -71,7 +59,6 @@ export class Storage implements AuthStorage { let locationOverride: { cwd: string; configName?: string } | undefined; if (this.options.configPath) { const parsed = path.parse(path.resolve(this.options.configPath)); - // conf appends `.json` to configName, so strip it to avoid double extension const configName = parsed.ext === '.json' ? parsed.name : parsed.base; locationOverride = { cwd: parsed.dir, configName }; } else if (this.options.cwd) { @@ -92,20 +79,20 @@ export class Storage implements AuthStorage { return this.config; } - getAuth(): AuthTokens | null { + getTokens(): AuthTokens | null { return this.getConfig().get('auth'); } - setAuth(auth: AuthTokens): void { - this.getConfig().set('auth', withComputedExpiry(auth)); + setTokens(tokens: AuthTokens): void { + this.getConfig().set('auth', withComputedExpiry(tokens)); } - clearAuth(): void { + clearTokens(): void { this.getConfig().set('auth', null); } isAuthenticated(): boolean { - return this.getAuth() !== null; + return this.getTokens() !== null; } getPendingDeviceAuth(): PendingDeviceAuth | null { @@ -138,64 +125,9 @@ export class Storage implements AuthStorage { try { fs.unlinkSync(this.getPath()); } catch { - // file already gone or inaccessible — treat as success + // File already gone or inaccessible; local logout is still complete. } } } -export class MemoryStorage implements AuthStorage { - private auth: AuthTokens | null; - private pendingAuth: PendingDeviceAuth | null = null; - - constructor(initialAuth: AuthTokens | null = null) { - this.auth = initialAuth ? withComputedExpiry(initialAuth) : null; - } - - getAuth(): AuthTokens | null { - return this.auth; - } - - setAuth(auth: AuthTokens): void { - this.auth = withComputedExpiry(auth); - } - - clearAuth(): void { - this.auth = null; - } - - isAuthenticated(): boolean { - return this.auth !== null; - } - - getPendingDeviceAuth(): PendingDeviceAuth | null { - if (!this.pendingAuth) return null; - if (Date.now() >= this.pendingAuth.expires_at) { - this.pendingAuth = null; - return null; - } - return this.pendingAuth; - } - - setPendingDeviceAuth(pending: PendingDeviceAuth): void { - this.pendingAuth = pending; - } - - clearPendingDeviceAuth(): void { - this.pendingAuth = null; - } - - clearAll(): void { - this.auth = null; - this.pendingAuth = null; - } - - getPath(): string { - return 'memory'; - } - - deleteConfig(): void { - // no-op: nothing to delete in memory - } -} - export const storage = new Storage(); diff --git a/packages/cli/src/auth/types.ts b/packages/cli/src/auth/types.ts index 702f0aa2..86946543 100644 --- a/packages/cli/src/auth/types.ts +++ b/packages/cli/src/auth/types.ts @@ -1,8 +1,30 @@ -import type { - AuthTokens, - JsonValue as LinkJsonValue, - SourceAction, -} from '@stripe/link-sdk'; +import type { JsonValue as LinkJsonValue } from '@stripe/link-sdk'; + +export const SOURCE_ACTIONS = [ + 'read_balances', + 'read_external_transactions', + 'read_link_transactions', + 'read_source_details', +] as const; + +export type SourceAction = (typeof SOURCE_ACTIONS)[number]; + +export interface AuthTokens { + access_token: string; + refresh_token: string; + expires_in: number; + token_type: string; + /** Absolute epoch-ms when the access token expires. */ + expires_at?: number; + scope?: string; + authorization_details?: LinkJsonValue[]; +} + +export interface AuthStorage { + getTokens(): AuthTokens | null | Promise; + setTokens(tokens: AuthTokens): void | Promise; + clearTokens(): void | Promise; +} export interface DeviceAuthRequest { device_code: string; diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index 2cbf9aae..d33a2fd0 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -1,5 +1,5 @@ -import { type AuthStorage, Storage, storage } from '@stripe/link-sdk'; import { Cli } from 'incur'; +import { type CliAuthStorage, Storage, storage } from './auth/storage'; import { createAuthCli } from './commands/auth'; import { createBalancesCli } from './commands/balances'; import { createDemoCli } from './commands/demo'; @@ -44,7 +44,7 @@ const credentialFilePath = if (authFileIndex !== -1) { process.argv.splice(authFileIndex, 2); } -const authStorage: AuthStorage = credentialFilePath +const authStorage: CliAuthStorage = credentialFilePath ? new Storage({ configPath: credentialFilePath }) : storage; diff --git a/packages/cli/src/commands/auth/index.tsx b/packages/cli/src/commands/auth/index.tsx index 8b3c3f58..06e6799f 100644 --- a/packages/cli/src/commands/auth/index.tsx +++ b/packages/cli/src/commands/auth/index.tsx @@ -1,8 +1,3 @@ -import { - type AuthStorage, - type SourceAction, - storage as defaultStorage, -} from '@stripe/link-sdk'; import { Cli } from 'incur'; import { Text } from 'ink'; import React from 'react'; @@ -12,7 +7,11 @@ import { } from '../../auth/authorization-details'; import { computeMergedAccess } from '../../auth/merge-access'; import { normalizeScopeInput } from '../../auth/scopes'; -import type { IAuthResource, JsonValue } from '../../auth/types'; +import { + type CliAuthStorage, + storage as defaultStorage, +} from '../../auth/storage'; +import type { IAuthResource, JsonValue, SourceAction } from '../../auth/types'; import { pollUntil } from '../../utils/poll-until'; import { renderInteractive } from '../../utils/render-interactive'; import { sanitizeDeep } from '../../utils/sanitize-text'; @@ -31,7 +30,7 @@ interface PollAuthOptions { async function* pollAuthStatus( authResource: IAuthResource, - storage: AuthStorage, + storage: CliAuthStorage, opts: PollAuthOptions, update?: { current_version: string; @@ -48,10 +47,10 @@ async function* pollAuthStatus( // and do NOT report the old session as done until the new tokens land. // On success, swap in the new tokens and revoke the old grant. if (pending?.replaces_existing_session) { - const previousRefreshToken = storage.getAuth()?.refresh_token; + const previousRefreshToken = storage.getTokens()?.refresh_token; const tokens = await authResource.pollDeviceAuth(pending.device_code); if (tokens) { - storage.setAuth(tokens); + storage.setTokens(tokens); storage.clearPendingDeviceAuth(); if (previousRefreshToken) { try { @@ -85,12 +84,12 @@ async function* pollAuthStatus( if (pending && !storage.isAuthenticated()) { const tokens = await authResource.pollDeviceAuth(pending.device_code); if (tokens) { - storage.setAuth(tokens); + storage.setTokens(tokens); storage.clearPendingDeviceAuth(); } } - const auth = storage.getAuth(); + const auth = storage.getTokens(); if (auth) { return { authenticated: true as const, @@ -130,9 +129,9 @@ async function* pollAuthStatus( async function maybeRevokeAndClearAuth( authResource: IAuthResource, - storage: AuthStorage, + storage: CliAuthStorage, ) { - const auth = storage.getAuth(); + const auth = storage.getTokens(); if (auth?.refresh_token) { try { await authResource.revokeToken(auth.refresh_token); @@ -140,7 +139,7 @@ async function maybeRevokeAndClearAuth( // best-effort: clear local storage regardless } } - storage.clearAuth(); + storage.clearTokens(); storage.clearPendingDeviceAuth(); } @@ -159,7 +158,7 @@ interface DeviceAuthParams { // optional `warning` is attached to the first yield for degraded-mode callers. async function* startDeviceAuthAndPoll( authResource: IAuthResource, - storage: AuthStorage, + storage: CliAuthStorage, params: DeviceAuthParams, opts: PollAuthOptions, warning?: string, @@ -213,7 +212,7 @@ async function* startDeviceAuthAndPoll( export function createAuthCli( authResource: IAuthResource, getUpdateInfo?: UpdateInfoProvider, - authStorage?: AuthStorage, + authStorage?: CliAuthStorage, envAccessToken?: string, ) { const storage = authStorage ?? defaultStorage; @@ -252,13 +251,13 @@ export function createAuthCli( }); } - const existingAuth = storage.getAuth(); + const existingAuth = storage.getTokens(); if (existingAuth?.refresh_token) { try { const refreshed = await authResource.refreshToken( existingAuth.refresh_token, ); - storage.setAuth(refreshed); + storage.setTokens(refreshed); const alreadyLoggedInMessage = 'You are already logged in. To switch accounts, run `link-cli auth logout` first.'; const alreadyLoggedIn = sanitizeDeep({ @@ -358,7 +357,7 @@ export function createAuthCli( let previousRefreshToken: string | undefined; let warning: string | undefined; - const existingAuth = storage.getAuth(); + const existingAuth = storage.getTokens(); if (existingAuth?.refresh_token) { try { const refreshed = await authResource.refreshToken( @@ -366,7 +365,7 @@ export function createAuthCli( ); // Persist the rotated tokens so the session stays valid throughout // the pending approval (and if initiateDeviceAuth below fails). - storage.setAuth(refreshed); + storage.setTokens(refreshed); previousRefreshToken = refreshed.refresh_token; const merged = computeMergedAccess({ requestedScope, @@ -382,7 +381,7 @@ export function createAuthCli( // Existing token is no longer valid — warn and continue with only the // requested access (per spec, upgrade never hard-fails on this). // Clear the dead session so the poll isn't short-circuited by it. - storage.clearAuth(); + storage.clearTokens(); storage.clearPendingDeviceAuth(); warning = 'could not refresh the existing session; continuing with only the requested access.'; diff --git a/packages/cli/src/commands/auth/login.tsx b/packages/cli/src/commands/auth/login.tsx index 329570c0..29a21259 100644 --- a/packages/cli/src/commands/auth/login.tsx +++ b/packages/cli/src/commands/auth/login.tsx @@ -1,15 +1,16 @@ -import { - type AuthStorage, - LinkAuthorizationDeclinedError, - type ScopeEligibility, - type SourceAction, - storage as defaultStorage, -} from '@stripe/link-sdk'; import { Box, Text, useInput } from 'ink'; import Spinner from 'ink-spinner'; import type React from 'react'; import { useEffect, useState } from 'react'; -import type { IAuthResource, JsonValue } from '../../auth/types'; +import { + LinkAuthorizationDeclinedError, + type ScopeEligibility, +} from '../../auth/errors'; +import { + type CliAuthStorage, + storage as defaultStorage, +} from '../../auth/storage'; +import type { IAuthResource, JsonValue, SourceAction } from '../../auth/types'; import { DISPLAY_DELAY_MS } from '../../utils/constants'; import { openUrl } from '../../utils/open-url'; @@ -19,7 +20,7 @@ interface LoginProps { scope?: string; sourceActions?: SourceAction[]; authorizationDetails?: JsonValue[]; - authStorage?: AuthStorage; + authStorage?: CliAuthStorage; // Set by `auth upgrade`: the still-valid refresh token of the session being // replaced. Revoked (best-effort) only after the new tokens are stored, so an // abandoned upgrade leaves the existing session intact. `login` omits it. @@ -92,7 +93,7 @@ export const Login: React.FC = ({ if (tokens) { clearInterval(pollInterval); - storage.setAuth(tokens); + storage.setTokens(tokens); // Upgrade only: revoke the replaced session's grant now that the // widened tokens are stored. Best-effort — a failure here must not // fail the login that just succeeded. diff --git a/packages/cli/src/commands/auth/logout.tsx b/packages/cli/src/commands/auth/logout.tsx index 3597723f..5ccaead3 100644 --- a/packages/cli/src/commands/auth/logout.tsx +++ b/packages/cli/src/commands/auth/logout.tsx @@ -1,14 +1,17 @@ -import { type AuthStorage, storage as defaultStorage } from '@stripe/link-sdk'; import { Box, Text } from 'ink'; import Spinner from 'ink-spinner'; import type React from 'react'; import { useCallback } from 'react'; +import { + type CliAuthStorage, + storage as defaultStorage, +} from '../../auth/storage'; import type { IAuthResource } from '../../auth/types'; import { useAsyncAction } from '../../hooks/use-async-action'; interface LogoutProps { authResource: IAuthResource; - authStorage?: AuthStorage; + authStorage?: CliAuthStorage; onComplete: () => void; } @@ -20,7 +23,7 @@ export const Logout: React.FC = ({ const storage = authStorage; const action = useCallback(async () => { - const auth = storage.getAuth(); + const auth = storage.getTokens(); if (auth?.refresh_token) { try { await authResource.revokeToken(auth.refresh_token); @@ -28,7 +31,7 @@ export const Logout: React.FC = ({ // best-effort: clear local storage regardless } } - storage.clearAuth(); + storage.clearTokens(); storage.deleteConfig(); }, [authResource, storage]); diff --git a/packages/cli/src/commands/auth/schema.ts b/packages/cli/src/commands/auth/schema.ts index 45b59666..a4e5f17c 100644 --- a/packages/cli/src/commands/auth/schema.ts +++ b/packages/cli/src/commands/auth/schema.ts @@ -1,5 +1,5 @@ -import { SOURCE_ACTIONS } from '@stripe/link-sdk'; import { z } from 'incur'; +import { SOURCE_ACTIONS } from '../../auth/types'; const SOURCE_ACTIONS_DESCRIPTION = SOURCE_ACTIONS.join(', '); diff --git a/packages/cli/src/commands/auth/status.tsx b/packages/cli/src/commands/auth/status.tsx index c375b6c2..8c0bc23f 100644 --- a/packages/cli/src/commands/auth/status.tsx +++ b/packages/cli/src/commands/auth/status.tsx @@ -1,12 +1,15 @@ -import { type AuthStorage, storage as defaultStorage } from '@stripe/link-sdk'; import { Box, Text } from 'ink'; import type React from 'react'; import { useEffect, useState } from 'react'; +import { + type CliAuthStorage, + storage as defaultStorage, +} from '../../auth/storage'; import { DISPLAY_DELAY_MS } from '../../utils/constants'; import { resolveAuthInfo } from './utils'; interface AuthStatusProps { - authStorage?: AuthStorage; + authStorage?: CliAuthStorage; envAccessToken?: string; onComplete: () => void; } diff --git a/packages/cli/src/commands/auth/utils.ts b/packages/cli/src/commands/auth/utils.ts index 3c780af4..a635b367 100644 --- a/packages/cli/src/commands/auth/utils.ts +++ b/packages/cli/src/commands/auth/utils.ts @@ -1,4 +1,5 @@ -import type { AuthStorage, JsonValue } from '@stripe/link-sdk'; +import type { JsonValue } from '@stripe/link-sdk'; +import type { CliAuthStorage } from '../../auth/storage'; export type AuthInfo = | { @@ -20,7 +21,7 @@ export type AuthInfo = export function resolveAuthInfo( envAccessToken: string | undefined, - authStorage: AuthStorage, + authStorage: CliAuthStorage, ): AuthInfo { if (envAccessToken) { return { @@ -30,7 +31,7 @@ export function resolveAuthInfo( tokenType: 'Bearer', }; } - const auth = authStorage.getAuth(); + const auth = authStorage.getTokens(); const credentialsPath = authStorage.getPath(); if (auth) { return { diff --git a/packages/cli/src/commands/balances/index.tsx b/packages/cli/src/commands/balances/index.tsx index 9f95af7f..91bffa2e 100644 --- a/packages/cli/src/commands/balances/index.tsx +++ b/packages/cli/src/commands/balances/index.tsx @@ -1,10 +1,7 @@ -import type { - AuthStorage, - IBalancesResource, - ListBalancesParams, -} from '@stripe/link-sdk'; +import type { IBalancesResource, ListBalancesParams } from '@stripe/link-sdk'; import { Cli } from 'incur'; import React from 'react'; +import type { CliAuthStorage } from '../../auth/storage'; import { renderInteractive } from '../../utils/render-interactive'; import { requireAuth } from '../../utils/require-auth'; import { BalancesList } from './list'; @@ -12,7 +9,7 @@ import { listOptions } from './schema'; export function createBalancesCli( createResource: () => IBalancesResource, - authStorage?: AuthStorage, + authStorage?: CliAuthStorage, envAccessToken?: string, ) { const cli = Cli.create('balances', { diff --git a/packages/cli/src/commands/demo/demo-runner.tsx b/packages/cli/src/commands/demo/demo-runner.tsx index f9c2ed5d..83ce9371 100644 --- a/packages/cli/src/commands/demo/demo-runner.tsx +++ b/packages/cli/src/commands/demo/demo-runner.tsx @@ -1,12 +1,14 @@ import type { - AuthStorage, IPaymentMethodsResource, ISpendRequestResource, } from '@stripe/link-sdk'; -import { storage as defaultStorage } from '@stripe/link-sdk'; import { Box, Text, useApp, useInput } from 'ink'; import type React from 'react'; import { useCallback, useState } from 'react'; +import { + type CliAuthStorage, + storage as defaultStorage, +} from '../../auth/storage'; import type { IAuthResource } from '../../auth/types'; import { DISPLAY_DELAY_MS } from '../../utils/constants'; import { MarkdownText } from '../../utils/markdown-text'; @@ -29,7 +31,7 @@ interface DemoRunnerProps { authRepo: IAuthResource; spendRequestRepo: ISpendRequestResource; paymentMethodsResource: IPaymentMethodsResource; - authStorage?: AuthStorage; + authStorage?: CliAuthStorage; paymentMethodId?: string; onlyCard?: boolean; onlySpt?: boolean; diff --git a/packages/cli/src/commands/demo/index.tsx b/packages/cli/src/commands/demo/index.tsx index 4900f4c4..4e4fbd9a 100644 --- a/packages/cli/src/commands/demo/index.tsx +++ b/packages/cli/src/commands/demo/index.tsx @@ -1,10 +1,10 @@ import type { - AuthStorage, IPaymentMethodsResource, ISpendRequestResource, } from '@stripe/link-sdk'; import { Cli, z } from 'incur'; import React from 'react'; +import type { CliAuthStorage } from '../../auth/storage'; import type { IAuthResource } from '../../auth/types'; import { renderInteractive } from '../../utils/render-interactive'; import { DemoRunner } from './demo-runner'; @@ -24,7 +24,7 @@ export function createDemoCli( authRepo: IAuthResource, spendRequestRepo: ISpendRequestResource, createPaymentMethodsResource: () => IPaymentMethodsResource, - authStorage?: AuthStorage, + authStorage?: CliAuthStorage, ) { return Cli.create('demo', { description: diff --git a/packages/cli/src/commands/mpp/index.tsx b/packages/cli/src/commands/mpp/index.tsx index 7959efcb..72488bd0 100644 --- a/packages/cli/src/commands/mpp/index.tsx +++ b/packages/cli/src/commands/mpp/index.tsx @@ -1,10 +1,10 @@ import type { - AuthStorage, IPaymentMethodsResource, ISpendRequestResource, } from '@stripe/link-sdk'; import { Cli, z } from 'incur'; import React from 'react'; +import type { CliAuthStorage } from '../../auth/storage'; import { renderInteractive } from '../../utils/render-interactive'; import { requireAuth } from '../../utils/require-auth'; import { decodeStripeChallenge } from './decode'; @@ -22,7 +22,7 @@ import { decodeOptions, payOptions } from './schema'; export function createMppCli( repository: ISpendRequestResource, paymentMethodsFactory: () => IPaymentMethodsResource, - authStorage?: AuthStorage, + authStorage?: CliAuthStorage, envAccessToken?: string, ) { const cli = Cli.create('mpp', { diff --git a/packages/cli/src/commands/onboard/index.tsx b/packages/cli/src/commands/onboard/index.tsx index bebb87fd..810ee1bd 100644 --- a/packages/cli/src/commands/onboard/index.tsx +++ b/packages/cli/src/commands/onboard/index.tsx @@ -1,10 +1,10 @@ import type { - AuthStorage, IPaymentMethodsResource, ISpendRequestResource, } from '@stripe/link-sdk'; import { Cli } from 'incur'; import React from 'react'; +import type { CliAuthStorage } from '../../auth/storage'; import type { IAuthResource } from '../../auth/types'; import { renderInteractive } from '../../utils/render-interactive'; import { OnboardRunner } from './onboard-runner'; @@ -13,7 +13,7 @@ export function createOnboardCli( authRepo: IAuthResource, spendRequestRepo: ISpendRequestResource, createPaymentMethodsResource: () => IPaymentMethodsResource, - authStorage?: AuthStorage, + authStorage?: CliAuthStorage, ) { return Cli.create('onboard', { description: diff --git a/packages/cli/src/commands/onboard/onboard-runner.tsx b/packages/cli/src/commands/onboard/onboard-runner.tsx index 89a60a8d..05869844 100644 --- a/packages/cli/src/commands/onboard/onboard-runner.tsx +++ b/packages/cli/src/commands/onboard/onboard-runner.tsx @@ -1,12 +1,14 @@ import type { - AuthStorage, IPaymentMethodsResource, ISpendRequestResource, } from '@stripe/link-sdk'; -import { storage as defaultStorage } from '@stripe/link-sdk'; import { Box, Text, useApp, useInput } from 'ink'; import type React from 'react'; import { useEffect, useRef, useState } from 'react'; +import { + type CliAuthStorage, + storage as defaultStorage, +} from '../../auth/storage'; import type { IAuthResource } from '../../auth/types'; import { Login } from '../auth/login'; import { ONBOARD as O } from '../demo/content'; @@ -18,7 +20,7 @@ interface OnboardRunnerProps { authRepo: IAuthResource; spendRequestRepo: ISpendRequestResource; paymentMethodsResource: IPaymentMethodsResource; - authStorage?: AuthStorage; + authStorage?: CliAuthStorage; onComplete: () => void; } diff --git a/packages/cli/src/commands/payment-methods/index.tsx b/packages/cli/src/commands/payment-methods/index.tsx index d1d9769c..375b4213 100644 --- a/packages/cli/src/commands/payment-methods/index.tsx +++ b/packages/cli/src/commands/payment-methods/index.tsx @@ -1,6 +1,7 @@ -import type { AuthStorage, IPaymentMethodsResource } from '@stripe/link-sdk'; +import type { IPaymentMethodsResource } from '@stripe/link-sdk'; import { Cli } from 'incur'; import React from 'react'; +import type { CliAuthStorage } from '../../auth/storage'; import { renderInteractive } from '../../utils/render-interactive'; import { requireAuth } from '../../utils/require-auth'; import { AddPaymentMethod, WALLET_URL } from './add'; @@ -8,7 +9,7 @@ import { PaymentMethodsList } from './list'; export function createPaymentMethodsCli( createResource: () => IPaymentMethodsResource, - authStorage?: AuthStorage, + authStorage?: CliAuthStorage, envAccessToken?: string, ) { const cli = Cli.create('payment-methods', { diff --git a/packages/cli/src/commands/report/index.tsx b/packages/cli/src/commands/report/index.tsx index 3aac3757..ff8bf8d7 100644 --- a/packages/cli/src/commands/report/index.tsx +++ b/packages/cli/src/commands/report/index.tsx @@ -1,11 +1,12 @@ -import type { AuthStorage, IReportResource } from '@stripe/link-sdk'; +import type { IReportResource } from '@stripe/link-sdk'; import { Cli } from 'incur'; +import type { CliAuthStorage } from '../../auth/storage'; import { requireAuthGuard } from '../../utils/require-auth'; import { reportOptions } from './schema'; export function createReportCli( createResource: () => IReportResource, - authStorage?: AuthStorage, + authStorage?: CliAuthStorage, envAccessToken?: string, ) { const cli = Cli.create('report', { diff --git a/packages/cli/src/commands/shipping-address/index.tsx b/packages/cli/src/commands/shipping-address/index.tsx index 501ca88d..d4c5bfa3 100644 --- a/packages/cli/src/commands/shipping-address/index.tsx +++ b/packages/cli/src/commands/shipping-address/index.tsx @@ -1,13 +1,14 @@ -import type { AuthStorage, IShippingAddressResource } from '@stripe/link-sdk'; +import type { IShippingAddressResource } from '@stripe/link-sdk'; import { Cli } from 'incur'; import React from 'react'; +import type { CliAuthStorage } from '../../auth/storage'; import { renderInteractive } from '../../utils/render-interactive'; import { requireAuth } from '../../utils/require-auth'; import { ShippingAddressList } from './list'; export function createShippingAddressCli( createResource: () => IShippingAddressResource, - authStorage?: AuthStorage, + authStorage?: CliAuthStorage, envAccessToken?: string, ) { const cli = Cli.create('shipping-address', { diff --git a/packages/cli/src/commands/sources/index.tsx b/packages/cli/src/commands/sources/index.tsx index 600cbad0..94ac8d8b 100644 --- a/packages/cli/src/commands/sources/index.tsx +++ b/packages/cli/src/commands/sources/index.tsx @@ -1,10 +1,7 @@ -import type { - AuthStorage, - ISourcesResource, - ListSourcesParams, -} from '@stripe/link-sdk'; +import type { ISourcesResource, ListSourcesParams } from '@stripe/link-sdk'; import { Cli } from 'incur'; import React from 'react'; +import type { CliAuthStorage } from '../../auth/storage'; import { renderInteractive } from '../../utils/render-interactive'; import { requireAuth } from '../../utils/require-auth'; import { SourcesList } from './list'; @@ -12,7 +9,7 @@ import { listOptions } from './schema'; export function createSourcesCli( createResource: () => ISourcesResource, - authStorage?: AuthStorage, + authStorage?: CliAuthStorage, envAccessToken?: string, ) { const cli = Cli.create('sources', { diff --git a/packages/cli/src/commands/spend-request/index.tsx b/packages/cli/src/commands/spend-request/index.tsx index 0c7f6ed8..4766cc9c 100644 --- a/packages/cli/src/commands/spend-request/index.tsx +++ b/packages/cli/src/commands/spend-request/index.tsx @@ -1,6 +1,5 @@ import { LinkApiError, getDuplicateSpendRequest } from '@stripe/link-sdk'; import type { - AuthStorage, CredentialType, ISpendRequestResource, LineItem, @@ -9,6 +8,7 @@ import type { } from '@stripe/link-sdk'; import { Cli, z } from 'incur'; import React from 'react'; +import type { CliAuthStorage } from '../../auth/storage'; import { writeCredentialFile } from '../../utils/credential-output'; import { parseKvString, @@ -74,7 +74,7 @@ async function applyOutputFile( export function createSpendRequestCli( repository: ISpendRequestResource, - authStorage?: AuthStorage, + authStorage?: CliAuthStorage, envAccessToken?: string, ) { const cli = Cli.create('spend-request', { diff --git a/packages/cli/src/commands/transactions/index.tsx b/packages/cli/src/commands/transactions/index.tsx index 73654d79..7bf4bf19 100644 --- a/packages/cli/src/commands/transactions/index.tsx +++ b/packages/cli/src/commands/transactions/index.tsx @@ -1,10 +1,10 @@ import type { - AuthStorage, ITransactionsResource, ListTransactionsParams, } from '@stripe/link-sdk'; import { Cli } from 'incur'; import React from 'react'; +import type { CliAuthStorage } from '../../auth/storage'; import { renderInteractive } from '../../utils/render-interactive'; import { requireAuth } from '../../utils/require-auth'; import { TransactionsList } from './list'; @@ -12,7 +12,7 @@ import { listOptions } from './schema'; export function createTransactionsCli( createResource: () => ITransactionsResource, - authStorage?: AuthStorage, + authStorage?: CliAuthStorage, envAccessToken?: string, ) { const cli = Cli.create('transactions', { diff --git a/packages/cli/src/commands/user-info/index.tsx b/packages/cli/src/commands/user-info/index.tsx index b4ab4886..b3dbf869 100644 --- a/packages/cli/src/commands/user-info/index.tsx +++ b/packages/cli/src/commands/user-info/index.tsx @@ -1,13 +1,14 @@ -import type { AuthStorage, IUserInfoResource } from '@stripe/link-sdk'; +import type { IUserInfoResource } from '@stripe/link-sdk'; import { Cli } from 'incur'; import React from 'react'; +import type { CliAuthStorage } from '../../auth/storage'; import { renderInteractive } from '../../utils/render-interactive'; import { requireAuth } from '../../utils/require-auth'; import { UserInfoRetrieve } from './retrieve'; export function createUserInfoCli( createResource: () => IUserInfoResource, - authStorage?: AuthStorage, + authStorage?: CliAuthStorage, envAccessToken?: string, ) { const cli = Cli.create('user-info', { diff --git a/packages/cli/src/commands/web-bot-auth/index.tsx b/packages/cli/src/commands/web-bot-auth/index.tsx index abe328fe..95351538 100644 --- a/packages/cli/src/commands/web-bot-auth/index.tsx +++ b/packages/cli/src/commands/web-bot-auth/index.tsx @@ -1,17 +1,14 @@ -import type { - AuthStorage, - IWebBotAuthResource, - WebBotAuthBlock, -} from '@stripe/link-sdk'; +import type { IWebBotAuthResource, WebBotAuthBlock } from '@stripe/link-sdk'; import { Cli, z } from 'incur'; import React from 'react'; +import type { CliAuthStorage } from '../../auth/storage'; import { renderInteractive } from '../../utils/render-interactive'; import { requireAuth } from '../../utils/require-auth'; import { WebBotAuthSign } from './sign'; export function createWebBotAuthCli( createResource: () => IWebBotAuthResource, - authStorage?: AuthStorage, + authStorage?: CliAuthStorage, ) { const cli = Cli.create('web-bot-auth', { description: 'Web Bot Auth commands for Cloudflare/Vercel bot verification', diff --git a/packages/cli/src/utils/__tests__/require-auth.test.ts b/packages/cli/src/utils/__tests__/require-auth.test.ts index 758657f1..55549e3b 100644 --- a/packages/cli/src/utils/__tests__/require-auth.test.ts +++ b/packages/cli/src/utils/__tests__/require-auth.test.ts @@ -1,16 +1,16 @@ -import type { AuthStorage } from '@stripe/link-sdk'; import { describe, expect, it, vi } from 'vitest'; +import type { CliAuthStorage } from '../../auth/storage'; import { requireAuth, requireAuthGuard } from '../require-auth'; -function makeStorage(authenticated: boolean): AuthStorage { +function makeStorage(authenticated: boolean): CliAuthStorage { return { isAuthenticated: vi.fn(() => authenticated), - getAuth: vi.fn(() => null), - setAuth: vi.fn(), - clearAuth: vi.fn(), + getTokens: vi.fn(() => null), + setTokens: vi.fn(), + clearTokens: vi.fn(), clearAll: vi.fn(), getPath: vi.fn(() => '/tmp/fake'), - } as unknown as AuthStorage; + } as unknown as CliAuthStorage; } function makeContext() { diff --git a/packages/cli/src/utils/__tests__/resource-factory.test.ts b/packages/cli/src/utils/__tests__/resource-factory.test.ts index acab355c..5649a90e 100644 --- a/packages/cli/src/utils/__tests__/resource-factory.test.ts +++ b/packages/cli/src/utils/__tests__/resource-factory.test.ts @@ -1,12 +1,6 @@ -import { - BalancesResource, - LinkAuthenticationError, - PaymentMethodsResource, - SpendRequestResource, - WebBotAuthResource, -} from '@stripe/link-sdk'; import { describe, expect, it, vi } from 'vitest'; import { LinkAuthResource } from '../../auth/auth-resource'; +import { LinkAuthenticationError } from '../../auth/errors'; import type { IAuthResource } from '../../auth/types'; import { ResourceFactory } from '../resource-factory'; @@ -44,16 +38,10 @@ describe('ResourceFactory', () => { factory.createWebBotAuthResource(), ); expect(factory.createAuthResource()).toBeInstanceOf(LinkAuthResource); - expect(factory.createSpendRequestResource()).toBeInstanceOf( - SpendRequestResource, - ); - expect(factory.createPaymentMethodsResource()).toBeInstanceOf( - PaymentMethodsResource, - ); - expect(factory.createBalancesResource()).toBeInstanceOf(BalancesResource); - expect(factory.createWebBotAuthResource()).toBeInstanceOf( - WebBotAuthResource, - ); + expect(factory.createSpendRequestResource().create).toBeTypeOf('function'); + expect(factory.createPaymentMethodsResource().list).toBeTypeOf('function'); + expect(factory.createBalancesResource().list).toBeTypeOf('function'); + expect(factory.createWebBotAuthResource().signUrl).toBeTypeOf('function'); }); describe('env-based token provider', () => { diff --git a/packages/cli/src/utils/require-auth.ts b/packages/cli/src/utils/require-auth.ts index 6ff39a93..2e729e2b 100644 --- a/packages/cli/src/utils/require-auth.ts +++ b/packages/cli/src/utils/require-auth.ts @@ -1,6 +1,8 @@ -import type { AuthStorage } from '@stripe/link-sdk'; -import { storage as defaultStorage } from '@stripe/link-sdk'; import type { MiddlewareHandler } from 'incur'; +import { + type CliAuthStorage, + storage as defaultStorage, +} from '../auth/storage'; interface AuthErrorOptions { code: string; @@ -17,7 +19,7 @@ export const NOT_AUTHENTICATED_ERROR: AuthErrorOptions = { }; export function requireAuth( - authStorage?: AuthStorage, + authStorage?: CliAuthStorage, envAccessToken?: string, ): MiddlewareHandler { const store = authStorage ?? defaultStorage; @@ -31,7 +33,7 @@ export function requireAuth( export function requireAuthGuard( c: { error: (err: AuthErrorOptions) => never }, - authStorage?: AuthStorage, + authStorage?: CliAuthStorage, envAccessToken?: string, ) { const store = authStorage ?? defaultStorage; diff --git a/packages/cli/src/utils/resource-factory.ts b/packages/cli/src/utils/resource-factory.ts index 107c35f7..86b23349 100644 --- a/packages/cli/src/utils/resource-factory.ts +++ b/packages/cli/src/utils/resource-factory.ts @@ -1,6 +1,5 @@ import { - type AuthStorage, - BalancesResource, + type AccessTokenProvider, type IBalancesResource, type IPaymentMethodsResource, type IReportResource, @@ -10,18 +9,14 @@ import { type ITransactionsResource, type IUserInfoResource, type IWebBotAuthResource, - LinkAuthenticationError, - PaymentMethodsResource, - ReportResource, - ShippingAddressResource, - SourcesResource, - SpendRequestResource, - TransactionsResource, - UserInfoResource, - WebBotAuthResource, + default as Link, + LinkConfigurationError, + type LinkOptions, } from '@stripe/link-sdk'; import { LinkAuthResource } from '../auth/auth-resource'; +import { LinkAuthenticationError } from '../auth/errors'; import { createAccessTokenProvider } from '../auth/session'; +import type { CliAuthStorage } from '../auth/storage'; import type { IAuthResource } from '../auth/types'; import { sanitizeDeep } from './sanitize-text'; @@ -65,22 +60,54 @@ export function sanitizeResource(resource: T): T { interface ResourceFactoryOptions { verbose?: boolean; defaultHeaders?: Record; - authStorage?: AuthStorage; + authStorage?: CliAuthStorage; envAccessToken?: string; envRefreshToken?: string; noRefresh?: boolean; authResource?: IAuthResource; + apiBaseUrl?: string; + spendRequestBaseUrl?: string; + fetch?: typeof globalThis.fetch; +} + +function createProxyFetch( + baseFetch: typeof globalThis.fetch, + proxyUrl: string, +): typeof globalThis.fetch { + let dispatcherPromise: Promise | null = null; + return ((input: RequestInfo | URL, init?: RequestInit) => { + const moduleName = 'undici'; + dispatcherPromise ??= ( + import(moduleName) as Promise<{ + ProxyAgent: new (url: string) => unknown; + }> + ) + .then(({ ProxyAgent }) => new ProxyAgent(proxyUrl)) + .catch((error) => { + throw new LinkConfigurationError( + 'LINK_HTTP_PROXY requires the "undici" package. Install it with: npm install undici', + { cause: error }, + ); + }); + return dispatcherPromise.then((dispatcher) => + baseFetch(input, { ...init, dispatcher } as RequestInit), + ); + }) as typeof globalThis.fetch; } export class ResourceFactory { private readonly verbose: boolean; private readonly defaultHeaders?: Record; - private readonly authStorage?: AuthStorage; + private readonly authStorage?: CliAuthStorage; private readonly envAccessToken?: string; private readonly envRefreshToken?: string; private readonly noRefresh: boolean; + private readonly apiBaseUrl?: string; + private readonly spendRequestBaseUrl?: string; + private readonly fetch?: typeof globalThis.fetch; private _authResource?: IAuthResource; private accessTokenProvider?: ReturnType; + private sdkClient?: Link; private spendRequestResource?: ISpendRequestResource; private paymentMethodsResource?: IPaymentMethodsResource; private shippingAddressResource?: IShippingAddressResource; @@ -98,9 +125,35 @@ export class ResourceFactory { this.envAccessToken = options.envAccessToken; this.envRefreshToken = options.envRefreshToken; this.noRefresh = options.noRefresh ?? false; + this.apiBaseUrl = options.apiBaseUrl ?? process.env.LINK_API_BASE_URL; + this.spendRequestBaseUrl = options.spendRequestBaseUrl ?? this.apiBaseUrl; + const proxyUrl = process.env.LINK_HTTP_PROXY; + this.fetch = + options.fetch ?? + (proxyUrl ? createProxyFetch(globalThis.fetch, proxyUrl) : undefined); this._authResource = options.authResource; } + private createSdkOptions(getAccessToken: AccessTokenProvider): LinkOptions { + return { + verbose: this.verbose, + defaultHeaders: this.defaultHeaders, + getAccessToken, + apiBaseUrl: this.apiBaseUrl, + spendRequestBaseUrl: this.spendRequestBaseUrl, + fetch: this.fetch, + logger: this.verbose + ? { + debug(message: string) { + process.stderr.write( + message.endsWith('\n') ? message : `${message}\n`, + ); + }, + } + : undefined, + }; + } + createAuthResource(): IAuthResource { if (this._authResource) { return this._authResource; @@ -116,7 +169,7 @@ export class ResourceFactory { return this._authResource; } - getAuthStorage(): AuthStorage | undefined { + getAuthStorage(): CliAuthStorage | undefined { return this.authStorage; } @@ -158,18 +211,22 @@ export class ResourceFactory { return this.accessTokenProvider; } + private createSdkClient(): Link { + if (!this.sdkClient) { + this.sdkClient = new Link( + this.createSdkOptions(this.createSdkAccessTokenProvider()), + ); + } + return this.sdkClient; + } + createSpendRequestResource(): ISpendRequestResource { if (this.spendRequestResource) { return this.spendRequestResource; } - const getAccessToken = this.createSdkAccessTokenProvider(); this.spendRequestResource = sanitizeResource( - new SpendRequestResource({ - verbose: this.verbose, - defaultHeaders: this.defaultHeaders, - getAccessToken, - }), + this.createSdkClient().spendRequests, ); return this.spendRequestResource; @@ -180,13 +237,8 @@ export class ResourceFactory { return this.paymentMethodsResource; } - const getAccessToken = this.createSdkAccessTokenProvider(); this.paymentMethodsResource = sanitizeResource( - new PaymentMethodsResource({ - verbose: this.verbose, - defaultHeaders: this.defaultHeaders, - getAccessToken, - }), + this.createSdkClient().paymentMethods, ); return this.paymentMethodsResource; @@ -197,13 +249,8 @@ export class ResourceFactory { return this.shippingAddressResource; } - const getAccessToken = this.createSdkAccessTokenProvider(); this.shippingAddressResource = sanitizeResource( - new ShippingAddressResource({ - verbose: this.verbose, - defaultHeaders: this.defaultHeaders, - getAccessToken, - }), + this.createSdkClient().shippingAddresses, ); return this.shippingAddressResource; @@ -214,14 +261,7 @@ export class ResourceFactory { return this.userInfoResource; } - const getAccessToken = this.createSdkAccessTokenProvider(); - this.userInfoResource = sanitizeResource( - new UserInfoResource({ - verbose: this.verbose, - defaultHeaders: this.defaultHeaders, - getAccessToken, - }), - ); + this.userInfoResource = sanitizeResource(this.createSdkClient().userInfo); return this.userInfoResource; } @@ -231,13 +271,8 @@ export class ResourceFactory { return this.transactionsResource; } - const getAccessToken = this.createSdkAccessTokenProvider(); this.transactionsResource = sanitizeResource( - new TransactionsResource({ - verbose: this.verbose, - defaultHeaders: this.defaultHeaders, - getAccessToken, - }), + this.createSdkClient().transactions, ); return this.transactionsResource; @@ -248,14 +283,7 @@ export class ResourceFactory { return this.sourcesResource; } - const getAccessToken = this.createSdkAccessTokenProvider(); - this.sourcesResource = sanitizeResource( - new SourcesResource({ - verbose: this.verbose, - defaultHeaders: this.defaultHeaders, - getAccessToken, - }), - ); + this.sourcesResource = sanitizeResource(this.createSdkClient().sources); return this.sourcesResource; } @@ -265,14 +293,7 @@ export class ResourceFactory { return this.balancesResource; } - const getAccessToken = this.createSdkAccessTokenProvider(); - this.balancesResource = sanitizeResource( - new BalancesResource({ - verbose: this.verbose, - defaultHeaders: this.defaultHeaders, - getAccessToken, - }), - ); + this.balancesResource = sanitizeResource(this.createSdkClient().balances); return this.balancesResource; } @@ -282,13 +303,8 @@ export class ResourceFactory { return this.webBotAuthResource; } - const getAccessToken = this.createSdkAccessTokenProvider(); this.webBotAuthResource = sanitizeResource( - new WebBotAuthResource({ - verbose: this.verbose, - defaultHeaders: this.defaultHeaders, - getAccessToken, - }), + this.createSdkClient().webBotAuth, ); return this.webBotAuthResource; @@ -299,14 +315,7 @@ export class ResourceFactory { return this.reportResource; } - const getAccessToken = this.createSdkAccessTokenProvider(); - this.reportResource = sanitizeResource( - new ReportResource({ - verbose: this.verbose, - defaultHeaders: this.defaultHeaders, - getAccessToken, - }), - ); + this.reportResource = sanitizeResource(this.createSdkClient().reports); return this.reportResource; } diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 6f06e961..f0d8b64f 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -7,8 +7,8 @@ "types": "./dist/index.d.ts", "exports": { ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" + "types": "./dist/index.d.ts", + "import": "./dist/index.js" } }, "scripts": { @@ -17,7 +17,6 @@ "test": "vitest run" }, "dependencies": { - "conf": "^15.1.0", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/sdk/src/__tests__/config.test.ts b/packages/sdk/src/__tests__/config.test.ts index 029ffcae..3eed4b55 100644 --- a/packages/sdk/src/__tests__/config.test.ts +++ b/packages/sdk/src/__tests__/config.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest'; function captureHeaders( fetchSpy: ReturnType, ): Headers | Record { - const [, init] = fetchSpy.mock.calls[0] as [unknown, RequestInit]; + const [, init] = fetchSpy.mock.calls[0]! as [unknown, RequestInit]; return init.headers as Headers | Record; } @@ -15,6 +15,7 @@ describe('resolveLinkSdkConfig', () => { .fn() .mockResolvedValue({ status: 200, text: async () => '{}' }); const config = resolveLinkSdkConfig({ + accessToken: 'test_token', fetch: mockFetch, defaultHeaders: { 'User-Agent': 'link-cli/0.1.0', @@ -35,6 +36,7 @@ describe('resolveLinkSdkConfig', () => { .fn() .mockResolvedValue({ status: 200, text: async () => '{}' }); const config = resolveLinkSdkConfig({ + accessToken: 'test_token', fetch: mockFetch, defaultHeaders: { 'User-Agent': 'link-cli/0.1.0' }, }); @@ -50,7 +52,10 @@ describe('resolveLinkSdkConfig', () => { it('does not wrap fetch when defaultHeaders is not provided', () => { const mockFetch = vi.fn(); - const config = resolveLinkSdkConfig({ fetch: mockFetch }); + const config = resolveLinkSdkConfig({ + accessToken: 'test_token', + fetch: mockFetch, + }); expect(config.fetch).toBe(mockFetch); }); @@ -58,6 +63,7 @@ describe('resolveLinkSdkConfig', () => { it('does not wrap fetch when defaultHeaders is empty', () => { const mockFetch = vi.fn(); const config = resolveLinkSdkConfig({ + accessToken: 'test_token', fetch: mockFetch, defaultHeaders: {}, }); @@ -65,4 +71,36 @@ describe('resolveLinkSdkConfig', () => { expect(config.fetch).toBe(mockFetch); }); }); + + describe('credentials', () => { + it('uses a fixed access token without enabling refresh', async () => { + const config = resolveLinkSdkConfig({ accessToken: 'test_token' }); + + expect(await config.getAccessToken()).toBe('test_token'); + expect(config.canRefreshAccessToken).toBe(false); + }); + + it('uses a token provider and enables refresh', () => { + const getAccessToken = vi.fn(() => 'test_token'); + const config = resolveLinkSdkConfig({ getAccessToken }); + + expect(config.getAccessToken).toBe(getAccessToken); + expect(config.canRefreshAccessToken).toBe(true); + }); + + it('rejects missing, empty, or conflicting credentials', () => { + expect(() => + resolveLinkSdkConfig({} as Parameters[0]), + ).toThrow('Pass `accessToken` or `getAccessToken`'); + expect(() => resolveLinkSdkConfig({ accessToken: ' ' })).toThrow( + '`accessToken` cannot be empty', + ); + expect(() => + resolveLinkSdkConfig({ + accessToken: 'test_token', + getAccessToken: () => 'other_token', + } as unknown as Parameters[0]), + ).toThrow('not both'); + }); + }); }); diff --git a/packages/sdk/src/__tests__/errors.test.ts b/packages/sdk/src/__tests__/errors.test.ts index b5110df9..b591ba5e 100644 --- a/packages/sdk/src/__tests__/errors.test.ts +++ b/packages/sdk/src/__tests__/errors.test.ts @@ -1,9 +1,7 @@ import { describe, expect, it } from 'vitest'; import { LinkApiError, - LinkAuthenticationError, LinkConfigurationError, - LinkResponseError, LinkSdkError, LinkTransportError, } from '../errors'; @@ -19,22 +17,11 @@ describe('SDK error codes', () => { expect(err.code).toBe('configuration_error'); }); - it('LinkAuthenticationError has code not_authenticated', () => { - const err = new LinkAuthenticationError('not logged in'); - expect(err.code).toBe('not_authenticated'); - }); - it('LinkTransportError has code transport_error', () => { const err = new LinkTransportError('network fail'); expect(err.code).toBe('transport_error'); }); - it('LinkResponseError has code invalid_response and preserves status', () => { - const err = new LinkResponseError('list resources', 200); - expect(err.code).toBe('invalid_response'); - expect(err.status).toBe(200); - }); - it('LinkApiError defaults to api_error', () => { const err = new LinkApiError('bad request', { status: 400 }); expect(err.code).toBe('api_error'); @@ -50,8 +37,6 @@ describe('SDK error codes', () => { it('all errors are instances of LinkSdkError', () => { expect(new LinkConfigurationError('x')).toBeInstanceOf(LinkSdkError); - expect(new LinkResponseError('x', 200)).toBeInstanceOf(LinkSdkError); - expect(new LinkAuthenticationError('x')).toBeInstanceOf(LinkSdkError); expect(new LinkTransportError('x')).toBeInstanceOf(LinkSdkError); expect(new LinkApiError('x', { status: 500 })).toBeInstanceOf(LinkSdkError); }); diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index c1a97665..32c16e5f 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -3,18 +3,22 @@ import { BalancesResource } from '@/resources/balances'; import type { IBalancesResource, IPaymentMethodsResource, + IReportResource, IShippingAddressResource, ISourcesResource, ISpendRequestResource, ITransactionsResource, IUserInfoResource, + IWebBotAuthResource, } from '@/resources/interfaces'; import { PaymentMethodsResource } from '@/resources/payment-methods'; +import { ReportResource } from '@/resources/report'; import { ShippingAddressResource } from '@/resources/shipping-address'; import { SourcesResource } from '@/resources/sources'; import { SpendRequestResource } from '@/resources/spend-request'; import { TransactionsResource } from '@/resources/transactions'; import { UserInfoResource } from '@/resources/user-info'; +import { WebBotAuthResource } from '@/resources/web-bot-auth'; export class Link { readonly spendRequests: ISpendRequestResource; @@ -24,8 +28,10 @@ export class Link { readonly transactions: ITransactionsResource; readonly sources: ISourcesResource; readonly balances: IBalancesResource; + readonly webBotAuth: IWebBotAuthResource; + readonly reports: IReportResource; - constructor(options: LinkOptions = {}) { + constructor(options: LinkOptions) { this.spendRequests = new SpendRequestResource(options); this.paymentMethods = new PaymentMethodsResource(options); this.shippingAddresses = new ShippingAddressResource(options); @@ -33,8 +39,9 @@ export class Link { this.transactions = new TransactionsResource(options); this.sources = new SourcesResource(options); this.balances = new BalancesResource(options); + this.webBotAuth = new WebBotAuthResource(options); + this.reports = new ReportResource(options); } } -export { Link as LinkClient }; export default Link; diff --git a/packages/sdk/src/config.ts b/packages/sdk/src/config.ts index 3d627c9b..2a6e8d07 100644 --- a/packages/sdk/src/config.ts +++ b/packages/sdk/src/config.ts @@ -1,71 +1,37 @@ import { LinkConfigurationError } from '@/errors'; import type { AccessTokenProvider } from '@/resources/interfaces'; -import { type AuthStorage, storage } from '@/utils/storage'; export interface LinkSdkLogger { debug(message: string): void; } -export interface LinkOptions { +interface LinkClientOptions { verbose?: boolean; - clientName?: string; defaultHeaders?: Record; - accessToken?: string; - getAccessToken?: AccessTokenProvider; - authStorage?: AuthStorage; fetch?: typeof globalThis.fetch; - authBaseUrl?: string; apiBaseUrl?: string; spendRequestBaseUrl?: string; logger?: LinkSdkLogger; } +export type LinkOptions = LinkClientOptions & + ( + | { accessToken: string; getAccessToken?: never } + | { accessToken?: never; getAccessToken: AccessTokenProvider } + ); + export interface ResolvedLinkSdkConfig { verbose: boolean; - clientName: string; getAccessToken: AccessTokenProvider; - authStorage: AuthStorage; + canRefreshAccessToken: boolean; fetch?: typeof globalThis.fetch; - authBaseUrl: string; apiBaseUrl: string; spendRequestBaseUrl: string; logger: LinkSdkLogger; } -const DEFAULT_AUTH_BASE_URL = 'https://login.link.com'; const DEFAULT_API_BASE_URL = 'https://api.link.com'; -function createProxyFetch( - baseFetch: typeof globalThis.fetch, - proxyUrl: string, -): typeof globalThis.fetch { - let dispatcherPromise: Promise | null = null; - - return ((input: RequestInfo | URL, init?: RequestInit) => { - if (!dispatcherPromise) { - // Dynamic import — undici is only needed when LINK_HTTP_PROXY is set. - // Node bundles undici but may not expose it publicly; install it - // explicitly if the import fails: npm install undici - const mod = 'undici'; - dispatcherPromise = ( - import(mod) as Promise<{ - ProxyAgent: new (url: string) => unknown; - }> - ) - .then((m) => new m.ProxyAgent(proxyUrl)) - .catch(() => { - throw new LinkConfigurationError( - 'LINK_HTTP_PROXY requires the "undici" package. Install it with: npm install undici', - ); - }); - } - - return dispatcherPromise.then((dispatcher) => - baseFetch(input, { ...init, dispatcher } as RequestInit), - ); - }) as typeof globalThis.fetch; -} - function createDefaultHeadersFetch( baseFetch: typeof globalThis.fetch, defaultHeaders: Record, @@ -82,70 +48,65 @@ function createDefaultHeadersFetch( } export interface LinkSdkConfigDefaults { - authBaseUrl?: string; apiBaseUrl?: string; spendRequestBaseUrl?: string; } -function createDefaultLogger(verbose: boolean): LinkSdkLogger { +function createDefaultLogger(): LinkSdkLogger { return { - debug(message: string) { - if (!verbose) { - return; - } - - process.stderr.write(message.endsWith('\n') ? message : `${message}\n`); - }, + debug() {}, }; } export function resolveLinkSdkConfig( - options: LinkOptions = {}, + options: LinkOptions, defaults: LinkSdkConfigDefaults = {}, ): ResolvedLinkSdkConfig { const verbose = options.verbose ?? false; - const logger = options.logger ?? createDefaultLogger(verbose); - const getAccessToken = - typeof options.getAccessToken === 'function' - ? options.getAccessToken - : typeof options.accessToken === 'string' - ? async () => options.accessToken as string - : async () => { - throw new LinkConfigurationError( - 'No access token configured. Pass `accessToken` or `getAccessToken` in Link SDK options.', - ); - }; - const authBaseUrl = - options.authBaseUrl ?? - defaults.authBaseUrl ?? - process.env.LINK_AUTH_BASE_URL ?? - DEFAULT_AUTH_BASE_URL; + const logger = options.logger ?? createDefaultLogger(); + if ( + options.accessToken !== undefined && + options.getAccessToken !== undefined + ) { + throw new LinkConfigurationError( + 'Pass either `accessToken` or `getAccessToken`, not both.', + ); + } + + let getAccessToken: AccessTokenProvider; + let canRefreshAccessToken: boolean; + if (options.accessToken !== undefined) { + if (options.accessToken.trim().length === 0) { + throw new LinkConfigurationError('`accessToken` cannot be empty.'); + } + const accessToken = options.accessToken; + getAccessToken = () => accessToken; + canRefreshAccessToken = false; + } else if (typeof options.getAccessToken === 'function') { + getAccessToken = options.getAccessToken; + canRefreshAccessToken = true; + } else { + throw new LinkConfigurationError( + 'Pass `accessToken` or `getAccessToken` to the Link client.', + ); + } + const apiBaseUrl = - options.apiBaseUrl ?? - defaults.apiBaseUrl ?? - process.env.LINK_API_BASE_URL ?? - DEFAULT_API_BASE_URL; + options.apiBaseUrl ?? defaults.apiBaseUrl ?? DEFAULT_API_BASE_URL; const spendRequestBaseUrl = options.spendRequestBaseUrl ?? defaults.spendRequestBaseUrl ?? apiBaseUrl; - const proxyUrl = process.env.LINK_HTTP_PROXY; const baseFetch = options.fetch ?? globalThis.fetch; - const proxyFetch = - proxyUrl && !options.fetch - ? createProxyFetch(baseFetch, proxyUrl) - : baseFetch; const effectiveFetch = options.defaultHeaders && Object.keys(options.defaultHeaders).length > 0 - ? createDefaultHeadersFetch(proxyFetch, options.defaultHeaders) - : proxyFetch; + ? createDefaultHeadersFetch(baseFetch, options.defaultHeaders) + : baseFetch; return { verbose, - clientName: options.clientName ?? 'Link CLI', getAccessToken, - authStorage: options.authStorage ?? storage, + canRefreshAccessToken, fetch: effectiveFetch, - authBaseUrl, apiBaseUrl, spendRequestBaseUrl, logger, diff --git a/packages/sdk/src/errors.ts b/packages/sdk/src/errors.ts index 4ac602f4..feccec23 100644 --- a/packages/sdk/src/errors.ts +++ b/packages/sdk/src/errors.ts @@ -38,41 +38,15 @@ export class LinkConfigurationError extends LinkSdkError { } } -export class LinkAuthenticationError extends LinkSdkError { - constructor(message: string, options?: { cause?: unknown }) { - super(message, { code: 'not_authenticated', ...options }); - } -} - export class LinkTransportError extends LinkSdkError { constructor(message: string, options?: { cause?: unknown }) { super(message, { code: 'transport_error', ...options }); } } -export interface ScopeEligibility { - eligible: boolean; - ineligibility_reasons: string[]; - description?: string; -} - -export class LinkAuthorizationDeclinedError extends LinkSdkError { - readonly scopeEligibility: Record; - - constructor(scopeEligibility: Record) { - super( - 'Authorization declined: account is not eligible for requested scopes', - { - code: 'authorization_declined', - }, - ); - this.scopeEligibility = scopeEligibility; - } -} - export class LinkApiError extends LinkSdkError { readonly status: number; - readonly rawBody?: string; + readonly rawBody: string | undefined; readonly details?: unknown; constructor( diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index d6557573..a987ade7 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -1,22 +1,12 @@ -export * from './client'; -export { default } from './client'; -export * from './config'; -export * from './errors'; +export { default, Link } from './client'; +export type { LinkOptions, LinkSdkLogger } from './config'; +export { + LinkApiError, + LinkConfigurationError, + LinkResponseError, + LinkSdkError, + LinkTransportError, +} from './errors'; export * from './types/index'; export * from './resources/interfaces'; -export * from './resources/auth'; -export * from './resources/spend-request'; -export * from './resources/payment-methods'; -export * from './resources/shipping-address'; -export * from './resources/user-info'; -export * from './resources/web-bot-auth'; -export * from './resources/transactions'; -export * from './resources/sources'; -export * from './resources/balances'; -export * from './resources/report'; -export { MemoryStorage, Storage, storage } from './utils/storage'; -export type { - AuthStorage, - PendingDeviceAuth, - StorageOptions, -} from './utils/storage'; +export { getDuplicateSpendRequest } from './resources/spend-request'; diff --git a/packages/sdk/src/resources/__tests__/auth.test.ts b/packages/sdk/src/resources/__tests__/auth.test.ts deleted file mode 100644 index ce0be7ce..00000000 --- a/packages/sdk/src/resources/__tests__/auth.test.ts +++ /dev/null @@ -1,545 +0,0 @@ -import { hostname } from 'node:os'; -import { AuthResource } from '@/resources/auth'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -const mockFetch = vi.fn(); - -function mockFetchResponse(status: number, body: Record) { - mockFetch.mockResolvedValue({ - status, - text: async () => JSON.stringify(body), - }); -} - -function mockFetchRawResponse(status: number, rawBody: string) { - mockFetch.mockResolvedValue({ - status, - text: async () => rawBody, - }); -} - -describe('AuthResource', () => { - let repo: AuthResource; - - beforeEach(() => { - vi.stubGlobal('fetch', mockFetch); - repo = new AuthResource(); - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - describe('initiateDeviceAuth', () => { - it('returns a DeviceAuthRequest on success', async () => { - mockFetchResponse(200, { - device_code: 'dev_123', - user_code: 'apple-grape', - verification_uri: 'https://app.link.com/device/setup', - verification_uri_complete: - 'https://app.link.com/device/setup?code=apple-grape', - expires_in: 300, - interval: 5, - }); - - const result = await repo.initiateDeviceAuth(); - - expect(result).toEqual({ - device_code: 'dev_123', - user_code: 'apple-grape', - verification_url: 'https://app.link.com/device/setup', - verification_url_complete: - 'https://app.link.com/device/setup?code=apple-grape', - expires_in: 300, - interval: 5, - }); - }); - - it('maps verification_uri to verification_url in the response', async () => { - mockFetchResponse(200, { - device_code: 'dev_456', - user_code: 'banana-kiwi', - verification_uri: 'https://app.link.com/device/setup', - verification_uri_complete: - 'https://app.link.com/device/setup?code=banana-kiwi', - expires_in: 600, - interval: 10, - }); - - const result = await repo.initiateDeviceAuth(); - - expect(result.verification_url).toBe('https://app.link.com/device/setup'); - expect(result.verification_url_complete).toBe( - 'https://app.link.com/device/setup?code=banana-kiwi', - ); - }); - - it('sends correct request parameters', async () => { - mockFetchResponse(200, { - device_code: 'dc', - user_code: 'uc', - verification_uri: 'https://example.com', - verification_uri_complete: 'https://example.com?code=uc', - expires_in: 300, - interval: 5, - }); - - await repo.initiateDeviceAuth(); - - expect(mockFetch).toHaveBeenCalledOnce(); - const [url, opts] = mockFetch.mock.calls[0]; - expect(url).toBe('https://login.link.com/device/code'); - expect(opts.method).toBe('POST'); - expect(opts.headers['Content-Type']).toBe( - 'application/x-www-form-urlencoded', - ); - - const params = new URLSearchParams(opts.body); - expect(params.get('client_id')).toBe('lwlpk_U7Qy7ThG69STZk'); - expect(params.get('scope')).toBe('userinfo:read payment_methods.agentic'); - expect(params.get('connection_label')).toBe(`Link CLI on ${hostname()}`); - expect(params.get('client_hint')).toBe('Link CLI'); - }); - - it('uses the default scope when none is provided', async () => { - mockFetchResponse(200, { - device_code: 'dc', - user_code: 'uc', - verification_uri: 'https://example.com', - verification_uri_complete: 'https://example.com?code=uc', - expires_in: 300, - interval: 5, - }); - - await repo.initiateDeviceAuth(); - - const [, opts] = mockFetch.mock.calls[0]; - const params = new URLSearchParams(opts.body); - expect(params.get('scope')).toBe('userinfo:read payment_methods.agentic'); - }); - - it('passes a custom scope when provided', async () => { - mockFetchResponse(200, { - device_code: 'dc', - user_code: 'uc', - verification_uri: 'https://example.com', - verification_uri_complete: 'https://example.com?code=uc', - expires_in: 300, - interval: 5, - }); - - await repo.initiateDeviceAuth({ - scope: 'userinfo:read spend_requests:approve', - }); - - const [, opts] = mockFetch.mock.calls[0]; - const params = new URLSearchParams(opts.body); - expect(params.get('scope')).toBe('userinfo:read spend_requests:approve'); - expect(params.get('authorization_details')).toBeNull(); - }); - - it('passes source actions via authorization_details', async () => { - mockFetchResponse(200, { - device_code: 'dc', - user_code: 'uc', - verification_uri: 'https://example.com', - verification_uri_complete: 'https://example.com?code=uc', - expires_in: 300, - interval: 5, - }); - - await repo.initiateDeviceAuth({ - sourceActions: ['read_source_details', 'read_balances'], - }); - - const [, opts] = mockFetch.mock.calls[0]; - const params = new URLSearchParams(opts.body); - expect(params.get('scope')).toBe('userinfo:read payment_methods.agentic'); - expect(params.get('authorization_details')).toBeNull(); - expect(params.getAll('authorization_details[][type]')).toEqual([ - 'source', - ]); - expect(params.getAll('authorization_details[][actions][]')).toEqual([ - 'read_source_details', - 'read_balances', - ]); - }); - - it('serializes raw authorization_details after generated source details', async () => { - mockFetchResponse(200, { - device_code: 'dc', - user_code: 'uc', - verification_uri: 'https://example.com', - verification_uri_complete: 'https://example.com?code=uc', - expires_in: 300, - interval: 5, - }); - - await repo.initiateDeviceAuth({ - sourceActions: ['read_link_transactions'], - authorizationDetails: [ - { - type: 'account', - filters: ['current', { include_inactive: true }], - }, - true, - ], - }); - - const [, opts] = mockFetch.mock.calls[0]; - const params = new URLSearchParams(opts.body); - expect(params.get('authorization_details')).toBeNull(); - expect(params.getAll('authorization_details[][type]')).toEqual([ - 'source', - 'account', - ]); - expect(params.getAll('authorization_details[][actions][]')).toEqual([ - 'read_link_transactions', - ]); - expect(params.getAll('authorization_details[][filters][]')).toEqual([ - 'current', - ]); - expect( - params.getAll('authorization_details[][filters][][include_inactive]'), - ).toEqual(['true']); - expect(params.getAll('authorization_details[]')).toEqual(['true']); - }); - - it('uses custom clientName in connection_label and client_hint when provided', async () => { - mockFetchResponse(200, { - device_code: 'dc', - user_code: 'uc', - verification_uri: 'https://example.com', - verification_uri_complete: 'https://example.com?code=uc', - expires_in: 300, - interval: 5, - }); - - const customRepo = new AuthResource({ clientName: 'Claude Code' }); - await customRepo.initiateDeviceAuth(); - - const [, opts] = mockFetch.mock.calls[0]; - const params = new URLSearchParams(opts.body); - expect(params.get('connection_label')).toBe( - `Claude Code on ${hostname()}`, - ); - expect(params.get('client_hint')).toBe('Claude Code'); - }); - - it('uses per-call clientName override over config default', async () => { - mockFetchResponse(200, { - device_code: 'dc', - user_code: 'uc', - verification_uri: 'https://example.com', - verification_uri_complete: 'https://example.com?code=uc', - expires_in: 300, - interval: 5, - }); - - await repo.initiateDeviceAuth({ clientName: 'My Agent' }); - - const [, opts] = mockFetch.mock.calls[0]; - const params = new URLSearchParams(opts.body); - expect(params.get('connection_label')).toBe(`My Agent on ${hostname()}`); - expect(params.get('client_hint')).toBe('My Agent'); - }); - - it('throws on HTTP error with error_description', async () => { - mockFetchResponse(403, { - error: 'forbidden', - error_description: 'Client not authorized', - }); - - await expect(repo.initiateDeviceAuth()).rejects.toThrow( - 'Device auth initiation failed (403): Client not authorized', - ); - }); - - it('throws on HTTP error falling back to error field', async () => { - mockFetchResponse(500, { error: 'server_error' }); - - await expect(repo.initiateDeviceAuth()).rejects.toThrow( - 'Device auth initiation failed (500): server_error', - ); - }); - - it('extracts message from nested error object instead of [object Object]', async () => { - mockFetchResponse(400, { - error: { message: 'invalid scope: something' }, - }); - - await expect(repo.initiateDeviceAuth()).rejects.toThrow( - 'Device auth initiation failed (400): invalid scope: something', - ); - }); - - it('preserves empty error_description rather than falling back to error code', async () => { - mockFetchResponse(400, { error: 'invalid_scope', error_description: '' }); - - await expect(repo.initiateDeviceAuth()).rejects.toThrow( - 'Device auth initiation failed (400): ', - ); - }); - }); - - describe('pollDeviceAuth', () => { - it('returns AuthTokens on success', async () => { - mockFetchResponse(200, { - access_token: 'at_abc', - refresh_token: 'rt_xyz', - expires_in: 3600, - token_type: 'Bearer', - }); - - const result = await repo.pollDeviceAuth('dev_123'); - - expect(result).toEqual({ - access_token: 'at_abc', - refresh_token: 'rt_xyz', - expires_in: 3600, - token_type: 'Bearer', - }); - }); - - it('returns scope and authorization_details when present', async () => { - mockFetchResponse(200, { - access_token: 'at_abc', - refresh_token: 'rt_xyz', - expires_in: 3600, - token_type: 'Bearer', - scope: 'userinfo:read payment_methods.agentic', - authorization_details: [{ type: 'source', actions: ['read'] }], - }); - - const result = await repo.pollDeviceAuth('dev_123'); - - expect(result).toEqual({ - access_token: 'at_abc', - refresh_token: 'rt_xyz', - expires_in: 3600, - token_type: 'Bearer', - scope: 'userinfo:read payment_methods.agentic', - authorization_details: [{ type: 'source', actions: ['read'] }], - }); - }); - - it('sends correct request parameters', async () => { - mockFetchResponse(200, { - access_token: 'at', - refresh_token: 'rt', - expires_in: 3600, - token_type: 'Bearer', - }); - - await repo.pollDeviceAuth('dev_code_999'); - - const [url, opts] = mockFetch.mock.calls[0]; - expect(url).toBe('https://login.link.com/device/token'); - const params = new URLSearchParams(opts.body); - expect(params.get('grant_type')).toBe( - 'urn:ietf:params:oauth:grant-type:device_code', - ); - expect(params.get('device_code')).toBe('dev_code_999'); - expect(params.get('client_id')).toBe('lwlpk_U7Qy7ThG69STZk'); - }); - - it('returns null on authorization_pending', async () => { - mockFetchResponse(400, { error: 'authorization_pending' }); - - const result = await repo.pollDeviceAuth('dev_123'); - expect(result).toBeNull(); - }); - - it('returns null on slow_down', async () => { - mockFetchResponse(400, { error: 'slow_down' }); - - const result = await repo.pollDeviceAuth('dev_123'); - expect(result).toBeNull(); - }); - - it('throws on expired_token', async () => { - mockFetchResponse(400, { error: 'expired_token' }); - - await expect(repo.pollDeviceAuth('dev_123')).rejects.toThrow( - 'Device code expired. Please restart the login flow.', - ); - }); - - it('throws on access_denied', async () => { - mockFetchResponse(400, { error: 'access_denied' }); - - await expect(repo.pollDeviceAuth('dev_123')).rejects.toThrow( - 'Authorization denied by user.', - ); - }); - - it('throws on unexpected 400 error', async () => { - mockFetchResponse(400, { - error: 'invalid_grant', - error_description: 'Grant is invalid', - }); - - await expect(repo.pollDeviceAuth('dev_123')).rejects.toThrow( - 'Token poll failed (400): Grant is invalid', - ); - }); - - it('throws on server error', async () => { - mockFetchResponse(500, { error: 'server_error' }); - - await expect(repo.pollDeviceAuth('dev_123')).rejects.toThrow( - 'Token poll failed (500): server_error', - ); - }); - - it('handles non-JSON error body gracefully', async () => { - mockFetchRawResponse(502, 'Bad Gateway'); - - await expect(repo.pollDeviceAuth('dev_123')).rejects.toThrow( - 'Token poll failed (502): Bad Gateway', - ); - }); - - it('throws with readable message when 400 error is a nested object instead of [object Object]', async () => { - mockFetchResponse(400, { - error: { message: 'invalid scope: something' }, - }); - - await expect(repo.pollDeviceAuth('dev_123')).rejects.toThrow( - 'Token poll failed (400): invalid scope: something', - ); - }); - - it('still returns null for authorization_pending when error is a string', async () => { - mockFetchResponse(400, { error: 'authorization_pending' }); - - const result = await repo.pollDeviceAuth('dev_123'); - expect(result).toBeNull(); - }); - }); - - describe('revokeToken', () => { - it('sends correct request parameters', async () => { - mockFetchResponse(200, {}); - - await repo.revokeToken('rt_to_revoke'); - - expect(mockFetch).toHaveBeenCalledOnce(); - const [url, opts] = mockFetch.mock.calls[0]; - expect(url).toBe('https://login.link.com/device/revoke'); - expect(opts.method).toBe('POST'); - expect(opts.headers['Content-Type']).toBe( - 'application/x-www-form-urlencoded', - ); - - const params = new URLSearchParams(opts.body); - expect(params.get('client_id')).toBe('lwlpk_U7Qy7ThG69STZk'); - expect(params.get('token')).toBe('rt_to_revoke'); - }); - - it('resolves on 200 success', async () => { - mockFetchResponse(200, {}); - - await expect(repo.revokeToken('rt_valid')).resolves.toBeUndefined(); - }); - - it('throws on HTTP error with error_description', async () => { - mockFetchResponse(400, { - error: 'invalid_client', - error_description: 'invalid client_id', - }); - - await expect(repo.revokeToken('rt_bad')).rejects.toThrow( - 'Token revocation failed (400): invalid client_id', - ); - }); - - it('handles non-JSON error body gracefully', async () => { - mockFetchRawResponse(502, 'Bad Gateway'); - - await expect(repo.revokeToken('rt_bad')).rejects.toThrow( - 'Token revocation failed (502): Bad Gateway', - ); - }); - }); - - describe('refreshToken', () => { - it('returns new AuthTokens on success', async () => { - mockFetchResponse(200, { - access_token: 'new_at', - refresh_token: 'new_rt', - expires_in: 7200, - token_type: 'Bearer', - }); - - const result = await repo.refreshToken('old_rt'); - - expect(result).toEqual({ - access_token: 'new_at', - refresh_token: 'new_rt', - expires_in: 7200, - token_type: 'Bearer', - }); - }); - - it('returns scope and authorization_details when present', async () => { - mockFetchResponse(200, { - access_token: 'new_at', - refresh_token: 'new_rt', - expires_in: 7200, - token_type: 'Bearer', - scope: 'userinfo:read', - authorization_details: [{ type: 'source', actions: ['read'] }], - }); - - const result = await repo.refreshToken('old_rt'); - - expect(result).toEqual({ - access_token: 'new_at', - refresh_token: 'new_rt', - expires_in: 7200, - token_type: 'Bearer', - scope: 'userinfo:read', - authorization_details: [{ type: 'source', actions: ['read'] }], - }); - }); - - it('sends correct request parameters', async () => { - mockFetchResponse(200, { - access_token: 'at', - refresh_token: 'rt', - expires_in: 3600, - token_type: 'Bearer', - }); - - await repo.refreshToken('my_refresh_token'); - - const [url, opts] = mockFetch.mock.calls[0]; - expect(url).toBe('https://login.link.com/device/token'); - const params = new URLSearchParams(opts.body); - expect(params.get('grant_type')).toBe('refresh_token'); - expect(params.get('refresh_token')).toBe('my_refresh_token'); - expect(params.get('client_id')).toBe('lwlpk_U7Qy7ThG69STZk'); - }); - - it('throws on HTTP error with error_description', async () => { - mockFetchResponse(401, { - error: 'invalid_grant', - error_description: 'Refresh token revoked', - }); - - await expect(repo.refreshToken('bad_rt')).rejects.toThrow( - 'Token refresh failed (401): Refresh token revoked', - ); - }); - - it('handles non-JSON error body gracefully', async () => { - mockFetchRawResponse(503, 'Service Unavailable'); - - await expect(repo.refreshToken('rt')).rejects.toThrow( - 'Token refresh failed (503): Service Unavailable', - ); - }); - }); -}); diff --git a/packages/sdk/src/resources/__tests__/factory.test.ts b/packages/sdk/src/resources/__tests__/factory.test.ts index 012f8ff4..8ecd8b96 100644 --- a/packages/sdk/src/resources/__tests__/factory.test.ts +++ b/packages/sdk/src/resources/__tests__/factory.test.ts @@ -1,7 +1,9 @@ -import Link, { LinkClient } from '@/client'; +import Link from '@/client'; import { PaymentMethodsResource } from '@/resources/payment-methods'; +import { ReportResource } from '@/resources/report'; import { SpendRequestResource } from '@/resources/spend-request'; import { TransactionsResource } from '@/resources/transactions'; +import { WebBotAuthResource } from '@/resources/web-bot-auth'; import { describe, expect, it, vi } from 'vitest'; describe('Link', () => { @@ -15,14 +17,12 @@ describe('Link', () => { expect(client.spendRequests).toBeInstanceOf(SpendRequestResource); expect(client.paymentMethods).toBeInstanceOf(PaymentMethodsResource); expect(client.transactions).toBeInstanceOf(TransactionsResource); + expect(client.webBotAuth).toBeInstanceOf(WebBotAuthResource); + expect(client.reports).toBeInstanceOf(ReportResource); expect(client.spendRequests.create).toBeTypeOf('function'); expect(client.spendRequests.update).toBeTypeOf('function'); expect(client.spendRequests.retrieve).toBeTypeOf('function'); expect(client.paymentMethods.list).toBeTypeOf('function'); expect(client.transactions.list).toBeTypeOf('function'); }); - - it('keeps LinkClient as a compatibility alias', () => { - expect(LinkClient).toBe(Link); - }); }); diff --git a/packages/sdk/src/resources/__tests__/payment-methods.test.ts b/packages/sdk/src/resources/__tests__/payment-methods.test.ts index 35c0811d..d362ebb4 100644 --- a/packages/sdk/src/resources/__tests__/payment-methods.test.ts +++ b/packages/sdk/src/resources/__tests__/payment-methods.test.ts @@ -89,6 +89,16 @@ describe('PaymentMethodsResource', () => { ); }); + it('does not retry a 401 when configured with a fixed access token', async () => { + repo = new PaymentMethodsResource({ accessToken: 'fixed_token' }); + mockFetchResponse(401, { error: 'expired_token' }); + + await expect(repo.list()).rejects.toThrow( + 'Failed to list payment methods (401): expired_token', + ); + expect(mockFetch).toHaveBeenCalledOnce(); + }); + it('never logs tokens or response bodies in verbose mode', async () => { const debug = vi.fn(); repo = new PaymentMethodsResource({ diff --git a/packages/sdk/src/resources/auth.ts b/packages/sdk/src/resources/auth.ts deleted file mode 100644 index 79714c36..00000000 --- a/packages/sdk/src/resources/auth.ts +++ /dev/null @@ -1,372 +0,0 @@ -import { hostname } from 'node:os'; -import { - type LinkOptions, - type ResolvedLinkSdkConfig, - requireFetchImplementation, - resolveLinkSdkConfig, -} from '@/config'; -import { LinkApiError, LinkTransportError } from '@/errors'; -import type { - IAuthResource, - InitiateDeviceAuthOptions, - SourceAction, -} from '@/resources/interfaces'; -import type { AuthTokens, DeviceAuthRequest, JsonValue } from '@/types/index'; - -const CLIENT_ID = 'lwlpk_U7Qy7ThG69STZk'; -const DEFAULT_SCOPE = 'userinfo:read payment_methods.agentic'; - -interface DeviceCodeResponse { - device_code: string; - user_code: string; - verification_uri: string; - verification_uri_complete: string; - expires_in: number; - interval: number; -} - -interface TokenResponse { - access_token: string; - refresh_token: string; - token_type: string; - expires_in: number; - scope?: string; - authorization_details?: JsonValue[]; -} - -interface OAuthError { - error: string | { message?: string }; - error_description?: string; -} - -function extractOAuthErrorMessage(err: OAuthError | null): string | undefined { - if (!err) return undefined; - if (err.error_description != null) return err.error_description; - if (typeof err.error === 'string') return err.error; - if (typeof err.error === 'object' && err.error !== null) { - return err.error.message ?? JSON.stringify(err.error); - } - return undefined; -} - -function extractOAuthErrorCode(err: OAuthError | null): string | undefined { - if (!err) return undefined; - return typeof err.error === 'string' ? err.error : undefined; -} - -function formatOAuthError( - prefix: string, - status: number, - data: unknown, - rawBody: string, -): string { - const err = data as OAuthError | null; - return `${prefix} (${status}): ${extractOAuthErrorMessage(err) ?? (rawBody || 'unknown error')}`; -} - -function dedupe(values: readonly T[]): T[] { - const seen = new Set(); - const result: T[] = []; - - for (const value of values) { - if (seen.has(value)) { - continue; - } - - seen.add(value); - result.push(value); - } - - return result; -} - -function buildAuthorizationDetails( - sourceActions: readonly SourceAction[] | undefined, - authorizationDetails: readonly JsonValue[] | undefined, -): JsonValue[] { - const details: JsonValue[] = []; - const uniqueSourceActions = dedupe(sourceActions ?? []); - - if (uniqueSourceActions.length > 0) { - details.push({ - type: 'source', - actions: uniqueSourceActions, - }); - } - - if (authorizationDetails) { - details.push(...authorizationDetails); - } - - return details; -} - -function appendAuthorizationDetailValue( - params: URLSearchParams, - key: string, - value: JsonValue, -): void { - if (Array.isArray(value)) { - for (const entry of value) { - appendAuthorizationDetailValue(params, `${key}[]`, entry); - } - return; - } - - if (value !== null && typeof value === 'object') { - for (const [entryKey, entryValue] of Object.entries(value)) { - appendAuthorizationDetailValue(params, `${key}[${entryKey}]`, entryValue); - } - return; - } - - params.append(key, String(value)); -} - -function buildDeviceCodeForm( - clientName: string, - options: InitiateDeviceAuthOptions, -): URLSearchParams { - const connectionLabel = `${clientName} on ${hostname()}`; - const params = new URLSearchParams({ - client_id: CLIENT_ID, - scope: options.scope ?? DEFAULT_SCOPE, - connection_label: connectionLabel, - client_hint: clientName, - }); - const authorizationDetails = buildAuthorizationDetails( - options.sourceActions, - options.authorizationDetails, - ); - - for (const detail of authorizationDetails) { - appendAuthorizationDetailValue(params, 'authorization_details[]', detail); - } - - return params; -} - -function serializeFormBody( - params: Record | URLSearchParams, -): string { - return params instanceof URLSearchParams - ? params.toString() - : new URLSearchParams(params).toString(); -} - -function serializeRedactedFormBody( - params: Record | URLSearchParams, -): string { - const redacted = new URLSearchParams(params); - if (redacted.has('device_code')) { - redacted.set('device_code', ''); - } - if (redacted.has('refresh_token')) { - redacted.set('refresh_token', ''); - } - return redacted.toString(); -} - -export class AuthResource implements IAuthResource { - private readonly config: ResolvedLinkSdkConfig; - private readonly fetchImpl: typeof globalThis.fetch; - - constructor(options: LinkOptions = {}) { - this.config = resolveLinkSdkConfig(options); - this.fetchImpl = requireFetchImplementation(this.config); - } - - private async postForm( - url: string, - params: Record | URLSearchParams, - ): Promise<{ status: number; data: unknown; rawBody: string }> { - if (this.config.verbose) { - this.config.logger.debug( - `> POST ${url}\n${serializeRedactedFormBody(params)}`, - ); - } - - let response: Response; - try { - response = await this.fetchImpl(url, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: serializeFormBody(params), - }); - } catch (error) { - throw new LinkTransportError(`Request failed: POST ${url}`, { - cause: error, - }); - } - - const rawBody = await response.text(); - - let data: unknown = null; - try { - data = JSON.parse(rawBody); - } catch { - // non-JSON response (e.g., from load balancer) - } - - if (this.config.verbose) { - this.config.logger.debug(`< ${response.status} ${response.statusText}`); - response.headers.forEach((value, key) => { - this.config.logger.debug(` ${key}: ${value}`); - }); - this.config.logger.debug(JSON.stringify(data, null, 2) ?? rawBody); - } - - return { status: response.status, data, rawBody }; - } - - async initiateDeviceAuth( - options: InitiateDeviceAuthOptions = {}, - ): Promise { - const effectiveName = options.clientName ?? this.config.clientName; - const params = buildDeviceCodeForm(effectiveName, options); - const { status, data, rawBody } = await this.postForm( - `${this.config.authBaseUrl}/device/code`, - params, - ); - - if (status < 200 || status >= 300) { - throw new LinkApiError( - formatOAuthError( - 'Device auth initiation failed', - status, - data, - rawBody, - ), - { status, rawBody, details: data }, - ); - } - - const resp = data as DeviceCodeResponse; - return { - device_code: resp.device_code, - user_code: resp.user_code, - verification_url: resp.verification_uri, - verification_url_complete: resp.verification_uri_complete, - expires_in: resp.expires_in, - interval: resp.interval, - }; - } - - async pollDeviceAuth(deviceCode: string): Promise { - const { status, data, rawBody } = await this.postForm( - `${this.config.authBaseUrl}/device/token`, - { - grant_type: 'urn:ietf:params:oauth:grant-type:device_code', - device_code: deviceCode, - client_id: CLIENT_ID, - }, - ); - - if (status >= 200 && status < 300) { - const resp = data as TokenResponse; - return { - access_token: resp.access_token, - refresh_token: resp.refresh_token, - expires_in: resp.expires_in, - token_type: resp.token_type, - ...(resp.scope && { scope: resp.scope }), - ...(resp.authorization_details && { - authorization_details: resp.authorization_details, - }), - }; - } - - if (status === 400) { - const err = data as OAuthError; - switch (extractOAuthErrorCode(err)) { - case 'authorization_pending': - case 'slow_down': - return null; - case 'expired_token': - throw new LinkApiError( - 'Device code expired. Please restart the login flow.', - { - status, - code: extractOAuthErrorCode(err), - rawBody, - details: data, - }, - ); - case 'access_denied': - throw new LinkApiError('Authorization denied by user.', { - status, - code: extractOAuthErrorCode(err), - rawBody, - details: data, - }); - } - } - - throw new LinkApiError( - formatOAuthError('Token poll failed', status, data, rawBody), - { - status, - code: extractOAuthErrorCode(data as OAuthError | null), - rawBody, - details: data, - }, - ); - } - - async revokeToken(token: string): Promise { - const { status, data, rawBody } = await this.postForm( - `${this.config.authBaseUrl}/device/revoke`, - { - client_id: CLIENT_ID, - token, - }, - ); - - if (status < 200 || status >= 300) { - throw new LinkApiError( - formatOAuthError('Token revocation failed', status, data, rawBody), - { - status, - code: extractOAuthErrorCode(data as OAuthError | null), - rawBody, - details: data, - }, - ); - } - } - - async refreshToken(refreshToken: string): Promise { - const { status, data, rawBody } = await this.postForm( - `${this.config.authBaseUrl}/device/token`, - { - grant_type: 'refresh_token', - refresh_token: refreshToken, - client_id: CLIENT_ID, - }, - ); - - if (status < 200 || status >= 300) { - throw new LinkApiError( - formatOAuthError('Token refresh failed', status, data, rawBody), - { - status, - code: extractOAuthErrorCode(data as OAuthError | null), - rawBody, - details: data, - }, - ); - } - - const resp = data as TokenResponse; - return { - access_token: resp.access_token, - refresh_token: resp.refresh_token, - expires_in: resp.expires_in, - token_type: resp.token_type, - ...(resp.scope && { scope: resp.scope }), - ...(resp.authorization_details && { - authorization_details: resp.authorization_details, - }), - }; - } -} diff --git a/packages/sdk/src/resources/base.ts b/packages/sdk/src/resources/base.ts index dddd4b17..ef24d68b 100644 --- a/packages/sdk/src/resources/base.ts +++ b/packages/sdk/src/resources/base.ts @@ -49,6 +49,7 @@ export function extractErrorMessage(data: unknown, rawBody: string): string { export abstract class BaseResource { protected readonly verbose: boolean; protected readonly getAccessToken: AccessTokenProvider; + protected readonly canRefreshAccessToken: boolean; protected readonly fetchImpl: typeof globalThis.fetch; protected readonly endpoint: string; protected readonly logger: { debug(message: string): void }; @@ -61,6 +62,7 @@ export abstract class BaseResource { const config = resolveLinkSdkConfig(options); this.verbose = config.verbose; this.getAccessToken = config.getAccessToken; + this.canRefreshAccessToken = config.canRefreshAccessToken; this.fetchImpl = requireFetchImplementation(config); const baseUrl = base === 'spend' ? config.spendRequestBaseUrl : config.apiBaseUrl; @@ -116,7 +118,7 @@ export abstract class BaseResource { const res = await this.rawFetch(authedOpts); - if (res.status === 401) { + if (res.status === 401 && this.canRefreshAccessToken) { const refreshedToken = await this.getAccessToken({ forceRefresh: true }); authedOpts.headers.Authorization = `Bearer ${refreshedToken}`; return this.rawFetch(authedOpts); diff --git a/packages/sdk/src/resources/interfaces.ts b/packages/sdk/src/resources/interfaces.ts index 033412b5..e9382d88 100644 --- a/packages/sdk/src/resources/interfaces.ts +++ b/packages/sdk/src/resources/interfaces.ts @@ -1,10 +1,7 @@ import type { ApprovalDetail, - AuthTokens, BalancesPage, CredentialType, - DeviceAuthRequest, - JsonValue, LineItem, PaymentMethod, RequestApprovalResponse, @@ -18,31 +15,6 @@ import type { WebBotAuthBlock, } from '@/types/index'; -export const SOURCE_ACTIONS = [ - 'read_balances', - 'read_external_transactions', - 'read_link_transactions', - 'read_source_details', -] as const; - -export type SourceAction = (typeof SOURCE_ACTIONS)[number]; - -export interface InitiateDeviceAuthOptions { - clientName?: string; - scope?: string; - sourceActions?: SourceAction[]; - authorizationDetails?: JsonValue[]; -} - -export interface IAuthResource { - initiateDeviceAuth( - options?: InitiateDeviceAuthOptions, - ): Promise; - pollDeviceAuth(deviceCode: string): Promise; - refreshToken(refreshToken: string): Promise; - revokeToken(token: string): Promise; -} - export interface GetAccessTokenOptions { forceRefresh?: boolean; } diff --git a/packages/sdk/src/types/index.ts b/packages/sdk/src/types/index.ts index f4b03c4e..088b8a88 100644 --- a/packages/sdk/src/types/index.ts +++ b/packages/sdk/src/types/index.ts @@ -4,28 +4,6 @@ export type JsonValue = | JsonValue[] | { [key: string]: JsonValue }; -export interface DeviceAuthRequest { - device_code: string; - user_code: string; - verification_url: string; - verification_url_complete: string; - expires_in: number; - interval: number; -} - -export interface AuthTokens { - access_token: string; - refresh_token: string; - expires_in: number; - token_type: string; - /** Absolute epoch-ms when the access token expires (computed on store). */ - expires_at?: number; - /** Space-separated scopes granted for this session (echoed by the token endpoint). */ - scope?: string; - /** Authorization details granted for this session (echoed by the token endpoint). */ - authorization_details?: JsonValue[]; -} - export interface LineItem { name: string; url?: string; diff --git a/packages/sdk/src/utils/__tests__/storage.test.ts b/packages/sdk/src/utils/__tests__/storage.test.ts deleted file mode 100644 index d9cd4cde..00000000 --- a/packages/sdk/src/utils/__tests__/storage.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -import * as fs from 'node:fs'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import { MemoryStorage, Storage } from '@/utils/storage'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -describe('MemoryStorage', () => { - it('computes expires_at when storing auth tokens', () => { - const authStorage = new MemoryStorage(); - - authStorage.setAuth({ - access_token: 'at_123', - refresh_token: 'rt_123', - expires_in: 60, - token_type: 'Bearer', - }); - - const stored = authStorage.getAuth(); - expect(stored?.expires_at).toBeTypeOf('number'); - expect(stored?.expires_at).toBeGreaterThan(Date.now()); - }); - - it('round-trips scope and authorization_details', () => { - const authStorage = new MemoryStorage(); - - authStorage.setAuth({ - access_token: 'at_123', - refresh_token: 'rt_123', - expires_in: 60, - token_type: 'Bearer', - scope: 'userinfo:read payment_methods.agentic', - authorization_details: [{ type: 'source', actions: ['read'] }], - }); - - const stored = authStorage.getAuth(); - expect(stored?.scope).toBe('userinfo:read payment_methods.agentic'); - expect(stored?.authorization_details).toEqual([ - { type: 'source', actions: ['read'] }, - ]); - }); - - it('can be initialized with an existing auth session', () => { - const authStorage = new MemoryStorage({ - access_token: 'at_123', - refresh_token: 'rt_123', - expires_in: 60, - token_type: 'Bearer', - }); - - expect(authStorage.isAuthenticated()).toBe(true); - expect(authStorage.getPath()).toBe('memory'); - }); - - it('deleteConfig is a no-op for MemoryStorage', () => { - const authStorage = new MemoryStorage({ - access_token: 'at_123', - refresh_token: 'rt_123', - expires_in: 60, - token_type: 'Bearer', - }); - expect(() => authStorage.deleteConfig()).not.toThrow(); - // auth is unaffected - expect(authStorage.isAuthenticated()).toBe(true); - }); -}); - -// Skip on Windows: POSIX file modes don't apply (NTFS uses ACLs and the -// stat.mode bits don't reflect the actual access controls). -const describePosix = process.platform === 'win32' ? describe.skip : describe; - -describePosix('Storage (disk-backed) file permissions', () => { - let tmpDir: string; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'link-cli-storage-test-')); - }); - - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - it('writes the config file with mode 0o600 (owner-only)', () => { - const storage = new Storage({ cwd: tmpDir }); - - storage.setAuth({ - access_token: 'at_test', - refresh_token: 'rt_test', - expires_in: 3600, - token_type: 'Bearer', - }); - - const mode = fs.statSync(storage.getPath()).mode & 0o777; - expect(mode).toBe(0o600); - }); - - // The fix must also remediate users who were created on a prior version that - // wrote the file with the conf default (0o666 masked by umask, typically - // 0o644). conf writes via atomic rename, so the new mode applies on the - // next write — no explicit chmod needed. - it('rewrites with mode 0o600 when an existing file is 0o644', () => { - const seedStorage = new Storage({ cwd: tmpDir }); - // Trigger initial write so we know where the file lives. - seedStorage.setAuth({ - access_token: 'at_seed', - refresh_token: 'rt_seed', - expires_in: 3600, - token_type: 'Bearer', - }); - const configPath = seedStorage.getPath(); - fs.chmodSync(configPath, 0o644); - expect(fs.statSync(configPath).mode & 0o777).toBe(0o644); - - // Simulate a CLI invocation after the upgrade: a new Storage instance - // opens the same file and writes (e.g., via a refreshed token). - const upgradedStorage = new Storage({ cwd: tmpDir }); - upgradedStorage.setAuth({ - access_token: 'at_after_upgrade', - refresh_token: 'rt_after_upgrade', - expires_in: 3600, - token_type: 'Bearer', - }); - - expect(fs.statSync(configPath).mode & 0o777).toBe(0o600); - }); - - it('configPath option writes to the specified file path', () => { - const customPath = path.join(tmpDir, 'custom-creds.json'); - const storage = new Storage({ configPath: customPath }); - - storage.setAuth({ - access_token: 'at_custom', - refresh_token: 'rt_custom', - expires_in: 3600, - token_type: 'Bearer', - }); - - expect(storage.getPath()).toBe(customPath); - expect(fs.existsSync(customPath)).toBe(true); - expect(storage.getAuth()?.access_token).toBe('at_custom'); - - const mode = fs.statSync(customPath).mode & 0o777; - expect(mode).toBe(0o600); - }); - - it('configPath takes precedence over cwd', () => { - const customPath = path.join(tmpDir, 'override.json'); - const otherDir = fs.mkdtempSync(path.join(os.tmpdir(), 'link-cli-other-')); - const storage = new Storage({ configPath: customPath, cwd: otherDir }); - - storage.setAuth({ - access_token: 'at_override', - refresh_token: 'rt_override', - expires_in: 3600, - token_type: 'Bearer', - }); - - expect(storage.getPath()).toBe(customPath); - fs.rmSync(otherDir, { recursive: true, force: true }); - }); - - it('also restricts pendingDeviceAuth, which is written to the same file', () => { - const storage = new Storage({ cwd: tmpDir }); - - storage.setPendingDeviceAuth({ - device_code: 'dc_test_must_not_leak', - interval: 5, - expires_at: Date.now() + 60_000, - verification_url: 'https://login.link.com/device', - phrase: 'test-phrase', - }); - - const mode = fs.statSync(storage.getPath()).mode & 0o777; - expect(mode).toBe(0o600); - }); -}); diff --git a/packages/sdk/tsup.config.ts b/packages/sdk/tsup.config.ts index 0201e57e..03aa989c 100644 --- a/packages/sdk/tsup.config.ts +++ b/packages/sdk/tsup.config.ts @@ -7,7 +7,7 @@ export default defineConfig({ entry: ['src/index.ts'], format: ['esm'], platform: 'node', - target: 'node18', + target: 'node20', outDir: 'dist', clean: true, dts: true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 00e5c541..83fbc6ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: packages/cli: dependencies: + conf: + specifier: ^15.1.0 + version: 15.1.0 incur: specifier: ^0.4.26 version: 0.4.26 @@ -90,9 +93,6 @@ importers: packages/sdk: dependencies: - conf: - specifier: ^15.1.0 - version: 15.1.0 zod: specifier: ^4.4.3 version: 4.4.3