From 19355eb999dd93fc139956a590917f5541a6eb77 Mon Sep 17 00:00:00 2001 From: Sylvia Wang Date: Mon, 1 Jun 2026 10:03:37 -0400 Subject: [PATCH 1/9] replace approval polling with redirect URI callback --- .../spend-request/approval-waiting-view.tsx | 12 +- .../cli/src/commands/spend-request/create.tsx | 104 ++++++++++++------ .../spend-request/request-approval.tsx | 90 ++++++++++----- .../spend-request/use-approval-polling.ts | 87 --------------- .../cli/src/utils/local-callback-server.ts | 39 +++++++ packages/sdk/src/resources/interfaces.ts | 5 +- packages/sdk/src/resources/spend-request.ts | 10 +- 7 files changed, 188 insertions(+), 159 deletions(-) delete mode 100644 packages/cli/src/commands/spend-request/use-approval-polling.ts create mode 100644 packages/cli/src/utils/local-callback-server.ts diff --git a/packages/cli/src/commands/spend-request/approval-waiting-view.tsx b/packages/cli/src/commands/spend-request/approval-waiting-view.tsx index c6e68ad..3652690 100644 --- a/packages/cli/src/commands/spend-request/approval-waiting-view.tsx +++ b/packages/cli/src/commands/spend-request/approval-waiting-view.tsx @@ -4,12 +4,10 @@ import type React from 'react'; import { AppDownloadQrCodes } from './app-download-qr-codes'; interface ApprovalWaitingViewProps { - status: 'waiting' | 'polling'; approvalUrl: string; } export const ApprovalWaitingView: React.FC = ({ - status, approvalUrl, }) => ( @@ -30,13 +28,9 @@ export const ApprovalWaitingView: React.FC = ({ - {status === 'polling' ? ( - - Waiting for approval... - - ) : ( - Waiting... - )} + + Waiting for approval... + ); diff --git a/packages/cli/src/commands/spend-request/create.tsx b/packages/cli/src/commands/spend-request/create.tsx index 09dabb0..fa0b954 100644 --- a/packages/cli/src/commands/spend-request/create.tsx +++ b/packages/cli/src/commands/spend-request/create.tsx @@ -3,15 +3,16 @@ import type { ISpendRequestResource, SpendRequest, } from '@stripe/link-sdk'; -import { Box, Text } from 'ink'; +import { Box, Text, useInput } from 'ink'; import Spinner from 'ink-spinner'; import type React from 'react'; -import { useCallback, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { DISPLAY_DELAY_MS } from '../../utils/constants'; import { writeCredentialFile } from '../../utils/credential-output'; +import { startCallbackServer } from '../../utils/local-callback-server'; +import { openUrl } from '../../utils/open-url'; import { AppDownloadQrCodes } from './app-download-qr-codes'; import { ApprovalWaitingView } from './approval-waiting-view'; -import { useApprovalPolling } from './use-approval-polling'; interface CreateSpendRequestProps { repository: ISpendRequestResource; @@ -31,52 +32,94 @@ export const CreateSpendRequest: React.FC = ({ onComplete, }) => { const [status, setStatus] = useState< - 'creating' | 'waiting' | 'polling' | 'success' | 'error' + 'creating' | 'waiting' | 'success' | 'error' >('creating'); const [request, setRequest] = useState(null); + const [approvalUrl, setApprovalUrl] = useState(''); const [error, setError] = useState(''); const [outputFilePath, setOutputFilePath] = useState(null); const [fileError, setFileError] = useState(''); - const approvalUrl = request?.approval_url ?? ''; - - const onSuccess = useCallback( - (result: SpendRequest) => setRequest(result), - [], + useInput( + (_input, key) => { + if (key.return && approvalUrl) openUrl(approvalUrl); + }, + { isActive: status === 'waiting' }, ); - const onError = useCallback((msg: string) => setError(msg), []); - - useApprovalPolling({ - status, - setStatus, - approvalUrl, - repository, - requestId: request?.id ?? null, - onComplete, - onSuccess, - onError, - }); useEffect(() => { + let close: (() => void) | null = null; + let cancelled = false; + const create = async () => { try { - const result = await repository.createSpendRequest(params); + let server: Awaited> | null = + null; + if (requestApproval) { + server = await startCallbackServer(); + close = server.close; + } + + // Strip request_approval so the spend request starts in `created` state; we call requestApproval explicitly below. + const { request_approval: _, ...createParams } = params; + const result = await repository.createSpendRequest(createParams); + if (cancelled) return; setRequest(result); - if (requestApproval) { - setStatus('waiting'); - } else { + if (!requestApproval || !server) { setStatus('success'); setTimeout(() => onComplete(result), DISPLAY_DELAY_MS); + return; + } + + const approval = await repository.requestApproval(result.id, { + redirect_uri: server.redirectUri, + }); + if (cancelled) return; + + setApprovalUrl(approval.approval_link); + setStatus('waiting'); + + const { approved } = await server.waitForCallback(); + if (cancelled) return; + + const final = await repository.retrieve(result.id); + if (cancelled) return; + + if (!final) { + setError('Spend request not found after approval'); + setStatus('error'); + setTimeout(() => onComplete(null), DISPLAY_DELAY_MS); + return; + } + + if (approved && final.status === 'approved') { + setRequest(final); + setStatus('success'); + setTimeout(() => onComplete(final), DISPLAY_DELAY_MS); + } else { + setError( + `Spend request did not reach approved (status: ${final.status})`, + ); + setStatus('error'); + setTimeout(() => onComplete(final), DISPLAY_DELAY_MS); } } catch (err) { - setError((err as Error).message); - setStatus('error'); - setTimeout(() => onComplete(null), DISPLAY_DELAY_MS); + if (!cancelled) { + setError((err as Error).message); + setStatus('error'); + setTimeout(() => onComplete(null), DISPLAY_DELAY_MS); + } + } finally { + close?.(); } }; create(); + return () => { + cancelled = true; + close?.(); + }; }, [repository, params, requestApproval, onComplete]); useEffect(() => { @@ -206,10 +249,7 @@ export const CreateSpendRequest: React.FC = ({ ✓ Spend request created (ID: {request?.id}) - + ); }; diff --git a/packages/cli/src/commands/spend-request/request-approval.tsx b/packages/cli/src/commands/spend-request/request-approval.tsx index 3c0415c..e8202a4 100644 --- a/packages/cli/src/commands/spend-request/request-approval.tsx +++ b/packages/cli/src/commands/spend-request/request-approval.tsx @@ -1,10 +1,12 @@ import type { ISpendRequestResource, SpendRequest } from '@stripe/link-sdk'; -import { Box, Text } from 'ink'; +import { Box, Text, useInput } from 'ink'; import Spinner from 'ink-spinner'; import type React from 'react'; -import { useCallback, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; +import { DISPLAY_DELAY_MS } from '../../utils/constants'; +import { startCallbackServer } from '../../utils/local-callback-server'; +import { openUrl } from '../../utils/open-url'; import { ApprovalWaitingView } from './approval-waiting-view'; -import { useApprovalPolling } from './use-approval-polling'; interface RequestApprovalProps { repository: ISpendRequestResource; @@ -18,40 +20,75 @@ export const RequestApproval: React.FC = ({ onComplete, }) => { const [status, setStatus] = useState< - 'requesting' | 'waiting' | 'polling' | 'success' | 'error' + 'requesting' | 'waiting' | 'success' | 'error' >('requesting'); const [approvalUrl, setApprovalUrl] = useState(''); const [result, setResult] = useState(null); const [error, setError] = useState(''); - const onSuccess = useCallback((r: SpendRequest) => setResult(r), []); - const onError = useCallback((msg: string) => setError(msg), []); - - useApprovalPolling({ - status, - setStatus, - approvalUrl, - repository, - requestId: id, - onComplete, - onSuccess, - onError, - }); + useInput( + (_input, key) => { + if (key.return && approvalUrl) openUrl(approvalUrl); + }, + { isActive: status === 'waiting' }, + ); useEffect(() => { - const request = async () => { + let close: (() => void) | null = null; + let cancelled = false; + + const run = async () => { try { - const res = await repository.requestApproval(id); + const server = await startCallbackServer(); + close = server.close; + + const res = await repository.requestApproval(id, { + redirect_uri: server.redirectUri, + }); + if (cancelled) return; + setApprovalUrl(res.approval_link); setStatus('waiting'); + + const { approved } = await server.waitForCallback(); + if (cancelled) return; + + const final = await repository.retrieve(id); + if (cancelled) return; + + if (!final) { + setError('Spend request not found after approval'); + setStatus('error'); + return; + } + + if (approved && final.status === 'approved') { + setResult(final); + setStatus('success'); + setTimeout(() => onComplete(final), DISPLAY_DELAY_MS); + } else { + setError( + `Spend request did not reach approved (status: ${final.status})`, + ); + setStatus('error'); + setTimeout(() => onComplete(final), DISPLAY_DELAY_MS); + } } catch (err) { - setError((err as Error).message); - setStatus('error'); + if (!cancelled) { + setError((err as Error).message); + setStatus('error'); + } + } finally { + close?.(); } }; - request(); - }, [repository, id]); + run(); + return () => { + cancelled = true; + close?.(); + }; + }, [repository, id, onComplete]); if (status === 'requesting') { return ( @@ -105,10 +142,5 @@ export const RequestApproval: React.FC = ({ ); } - return ( - - ); + return ; }; diff --git a/packages/cli/src/commands/spend-request/use-approval-polling.ts b/packages/cli/src/commands/spend-request/use-approval-polling.ts deleted file mode 100644 index 599a3dd..0000000 --- a/packages/cli/src/commands/spend-request/use-approval-polling.ts +++ /dev/null @@ -1,87 +0,0 @@ -import type { ISpendRequestResource, SpendRequest } from '@stripe/link-sdk'; -import { useInput } from 'ink'; -import { useEffect } from 'react'; -import { DISPLAY_DELAY_MS } from '../../utils/constants'; -import { openUrl } from '../../utils/open-url'; -import { pollUntilApproved } from '../../utils/poll-until-approved'; - -export type ApprovalStatus = 'waiting' | 'polling' | 'success' | 'error'; - -interface UseApprovalPollingOptions { - status: string; - setStatus: (s: 'polling' | 'success' | 'error') => void; - approvalUrl: string; - repository: ISpendRequestResource; - requestId: string | null; - onComplete: (result: SpendRequest) => void; - onSuccess: (result: SpendRequest) => void; - onError: (msg: string) => void; -} - -export function useApprovalPolling({ - status, - setStatus, - approvalUrl, - repository, - requestId, - onComplete, - onSuccess, - onError, -}: UseApprovalPollingOptions): void { - const isWaiting = status === 'waiting' || status === 'polling'; - - useInput( - (_input, key) => { - if (key.return && approvalUrl) openUrl(approvalUrl); - }, - { isActive: isWaiting }, - ); - - useEffect(() => { - if (status !== 'waiting') return; - const timeout = setTimeout(() => setStatus('polling'), 1000); - return () => clearTimeout(timeout); - }, [status, setStatus]); - - useEffect(() => { - if (status !== 'polling' || !requestId) return; - - let cancelled = false; - - const poll = async () => { - try { - const final = await pollUntilApproved(repository, requestId); - if (cancelled) return; - if (final.status !== 'approved') { - onError( - `Spend request did not reach approved (status: ${final.status})`, - ); - setStatus('error'); - setTimeout(() => onComplete(final), DISPLAY_DELAY_MS); - return; - } - onSuccess(final); - setStatus('success'); - setTimeout(() => onComplete(final), DISPLAY_DELAY_MS); - } catch (err) { - if (!cancelled) { - onError((err as Error).message); - setStatus('error'); - } - } - }; - - poll(); - return () => { - cancelled = true; - }; - }, [ - status, - requestId, - repository, - onComplete, - onSuccess, - onError, - setStatus, - ]); -} diff --git a/packages/cli/src/utils/local-callback-server.ts b/packages/cli/src/utils/local-callback-server.ts new file mode 100644 index 0000000..268caf5 --- /dev/null +++ b/packages/cli/src/utils/local-callback-server.ts @@ -0,0 +1,39 @@ +import { createServer } from 'node:http'; +import type { AddressInfo } from 'node:net'; + +export interface CallbackResult { + approved: boolean; +} + +export interface CallbackServer { + redirectUri: string; + waitForCallback: () => Promise; + close: () => void; +} + +export function startCallbackServer(): Promise { + return new Promise((resolve, reject) => { + let resolveCallback: ((result: CallbackResult) => void) | undefined; + const callbackPromise = new Promise((res) => { + resolveCallback = res; + }); + + const server = createServer((req, res) => { + const url = new URL(req.url ?? '/', 'http://localhost'); + const approved = url.searchParams.get('status') === 'approved'; + res.writeHead(200).end(); + resolveCallback?.({ approved }); + }); + + server.listen(0, '127.0.0.1', () => { + const port = (server.address() as AddressInfo).port; + resolve({ + redirectUri: `http://localhost:${port}/callback`, + waitForCallback: () => callbackPromise, + close: () => server.close(), + }); + }); + + server.on('error', reject); + }); +} diff --git a/packages/sdk/src/resources/interfaces.ts b/packages/sdk/src/resources/interfaces.ts index b8d2134..90d8b00 100644 --- a/packages/sdk/src/resources/interfaces.ts +++ b/packages/sdk/src/resources/interfaces.ts @@ -63,7 +63,10 @@ export interface ISpendRequestResource { id: string, params: UpdateSpendRequestParams, ): Promise; - requestApproval(id: string): Promise; + requestApproval( + id: string, + options?: { redirect_uri?: string }, + ): Promise; cancel(id: string): Promise; cancelSpendRequest(id: string): Promise; retrieve( diff --git a/packages/sdk/src/resources/spend-request.ts b/packages/sdk/src/resources/spend-request.ts index eb43044..988af36 100644 --- a/packages/sdk/src/resources/spend-request.ts +++ b/packages/sdk/src/resources/spend-request.ts @@ -234,10 +234,18 @@ export class SpendRequestResource implements ISpendRequestResource { return normalizeSpendRequest(data); } - async requestApproval(id: string): Promise { + async requestApproval( + id: string, + options?: { redirect_uri?: string }, + ): Promise { + const body = options?.redirect_uri + ? JSON.stringify({ redirect_uri: options.redirect_uri }) + : undefined; const { status, data, rawBody } = await this.apiFetch({ method: 'POST', url: `${this.spendRequestsEndpoint}/${id}/request_approval`, + headers: body ? { 'Content-Type': 'application/json' } : undefined, + body, }); if (status < 200 || status >= 300) { From d14fc7257a699bf697addc6f7c9de78a428c4712 Mon Sep 17 00:00:00 2001 From: Sylvia Wang Date: Wed, 3 Jun 2026 09:54:32 -0400 Subject: [PATCH 2/9] add support for deny/expired status --- .../cli/src/commands/spend-request/create.tsx | 4 +- .../spend-request/request-approval.tsx | 50 ++++++++++++++++--- .../cli/src/utils/local-callback-server.ts | 29 +++++++++-- 3 files changed, 71 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/commands/spend-request/create.tsx b/packages/cli/src/commands/spend-request/create.tsx index fa0b954..9fb52e8 100644 --- a/packages/cli/src/commands/spend-request/create.tsx +++ b/packages/cli/src/commands/spend-request/create.tsx @@ -80,7 +80,7 @@ export const CreateSpendRequest: React.FC = ({ setApprovalUrl(approval.approval_link); setStatus('waiting'); - const { approved } = await server.waitForCallback(); + const { status: callbackStatus } = await server.waitForCallback(); if (cancelled) return; const final = await repository.retrieve(result.id); @@ -93,7 +93,7 @@ export const CreateSpendRequest: React.FC = ({ return; } - if (approved && final.status === 'approved') { + if (callbackStatus === 'approved') { setRequest(final); setStatus('success'); setTimeout(() => onComplete(final), DISPLAY_DELAY_MS); diff --git a/packages/cli/src/commands/spend-request/request-approval.tsx b/packages/cli/src/commands/spend-request/request-approval.tsx index e8202a4..dc788ab 100644 --- a/packages/cli/src/commands/spend-request/request-approval.tsx +++ b/packages/cli/src/commands/spend-request/request-approval.tsx @@ -20,7 +20,7 @@ export const RequestApproval: React.FC = ({ onComplete, }) => { const [status, setStatus] = useState< - 'requesting' | 'waiting' | 'success' | 'error' + 'requesting' | 'waiting' | 'success' | 'denied' | 'expired' | 'error' >('requesting'); const [approvalUrl, setApprovalUrl] = useState(''); const [result, setResult] = useState(null); @@ -50,7 +50,7 @@ export const RequestApproval: React.FC = ({ setApprovalUrl(res.approval_link); setStatus('waiting'); - const { approved } = await server.waitForCallback(); + const { status: callbackStatus } = await server.waitForCallback(); if (cancelled) return; const final = await repository.retrieve(id); @@ -62,14 +62,20 @@ export const RequestApproval: React.FC = ({ return; } - if (approved && final.status === 'approved') { + if (callbackStatus === 'approved') { setResult(final); setStatus('success'); setTimeout(() => onComplete(final), DISPLAY_DELAY_MS); + } else if (callbackStatus === 'denied') { + setResult(final); + setStatus('denied'); + setTimeout(() => onComplete(final), DISPLAY_DELAY_MS); + } else if (callbackStatus === 'expired') { + setResult(final); + setStatus('expired'); + setTimeout(() => onComplete(final), DISPLAY_DELAY_MS); } else { - setError( - `Spend request did not reach approved (status: ${final.status})`, - ); + setError('An error occurred during approval'); setStatus('error'); setTimeout(() => onComplete(final), DISPLAY_DELAY_MS); } @@ -109,6 +115,38 @@ export const RequestApproval: React.FC = ({ ); } + if (status === 'denied') { + return ( + + ✗ Spend request denied + + + ID: {result?.id} + + + Status: {result?.status} + + + + ); + } + + if (status === 'expired') { + return ( + + ✗ Spend request expired + + + ID: {result?.id} + + + Status: {result?.status} + + + + ); + } + if (status === 'success') { return ( diff --git a/packages/cli/src/utils/local-callback-server.ts b/packages/cli/src/utils/local-callback-server.ts index 268caf5..2413f20 100644 --- a/packages/cli/src/utils/local-callback-server.ts +++ b/packages/cli/src/utils/local-callback-server.ts @@ -1,8 +1,10 @@ import { createServer } from 'node:http'; import type { AddressInfo } from 'node:net'; +export type CallbackStatus = 'approved' | 'denied' | 'expired' | 'error'; + export interface CallbackResult { - approved: boolean; + status: CallbackStatus; } export interface CallbackServer { @@ -11,6 +13,19 @@ export interface CallbackServer { close: () => void; } +const VALID_STATUSES: ReadonlySet = new Set([ + 'approved', + 'denied', + 'expired', + 'error', +]); + +const STATUS_MESSAGES: Record = { + approved: 'Spend request approved. You can close this tab.', + denied: 'Spend request denied. You can close this tab.', + expired: 'Spend request expired. You can close this tab.', +}; + export function startCallbackServer(): Promise { return new Promise((resolve, reject) => { let resolveCallback: ((result: CallbackResult) => void) | undefined; @@ -20,9 +35,15 @@ export function startCallbackServer(): Promise { const server = createServer((req, res) => { const url = new URL(req.url ?? '/', 'http://localhost'); - const approved = url.searchParams.get('status') === 'approved'; - res.writeHead(200).end(); - resolveCallback?.({ approved }); + const raw = url.searchParams.get('status') ?? ''; + const status: CallbackStatus = VALID_STATUSES.has(raw) + ? (raw as CallbackStatus) + : 'error'; + const message = + STATUS_MESSAGES[raw] ?? 'Something went wrong. You can close this tab.'; + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(`

${message}

`); + resolveCallback?.({ status }); }); server.listen(0, '127.0.0.1', () => { From 4d83661fed2bdbe581e7ec41d84041d379a29ee0 Mon Sep 17 00:00:00 2001 From: Sylvia Wang Date: Wed, 3 Jun 2026 12:55:28 -0400 Subject: [PATCH 3/9] support IPv6 loopback in callback server for macOS compatibility --- .../cli/src/utils/local-callback-server.ts | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/utils/local-callback-server.ts b/packages/cli/src/utils/local-callback-server.ts index 2413f20..29d1f7e 100644 --- a/packages/cli/src/utils/local-callback-server.ts +++ b/packages/cli/src/utils/local-callback-server.ts @@ -46,15 +46,26 @@ export function startCallbackServer(): Promise { resolveCallback?.({ status }); }); - server.listen(0, '127.0.0.1', () => { - const port = (server.address() as AddressInfo).port; - resolve({ - redirectUri: `http://localhost:${port}/callback`, - waitForCallback: () => callbackPromise, - close: () => server.close(), + const startListening = (host: string, fallback?: string) => { + server.listen(0, host, () => { + const port = (server.address() as AddressInfo).port; + resolve({ + redirectUri: `http://localhost:${port}/callback`, + waitForCallback: () => callbackPromise, + close: () => server.close(), + }); }); - }); - server.on('error', reject); + server.once('error', (err) => { + if (fallback) { + server.removeAllListeners('error'); + startListening(fallback); + } else { + reject(err); + } + }); + }; + + startListening('::1', '127.0.0.1'); }); } From e2d1fc8042ebd11e12fb55524dfa3ee24fbe3a3c Mon Sep 17 00:00:00 2001 From: Sylvia Wang Date: Wed, 3 Jun 2026 13:24:42 -0400 Subject: [PATCH 4/9] fix: biome formatting --- packages/cli/src/commands/spend-request/request-approval.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/spend-request/request-approval.tsx b/packages/cli/src/commands/spend-request/request-approval.tsx index dc788ab..d9b9a2d 100644 --- a/packages/cli/src/commands/spend-request/request-approval.tsx +++ b/packages/cli/src/commands/spend-request/request-approval.tsx @@ -124,7 +124,10 @@ export const RequestApproval: React.FC = ({ ID: {result?.id} - Status: {result?.status} + Status:{' '} + + {result?.status} +
From aba784a6ee87f4b1f44397c19756fd21a1f65271 Mon Sep 17 00:00:00 2001 From: Sylvia Wang Date: Wed, 3 Jun 2026 16:34:30 -0400 Subject: [PATCH 5/9] use polling as a fallback --- .../cli/src/commands/spend-request/create.tsx | 57 ++++++++----- .../spend-request/request-approval.tsx | 49 +++++++---- .../spend-request/use-approval-polling.ts | 83 +++++++++++++++++++ .../cli/src/utils/local-callback-server.ts | 8 ++ 4 files changed, 163 insertions(+), 34 deletions(-) create mode 100644 packages/cli/src/commands/spend-request/use-approval-polling.ts diff --git a/packages/cli/src/commands/spend-request/create.tsx b/packages/cli/src/commands/spend-request/create.tsx index 9fb52e8..8668e7c 100644 --- a/packages/cli/src/commands/spend-request/create.tsx +++ b/packages/cli/src/commands/spend-request/create.tsx @@ -3,16 +3,16 @@ import type { ISpendRequestResource, SpendRequest, } from '@stripe/link-sdk'; -import { Box, Text, useInput } from 'ink'; +import { Box, Text } from 'ink'; import Spinner from 'ink-spinner'; import type React from 'react'; import { useEffect, useState } from 'react'; import { DISPLAY_DELAY_MS } from '../../utils/constants'; import { writeCredentialFile } from '../../utils/credential-output'; -import { startCallbackServer } from '../../utils/local-callback-server'; -import { openUrl } from '../../utils/open-url'; +import { tryStartCallbackServer } from '../../utils/local-callback-server'; import { AppDownloadQrCodes } from './app-download-qr-codes'; import { ApprovalWaitingView } from './approval-waiting-view'; +import { useApprovalPolling } from './use-approval-polling'; interface CreateSpendRequestProps { repository: ISpendRequestResource; @@ -32,20 +32,37 @@ export const CreateSpendRequest: React.FC = ({ onComplete, }) => { const [status, setStatus] = useState< - 'creating' | 'waiting' | 'success' | 'error' + 'creating' | 'waiting' | 'polling' | 'success' | 'error' >('creating'); const [request, setRequest] = useState(null); const [approvalUrl, setApprovalUrl] = useState(''); const [error, setError] = useState(''); const [outputFilePath, setOutputFilePath] = useState(null); const [fileError, setFileError] = useState(''); + const [pollingFallback, setPollingFallback] = useState(false); - useInput( - (_input, key) => { - if (key.return && approvalUrl) openUrl(approvalUrl); + useApprovalPolling({ + enabled: pollingFallback, + status, + setStatus: () => setStatus('polling'), + approvalUrl, + repository, + requestId: request?.id ?? null, + onComplete, + onTerminal: (final, s) => { + if (s === 'approved') { + setRequest(final); + setStatus('success'); + } else { + setError(`Spend request did not reach approved (status: ${final.status})`); + setStatus('error'); + } }, - { isActive: status === 'waiting' }, - ); + onPollError: (msg) => { + setError(msg); + setStatus('error'); + }, + }); useEffect(() => { let close: (() => void) | null = null; @@ -53,12 +70,8 @@ export const CreateSpendRequest: React.FC = ({ const create = async () => { try { - let server: Awaited> | null = - null; - if (requestApproval) { - server = await startCallbackServer(); - close = server.close; - } + const server = requestApproval ? await tryStartCallbackServer() : null; + if (server) close = server.close; // Strip request_approval so the spend request starts in `created` state; we call requestApproval explicitly below. const { request_approval: _, ...createParams } = params; @@ -66,20 +79,26 @@ export const CreateSpendRequest: React.FC = ({ if (cancelled) return; setRequest(result); - if (!requestApproval || !server) { + if (!requestApproval) { setStatus('success'); setTimeout(() => onComplete(result), DISPLAY_DELAY_MS); return; } - const approval = await repository.requestApproval(result.id, { - redirect_uri: server.redirectUri, - }); + const approval = await repository.requestApproval( + result.id, + server ? { redirect_uri: server.redirectUri } : undefined, + ); if (cancelled) return; setApprovalUrl(approval.approval_link); setStatus('waiting'); + if (!server) { + setPollingFallback(true); + return; + } + const { status: callbackStatus } = await server.waitForCallback(); if (cancelled) return; diff --git a/packages/cli/src/commands/spend-request/request-approval.tsx b/packages/cli/src/commands/spend-request/request-approval.tsx index d9b9a2d..d5409d4 100644 --- a/packages/cli/src/commands/spend-request/request-approval.tsx +++ b/packages/cli/src/commands/spend-request/request-approval.tsx @@ -1,12 +1,12 @@ import type { ISpendRequestResource, SpendRequest } from '@stripe/link-sdk'; -import { Box, Text, useInput } from 'ink'; +import { Box, Text } from 'ink'; import Spinner from 'ink-spinner'; import type React from 'react'; import { useEffect, useState } from 'react'; import { DISPLAY_DELAY_MS } from '../../utils/constants'; -import { startCallbackServer } from '../../utils/local-callback-server'; -import { openUrl } from '../../utils/open-url'; +import { tryStartCallbackServer } from '../../utils/local-callback-server'; import { ApprovalWaitingView } from './approval-waiting-view'; +import { useApprovalPolling } from './use-approval-polling'; interface RequestApprovalProps { repository: ISpendRequestResource; @@ -20,18 +20,31 @@ export const RequestApproval: React.FC = ({ onComplete, }) => { const [status, setStatus] = useState< - 'requesting' | 'waiting' | 'success' | 'denied' | 'expired' | 'error' + 'requesting' | 'waiting' | 'polling' | 'success' | 'denied' | 'expired' | 'error' >('requesting'); const [approvalUrl, setApprovalUrl] = useState(''); const [result, setResult] = useState(null); const [error, setError] = useState(''); - - useInput( - (_input, key) => { - if (key.return && approvalUrl) openUrl(approvalUrl); + const [pollingFallback, setPollingFallback] = useState(false); + + useApprovalPolling({ + enabled: pollingFallback, + status, + setStatus: () => setStatus('polling'), + approvalUrl, + repository, + requestId: id, + onComplete, + onTerminal: (final, s) => { + setResult(final); + if (s === 'error') setError('An error occurred during approval'); + setStatus(s === 'approved' ? 'success' : s); + }, + onPollError: (msg) => { + setError(msg); + setStatus('error'); }, - { isActive: status === 'waiting' }, - ); + }); useEffect(() => { let close: (() => void) | null = null; @@ -39,17 +52,23 @@ export const RequestApproval: React.FC = ({ const run = async () => { try { - const server = await startCallbackServer(); - close = server.close; + const server = await tryStartCallbackServer(); + close = server?.close ?? null; - const res = await repository.requestApproval(id, { - redirect_uri: server.redirectUri, - }); + const res = await repository.requestApproval( + id, + server ? { redirect_uri: server.redirectUri } : undefined, + ); if (cancelled) return; setApprovalUrl(res.approval_link); setStatus('waiting'); + if (!server) { + setPollingFallback(true); + return; + } + const { status: callbackStatus } = await server.waitForCallback(); if (cancelled) return; diff --git a/packages/cli/src/commands/spend-request/use-approval-polling.ts b/packages/cli/src/commands/spend-request/use-approval-polling.ts new file mode 100644 index 0000000..37afb37 --- /dev/null +++ b/packages/cli/src/commands/spend-request/use-approval-polling.ts @@ -0,0 +1,83 @@ +import type { ISpendRequestResource, SpendRequest } from '@stripe/link-sdk'; +import { useInput } from 'ink'; +import { useEffect } from 'react'; +import { DISPLAY_DELAY_MS } from '../../utils/constants'; +import { openUrl } from '../../utils/open-url'; +import { pollUntilApproved } from '../../utils/poll-until-approved'; + +export type PollingTerminalStatus = 'approved' | 'denied' | 'expired' | 'error'; + +interface UseApprovalPollingOptions { + // When false, only useInput is active — polling does not start. Use for the + // callback-server path so the hook stays mounted without racing the server. + enabled: boolean; + status: string; + setStatus: (s: 'polling') => void; + approvalUrl: string; + repository: ISpendRequestResource; + requestId: string | null; + onComplete: (result: SpendRequest) => void; + onTerminal: (result: SpendRequest, status: PollingTerminalStatus) => void; + onPollError: (msg: string) => void; +} + +export function useApprovalPolling({ + enabled, + status, + setStatus, + approvalUrl, + repository, + requestId, + onComplete, + onTerminal, + onPollError, +}: UseApprovalPollingOptions): void { + const isWaiting = status === 'waiting' || status === 'polling'; + + useInput( + (_input, key) => { + if (key.return && approvalUrl) openUrl(approvalUrl); + }, + { isActive: isWaiting }, + ); + + useEffect(() => { + if (!enabled || status !== 'waiting') return; + const timeout = setTimeout(() => setStatus('polling'), 1000); + return () => clearTimeout(timeout); + }, [enabled, status, setStatus]); + + useEffect(() => { + if (!enabled || status !== 'polling' || !requestId) return; + + let cancelled = false; + + const poll = async () => { + try { + const final = await pollUntilApproved(repository, requestId); + if (cancelled) return; + + const resolvedStatus: PollingTerminalStatus = + final.status === 'approved' + ? 'approved' + : final.status === 'denied' + ? 'denied' + : final.status === 'expired' + ? 'expired' + : 'error'; + + onTerminal(final, resolvedStatus); + setTimeout(() => onComplete(final), DISPLAY_DELAY_MS); + } catch (err) { + if (!cancelled) { + onPollError((err as Error).message); + } + } + }; + + poll(); + return () => { + cancelled = true; + }; + }, [enabled, status, requestId, repository, onComplete, onTerminal, onPollError]); +} diff --git a/packages/cli/src/utils/local-callback-server.ts b/packages/cli/src/utils/local-callback-server.ts index 29d1f7e..19065dd 100644 --- a/packages/cli/src/utils/local-callback-server.ts +++ b/packages/cli/src/utils/local-callback-server.ts @@ -26,6 +26,14 @@ const STATUS_MESSAGES: Record = { expired: 'Spend request expired. You can close this tab.', }; +export async function tryStartCallbackServer(): Promise { + try { + return await startCallbackServer(); + } catch { + return null; + } +} + export function startCallbackServer(): Promise { return new Promise((resolve, reject) => { let resolveCallback: ((result: CallbackResult) => void) | undefined; From 7944ffcc04aaecc27e15633783b7a742eb0659c7 Mon Sep 17 00:00:00 2001 From: Sylvia Wang Date: Wed, 3 Jun 2026 17:13:07 -0400 Subject: [PATCH 6/9] biome format --- packages/cli/src/commands/spend-request/create.tsx | 4 +++- .../src/commands/spend-request/request-approval.tsx | 8 +++++++- .../src/commands/spend-request/use-approval-polling.ts | 10 +++++++++- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/spend-request/create.tsx b/packages/cli/src/commands/spend-request/create.tsx index 8668e7c..69582ef 100644 --- a/packages/cli/src/commands/spend-request/create.tsx +++ b/packages/cli/src/commands/spend-request/create.tsx @@ -54,7 +54,9 @@ export const CreateSpendRequest: React.FC = ({ setRequest(final); setStatus('success'); } else { - setError(`Spend request did not reach approved (status: ${final.status})`); + setError( + `Spend request did not reach approved (status: ${final.status})`, + ); setStatus('error'); } }, diff --git a/packages/cli/src/commands/spend-request/request-approval.tsx b/packages/cli/src/commands/spend-request/request-approval.tsx index d5409d4..7ee9442 100644 --- a/packages/cli/src/commands/spend-request/request-approval.tsx +++ b/packages/cli/src/commands/spend-request/request-approval.tsx @@ -20,7 +20,13 @@ export const RequestApproval: React.FC = ({ onComplete, }) => { const [status, setStatus] = useState< - 'requesting' | 'waiting' | 'polling' | 'success' | 'denied' | 'expired' | 'error' + | 'requesting' + | 'waiting' + | 'polling' + | 'success' + | 'denied' + | 'expired' + | 'error' >('requesting'); const [approvalUrl, setApprovalUrl] = useState(''); const [result, setResult] = useState(null); diff --git a/packages/cli/src/commands/spend-request/use-approval-polling.ts b/packages/cli/src/commands/spend-request/use-approval-polling.ts index 37afb37..23fa65b 100644 --- a/packages/cli/src/commands/spend-request/use-approval-polling.ts +++ b/packages/cli/src/commands/spend-request/use-approval-polling.ts @@ -79,5 +79,13 @@ export function useApprovalPolling({ return () => { cancelled = true; }; - }, [enabled, status, requestId, repository, onComplete, onTerminal, onPollError]); + }, [ + enabled, + status, + requestId, + repository, + onComplete, + onTerminal, + onPollError, + ]); } From 942831f6d0dffc62d76fc63b91e1200817f6a73c Mon Sep 17 00:00:00 2001 From: Sylvia Wang Date: Wed, 3 Jun 2026 18:21:47 -0400 Subject: [PATCH 7/9] add timeout for callback server --- .../cli/src/commands/spend-request/create.tsx | 5 +++++ .../spend-request/request-approval.tsx | 5 +++++ .../cli/src/utils/local-callback-server.ts | 18 +++++++++++++++--- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/spend-request/create.tsx b/packages/cli/src/commands/spend-request/create.tsx index 69582ef..916846e 100644 --- a/packages/cli/src/commands/spend-request/create.tsx +++ b/packages/cli/src/commands/spend-request/create.tsx @@ -104,6 +104,11 @@ export const CreateSpendRequest: React.FC = ({ const { status: callbackStatus } = await server.waitForCallback(); if (cancelled) return; + if (callbackStatus === 'timeout') { + setPollingFallback(true); + return; + } + const final = await repository.retrieve(result.id); if (cancelled) return; diff --git a/packages/cli/src/commands/spend-request/request-approval.tsx b/packages/cli/src/commands/spend-request/request-approval.tsx index 7ee9442..8de7be2 100644 --- a/packages/cli/src/commands/spend-request/request-approval.tsx +++ b/packages/cli/src/commands/spend-request/request-approval.tsx @@ -78,6 +78,11 @@ export const RequestApproval: React.FC = ({ const { status: callbackStatus } = await server.waitForCallback(); if (cancelled) return; + if (callbackStatus === 'timeout') { + setPollingFallback(true); + return; + } + const final = await repository.retrieve(id); if (cancelled) return; diff --git a/packages/cli/src/utils/local-callback-server.ts b/packages/cli/src/utils/local-callback-server.ts index 19065dd..a859491 100644 --- a/packages/cli/src/utils/local-callback-server.ts +++ b/packages/cli/src/utils/local-callback-server.ts @@ -1,7 +1,7 @@ import { createServer } from 'node:http'; import type { AddressInfo } from 'node:net'; -export type CallbackStatus = 'approved' | 'denied' | 'expired' | 'error'; +export type CallbackStatus = 'approved' | 'denied' | 'expired' | 'error' | 'timeout'; export interface CallbackResult { status: CallbackStatus; @@ -9,10 +9,14 @@ export interface CallbackResult { export interface CallbackServer { redirectUri: string; - waitForCallback: () => Promise; + waitForCallback: (timeoutMs?: number) => Promise; close: () => void; } +// If no browser redirect arrives within this window, fall back to polling. +// Shorter than the approval window — polling recovers the result either way. +const DEFAULT_CALLBACK_TIMEOUT_MS = 2 * 60 * 1000; + const VALID_STATUSES: ReadonlySet = new Set([ 'approved', 'denied', @@ -59,7 +63,15 @@ export function startCallbackServer(): Promise { const port = (server.address() as AddressInfo).port; resolve({ redirectUri: `http://localhost:${port}/callback`, - waitForCallback: () => callbackPromise, + waitForCallback: (timeoutMs = DEFAULT_CALLBACK_TIMEOUT_MS) => { + let timerId: ReturnType; + const timeoutPromise = new Promise((res) => { + timerId = setTimeout(() => res({ status: 'timeout' }), timeoutMs); + }); + return Promise.race([callbackPromise, timeoutPromise]).finally( + () => clearTimeout(timerId), + ); + }, close: () => server.close(), }); }); From d99e4ef0f5859c84df74395bbd49ae7d13141a5f Mon Sep 17 00:00:00 2001 From: Sylvia Wang Date: Wed, 3 Jun 2026 18:35:15 -0400 Subject: [PATCH 8/9] fix formatting issue --- packages/cli/src/utils/local-callback-server.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/utils/local-callback-server.ts b/packages/cli/src/utils/local-callback-server.ts index a859491..b3d0027 100644 --- a/packages/cli/src/utils/local-callback-server.ts +++ b/packages/cli/src/utils/local-callback-server.ts @@ -1,7 +1,12 @@ import { createServer } from 'node:http'; import type { AddressInfo } from 'node:net'; -export type CallbackStatus = 'approved' | 'denied' | 'expired' | 'error' | 'timeout'; +export type CallbackStatus = + | 'approved' + | 'denied' + | 'expired' + | 'error' + | 'timeout'; export interface CallbackResult { status: CallbackStatus; @@ -68,8 +73,8 @@ export function startCallbackServer(): Promise { const timeoutPromise = new Promise((res) => { timerId = setTimeout(() => res({ status: 'timeout' }), timeoutMs); }); - return Promise.race([callbackPromise, timeoutPromise]).finally( - () => clearTimeout(timerId), + return Promise.race([callbackPromise, timeoutPromise]).finally(() => + clearTimeout(timerId), ); }, close: () => server.close(), From 5fdb77241ad75e2c0671520e5e57dae14e408516 Mon Sep 17 00:00:00 2001 From: Sylvia Wang Date: Wed, 3 Jun 2026 18:35:15 -0400 Subject: [PATCH 9/9] fix formatting issue --- .../cli/src/commands/spend-request/create.tsx | 71 +++++++++++++++---- .../spend-request/request-approval.tsx | 20 +++--- 2 files changed, 69 insertions(+), 22 deletions(-) diff --git a/packages/cli/src/commands/spend-request/create.tsx b/packages/cli/src/commands/spend-request/create.tsx index 916846e..5fe75fb 100644 --- a/packages/cli/src/commands/spend-request/create.tsx +++ b/packages/cli/src/commands/spend-request/create.tsx @@ -10,6 +10,7 @@ import { useEffect, useState } from 'react'; import { DISPLAY_DELAY_MS } from '../../utils/constants'; import { writeCredentialFile } from '../../utils/credential-output'; import { tryStartCallbackServer } from '../../utils/local-callback-server'; +import { sanitizeDeep } from '../../utils/sanitize-text'; import { AppDownloadQrCodes } from './app-download-qr-codes'; import { ApprovalWaitingView } from './approval-waiting-view'; import { useApprovalPolling } from './use-approval-polling'; @@ -32,7 +33,13 @@ export const CreateSpendRequest: React.FC = ({ onComplete, }) => { const [status, setStatus] = useState< - 'creating' | 'waiting' | 'polling' | 'success' | 'error' + | 'creating' + | 'waiting' + | 'polling' + | 'success' + | 'denied' + | 'expired' + | 'error' >('creating'); const [request, setRequest] = useState(null); const [approvalUrl, setApprovalUrl] = useState(''); @@ -50,13 +57,15 @@ export const CreateSpendRequest: React.FC = ({ requestId: request?.id ?? null, onComplete, onTerminal: (final, s) => { + setRequest(sanitizeDeep(final)); if (s === 'approved') { - setRequest(final); setStatus('success'); + } else if (s === 'denied') { + setStatus('denied'); + } else if (s === 'expired') { + setStatus('expired'); } else { - setError( - `Spend request did not reach approved (status: ${final.status})`, - ); + setError('An error occurred during approval'); setStatus('error'); } }, @@ -79,7 +88,7 @@ export const CreateSpendRequest: React.FC = ({ const { request_approval: _, ...createParams } = params; const result = await repository.createSpendRequest(createParams); if (cancelled) return; - setRequest(result); + setRequest(sanitizeDeep(result)); if (!requestApproval) { setStatus('success'); @@ -93,7 +102,7 @@ export const CreateSpendRequest: React.FC = ({ ); if (cancelled) return; - setApprovalUrl(approval.approval_link); + setApprovalUrl(sanitizeDeep(approval.approval_link)); setStatus('waiting'); if (!server) { @@ -119,17 +128,18 @@ export const CreateSpendRequest: React.FC = ({ return; } + setRequest(sanitizeDeep(final)); if (callbackStatus === 'approved') { - setRequest(final); setStatus('success'); - setTimeout(() => onComplete(final), DISPLAY_DELAY_MS); + } else if (callbackStatus === 'denied') { + setStatus('denied'); + } else if (callbackStatus === 'expired') { + setStatus('expired'); } else { - setError( - `Spend request did not reach approved (status: ${final.status})`, - ); + setError('An error occurred during approval'); setStatus('error'); - setTimeout(() => onComplete(final), DISPLAY_DELAY_MS); } + setTimeout(() => onComplete(final), DISPLAY_DELAY_MS); } catch (err) { if (!cancelled) { setError((err as Error).message); @@ -183,6 +193,41 @@ export const CreateSpendRequest: React.FC = ({ ); } + if (status === 'denied') { + return ( + + ✗ Spend request denied + + + ID: {request?.id} + + + Status:{' '} + + {request?.status} + + + + + ); + } + + if (status === 'expired') { + return ( + + ✗ Spend request expired + + + ID: {request?.id} + + + Status: {request?.status} + + + + ); + } + if (status === 'success') { return ( diff --git a/packages/cli/src/commands/spend-request/request-approval.tsx b/packages/cli/src/commands/spend-request/request-approval.tsx index 8de7be2..02b495c 100644 --- a/packages/cli/src/commands/spend-request/request-approval.tsx +++ b/packages/cli/src/commands/spend-request/request-approval.tsx @@ -5,6 +5,7 @@ import type React from 'react'; import { useEffect, useState } from 'react'; import { DISPLAY_DELAY_MS } from '../../utils/constants'; import { tryStartCallbackServer } from '../../utils/local-callback-server'; +import { sanitizeDeep } from '../../utils/sanitize-text'; import { ApprovalWaitingView } from './approval-waiting-view'; import { useApprovalPolling } from './use-approval-polling'; @@ -42,7 +43,7 @@ export const RequestApproval: React.FC = ({ requestId: id, onComplete, onTerminal: (final, s) => { - setResult(final); + setResult(sanitizeDeep(final)); if (s === 'error') setError('An error occurred during approval'); setStatus(s === 'approved' ? 'success' : s); }, @@ -67,7 +68,7 @@ export const RequestApproval: React.FC = ({ ); if (cancelled) return; - setApprovalUrl(res.approval_link); + setApprovalUrl(sanitizeDeep(res.approval_link)); setStatus('waiting'); if (!server) { @@ -92,22 +93,23 @@ export const RequestApproval: React.FC = ({ return; } + const sanitizedFinal = sanitizeDeep(final); if (callbackStatus === 'approved') { - setResult(final); + setResult(sanitizedFinal); setStatus('success'); - setTimeout(() => onComplete(final), DISPLAY_DELAY_MS); + setTimeout(() => onComplete(sanitizedFinal), DISPLAY_DELAY_MS); } else if (callbackStatus === 'denied') { - setResult(final); + setResult(sanitizedFinal); setStatus('denied'); - setTimeout(() => onComplete(final), DISPLAY_DELAY_MS); + setTimeout(() => onComplete(sanitizedFinal), DISPLAY_DELAY_MS); } else if (callbackStatus === 'expired') { - setResult(final); + setResult(sanitizedFinal); setStatus('expired'); - setTimeout(() => onComplete(final), DISPLAY_DELAY_MS); + setTimeout(() => onComplete(sanitizedFinal), DISPLAY_DELAY_MS); } else { setError('An error occurred during approval'); setStatus('error'); - setTimeout(() => onComplete(final), DISPLAY_DELAY_MS); + setTimeout(() => onComplete(sanitizedFinal), DISPLAY_DELAY_MS); } } catch (err) { if (!cancelled) {