Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/curvy-wombats-allow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@stripe/link-cli': minor
'@stripe/link-sdk': minor
---

Add the `approval-policy retrieve` CLI command and the SDK approval policy resource.
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,19 @@ response does not include a currency. A null limit or remaining amount means
unlimited. The verification requirement's action_url is null when no action is
available.

### Retrieve approval policy

Retrieve the rules that grant the current app authority to create spend requests
without manual approval:

```bash
link-cli approval-policy retrieve --format json
```

Each rule includes an action, a per-purchase limit, and optionally an ordered
list of allowed payment method IDs. The API returns an error when no approval
policy has been configured.

### List payment methods

```bash
Expand Down
57 changes: 57 additions & 0 deletions packages/cli/src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1861,6 +1861,63 @@ describe('production mode', () => {
});
});

describe('approval-policy retrieve', () => {
it('GETs and returns the approval policy', async () => {
const policy = {
rules: [
{
action: 'spend_request_create',
limits: {
per_purchase: { amount: 5000, currency: 'usd' },
},
allowed_payment_methods: ['csmrpd_2', 'csmrpd_1'],
},
],
};
setResponseForUrl('/approval-policy', 200, policy);

const result = await runProdCli('approval-policy', 'retrieve', '--json');

expect(result.exitCode).toBe(0);
expect(lastRequest.method).toBe('GET');
expect(lastRequest.url).toBe('/approval-policy');
expect(lastRequest.headers.authorization).toBe(
'Bearer prod_test_access_token',
);
expect(parseJson(result.stdout)).toEqual(policy);
});

it('surfaces the configured-policy not-found error', async () => {
setResponseForUrl('/approval-policy', 404, {
error: {
message: 'No approval policy has been configured',
code: 'approval_policy_not_found',
},
});

const result = await runProdCli('approval-policy', 'retrieve', '--json');

expect(result.exitCode).toBe(1);
expect(parseJson(result.stdout)).toMatchObject({
message:
'Failed to retrieve approval policy (404): No approval policy has been configured',
});
});

it('rejects unauthenticated requests before hitting the API', async () => {
storage.clearTokens();

const result = await runProdCli('approval-policy', 'retrieve', '--json');

expect(result.exitCode).toBe(1);
const output = parseJson(result.stdout) as Record<string, unknown>;
expect(output.code).toBe('NOT_AUTHENTICATED');
expect(
requests.find((request) => request.url === '/approval-policy'),
).toBeUndefined();
});
});

const SAMPLE_BALANCE = {
source_id: 'csmrpd_001',
type: 'cash',
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/cli.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Cli } from 'incur';
import { type CliAuthStorage, Storage, storage } from './auth/storage';
import { createApprovalPolicyCli } from './commands/approval-policy';
import { createAuthCli } from './commands/auth';
import { createBalancesCli } from './commands/balances';
import { createDemoCli } from './commands/demo';
Expand Down Expand Up @@ -132,6 +133,13 @@ cli.command(
envAccessToken,
),
);
cli.command(
createApprovalPolicyCli(
() => factory.createApprovalPolicyResource(),
authStorage,
envAccessToken,
),
);
cli.command(
createMppCli(
spendRequestRepo,
Expand Down
36 changes: 36 additions & 0 deletions packages/cli/src/commands/approval-policy/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { IApprovalPolicyResource } from '@stripe/link-sdk';
import { Cli } from 'incur';
import type { CliAuthStorage } from '../../auth/storage';
import { renderInteractive } from '../../utils/render-interactive';
import { requireAuth } from '../../utils/require-auth';
import { ApprovalPolicyRetrieve } from './retrieve';

export function createApprovalPolicyCli(
createResource: () => IApprovalPolicyResource,
authStorage?: CliAuthStorage,
envAccessToken?: string,
) {
const cli = Cli.create('approval-policy', {
description: 'Approval policy commands',
});

cli.command('retrieve', {
description: 'Retrieve the approval policy for the current app and user',
outputPolicy: 'agent-only' as const,
middleware: [requireAuth(authStorage, envAccessToken)],
async run(c) {
const resource = createResource();

if (!c.agent && !c.formatExplicit) {
return renderInteractive(
<ApprovalPolicyRetrieve resource={resource} onComplete={() => {}} />,
() => resource.retrieve(),
);
}

return resource.retrieve();
},
});

return cli;
}
61 changes: 61 additions & 0 deletions packages/cli/src/commands/approval-policy/retrieve.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { ApprovalPolicy, IApprovalPolicyResource } from '@stripe/link-sdk';
import { render } from 'ink-testing-library';
import { describe, expect, it, vi } from 'vitest';
import { ApprovalPolicyRetrieve } from './retrieve';

function makeResource(policy: ApprovalPolicy): IApprovalPolicyResource {
return {
retrieve: vi.fn(async () => policy),
};
}

describe('approval-policy retrieve component', () => {
it('renders all policy rule fields in order', async () => {
const resource = makeResource({
rules: [
{
action: 'spend_request_create',
limits: {
per_purchase: { amount: 5000, currency: 'usd' },
},
allowed_payment_methods: ['csmrpd_2', 'csmrpd_1'],
},
],
});

const { lastFrame } = render(
<ApprovalPolicyRetrieve resource={resource} onComplete={() => {}} />,
);

await vi.waitFor(() => {
const frame = lastFrame();
expect(frame).toContain('Approval Policy');
expect(frame).toContain('Rule 1');
expect(frame).toContain('Action: spend_request_create');
expect(frame).toContain('Per-purchase limit: $50.00');
expect(frame).toContain('Allowed payment methods: csmrpd_2, csmrpd_1');
});
});

it('renders an explicitly empty payment method allowlist', async () => {
const resource = makeResource({
rules: [
{
action: 'spend_request_create',
limits: {
per_purchase: { amount: 5000, currency: 'usd' },
},
allowed_payment_methods: [],
},
],
});

const { lastFrame } = render(
<ApprovalPolicyRetrieve resource={resource} onComplete={() => {}} />,
);

await vi.waitFor(() => {
expect(lastFrame()).toContain('Allowed payment methods: None');
});
});
});
75 changes: 75 additions & 0 deletions packages/cli/src/commands/approval-policy/retrieve.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// biome-ignore-all lint/suspicious/noArrayIndexKey: Policy rules are ordered and have no identifiers.
import type { ApprovalPolicy, IApprovalPolicyResource } 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';
import { formatAmount } from '../../utils/format-amount';

interface ApprovalPolicyRetrieveProps {
resource: IApprovalPolicyResource;
onComplete: (result: ApprovalPolicy | null) => void;
}

export const ApprovalPolicyRetrieve: React.FC<ApprovalPolicyRetrieveProps> = ({
resource,
onComplete,
}) => {
const action = useCallback(() => resource.retrieve(), [resource]);
const { status, data: policy, error } = useAsyncAction(action, onComplete);

if (status === 'loading') {
return (
<Box>
<Text color="cyan">
<Spinner type="dots" /> Loading approval policy...
</Text>
</Box>
);
}

if (status === 'error') {
return (
<Box flexDirection="column">
<Text color="red">✗ Failed to load approval policy</Text>
<Text color="red">{error}</Text>
</Box>
);
}

return (
<Box flexDirection="column">
<Text bold>Approval Policy</Text>
{policy?.rules.map((rule, index) => (
<Box
key={`rule-${index}`}
flexDirection="column"
marginTop={1}
paddingX={2}
>
<Text bold>Rule {index + 1}</Text>
<Text>
<Text dimColor>Action: </Text>
{rule.action}
</Text>
<Text>
<Text dimColor>Per-purchase limit: </Text>
{formatAmount(
rule.limits.per_purchase.amount,
rule.limits.per_purchase.currency,
)}
</Text>
{rule.allowed_payment_methods ? (
<Text>
<Text dimColor>Allowed payment methods: </Text>
{rule.allowed_payment_methods.length > 0
? rule.allowed_payment_methods.join(', ')
: 'None'}
</Text>
) : null}
</Box>
))}
</Box>
);
};
12 changes: 12 additions & 0 deletions packages/cli/src/utils/resource-factory.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
type AccessTokenProvider,
type IApprovalPolicyResource,
type IAttestationsResource,
type IBalancesResource,
type IPaymentMethodsResource,
Expand Down Expand Up @@ -115,6 +116,7 @@ export class ResourceFactory {
private paymentMethodsResource?: IPaymentMethodsResource;
private shippingAddressResource?: IShippingAddressResource;
private userInfoResource?: IUserInfoResource;
private approvalPolicyResource?: IApprovalPolicyResource;
private transactionsResource?: ITransactionsResource;
private sourcesResource?: ISourcesResource;
private summariesResource?: ISummariesResource;
Expand Down Expand Up @@ -274,6 +276,16 @@ export class ResourceFactory {
return resource;
}

createApprovalPolicyResource(): IApprovalPolicyResource {
if (this.approvalPolicyResource) {
return this.approvalPolicyResource;
}

const resource = sanitizeResource(this.createSdkClient().approvalPolicy);
this.approvalPolicyResource = resource;
return resource;
}

createTransactionsResource(): ITransactionsResource {
if (this.transactionsResource) {
return this.transactionsResource;
Expand Down
6 changes: 6 additions & 0 deletions packages/sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ const link = new Link({ accessToken: process.env.LINK_ACCESS_TOKEN! });
const paymentMethods = await link.paymentMethods.list();
```

Retrieve the approval policy for the current app and user:

```ts
const approvalPolicy = await link.approvalPolicy.retrieve();
```

Use a fixed token for a short-lived job or when the caller replaces the entire
client as credentials change.

Expand Down
4 changes: 4 additions & 0 deletions packages/sdk/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type { LinkOptions } from '@/config';
import { ApprovalPolicyResource } from '@/resources/approval-policy';
import { AttestationsResource } from '@/resources/attestations';
import { BalancesResource } from '@/resources/balances';
import type {
IApprovalPolicyResource,
IAttestationsResource,
IBalancesResource,
IPaymentMethodsResource,
Expand Down Expand Up @@ -30,6 +32,7 @@ export class Link {
readonly paymentMethods: IPaymentMethodsResource;
readonly shippingAddresses: IShippingAddressResource;
readonly userInfo: IUserInfoResource;
readonly approvalPolicy: IApprovalPolicyResource;
readonly transactions: ITransactionsResource;
readonly sources: ISourcesResource;
readonly balances: IBalancesResource;
Expand All @@ -43,6 +46,7 @@ export class Link {
this.paymentMethods = new PaymentMethodsResource(options);
this.shippingAddresses = new ShippingAddressResource(options);
this.userInfo = new UserInfoResource(options);
this.approvalPolicy = new ApprovalPolicyResource(options);
this.transactions = new TransactionsResource(options);
this.sources = new SourcesResource(options);
this.balances = new BalancesResource(options);
Expand Down
1 change: 1 addition & 0 deletions packages/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export {
LinkSdkError,
LinkTransportError,
} from './errors';
export { ApprovalPolicyResource } from './resources/approval-policy';
export * from './resources/attestations';
export * from './resources/interfaces';
export { getDuplicateSpendRequest } from './resources/spend-request';
Expand Down
Loading
Loading