diff --git a/packages/cli/src/__tests__/cli.test.ts b/packages/cli/src/__tests__/cli.test.ts index ef94f15..a6e0a8e 100644 --- a/packages/cli/src/__tests__/cli.test.ts +++ b/packages/cli/src/__tests__/cli.test.ts @@ -1037,6 +1037,86 @@ describe('production mode', () => { }); }); + const SAMPLE_SOURCE = { + id: 'csmrpd_001', + name: 'Checking 1234', + type: 'bank_account', + capabilities: { + transactions: { status: 'eligible' }, + }, + external_connection: { status: 'active' }, + }; + + describe('sources list', () => { + it('GETs the Link API endpoint with bearer auth', async () => { + setResponseForUrl('/sources', 200, { + data: [SAMPLE_SOURCE], + has_more: true, + }); + + const result = await runProdCli('sources', 'list', '--json'); + + expect(result.exitCode).toBe(0); + expect(lastRequest.method).toBe('GET'); + expect(lastRequest.url).toBe('/sources'); + expect(lastRequest.headers.authorization).toBe( + 'Bearer prod_test_access_token', + ); + + const output = parseJson(result.stdout) as Record; + expect(output.has_more).toBe(true); + expect(Array.isArray(output.data)).toBe(true); + const data = output.data as Record[]; + expect(data).toHaveLength(1); + expect(data[0].id).toBe('csmrpd_001'); + expect(data[0].type).toBe('bank_account'); + }); + + it('forwards pagination flags into the query string', async () => { + setNextResponse(200, { + data: [], + }); + + const result = await runProdCli( + 'sources', + 'list', + '--limit', + '5', + '--starting-after', + 'csmrpd_cursor', + '--ending-before', + 'csmrpd_prev', + '--json', + ); + + expect(result.exitCode).toBe(0); + expect(lastRequest.url).toContain('/sources?'); + expect(lastRequest.url).toContain('limit=5'); + expect(lastRequest.url).toContain('starting_after=csmrpd_cursor'); + expect(lastRequest.url).toContain('ending_before=csmrpd_prev'); + }); + + it('rejects unauthenticated requests before hitting the API', async () => { + storage.clearAuth(); + + const result = await runProdCli('sources', 'list', '--json'); + + expect(result.exitCode).toBe(1); + const output = parseJson(result.stdout) as Record; + expect(output.code).toBe('NOT_AUTHENTICATED'); + expect(String(output.message)).toMatch(/auth login/i); + const sourcesRequest = requests.find((r) => r.url === '/sources'); + expect(sourcesRequest).toBeUndefined(); + }); + + it('does not show sources in root help', async () => { + const result = await runProdCli('--help'); + + expect(result.exitCode).toBe(0); + expect(result.stdout + result.stderr).not.toContain('sources'); + }); + }); + describe('auth login', () => { const DEVICE_CODE_RESPONSE = { device_code: 'test_device_code', diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index ecddba9..f958c0a 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -8,6 +8,7 @@ import { createPaymentMethodsCli } from './commands/payment-methods'; import { createReportCli } from './commands/report'; import { createServeCli } from './commands/serve'; import { createShippingAddressCli } from './commands/shipping-address'; +import { createSourcesCli } from './commands/sources'; import { createSpendRequestCli } from './commands/spend-request'; import { createTransactionsCli } from './commands/transactions'; import { createUserInfoCli } from './commands/user-info'; @@ -62,20 +63,26 @@ const authRepo = factory.createAuthResource(); const spendRequestRepo = factory.createSpendRequestResource(); const requestedCommand = process.argv[2]; -const transactionsCli = +const hiddenCli = requestedCommand === 'transactions' ? createTransactionsCli( () => factory.createTransactionsResource(), authStorage, envAccessToken, ) - : null; -if (transactionsCli) { + : requestedCommand === 'sources' + ? createSourcesCli( + () => factory.createSourcesResource(), + authStorage, + envAccessToken, + ) + : null; +if (hiddenCli) { process.argv.splice(2, 1); } const cli = - transactionsCli ?? + hiddenCli ?? Cli.create('link-cli', { description: 'Create a secure, one-time payment credential from a Link wallet to let agents complete purchases on behalf of users.', @@ -98,7 +105,7 @@ if (!isAgent && process.stdout.isTTY) { } } -if (!transactionsCli) { +if (!hiddenCli) { cli.command( createAuthCli(authRepo, getUpdateInfo, authStorage, envAccessToken), ); diff --git a/packages/cli/src/commands/sources/__tests__/sources.test.tsx b/packages/cli/src/commands/sources/__tests__/sources.test.tsx new file mode 100644 index 0000000..a8c12cf --- /dev/null +++ b/packages/cli/src/commands/sources/__tests__/sources.test.tsx @@ -0,0 +1,160 @@ +import type { ISourcesResource, SourcesPage } from '@stripe/link-sdk'; +import { render } from 'ink-testing-library'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { sanitizeResource } from '../../../utils/resource-factory'; +import { SourcesList } from '../list'; + +const ESCAPE_PAYLOAD = '\x1b[2JEvil\rHidden'; +const CLEAN_TEXT = 'EvilHidden'; + +function makeResource(page: SourcesPage): ISourcesResource { + return sanitizeResource({ + listSources: vi.fn(async () => page), + } as unknown as ISourcesResource); +} + +function setTerminalWidth(columns: number) { + Object.defineProperty(process.stdout, 'columns', { + configurable: true, + value: columns, + }); +} + +describe('sources list component', () => { + afterEach(() => { + Object.defineProperty(process.stdout, 'columns', { + configurable: true, + value: undefined, + }); + }); + + it('renders source details', async () => { + setTerminalWidth(160); + const resource = makeResource({ + data: [ + { + id: 'csmrpd_1', + name: 'Checking 1234', + type: 'bank_account', + capabilities: { + balances: { status: 'eligible' }, + transactions: { status: 'pending' }, + }, + external_connection: { status: 'active' }, + }, + ], + }); + + const { lastFrame } = render( + {}} />, + ); + + await vi.waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('Name'); + expect(frame).toContain('Type'); + expect(frame).toContain('ID'); + expect(frame).toContain('Checking 1234'); + expect(frame).toContain('bank_account'); + expect(frame).toContain('csmrpd_1'); + expect(frame).toContain('balances:eligible'); + expect(frame).toContain('transactions'); + expect(frame).toContain('active'); + }); + }); + + it('clips wide table columns in narrow terminals', async () => { + setTerminalWidth(72); + const resource = makeResource({ + data: [ + { + id: 'csmrpd_1', + name: 'Checking 1234', + type: 'bank_account', + capabilities: { + transactions: { status: 'pending' }, + }, + external_connection: { status: 'active' }, + }, + ], + }); + + const { lastFrame } = render( + {}} />, + ); + + await vi.waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('Checking 1234'); + expect(frame).toContain('Name'); + expect(frame).toContain('Type'); + expect(frame).toContain('ID'); + expect(frame).toContain('Capabi'); + expect(frame).not.toContain('Details'); + + const tableLines = frame + ?.split('\n') + .map((line) => line.trimEnd()) + .filter((line) => line.trim().length > 0 && line !== 'Sources'); + expect(tableLines?.every((line) => line.length <= 72)).toBe(true); + }); + }); + + it('renders an empty state when there are no sources', async () => { + const resource = makeResource({ data: [] }); + + const { lastFrame } = render( + {}} />, + ); + + await vi.waitFor(() => { + expect(lastFrame()).toContain('No sources found'); + }); + }); + + it('sanitizes escape sequences in source fields', async () => { + const resource = makeResource({ + data: [ + { + id: 'csmrpd_1', + name: ESCAPE_PAYLOAD, + type: ESCAPE_PAYLOAD, + }, + ], + }); + + const { lastFrame } = render( + {}} />, + ); + + await vi.waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain(CLEAN_TEXT); + expect(frame).not.toContain('\x1b[2J'); + expect(frame).not.toContain('\r'); + }); + }); + + it('renders pagination metadata when available', async () => { + const resource = makeResource({ + data: [ + { + id: 'csmrpd_2', + name: 'Savings', + type: 'bank_account', + }, + ], + has_more: true, + }); + + const { lastFrame } = render( + {}} />, + ); + + await vi.waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('has_more: true'); + expect(frame).toContain('--starting-after csmrpd_2'); + }); + }); +}); diff --git a/packages/cli/src/commands/sources/index.tsx b/packages/cli/src/commands/sources/index.tsx new file mode 100644 index 0000000..76d3fde --- /dev/null +++ b/packages/cli/src/commands/sources/index.tsx @@ -0,0 +1,54 @@ +import type { + AuthStorage, + ISourcesResource, + ListSourcesParams, +} from '@stripe/link-sdk'; +import { Cli } from 'incur'; +import React from 'react'; +import { renderInteractive } from '../../utils/render-interactive'; +import { requireAuth } from '../../utils/require-auth'; +import { SourcesList } from './list'; +import { listOptions } from './schema'; + +export function createSourcesCli( + createResource: () => ISourcesResource, + authStorage?: AuthStorage, + envAccessToken?: string, +) { + const cli = Cli.create('sources', { + description: 'List sources from your Link wallet', + }); + + cli.command('list', { + description: 'List sources from your Link wallet', + options: listOptions, + outputPolicy: 'agent-only' as const, + middleware: [requireAuth(authStorage, envAccessToken)], + async run(c) { + const opts = c.options; + const resource = createResource(); + + const params: ListSourcesParams = {}; + if (opts.limit !== undefined) params.limit = opts.limit; + if (opts.startingAfter !== undefined) + params.starting_after = opts.startingAfter; + if (opts.endingBefore !== undefined) + params.ending_before = opts.endingBefore; + + if (!c.agent && !c.formatExplicit) { + return renderInteractive( + {}} + />, + () => resource.listSources(params), + ); + } + + return resource.listSources(params); + }, + }); + + return cli; +} diff --git a/packages/cli/src/commands/sources/list.tsx b/packages/cli/src/commands/sources/list.tsx new file mode 100644 index 0000000..88dd5f5 --- /dev/null +++ b/packages/cli/src/commands/sources/list.tsx @@ -0,0 +1,250 @@ +import type { + ISourcesResource, + ListSourcesParams, + Source, + SourcesPage, +} from '@stripe/link-sdk'; +import { Box, Text } from 'ink'; +import Spinner from 'ink-spinner'; +import type React from 'react'; +import { useCallback } from 'react'; +import { useAsyncAction } from '../../hooks/use-async-action'; + +interface SourcesListProps { + resource: ISourcesResource; + params?: ListSourcesParams; + onComplete: (result: SourcesPage | null) => void; +} + +const COLUMN_GAP = ' '; +const HORIZONTAL_PADDING = 4; + +interface SourceRow { + key: string; + name: string; + type: string; + id: string; + capabilities: string; + external: string; +} + +interface TableColumn { + label: string; + value: (row: SourceRow) => string; + minWidth: number; + maxWidth: number; +} + +function truncateCell(value: string, width: number): string { + if (value.length <= width) { + return value; + } + + if (width <= 3) { + return value.slice(0, width); + } + + return `${value.slice(0, width - 3)}...`; +} + +function formatCell(value: string, width: number): string { + return truncateCell(value, width).padEnd(width); +} + +function sourceId(source: Source, index: number): string { + return typeof source.id === 'string' && source.id.length > 0 + ? source.id + : `source-${index + 1}`; +} + +function statusFromValue(value: unknown): string | null { + if (value && typeof value === 'object' && !Array.isArray(value)) { + const status = (value as Record).status; + return typeof status === 'string' ? status : null; + } + return null; +} + +function formatCapabilities(source: Source): string { + const capabilities = source.capabilities; + if (!capabilities || typeof capabilities !== 'object') { + return '-'; + } + + const entries = Object.entries(capabilities) + .map(([capability, value]) => { + const status = statusFromValue(value); + return status ? `${capability}:${status}` : capability; + }) + .sort(); + + return entries.length > 0 ? entries.join(', ') : '-'; +} + +function formatExternalConnection(source: Source): string { + const status = statusFromValue(source.external_connection); + return status ?? '-'; +} + +function sourceRow(source: Source, index: number): SourceRow { + const capabilities = formatCapabilities(source); + const external = formatExternalConnection(source); + + return { + key: sourceId(source, index), + name: source.name ?? 'Source', + type: source.type ?? '-', + id: sourceId(source, index), + capabilities, + external, + }; +} + +function tableColumns(): TableColumn[] { + return [ + { label: 'Name', value: (row) => row.name, minWidth: 14, maxWidth: 24 }, + { label: 'Type', value: (row) => row.type, minWidth: 10, maxWidth: 14 }, + { label: 'ID', value: (row) => row.id, minWidth: 16, maxWidth: 48 }, + { + label: 'Capabilities', + value: (row) => row.capabilities, + minWidth: 8, + maxWidth: 48, + }, + { + label: 'External connection status', + value: (row) => row.external, + minWidth: 8, + maxWidth: 36, + }, + ]; +} + +function distributeWidths( + columns: TableColumn[], + availableWidth: number, +): number[] { + const gapWidth = COLUMN_GAP.length * Math.max(0, columns.length - 1); + const contentWidth = Math.max(columns.length, availableWidth - gapWidth); + const widths = columns.map((column) => column.minWidth); + let remaining = + contentWidth - widths.reduce((total, width) => total + width, 0); + + while (remaining > 0) { + let changed = false; + + for (let index = 0; index < columns.length && remaining > 0; index += 1) { + if (widths[index] >= columns[index].maxWidth) { + continue; + } + + widths[index] += 1; + remaining -= 1; + changed = true; + } + + if (!changed) { + break; + } + } + + return widths; +} + +function renderTableRows(rows: SourceRow[], terminalWidth: number) { + const columns = tableColumns(); + const availableWidth = contentWidth(terminalWidth); + const widths = distributeWidths(columns, availableWidth); + const headerRow = columns + .map((column, index) => formatCell(column.label, widths[index])) + .join(COLUMN_GAP) + .slice(0, availableWidth); + const separatorRow = '-'.repeat(headerRow.length).slice(0, availableWidth); + const bodyRows = rows.map((row) => + columns + .map((column, index) => formatCell(column.value(row), widths[index])) + .join(COLUMN_GAP) + .slice(0, availableWidth), + ); + + return { headerRow, separatorRow, bodyRows }; +} + +function contentWidth(terminalWidth: number): number { + return Math.max(1, terminalWidth - HORIZONTAL_PADDING); +} + +export const SourcesList: React.FC = ({ + resource, + params, + onComplete, +}) => { + const action = useCallback( + () => resource.listSources(params), + [resource, params], + ); + const { + status, + data: page, + error, + } = useAsyncAction(action, onComplete); + const sources = page?.data ?? []; + const nextCursor = + page?.has_more && sources.length > 0 + ? sources[sources.length - 1].id + : null; + const rows = sources.map(sourceRow); + const terminalWidth = process.stdout.columns ?? 140; + const { headerRow, separatorRow, bodyRows } = renderTableRows( + rows, + terminalWidth, + ); + + if (status === 'loading') { + return ( + + + Loading sources... + + + ); + } + + if (status === 'error') { + return ( + + Failed to load sources + {error} + + ); + } + + if (sources.length === 0) { + return ( + + No sources found + + ); + } + + return ( + + Sources + + {headerRow} + {separatorRow} + {bodyRows.map((row, index) => ( + {row} + ))} + + {page?.has_more !== undefined ? ( + + has_more: {String(page.has_more)} + {typeof nextCursor === 'string' && nextCursor.length > 0 ? ( + {`next page: --starting-after ${nextCursor}`} + ) : null} + + ) : null} + + ); +}; diff --git a/packages/cli/src/commands/sources/schema.ts b/packages/cli/src/commands/sources/schema.ts new file mode 100644 index 0000000..4a08cfd --- /dev/null +++ b/packages/cli/src/commands/sources/schema.ts @@ -0,0 +1,19 @@ +import { z } from 'incur'; + +export const listOptions = z.object({ + limit: z.coerce + .number() + .int() + .positive() + .max(100) + .optional() + .describe('Maximum number of sources to return (1-100).'), + startingAfter: z + .string() + .optional() + .describe('Cursor: return sources after this source ID.'), + endingBefore: z + .string() + .optional() + .describe('Cursor: return sources before this source ID.'), +}); diff --git a/packages/cli/src/utils/resource-factory.ts b/packages/cli/src/utils/resource-factory.ts index 3b0bfa4..8af63b0 100644 --- a/packages/cli/src/utils/resource-factory.ts +++ b/packages/cli/src/utils/resource-factory.ts @@ -3,6 +3,7 @@ import { type IPaymentMethodsResource, type IReportResource, type IShippingAddressResource, + type ISourcesResource, type ISpendRequestResource, type ITransactionsResource, type IUserInfoResource, @@ -11,6 +12,7 @@ import { PaymentMethodsResource, ReportResource, ShippingAddressResource, + SourcesResource, SpendRequestResource, TransactionsResource, UserInfoResource, @@ -82,6 +84,7 @@ export class ResourceFactory { private shippingAddressResource?: IShippingAddressResource; private userInfoResource?: IUserInfoResource; private transactionsResource?: ITransactionsResource; + private sourcesResource?: ISourcesResource; private webBotAuthResource?: IWebBotAuthResource; private reportResource?: IReportResource; @@ -237,6 +240,23 @@ export class ResourceFactory { return this.transactionsResource; } + createSourcesResource(): ISourcesResource { + if (this.sourcesResource) { + return this.sourcesResource; + } + + const getAccessToken = this.createSdkAccessTokenProvider(); + this.sourcesResource = sanitizeResource( + new SourcesResource({ + verbose: this.verbose, + defaultHeaders: this.defaultHeaders, + getAccessToken, + }), + ); + + return this.sourcesResource; + } + createWebBotAuthResource(): IWebBotAuthResource { if (this.webBotAuthResource) { return this.webBotAuthResource; diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 901aba9..cb8c0ae 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -2,12 +2,14 @@ import type { LinkOptions } from '@/config'; import type { IPaymentMethodsResource, IShippingAddressResource, + ISourcesResource, ISpendRequestResource, ITransactionsResource, IUserInfoResource, } from '@/resources/interfaces'; import { PaymentMethodsResource } from '@/resources/payment-methods'; 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'; @@ -18,6 +20,7 @@ export class Link { readonly shippingAddresses: IShippingAddressResource; readonly userInfo: IUserInfoResource; readonly transactions: ITransactionsResource; + readonly sources: ISourcesResource; constructor(options: LinkOptions = {}) { this.spendRequests = new SpendRequestResource(options); @@ -25,6 +28,7 @@ export class Link { this.shippingAddresses = new ShippingAddressResource(options); this.userInfo = new UserInfoResource(options); this.transactions = new TransactionsResource(options); + this.sources = new SourcesResource(options); } } diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index ad84de8..caa52b4 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -11,6 +11,7 @@ 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/report'; export { MemoryStorage, Storage, storage } from './utils/storage'; export type { diff --git a/packages/sdk/src/resources/__tests__/sources.test.ts b/packages/sdk/src/resources/__tests__/sources.test.ts new file mode 100644 index 0000000..caf5c9d --- /dev/null +++ b/packages/sdk/src/resources/__tests__/sources.test.ts @@ -0,0 +1,162 @@ +import { SourcesResource } from '@/resources/sources'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockFetch = vi.fn(); +const getAccessToken = vi.fn(); + +function mockFetchResponse(status: number, body: Record) { + mockFetch.mockResolvedValue({ + status, + statusText: '', + headers: new Headers(), + text: async () => JSON.stringify(body), + }); +} + +describe('SourcesResource', () => { + let repo: SourcesResource; + + beforeEach(() => { + vi.stubGlobal('fetch', mockFetch); + vi.clearAllMocks(); + vi.stubEnv('LINK_API_BASE_URL', undefined); + getAccessToken.mockResolvedValue('test_token'); + repo = new SourcesResource({ getAccessToken }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + }); + + it('GETs the Link API sources endpoint with bearer auth', async () => { + mockFetchResponse(200, { + data: [ + { + id: 'csmrpd_123', + name: 'Checking 1234', + type: 'bank_account', + capabilities: { + transactions: { status: 'eligible' }, + }, + }, + ], + has_more: true, + }); + + const result = await repo.listSources(); + + expect(mockFetch).toHaveBeenCalledOnce(); + const [url, opts] = mockFetch.mock.calls[0]; + expect(url).toBe('https://api.link.com/sources'); + expect(opts.method).toBe('GET'); + expect(opts.headers.Authorization).toBe('Bearer test_token'); + + expect(result).toEqual({ + data: [ + { + id: 'csmrpd_123', + name: 'Checking 1234', + type: 'bank_account', + capabilities: { + transactions: { status: 'eligible' }, + }, + }, + ], + has_more: true, + }); + }); + + it('encodes optional list params in the query string', async () => { + mockFetchResponse(200, { data: [] }); + + await repo.listSources({ + limit: 50, + starting_after: 'cursor_a', + ending_before: 'cursor_b', + }); + + const url = new URL(mockFetch.mock.calls[0][0]); + expect(url.searchParams.get('limit')).toBe('50'); + expect(url.searchParams.get('starting_after')).toBe('cursor_a'); + expect(url.searchParams.get('ending_before')).toBe('cursor_b'); + }); + + it('resolves the base URL from LINK_API_BASE_URL when set', async () => { + vi.stubEnv('LINK_API_BASE_URL', 'https://api.qa.link.com'); + repo = new SourcesResource({ getAccessToken }); + mockFetchResponse(200, { data: [] }); + + await repo.listSources(); + + expect(mockFetch.mock.calls[0][0]).toBe( + 'https://api.qa.link.com/sources', + ); + }); + + it('refreshes the token and retries once on 401', async () => { + mockFetch + .mockResolvedValueOnce({ + status: 401, + statusText: '', + headers: new Headers(), + text: async () => JSON.stringify({ error: 'expired_token' }), + }) + .mockResolvedValueOnce({ + status: 200, + statusText: '', + headers: new Headers(), + text: async () => JSON.stringify({ data: [] }), + }); + getAccessToken + .mockResolvedValueOnce('test_token') + .mockResolvedValueOnce('fresh_token'); + + const result = await repo.listSources(); + + expect(result).toEqual({ data: [] }); + expect(getAccessToken).toHaveBeenNthCalledWith(1); + expect(getAccessToken).toHaveBeenNthCalledWith(2, { forceRefresh: true }); + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch.mock.calls[1][1].headers.Authorization).toBe( + 'Bearer fresh_token', + ); + }); + + it('throws API errors with the response message', async () => { + mockFetchResponse(500, { message: 'boom' }); + + await expect(repo.listSources()).rejects.toThrow( + 'Failed to list sources (500): boom', + ); + }); + + it('formats nested API error messages', async () => { + mockFetchResponse(401, { + error: { + message: 'Access token is missing required scopes: source_details:read', + }, + }); + getAccessToken + .mockResolvedValueOnce('test_token') + .mockResolvedValueOnce('fresh_token'); + + await expect(repo.listSources()).rejects.toThrow( + 'Failed to list sources (401): Access token is missing required scopes: source_details:read', + ); + }); + + it('throws when no access token is available', async () => { + getAccessToken.mockRejectedValueOnce(new Error('Missing access token')); + + await expect(repo.listSources()).rejects.toThrow('Missing access token'); + }); + + it('throws when the response shape is invalid', async () => { + mockFetchResponse(200, { data: 'not an array' }); + + await expect(repo.listSources()).rejects.toThrow( + 'Failed to list sources (200): invalid response shape: Expected sources to be an array', + ); + }); +}); diff --git a/packages/sdk/src/resources/base.ts b/packages/sdk/src/resources/base.ts new file mode 100644 index 0000000..4e4da8b --- /dev/null +++ b/packages/sdk/src/resources/base.ts @@ -0,0 +1,147 @@ +import { + type LinkOptions, + requireFetchImplementation, + resolveLinkSdkConfig, +} from '@/config'; +import { LinkApiError, LinkTransportError } from '@/errors'; +import type { AccessTokenProvider } from '@/resources/interfaces'; + +export interface ApiFetchOptions { + method: string; + url: string; + headers?: Record; +} + +export interface ApiFetchResult { + status: number; + data: unknown; + rawBody: string; +} + +export function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +export function requireBoolean(value: unknown, field: string): boolean { + if (typeof value !== 'boolean') { + throw new TypeError(`Expected ${field} to be a boolean`); + } + return value; +} + +export function extractErrorMessage(data: unknown, rawBody: string): string { + if (isRecord(data)) { + if (typeof data.error === 'string') { + return data.error; + } + if (isRecord(data.error)) { + const nested = data.error; + if (typeof nested.message === 'string') { + return nested.message; + } + if (typeof nested.code === 'string') { + return nested.code; + } + } + if (typeof data.message === 'string') { + return data.message; + } + } + + return rawBody || 'unknown error'; +} + +export abstract class BaseResource { + protected readonly verbose: boolean; + protected readonly getAccessToken: AccessTokenProvider; + protected readonly fetchImpl: typeof globalThis.fetch; + protected readonly endpoint: string; + protected readonly logger: { debug(message: string): void }; + + constructor(options: LinkOptions, endpointPath: string) { + const config = resolveLinkSdkConfig(options); + this.verbose = config.verbose; + this.getAccessToken = config.getAccessToken; + this.fetchImpl = requireFetchImplementation(config); + this.endpoint = `${config.apiBaseUrl}${endpointPath}`; + this.logger = config.logger; + } + + protected async rawFetch(opts: ApiFetchOptions): Promise { + if (this.verbose) { + const redactedHeaders = { ...opts.headers }; + if (redactedHeaders.Authorization) + redactedHeaders.Authorization = 'Bearer '; + this.logger.debug(`> ${opts.method} ${opts.url}`); + this.logger.debug(` Headers: ${JSON.stringify(redactedHeaders)}`); + } + + let response: Response; + try { + response = await this.fetchImpl(opts.url, { + method: opts.method, + headers: opts.headers, + }); + } catch (error) { + throw new LinkTransportError( + `Request failed: ${opts.method} ${opts.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.verbose) { + this.logger.debug(`< ${response.status} ${response.statusText}`); + response.headers.forEach((value, key) => { + this.logger.debug(` ${key}: ${value}`); + }); + this.logger.debug(rawBody); + } + + return { status: response.status, data, rawBody }; + } + + protected async apiFetch(opts: ApiFetchOptions): Promise { + const token = await this.getAccessToken(); + const authedOpts = { + ...opts, + headers: { + ...opts.headers, + Authorization: `Bearer ${token}`, + }, + }; + + const res = await this.rawFetch(authedOpts); + + if (res.status === 401) { + const refreshedToken = await this.getAccessToken({ forceRefresh: true }); + authedOpts.headers.Authorization = `Bearer ${refreshedToken}`; + return this.rawFetch(authedOpts); + } + + return res; + } + + protected throwApiError( + operation: string, + status: number, + data: unknown, + rawBody: string, + cause?: unknown, + ): never { + const msg = extractErrorMessage(data, rawBody); + throw new LinkApiError(`Failed to ${operation} (${status}): ${msg}`, { + status, + rawBody, + details: data, + cause, + }); + } +} diff --git a/packages/sdk/src/resources/interfaces.ts b/packages/sdk/src/resources/interfaces.ts index 0628fdb..8877c19 100644 --- a/packages/sdk/src/resources/interfaces.ts +++ b/packages/sdk/src/resources/interfaces.ts @@ -6,6 +6,7 @@ import type { PaymentMethod, RequestApprovalResponse, ShippingAddressRecord, + SourcesPage, SpendRequest, Total, TransactionOrigin, @@ -115,6 +116,17 @@ export interface ITransactionsResource { listTransactions(params?: ListTransactionsParams): Promise; } +export interface ListSourcesParams { + limit?: number; + starting_after?: string; + ending_before?: string; +} + +export interface ISourcesResource { + list(params?: ListSourcesParams): Promise; + listSources(params?: ListSourcesParams): Promise; +} + export const REPORT_OUTCOMES = ['success', 'blocked', 'abandoned'] as const; export type ReportOutcome = (typeof REPORT_OUTCOMES)[number]; diff --git a/packages/sdk/src/resources/sources.ts b/packages/sdk/src/resources/sources.ts new file mode 100644 index 0000000..3a22d6b --- /dev/null +++ b/packages/sdk/src/resources/sources.ts @@ -0,0 +1,85 @@ +import type { LinkOptions } from '@/config'; +import { LinkApiError } from '@/errors'; +import { BaseResource, isRecord, requireBoolean } from '@/resources/base'; +import type { + ISourcesResource, + ListSourcesParams, +} from '@/resources/interfaces'; +import type { Source, SourcesPage } from '@/types/index'; + +function normalizeSources(value: unknown): Source[] { + if (!Array.isArray(value)) { + throw new TypeError('Expected sources to be an array'); + } + + return value.map((item, index) => { + if (!isRecord(item)) { + throw new TypeError(`Expected sources[${index}] to be an object`); + } + return item as Source; + }); +} + +function normalizeSourcesPage(value: unknown): SourcesPage { + if (!isRecord(value)) { + throw new TypeError('Expected response body to be an object'); + } + + const { data, has_more, ...rest } = value; + const normalized = normalizeSources(data); + + return { + ...rest, + data: normalized, + ...(has_more !== undefined + ? { has_more: requireBoolean(has_more, 'has_more') } + : {}), + }; +} + +export class SourcesResource extends BaseResource implements ISourcesResource { + constructor(options: LinkOptions = {}) { + super(options, '/sources'); + } + + private buildUrl(params: ListSourcesParams): string { + const url = new URL(this.endpoint); + + if (params.limit !== undefined) { + url.searchParams.set('limit', String(params.limit)); + } + if (params.starting_after !== undefined) { + url.searchParams.set('starting_after', params.starting_after); + } + if (params.ending_before !== undefined) { + url.searchParams.set('ending_before', params.ending_before); + } + + return url.toString(); + } + + list(params: ListSourcesParams = {}): Promise { + return this.listSources(params); + } + + async listSources(params: ListSourcesParams = {}): Promise { + const { status, data, rawBody } = await this.apiFetch({ + method: 'GET', + url: this.buildUrl(params), + }); + + if (status < 200 || status >= 300) { + this.throwApiError('list sources', status, data, rawBody); + } + + try { + return normalizeSourcesPage(data); + } catch (error) { + const reason = error instanceof Error ? `: ${error.message}` : ''; + throw new LinkApiError( + `Failed to list sources (${status}): invalid response shape${reason}`, + { status, rawBody, details: data, cause: error }, + ); + } + } +} diff --git a/packages/sdk/src/resources/transactions.ts b/packages/sdk/src/resources/transactions.ts index 70c2e86..cff88bb 100644 --- a/packages/sdk/src/resources/transactions.ts +++ b/packages/sdk/src/resources/transactions.ts @@ -1,11 +1,7 @@ -import { - type LinkOptions, - requireFetchImplementation, - resolveLinkSdkConfig, -} from '@/config'; -import { LinkApiError, LinkTransportError } from '@/errors'; +import type { LinkOptions } from '@/config'; +import { LinkApiError } from '@/errors'; +import { BaseResource, isRecord, requireBoolean } from '@/resources/base'; import type { - AccessTokenProvider, ITransactionsResource, ListTransactionsParams, } from '@/resources/interfaces'; @@ -15,16 +11,6 @@ import type { TransactionsPage, } from '@/types/index'; -interface ApiFetchOptions { - method: string; - url: string; - headers?: Record; -} - -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} - function requireString(value: unknown, field: string): string { if (typeof value !== 'string') { throw new TypeError(`Expected ${field} to be a string`); @@ -49,13 +35,6 @@ function requireNumber(value: unknown, field: string): number { return value; } -function requireBoolean(value: unknown, field: string): boolean { - if (typeof value !== 'boolean') { - throw new TypeError(`Expected ${field} to be a boolean`); - } - return value; -} - function requireTransactionOrigin( value: unknown, field: string, @@ -126,86 +105,12 @@ function normalizeTransactionsPage(value: unknown): TransactionsPage { }; } -export class TransactionsResource implements ITransactionsResource { - private readonly verbose: boolean; - private readonly getAccessToken: AccessTokenProvider; - private readonly fetchImpl: typeof globalThis.fetch; - private readonly endpoint: string; - private readonly logger: { debug(message: string): void }; - +export class TransactionsResource + extends BaseResource + implements ITransactionsResource +{ constructor(options: LinkOptions = {}) { - const config = resolveLinkSdkConfig(options); - this.verbose = config.verbose; - this.getAccessToken = config.getAccessToken; - this.fetchImpl = requireFetchImplementation(config); - this.endpoint = `${config.apiBaseUrl}/transactions`; - this.logger = config.logger; - } - - private async rawFetch( - opts: ApiFetchOptions, - ): Promise<{ status: number; data: unknown; rawBody: string }> { - if (this.verbose) { - const redactedHeaders = { ...opts.headers }; - if (redactedHeaders.Authorization) - redactedHeaders.Authorization = 'Bearer '; - this.logger.debug(`> ${opts.method} ${opts.url}`); - this.logger.debug(` Headers: ${JSON.stringify(redactedHeaders)}`); - } - - let response: Response; - try { - response = await this.fetchImpl(opts.url, { - method: opts.method, - headers: opts.headers, - }); - } catch (error) { - throw new LinkTransportError( - `Request failed: ${opts.method} ${opts.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.verbose) { - this.logger.debug(`< ${response.status} ${response.statusText}`); - response.headers.forEach((value, key) => { - this.logger.debug(` ${key}: ${value}`); - }); - this.logger.debug(rawBody); - } - - return { status: response.status, data, rawBody }; - } - - private async apiFetch( - opts: ApiFetchOptions, - ): Promise<{ status: number; data: unknown; rawBody: string }> { - const token = await this.getAccessToken(); - const authedOpts = { - ...opts, - headers: { - ...opts.headers, - Authorization: `Bearer ${token}`, - }, - }; - - const res = await this.rawFetch(authedOpts); - - if (res.status === 401) { - const refreshedToken = await this.getAccessToken({ forceRefresh: true }); - authedOpts.headers.Authorization = `Bearer ${refreshedToken}`; - return this.rawFetch(authedOpts); - } - - return res; + super(options, '/transactions'); } private buildUrl(params: ListTransactionsParams): string { @@ -254,15 +159,7 @@ export class TransactionsResource implements ITransactionsResource { }); if (status < 200 || status >= 300) { - const body = data as Record | null; - const msg = - (body?.error as string | undefined) ?? - (body?.message as string | undefined) ?? - (rawBody || 'unknown error'); - throw new LinkApiError( - `Failed to list transactions (${status}): ${msg}`, - { status, rawBody, details: data }, - ); + this.throwApiError('list transactions', status, data, rawBody); } try { @@ -270,7 +167,7 @@ export class TransactionsResource implements ITransactionsResource { } catch (error) { const reason = error instanceof Error ? `: ${error.message}` : ''; throw new LinkApiError( - `Failed to list transactions (200): invalid response shape${reason}`, + `Failed to list transactions (${status}): invalid response shape${reason}`, { status, rawBody, details: data, cause: error }, ); } diff --git a/packages/sdk/src/types/index.ts b/packages/sdk/src/types/index.ts index 83fddbc..a97d9a6 100644 --- a/packages/sdk/src/types/index.ts +++ b/packages/sdk/src/types/index.ts @@ -192,6 +192,24 @@ export interface TransactionsPage { [key: string]: unknown; } +export interface Source { + id?: string | null; + name?: string | null; + type?: string | null; + capabilities?: Record | null; + external_connection?: Record | null; + granted_scopes?: string[] | null; + bank_account?: Record | null; + card?: Record | null; + [key: string]: unknown; +} + +export interface SourcesPage { + data: Source[]; + has_more?: boolean; + [key: string]: unknown; +} + export interface WebBotAuthBlock { signature: string; signature_input: string;