Skip to content
60 changes: 41 additions & 19 deletions docs/payments.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,26 @@ For details on IAM role separation (ManagementRole vs ProcessPaymentRole), see
A payment connector links a credential provider (wallet credentials) to a payment manager. Each manager needs at least
one connector before it can process payments.

### Supplying secrets securely (`--credentials-file`)

Passing secrets as literal flags (`--api-key-secret`, `--wallet-secret`, `--app-secret`, `--authorization-private-key`)
exposes them to your shell history and the process table. For non-interactive use, put the provider secrets in a JSON
file and pass `--credentials-file` instead (or pipe the JSON via `--credentials-file -`). The CLI prints a warning if
you use the literal secret flags.

```bash
# CoinbaseCDP — cdp-creds.json (chmod 600; .env.local-style secrets, gitignored)
{ "apiKeyId": "...", "apiKeySecret": "...", "walletSecret": "..." }

agentcore add payment-connector --manager MyManager --name MyCDPConnector \
--provider CoinbaseCDP --credentials-file ./cdp-creds.json

# StripePrivy keys: appId, appSecret, authorizationPrivateKey, authorizationId
# Or pipe from a secret manager without a temp file:
get-secret cdp-creds | agentcore add payment-connector --manager MyManager \
--name MyCDPConnector --provider CoinbaseCDP --credentials-file -
```

### CoinbaseCDP Provider

```bash
Expand All @@ -138,15 +158,16 @@ agentcore add payment-connector \
--wallet-secret your-wallet-secret
```

| Flag | Description |
| --------------------------- | ---------------------------------------- |
| `--manager <name>` | Parent payment manager (required) |
| `--name <name>` | Connector name (required) |
| `--provider <provider>` | `CoinbaseCDP` (default) or `StripePrivy` |
| `--api-key-id <id>` | Coinbase CDP API Key ID |
| `--api-key-secret <secret>` | Coinbase CDP API Key Secret |
| `--wallet-secret <secret>` | Coinbase CDP Wallet Secret (ECDSA P-256) |
| `--json` | Output result as JSON |
| Flag | Description |
| --------------------------- | ------------------------------------------------------------------------------------------- |
| `--manager <name>` | Parent payment manager (required) |
| `--name <name>` | Connector name (required) |
| `--provider <provider>` | `CoinbaseCDP` (default) or `StripePrivy` |
| `--api-key-id <id>` | Coinbase CDP API Key ID |
| `--api-key-secret <secret>` | Coinbase CDP API Key Secret |
| `--wallet-secret <secret>` | Coinbase CDP Wallet Secret (ECDSA P-256) |
| `--credentials-file <path>` | JSON file (or `-` for stdin) with the provider secrets; preferred over literal secret flags |
| `--json` | Output result as JSON |

### StripePrivy Provider

Expand All @@ -161,16 +182,17 @@ agentcore add payment-connector \
--authorization-id your-authorization-key-id
```

| Flag | Description |
| ----------------------------------- | ----------------------------------- |
| `--manager <name>` | Parent payment manager (required) |
| `--name <name>` | Connector name (required) |
| `--provider <provider>` | Must be `StripePrivy` |
| `--app-id <id>` | Privy App ID |
| `--app-secret <secret>` | Privy App Secret |
| `--authorization-private-key <key>` | ECDSA P-256 private key for signing |
| `--authorization-id <id>` | Authorization key identifier |
| `--json` | Output result as JSON |
| Flag | Description |
| ----------------------------------- | ------------------------------------------------------------------------------------------- |
| `--manager <name>` | Parent payment manager (required) |
| `--name <name>` | Connector name (required) |
| `--provider <provider>` | Must be `StripePrivy` |
| `--app-id <id>` | Privy App ID |
| `--app-secret <secret>` | Privy App Secret |
| `--authorization-private-key <key>` | ECDSA P-256 private key for signing |
| `--authorization-id <id>` | Authorization key identifier |
| `--credentials-file <path>` | JSON file (or `-` for stdin) with the provider secrets; preferred over literal secret flags |
| `--json` | Output result as JSON |

### Credential Storage

Expand Down
179 changes: 179 additions & 0 deletions src/cli/aws/__tests__/agentcore-payments.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { isQuotaExceededError } from '../../errors';
import { buildPaymentApiError, createPaymentCredentialProvider, getPaymentManager } from '../agentcore-payments';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

describe('buildPaymentApiError', () => {
it('never echoes the response body, even secret-bearing nested/snake_case fields', () => {
const body = JSON.stringify({
message: 'validation failed',
coinbaseCdpConfiguration: { api_key_secret: 'SUPER_SECRET', walletSecret: 'WALLET_SECRET' },
stripePrivyConfiguration: { authorization_private_key: 'PRIV_KEY' },
});
const err = buildPaymentApiError(400, body);
expect(err.message).not.toContain('SUPER_SECRET');
expect(err.message).not.toContain('WALLET_SECRET');
expect(err.message).not.toContain('PRIV_KEY');
expect(err.message).not.toContain('validation failed');
expect(err.message).toContain('(400)');
});

it('surfaces the parsed code from `code`', () => {
const err = buildPaymentApiError(409, JSON.stringify({ code: 'ConflictException', appSecret: 'leak' }));
expect(err.code).toBe('ConflictException');
expect(err.message).toBe('Payment API error (409): ConflictException');
expect(err.message).not.toContain('leak');
});

it('falls back to `__type` when `code` is absent', () => {
const err = buildPaymentApiError(404, JSON.stringify({ __type: 'ResourceNotFoundException' }));
expect(err.code).toBe('ResourceNotFoundException');
expect(err.message).toContain('ResourceNotFoundException');
});

it('uses a static message when the body is unparseable, with no body content', () => {
const err = buildPaymentApiError(500, '<html>secret-token-xyz</html>');
expect(err.code).toBeUndefined();
expect(err.message).toBe('Payment API error (500): request failed');
expect(err.message).not.toContain('secret-token-xyz');
});

it('uses the data-plane label when opts.dataPlane is set', () => {
const err = buildPaymentApiError(403, JSON.stringify({ code: 'AccessDenied' }), { dataPlane: true });
expect(err.message).toBe('Payment data plane API error (403): AccessDenied');
});

describe('safe server message surfacing', () => {
it('surfaces a server message when the body has no secret fields', () => {
const err = buildPaymentApiError(
400,
JSON.stringify({ code: 'ValidationException', message: 'paymentManagerId must be <= 64 chars' })
);
expect(err.code).toBe('ValidationException');
expect(err.message).toBe('Payment API error (400): ValidationException: paymentManagerId must be <= 64 chars');
});

it('surfaces a message even without a code', () => {
const err = buildPaymentApiError(400, JSON.stringify({ message: 'invalid network preference' }));
expect(err.message).toBe('Payment API error (400): invalid network preference');
});

it('suppresses the message when a secret field appears anywhere in the body', () => {
const err = buildPaymentApiError(
400,
JSON.stringify({ message: 'bad config', coinbaseCdpConfiguration: { walletSecret: 'LEAK' } })
);
expect(err.message).toBe('Payment API error (400): request failed');
expect(err.message).not.toContain('bad config');
expect(err.message).not.toContain('LEAK');
});

it('suppresses the message when the message itself names a secret field', () => {
const err = buildPaymentApiError(
400,
JSON.stringify({ code: 'ValidationException', message: 'apiKeySecret is malformed: BADVALUE' })
);
expect(err.message).toBe('Payment API error (400): ValidationException');
expect(err.message).not.toContain('BADVALUE');
expect(err.message).not.toContain('apiKeySecret');
});

it('truncates an overlong server message', () => {
const long = 'x'.repeat(500);
const err = buildPaymentApiError(400, JSON.stringify({ message: long }));
expect(err.message.length).toBeLessThan(260);
expect(err.message).toContain('Payment API error (400):');
});
});
});

vi.mock('@smithy/signature-v4', () => ({
SignatureV4: class {
sign = vi.fn().mockResolvedValue({ headers: {} });
},
}));
vi.mock('@aws-crypto/sha256-js', () => ({ Sha256: class {} }));
vi.mock('@smithy/protocol-http', () => ({
HttpRequest: class {
constructor(public options: unknown) {}
},
}));
vi.mock('@aws-sdk/credential-provider-node', () => ({ defaultProvider: () => vi.fn() }));
vi.mock('../account', () => ({ getCredentialProvider: () => undefined }));
vi.mock('../stage-endpoint', () => ({
controlPlaneEndpoint: () => 'https://cp.example.com',
dataPlaneEndpoint: () => 'https://dp.example.com',
}));

const mockFetch = vi.fn();

describe('getPaymentManager 404 handling', () => {
beforeEach(() => vi.stubGlobal('fetch', mockFetch));
afterEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
});

it('returns null on a 404 with a parsed code and never leaks the body', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 404,
text: () => Promise.resolve(JSON.stringify({ __type: 'ResourceNotFoundException', appSecret: 'leak' })),
});
const result = await getPaymentManager({ region: 'us-east-1', paymentManagerId: 'pm-1' });
expect(result).toBeNull();
});

it('rethrows non-404 errors without echoing the body', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 400,
text: () => Promise.resolve(JSON.stringify({ code: 'ValidationException', walletSecret: 'leak' })),
});
await expect(getPaymentManager({ region: 'us-east-1', paymentManagerId: 'pm-1' })).rejects.toThrow(
/Failed to get payment manager .* ValidationException/
);
await expect(getPaymentManager({ region: 'us-east-1', paymentManagerId: 'pm-1' })).rejects.not.toThrow(/leak/);
});
});

describe('createPaymentCredentialProvider quota error propagation', () => {
beforeEach(() => vi.stubGlobal('fetch', mockFetch));
afterEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
});

it('preserves .code so isQuotaExceededError detects quota errors without leaking secrets', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 402,
text: () =>
Promise.resolve(
JSON.stringify({
code: 'ServiceQuotaExceededException',
message: 'too many credential providers',
apiKeySecret: 'leak',
})
),
});

let error: unknown;
try {
await createPaymentCredentialProvider({
region: 'us-east-1',
name: 'cp1',
vendor: 'CoinbaseCDP',
apiKeyId: 'a',
apiKeySecret: 'b',
walletSecret: 'c',
});
} catch (err) {
error = err;
}

expect(error).toBeDefined();
expect(isQuotaExceededError(error)).toBe(true);
expect((error as { code?: string }).code).toBe('ServiceQuotaExceededException');
expect(String((error as Error).message)).not.toContain('leak');
});
});
Loading
Loading