Skip to content

Commit 8cc93e9

Browse files
feat: allow writing payment credentials to file to avoid context (#67)
1 parent 046557e commit 8cc93e9

9 files changed

Lines changed: 270 additions & 8 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ Key input field notes:
7070
- `create --request-approval` and `request-approval` both show an approval URL in interactive mode and poll until approved/denied/expired/failed. In JSON mode (`--format json`), they return immediately with an `_next.command` for `spend-request retrieve`.
7171
- `retrieve --interval <seconds>` polls until approved/denied/expired/succeeded/failed. If `--timeout` is reached or `--max-attempts` is exhausted while the request is still non-terminal, it exits non-zero with `POLLING_TIMEOUT`.
7272
- `card` credentials include `billing_address` (name, line1, line2, city, state, postal_code, country) and `valid_until` (ISO date string — when the card expires/stops working)
73+
- `--output-file <path>` on `retrieve` or `create` writes full card credentials to a local file (0600 permissions) and redacts card data in stdout. `--force` allows overwriting an existing file.
7374

7475
### mpp pay
7576

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,14 @@ link-cli spend-request retrieve lsrq_001
134134
```
135135
By default, retrieving a spend request doesn't include card details. Pass `--include card` to see unmasked card details.
136136

137+
To avoid leaking card credentials into agent transcripts or logs, use `--output-file` to write the full card to a secure local file while stdout shows only redacted data (brand, last4, expiry):
138+
139+
```bash
140+
link-cli spend-request retrieve lsrq_001 --include card --output-file /tmp/link-card.json --format json
141+
```
142+
143+
The file is created with `0600` permissions. If the file already exists, the command fails unless `--force` is passed. When `--output-file` is set, the JSON output replaces the `card` object with redacted fields and adds a `card_output_file` path.
144+
137145
For agent polling, pass `--interval` and optionally `--max-attempts`:
138146

139147
```bash

packages/cli/src/commands/spend-request/create.tsx

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Box, Text } from 'ink';
77
import Spinner from 'ink-spinner';
88
import type React from 'react';
99
import { useCallback, useEffect, useState } from 'react';
10+
import { writeCredentialFile } from '../../utils/credential-output';
1011
import { AppDownloadQrCodes } from './app-download-qr-codes';
1112
import { ApprovalWaitingView } from './approval-waiting-view';
1213
import { useApprovalPolling } from './use-approval-polling';
@@ -15,20 +16,26 @@ interface CreateSpendRequestProps {
1516
repository: ISpendRequestResource;
1617
params: CreateSpendRequestParams;
1718
requestApproval?: boolean;
19+
outputFile?: string;
20+
force?: boolean;
1821
onComplete: (result: SpendRequest | null) => void;
1922
}
2023

2124
export const CreateSpendRequest: React.FC<CreateSpendRequestProps> = ({
2225
repository,
2326
params,
2427
requestApproval = false,
28+
outputFile,
29+
force,
2530
onComplete,
2631
}) => {
2732
const [status, setStatus] = useState<
2833
'creating' | 'waiting' | 'polling' | 'success' | 'error'
2934
>('creating');
3035
const [request, setRequest] = useState<SpendRequest | null>(null);
3136
const [error, setError] = useState<string>('');
37+
const [outputFilePath, setOutputFilePath] = useState<string | null>(null);
38+
const [fileError, setFileError] = useState<string>('');
3239

3340
const approvalUrl = request?.approval_url ?? '';
3441

@@ -71,6 +78,22 @@ export const CreateSpendRequest: React.FC<CreateSpendRequestProps> = ({
7178
create();
7279
}, [repository, params, requestApproval, onComplete]);
7380

81+
useEffect(() => {
82+
if (status !== 'success' || !outputFile || !request?.card) return;
83+
84+
const fileData = {
85+
spend_request_id: request.id,
86+
merchant_name: request.merchant_name,
87+
merchant_url: request.merchant_url,
88+
context: request.context,
89+
created_at: request.created_at,
90+
card: request.card,
91+
};
92+
writeCredentialFile(outputFile, fileData, force ?? false)
93+
.then((path) => setOutputFilePath(path))
94+
.catch((err) => setFileError((err as Error).message));
95+
}, [status, outputFile, force, request]);
96+
7497
if (status === 'creating') {
7598
return (
7699
<Box>
@@ -125,6 +148,51 @@ export const CreateSpendRequest: React.FC<CreateSpendRequestProps> = ({
125148
</Text>
126149
)}
127150
</Box>
151+
{request?.card && !outputFile && (
152+
<Box flexDirection="column" marginTop={1}>
153+
<Text bold>Card Details:</Text>
154+
<Text>
155+
{' '}
156+
Number: <Text bold>{request.card.number}</Text>
157+
</Text>
158+
<Text>
159+
{' '}
160+
Brand: <Text bold>{request.card.brand}</Text>
161+
</Text>
162+
<Text>
163+
{' '}
164+
Expiry:{' '}
165+
<Text bold>
166+
{String(request.card.exp_month).padStart(2, '0')}/
167+
{request.card.exp_year}
168+
</Text>
169+
</Text>
170+
{request.card.cvc && (
171+
<Text>
172+
{' '}
173+
CVC: <Text bold>{request.card.cvc}</Text>
174+
</Text>
175+
)}
176+
{request.card.valid_until && (
177+
<Text>
178+
{' '}
179+
Valid Until: <Text bold>{request.card.valid_until}</Text>
180+
</Text>
181+
)}
182+
</Box>
183+
)}
184+
{request?.card && outputFile && (
185+
<Box flexDirection="column" marginTop={1}>
186+
{outputFilePath && (
187+
<Text color="green">
188+
Card credentials written to <Text bold>{outputFilePath}</Text>
189+
</Text>
190+
)}
191+
{fileError && (
192+
<Text color="red">Failed to write card file: {fileError}</Text>
193+
)}
194+
</Box>
195+
)}
128196
<AppDownloadQrCodes />
129197
</Box>
130198
);

packages/cli/src/commands/spend-request/index.tsx

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { storage } from '@stripe/link-sdk';
99
import { Cli, z } from 'incur';
1010
import { render } from 'ink';
1111
import React from 'react';
12+
import { writeCredentialFile } from '../../utils/credential-output';
1213
import {
1314
parseLineItemFlag,
1415
parseTotalFlag,
@@ -19,6 +20,29 @@ import { RetrieveSpendRequest } from './retrieve';
1920
import { createOptions, retrieveOptions, updateOptions } from './schema';
2021
import { UpdateSpendRequest } from './update';
2122

23+
async function applyOutputFile(
24+
request: SpendRequest,
25+
outputFile: string | undefined,
26+
force: boolean,
27+
): Promise<SpendRequest & { card_output_file?: string }> {
28+
if (!outputFile || !request.card) return request;
29+
30+
const fileData = {
31+
spend_request_id: request.id,
32+
merchant_name: request.merchant_name,
33+
merchant_url: request.merchant_url,
34+
context: request.context,
35+
created_at: request.created_at,
36+
card: request.card,
37+
};
38+
const resolvedPath = await writeCredentialFile(outputFile, fileData, force);
39+
const { card: _, ...withoutCard } = request;
40+
return {
41+
...withoutCard,
42+
card_output_file: resolvedPath,
43+
} as SpendRequest & { card_output_file?: string };
44+
}
45+
2246
export function createSpendRequestCli(repository: ISpendRequestResource) {
2347
const cli = Cli.create('spend-request', {
2448
description: 'Spend request management commands',
@@ -110,6 +134,9 @@ export function createSpendRequestCli(repository: ISpendRequestResource) {
110134
test: opts.test ? true : undefined,
111135
};
112136

137+
const outputFile = opts.outputFile;
138+
const forceOverwrite = opts.force;
139+
113140
if (!c.agent && !c.formatExplicit) {
114141
return new Promise((resolve) => {
115142
let capturedResult: SpendRequest | null = null;
@@ -118,6 +145,8 @@ export function createSpendRequestCli(repository: ISpendRequestResource) {
118145
repository={repository}
119146
params={createParams}
120147
requestApproval={requestApproval}
148+
outputFile={outputFile}
149+
force={forceOverwrite}
121150
onComplete={(result) => {
122151
capturedResult = result;
123152
}}
@@ -133,7 +162,15 @@ export function createSpendRequestCli(repository: ISpendRequestResource) {
133162
// The agent drives the polling loop via `spend-request retrieve`.
134163
const created = await repository.createSpendRequest(createParams);
135164
if (!requestApproval) {
136-
yield created;
165+
try {
166+
yield await applyOutputFile(created, outputFile, forceOverwrite);
167+
} catch (err) {
168+
const message = (err as Error).message;
169+
if (message.startsWith('OUTPUT_FILE_EXISTS')) {
170+
return c.error({ code: 'OUTPUT_FILE_EXISTS', message });
171+
}
172+
return c.error({ code: 'OUTPUT_FILE_WRITE_ERROR', message });
173+
}
137174
return;
138175
}
139176
yield {
@@ -291,6 +328,8 @@ export function createSpendRequestCli(repository: ISpendRequestResource) {
291328
const maxAttempts = opts.maxAttempts;
292329
const includeArr = opts.include;
293330
const include = includeArr?.length ? includeArr : undefined;
331+
const outputFile = opts.outputFile;
332+
const forceOverwrite = opts.force;
294333

295334
if (!c.agent && !c.formatExplicit) {
296335
return new Promise((resolve) => {
@@ -301,6 +340,8 @@ export function createSpendRequestCli(repository: ISpendRequestResource) {
301340
id={id}
302341
timeout={timeout}
303342
include={include}
343+
outputFile={outputFile}
344+
force={forceOverwrite}
304345
onComplete={(result) => {
305346
capturedResult = result;
306347
}}
@@ -332,16 +373,22 @@ export function createSpendRequestCli(repository: ISpendRequestResource) {
332373
});
333374
}
334375

335-
if (terminalStatuses.has(request.status)) {
336-
yield request;
376+
const shouldEmitFinal =
377+
terminalStatuses.has(request.status) || interval <= 0;
378+
if (shouldEmitFinal) {
379+
try {
380+
yield await applyOutputFile(request, outputFile, forceOverwrite);
381+
} catch (err) {
382+
const message = (err as Error).message;
383+
if (message.startsWith('OUTPUT_FILE_EXISTS')) {
384+
return c.error({ code: 'OUTPUT_FILE_EXISTS', message });
385+
}
386+
return c.error({ code: 'OUTPUT_FILE_WRITE_ERROR', message });
387+
}
337388
return;
338389
}
339390

340391
attempts++;
341-
if (interval <= 0) {
342-
yield request;
343-
return;
344-
}
345392

346393
const maxAttemptsExhausted = maxAttempts > 0 && attempts >= maxAttempts;
347394
const timeoutReached = Date.now() >= deadline;

packages/cli/src/commands/spend-request/retrieve.tsx

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,15 @@ import { Box, Text } from 'ink';
33
import Spinner from 'ink-spinner';
44
import type React from 'react';
55
import { useEffect, useRef, useState } from 'react';
6+
import { writeCredentialFile } from '../../utils/credential-output';
67

78
interface RetrieveSpendRequestProps {
89
repository: ISpendRequestResource;
910
id: string;
1011
timeout?: number;
1112
include?: string[];
13+
outputFile?: string;
14+
force?: boolean;
1215
onComplete: (result: SpendRequest | null) => void;
1316
}
1417

@@ -25,12 +28,16 @@ export const RetrieveSpendRequest: React.FC<RetrieveSpendRequestProps> = ({
2528
id,
2629
timeout = 300,
2730
include,
31+
outputFile,
32+
force,
2833
onComplete,
2934
}) => {
3035
const [phase, setPhase] = useState<Phase>('fetching');
3136
const [request, setRequest] = useState<SpendRequest | null>(null);
3237
const [error, setError] = useState<string>('');
3338
const [elapsed, setElapsed] = useState<number>(0);
39+
const [outputFilePath, setOutputFilePath] = useState<string | null>(null);
40+
const [fileError, setFileError] = useState<string>('');
3441
const startTimeRef = useRef<number>(Date.now());
3542
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
3643
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -43,6 +50,22 @@ export const RetrieveSpendRequest: React.FC<RetrieveSpendRequestProps> = ({
4350
};
4451
}, []);
4552

53+
useEffect(() => {
54+
if (phase !== 'success' || !outputFile || !request?.card) return;
55+
56+
const fileData = {
57+
spend_request_id: request.id,
58+
merchant_name: request.merchant_name,
59+
merchant_url: request.merchant_url,
60+
context: request.context,
61+
created_at: request.created_at,
62+
card: request.card,
63+
};
64+
writeCredentialFile(outputFile, fileData, force ?? false)
65+
.then((path) => setOutputFilePath(path))
66+
.catch((err) => setFileError((err as Error).message));
67+
}, [phase, outputFile, force, request]);
68+
4669
useEffect(() => {
4770
const fetch = async () => {
4871
try {
@@ -251,7 +274,7 @@ export const RetrieveSpendRequest: React.FC<RetrieveSpendRequestProps> = ({
251274
</Text>
252275
</Box>
253276
)}
254-
{request?.card && (
277+
{request?.card && !outputFile && (
255278
<Box flexDirection="column" marginTop={1}>
256279
<Text bold>Card Details:</Text>
257280
<Text>
@@ -317,6 +340,18 @@ export const RetrieveSpendRequest: React.FC<RetrieveSpendRequestProps> = ({
317340
)}
318341
</Box>
319342
)}
343+
{request?.card && outputFile && (
344+
<Box flexDirection="column" marginTop={1}>
345+
{outputFilePath && (
346+
<Text color="green">
347+
Card credentials written to <Text bold>{outputFilePath}</Text>
348+
</Text>
349+
)}
350+
{fileError && (
351+
<Text color="red">Failed to write card file: {fileError}</Text>
352+
)}
353+
</Box>
354+
)}
320355
</Box>
321356
</Box>
322357
);

packages/cli/src/commands/spend-request/schema.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,16 @@ export const createOptions = z.object({
6161
.describe(
6262
'Use test mode (creates testmode credentials from test card data)',
6363
),
64+
outputFile: z
65+
.string()
66+
.optional()
67+
.describe(
68+
'Write full card credentials to this file path; stdout shows redacted card data only',
69+
),
70+
force: z
71+
.boolean()
72+
.default(false)
73+
.describe('Overwrite output file if it already exists'),
6474
});
6575

6676
export const retrieveOptions = z.object({
@@ -86,6 +96,16 @@ export const retrieveOptions = z.object({
8696
.array(z.string())
8797
.default([])
8898
.describe('Include extra data (repeatable, e.g. --include card)'),
99+
outputFile: z
100+
.string()
101+
.optional()
102+
.describe(
103+
'Write full card credentials to this file path; stdout shows redacted card data only',
104+
),
105+
force: z
106+
.boolean()
107+
.default(false)
108+
.describe('Overwrite output file if it already exists'),
89109
});
90110

91111
export const updateOptions = z.object({

0 commit comments

Comments
 (0)