@agent-cards/checkout 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +126 -0
- package/dist/cdp.d.ts +41 -0
- package/dist/cdp.js +123 -0
- package/dist/client.d.ts +82 -0
- package/dist/client.js +164 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +3 -0
- package/dist/registry.d.ts +29 -0
- package/dist/registry.js +45 -0
- package/package.json +37 -0
package/README.md
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# @agent-cards/checkout
|
|
2
|
+
|
|
3
|
+
Let your browser agents pay with **the user's own card**, without your
|
|
4
|
+
infrastructure ever touching card data.
|
|
5
|
+
|
|
6
|
+
Your agent drives checkout normally. When the page tries to tokenize a card, we
|
|
7
|
+
pause that one request, ask the cardholder to approve on their device, and their
|
|
8
|
+
device supplies the card and calls the merchant. You get back the response to
|
|
9
|
+
replay. A real card never enters your process, your logs, or your network.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
your agent ──drives──> merchant checkout
|
|
13
|
+
│ tokenization request
|
|
14
|
+
▼
|
|
15
|
+
[ paused by this SDK ]
|
|
16
|
+
│ template only, dummy card
|
|
17
|
+
▼
|
|
18
|
+
Agentcard ──notify──> cardholder's device
|
|
19
|
+
│ decrypts card locally
|
|
20
|
+
▼
|
|
21
|
+
merchant's card vault
|
|
22
|
+
┌────────token────────┘
|
|
23
|
+
▼
|
|
24
|
+
[ request resumed ] ──> order completes
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm i @agent-cards/checkout
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Use it
|
|
34
|
+
|
|
35
|
+
Two lines against a CDP session you already have:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
import { VaultClient, attachToCdp } from '@agent-cards/checkout';
|
|
39
|
+
|
|
40
|
+
const vault = new VaultClient({
|
|
41
|
+
clientId: process.env.AGENTCARD_CLIENT_ID!,
|
|
42
|
+
clientSecret: process.env.AGENTCARD_CLIENT_SECRET!,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
await attachToCdp(cdp, pageSessionId, {
|
|
46
|
+
vault,
|
|
47
|
+
user: 'usr_123', // whose card should pay
|
|
48
|
+
merchant: 'vanman.shop',
|
|
49
|
+
amount: '$5.83',
|
|
50
|
+
onApprovalUrl: (url) => sendToUser(url), // iMessage, SMS, push, your call
|
|
51
|
+
});
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Then let your agent click "Pay" like it always does. `attachToCdp` holds the
|
|
55
|
+
request open until the cardholder approves, so the checkout simply continues.
|
|
56
|
+
|
|
57
|
+
Playwright:
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import { attachToPlaywright } from '@agent-cards/checkout/playwright';
|
|
61
|
+
await attachToPlaywright(page, { vault, user, merchant, amount });
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Credentials
|
|
65
|
+
|
|
66
|
+
Use your **OAuth client credentials**, not an `sk_` API key — those are retired,
|
|
67
|
+
and the checkout endpoints reject them (`client_credentials_required`) because an
|
|
68
|
+
authorization is bound to the confidential client that created it. The SDK does
|
|
69
|
+
the `client_credentials` exchange for you, caches the token, and refreshes it
|
|
70
|
+
once on a 401. Create a client from the dashboard Credentials page or with
|
|
71
|
+
`agent-cards-admin oauth-clients create`.
|
|
72
|
+
|
|
73
|
+
## Why you need the SDK and not just `Fetch.enable`
|
|
74
|
+
|
|
75
|
+
Card fields render in **cross-origin iframes**, which are separate CDP targets.
|
|
76
|
+
Enabling `Fetch` on the page session never sees the tokenization request. You
|
|
77
|
+
need recursive `Target.setAutoAttach({ flatten: true })` on every nested target,
|
|
78
|
+
then `Fetch.enable` on each, then `Runtime.runIfWaitingForDebugger` to unpause
|
|
79
|
+
them. That, plus which headers a merchant requires you to replay verbatim, is
|
|
80
|
+
what this package encapsulates.
|
|
81
|
+
|
|
82
|
+
## What runs where
|
|
83
|
+
|
|
84
|
+
| | Sees the real card |
|
|
85
|
+
|---|---|
|
|
86
|
+
| Your agent / browser | **no** — only a dummy PAN and a token |
|
|
87
|
+
| Agentcard servers | **no** — a request template and a token |
|
|
88
|
+
| Cardholder's device | yes — decrypts locally, calls the merchant directly |
|
|
89
|
+
|
|
90
|
+
Because your process only ever handles a dummy card and an opaque token, this
|
|
91
|
+
integration is designed to keep you out of PCI scope. Get your own QSA's read
|
|
92
|
+
before you put that in writing.
|
|
93
|
+
|
|
94
|
+
## Supported processors
|
|
95
|
+
|
|
96
|
+
Merchants inherit their processor, so one entry covers every store on it.
|
|
97
|
+
|
|
98
|
+
| Processor | Status |
|
|
99
|
+
|---|---|
|
|
100
|
+
| Shopify | supported, verified end to end |
|
|
101
|
+
| Stripe | supported, verified end to end |
|
|
102
|
+
| Braintree / PayPal | supported, verified end to end |
|
|
103
|
+
| Checkout.com | supported |
|
|
104
|
+
| Adyen | not yet — the card is encrypted in-page, so a paused request carries a blob. Throws `CardEncryptedError` |
|
|
105
|
+
|
|
106
|
+
The recognizer list is fetched from the API at runtime (`vault.syncRegistry()`),
|
|
107
|
+
so new processors work without you shipping a release.
|
|
108
|
+
|
|
109
|
+
## Errors worth handling
|
|
110
|
+
|
|
111
|
+
- `ApprovalTimeoutError` — the user never approved. Default window is 15 minutes;
|
|
112
|
+
we have completed checkouts after a 5.5 minute approval delay.
|
|
113
|
+
- `ApprovalDeclinedError` — the user said no.
|
|
114
|
+
- `CardEncryptedError` — this processor needs vault-side crypto; route the
|
|
115
|
+
purchase to an Agentcard-issued card instead.
|
|
116
|
+
|
|
117
|
+
## Building this package
|
|
118
|
+
|
|
119
|
+
It declares **no dependencies**, matching `packages/vault`, so the workspace
|
|
120
|
+
lockfile needs no importer entry for it (a new package with its own deps cannot
|
|
121
|
+
be installed here without regenerating the lockfile, and a full regen drifts
|
|
122
|
+
unrelated transitive versions). Build it with the workspace TypeScript:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
pnpm --filter backend exec tsc -p packages/checkout/tsconfig.json
|
|
126
|
+
```
|
package/dist/cdp.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { VaultClient } from './client.js';
|
|
2
|
+
/**
|
|
3
|
+
* Minimal shape of a CDP connection. Works with a raw websocket client, a
|
|
4
|
+
* Puppeteer CDPSession, or Playwright's CDPSession.
|
|
5
|
+
*/
|
|
6
|
+
export interface CdpLike {
|
|
7
|
+
send(method: string, params?: any, sessionId?: string): Promise<any>;
|
|
8
|
+
on(handler: (method: string, params: any, sessionId?: string) => void): void;
|
|
9
|
+
}
|
|
10
|
+
export interface AttachOptions {
|
|
11
|
+
vault: VaultClient;
|
|
12
|
+
user: string;
|
|
13
|
+
merchant: string;
|
|
14
|
+
amount: string;
|
|
15
|
+
onApprovalUrl?: (url: string) => void;
|
|
16
|
+
onEvent?: (e: {
|
|
17
|
+
type: string;
|
|
18
|
+
detail?: unknown;
|
|
19
|
+
}) => void;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Take over card tokenization for a page.
|
|
23
|
+
*
|
|
24
|
+
* IMPORTANT: card fields render in cross-origin iframes, which are separate CDP
|
|
25
|
+
* targets. Enabling Fetch on the page session alone will never see the
|
|
26
|
+
* tokenization request. This attaches recursively so every nested target is
|
|
27
|
+
* armed, which is the whole reason this adapter exists.
|
|
28
|
+
*/
|
|
29
|
+
export declare function attachToCdp(cdp: CdpLike, pageSessionId: string, opts: AttachOptions): Promise<void>;
|
|
30
|
+
/**
|
|
31
|
+
* Playwright convenience wrapper — the path for cloud browsers that hand you a
|
|
32
|
+
* CDP websocket (Kernel's `cdp_ws_url`, Browserbase, etc.):
|
|
33
|
+
*
|
|
34
|
+
* const browser = await chromium.connectOverCDP(kernelBrowser.cdp_ws_url);
|
|
35
|
+
* const page = await browser.contexts()[0].newPage();
|
|
36
|
+
* await attachToPlaywright(page, { vault, user, merchant, amount });
|
|
37
|
+
*
|
|
38
|
+
* Uses Playwright's own request routing, which already spans subframes — see
|
|
39
|
+
* the note in the body for why a hand-rolled CDPSession does not work here.
|
|
40
|
+
*/
|
|
41
|
+
export declare function attachToPlaywright(page: any, opts: AttachOptions): Promise<void>;
|
package/dist/cdp.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
const CARD_PATTERNS = [
|
|
2
|
+
'*pci.shopifyinc.com/sessions*',
|
|
3
|
+
'*shopifycs.com/sessions*',
|
|
4
|
+
'*api.stripe.com/v1/payment_methods*',
|
|
5
|
+
'*api.stripe.com/v1/tokens*',
|
|
6
|
+
'*braintree-api.com/graphql*',
|
|
7
|
+
'*checkout.com/tokens*',
|
|
8
|
+
];
|
|
9
|
+
/**
|
|
10
|
+
* Take over card tokenization for a page.
|
|
11
|
+
*
|
|
12
|
+
* IMPORTANT: card fields render in cross-origin iframes, which are separate CDP
|
|
13
|
+
* targets. Enabling Fetch on the page session alone will never see the
|
|
14
|
+
* tokenization request. This attaches recursively so every nested target is
|
|
15
|
+
* armed, which is the whole reason this adapter exists.
|
|
16
|
+
*/
|
|
17
|
+
export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
18
|
+
const armed = new Set();
|
|
19
|
+
const arm = async (sessionId) => {
|
|
20
|
+
const key = sessionId ?? '__root__';
|
|
21
|
+
if (armed.has(key))
|
|
22
|
+
return;
|
|
23
|
+
armed.add(key);
|
|
24
|
+
await cdp.send('Fetch.enable', {
|
|
25
|
+
patterns: CARD_PATTERNS.map((urlPattern) => ({ urlPattern, requestStage: 'Request' })),
|
|
26
|
+
}, sessionId).catch(() => { });
|
|
27
|
+
// Descend into this target's own children (iframes inside iframes).
|
|
28
|
+
await cdp.send('Target.setAutoAttach', {
|
|
29
|
+
autoAttach: true, waitForDebuggerOnStart: true, flatten: true,
|
|
30
|
+
}, sessionId).catch(() => { });
|
|
31
|
+
};
|
|
32
|
+
cdp.on(async (method, params, sessionId) => {
|
|
33
|
+
if (method === 'Target.attachedToTarget') {
|
|
34
|
+
const child = params.sessionId;
|
|
35
|
+
await arm(child);
|
|
36
|
+
// Child targets start paused when waitForDebuggerOnStart is set.
|
|
37
|
+
await cdp.send('Runtime.runIfWaitingForDebugger', {}, child).catch(() => { });
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (method !== 'Fetch.requestPaused')
|
|
41
|
+
return;
|
|
42
|
+
const { requestId, request } = params;
|
|
43
|
+
if (!opts.vault.isCardRequest(request.url, request.method)) {
|
|
44
|
+
await cdp.send('Fetch.continueRequest', { requestId }, sessionId).catch(() => { });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
let body = request.postData ?? '';
|
|
48
|
+
if (!body && request.hasPostData) {
|
|
49
|
+
body = (await cdp.send('Fetch.getRequestPostData', { requestId }, sessionId).catch(() => ({ postData: '' }))).postData ?? '';
|
|
50
|
+
}
|
|
51
|
+
opts.onEvent?.({ type: 'card_request_paused', detail: { url: request.url } });
|
|
52
|
+
try {
|
|
53
|
+
const replay = await opts.vault.authorize({
|
|
54
|
+
user: opts.user,
|
|
55
|
+
merchant: opts.merchant,
|
|
56
|
+
amount: opts.amount,
|
|
57
|
+
onApprovalUrl: opts.onApprovalUrl,
|
|
58
|
+
request: { url: request.url, method: request.method, headers: request.headers, body },
|
|
59
|
+
});
|
|
60
|
+
await cdp.send('Fetch.fulfillRequest', {
|
|
61
|
+
requestId,
|
|
62
|
+
responseCode: replay.status,
|
|
63
|
+
responseHeaders: Object.entries(replay.headers).map(([name, value]) => ({ name, value: String(value) })),
|
|
64
|
+
body: Buffer.from(replay.body).toString('base64'),
|
|
65
|
+
}, sessionId);
|
|
66
|
+
opts.onEvent?.({ type: 'authorized' });
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
opts.onEvent?.({ type: 'failed', detail: String(err) });
|
|
70
|
+
await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
await arm(pageSessionId);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Playwright convenience wrapper — the path for cloud browsers that hand you a
|
|
77
|
+
* CDP websocket (Kernel's `cdp_ws_url`, Browserbase, etc.):
|
|
78
|
+
*
|
|
79
|
+
* const browser = await chromium.connectOverCDP(kernelBrowser.cdp_ws_url);
|
|
80
|
+
* const page = await browser.contexts()[0].newPage();
|
|
81
|
+
* await attachToPlaywright(page, { vault, user, merchant, amount });
|
|
82
|
+
*
|
|
83
|
+
* Uses Playwright's own request routing, which already spans subframes — see
|
|
84
|
+
* the note in the body for why a hand-rolled CDPSession does not work here.
|
|
85
|
+
*/
|
|
86
|
+
export async function attachToPlaywright(page, opts) {
|
|
87
|
+
// Playwright's own routing, NOT a hand-rolled CDP session.
|
|
88
|
+
//
|
|
89
|
+
// A CDPSession from `newCDPSession(page)` is bound to the PAGE target and its
|
|
90
|
+
// send() takes no session id, so every command meant for an attached child
|
|
91
|
+
// target lands on the page instead — the nested card iframes are never armed
|
|
92
|
+
// and the tokenization request sails past. Card fields are cross-origin
|
|
93
|
+
// iframes essentially always, so that adapter was broken for the only case
|
|
94
|
+
// that matters.
|
|
95
|
+
//
|
|
96
|
+
// page.route already spans subframes, so Playwright does the target
|
|
97
|
+
// bookkeeping that attachToCdp has to do by hand for a raw connection.
|
|
98
|
+
await page.route((url) => opts.vault.isCardRequest(url.toString()), async (route) => {
|
|
99
|
+
const request = route.request();
|
|
100
|
+
// The matcher only sees the URL; a preflight or a GET must pass through
|
|
101
|
+
// untouched or the browser's CORS check fails on our synthetic answer.
|
|
102
|
+
if (!opts.vault.isCardRequest(request.url(), request.method())) {
|
|
103
|
+
return route.fallback();
|
|
104
|
+
}
|
|
105
|
+
const body = request.postData() ?? '';
|
|
106
|
+
opts.onEvent?.({ type: 'card_request_paused', detail: { url: request.url() } });
|
|
107
|
+
try {
|
|
108
|
+
const replay = await opts.vault.authorize({
|
|
109
|
+
user: opts.user,
|
|
110
|
+
merchant: opts.merchant,
|
|
111
|
+
amount: opts.amount,
|
|
112
|
+
onApprovalUrl: opts.onApprovalUrl,
|
|
113
|
+
request: { url: request.url(), method: request.method(), headers: request.headers(), body },
|
|
114
|
+
});
|
|
115
|
+
await route.fulfill({ status: replay.status, headers: replay.headers, body: replay.body });
|
|
116
|
+
opts.onEvent?.({ type: 'authorized' });
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
opts.onEvent?.({ type: 'failed', detail: String(err) });
|
|
120
|
+
await route.abort();
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { type Recognizer } from './registry.js';
|
|
2
|
+
export interface PausedRequest {
|
|
3
|
+
url: string;
|
|
4
|
+
method: string;
|
|
5
|
+
headers: Record<string, string>;
|
|
6
|
+
body: string;
|
|
7
|
+
}
|
|
8
|
+
export interface ReplayResponse {
|
|
9
|
+
status: number;
|
|
10
|
+
headers: Record<string, string>;
|
|
11
|
+
/** Body to hand back to the browser, verbatim. */
|
|
12
|
+
body: string;
|
|
13
|
+
}
|
|
14
|
+
export interface AuthorizeInput {
|
|
15
|
+
/** Your identifier for the person whose card should pay. */
|
|
16
|
+
user: string;
|
|
17
|
+
/** Shown to the user on the approval screen. */
|
|
18
|
+
merchant: string;
|
|
19
|
+
amount: string;
|
|
20
|
+
request: PausedRequest;
|
|
21
|
+
/** Abort if the user has not approved within this many ms. Default 15 min. */
|
|
22
|
+
timeoutMs?: number;
|
|
23
|
+
/** Called once with the URL to surface to the user, if you deliver it yourself. */
|
|
24
|
+
onApprovalUrl?: (url: string) => void;
|
|
25
|
+
}
|
|
26
|
+
export declare class CardEncryptedError extends Error {
|
|
27
|
+
psp: string;
|
|
28
|
+
constructor(psp: string);
|
|
29
|
+
}
|
|
30
|
+
export declare class ApprovalTimeoutError extends Error {
|
|
31
|
+
constructor(ms: number);
|
|
32
|
+
}
|
|
33
|
+
export declare class ApprovalDeclinedError extends Error {
|
|
34
|
+
constructor(reason: string);
|
|
35
|
+
}
|
|
36
|
+
export interface VaultClientOptions {
|
|
37
|
+
/**
|
|
38
|
+
* Your Agentcard OAuth client credentials. The SDK exchanges them for a
|
|
39
|
+
* short-lived access token and refreshes it when it expires.
|
|
40
|
+
*
|
|
41
|
+
* NOT an `sk_` API key: those are retired, and the checkout endpoints reject
|
|
42
|
+
* them with `client_credentials_required` because an authorization has to be
|
|
43
|
+
* bound to the confidential client that created it.
|
|
44
|
+
*/
|
|
45
|
+
clientId: string;
|
|
46
|
+
clientSecret: string;
|
|
47
|
+
baseUrl?: string;
|
|
48
|
+
/** Override the PSP registry (tests, or pinning). Defaults to the hosted list. */
|
|
49
|
+
registry?: Recognizer[];
|
|
50
|
+
fetchImpl?: typeof fetch;
|
|
51
|
+
pollIntervalMs?: number;
|
|
52
|
+
}
|
|
53
|
+
export declare class VaultClient {
|
|
54
|
+
private readonly opts;
|
|
55
|
+
private readonly baseUrl;
|
|
56
|
+
private readonly fetch;
|
|
57
|
+
private readonly pollIntervalMs;
|
|
58
|
+
private registry;
|
|
59
|
+
constructor(opts: VaultClientOptions);
|
|
60
|
+
/** Refresh recognizers from the API so new PSPs work without a redeploy. */
|
|
61
|
+
syncRegistry(): Promise<void>;
|
|
62
|
+
/** True when this request is a card tokenization we can take over. */
|
|
63
|
+
isCardRequest(url: string, method?: string): boolean;
|
|
64
|
+
/**
|
|
65
|
+
* Hand us a paused tokenization request. We ask the cardholder to approve,
|
|
66
|
+
* their device supplies the card and calls the merchant, and you get back the
|
|
67
|
+
* response to replay into the browser. Your process never sees a card.
|
|
68
|
+
*/
|
|
69
|
+
authorize(input: AuthorizeInput): Promise<ReplayResponse>;
|
|
70
|
+
private token;
|
|
71
|
+
private inflight;
|
|
72
|
+
/**
|
|
73
|
+
* Exchange client credentials for an access token, reusing the cached one
|
|
74
|
+
* until it is nearly expired. Concurrent callers share a single in-flight
|
|
75
|
+
* exchange rather than each minting their own token.
|
|
76
|
+
*/
|
|
77
|
+
private accessToken;
|
|
78
|
+
/** Authenticated request that retries ONCE on a 401 with a fresh token. */
|
|
79
|
+
private call;
|
|
80
|
+
private post;
|
|
81
|
+
private get;
|
|
82
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { BUILTIN_REGISTRY, findRecognizer } from './registry.js';
|
|
2
|
+
export class CardEncryptedError extends Error {
|
|
3
|
+
psp;
|
|
4
|
+
constructor(psp) {
|
|
5
|
+
super(`${psp} encrypts the card in-page; a paused request carries a blob, not a PAN. ` +
|
|
6
|
+
`Agentcard must run this PSP's client-side crypto in the vault.`);
|
|
7
|
+
this.psp = psp;
|
|
8
|
+
this.name = 'CardEncryptedError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export class ApprovalTimeoutError extends Error {
|
|
12
|
+
constructor(ms) { super(`user did not approve within ${ms}ms`); this.name = 'ApprovalTimeoutError'; }
|
|
13
|
+
}
|
|
14
|
+
export class ApprovalDeclinedError extends Error {
|
|
15
|
+
constructor(reason) { super(`user declined: ${reason}`); this.name = 'ApprovalDeclinedError'; }
|
|
16
|
+
}
|
|
17
|
+
export class VaultClient {
|
|
18
|
+
opts;
|
|
19
|
+
baseUrl;
|
|
20
|
+
fetch;
|
|
21
|
+
pollIntervalMs;
|
|
22
|
+
registry;
|
|
23
|
+
constructor(opts) {
|
|
24
|
+
this.opts = opts;
|
|
25
|
+
this.baseUrl = (opts.baseUrl ?? 'https://api.agentcard.sh').replace(/\/$/, '');
|
|
26
|
+
this.fetch = opts.fetchImpl ?? globalThis.fetch;
|
|
27
|
+
this.registry = opts.registry ?? BUILTIN_REGISTRY;
|
|
28
|
+
this.pollIntervalMs = opts.pollIntervalMs ?? 2000;
|
|
29
|
+
}
|
|
30
|
+
/** Refresh recognizers from the API so new PSPs work without a redeploy. */
|
|
31
|
+
async syncRegistry() {
|
|
32
|
+
// Never break checkout over a registry fetch — an auth blip or a bad
|
|
33
|
+
// response leaves the built-in recognizers in place.
|
|
34
|
+
let raw;
|
|
35
|
+
try {
|
|
36
|
+
raw = await this.get('/v2/checkout/recognizers');
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (!Array.isArray(raw))
|
|
42
|
+
return;
|
|
43
|
+
this.registry = raw.map((e) => ({
|
|
44
|
+
...e,
|
|
45
|
+
match: new RegExp(e.match, 'i'),
|
|
46
|
+
passthroughHeaders: e.passthroughHeaders.map((h) => new RegExp(h, 'i')),
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
/** True when this request is a card tokenization we can take over. */
|
|
50
|
+
isCardRequest(url, method = 'POST') {
|
|
51
|
+
return method.toUpperCase() === 'POST' && findRecognizer(url, this.registry) !== null;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Hand us a paused tokenization request. We ask the cardholder to approve,
|
|
55
|
+
* their device supplies the card and calls the merchant, and you get back the
|
|
56
|
+
* response to replay into the browser. Your process never sees a card.
|
|
57
|
+
*/
|
|
58
|
+
async authorize(input) {
|
|
59
|
+
const rec = findRecognizer(input.request.url, this.registry);
|
|
60
|
+
if (!rec)
|
|
61
|
+
throw new Error(`not a known tokenization endpoint: ${input.request.url}`);
|
|
62
|
+
if (rec.clientSideEncrypted)
|
|
63
|
+
throw new CardEncryptedError(rec.psp);
|
|
64
|
+
const timeoutMs = input.timeoutMs ?? 15 * 60_000;
|
|
65
|
+
const created = await this.post('/v2/checkout/authorizations', {
|
|
66
|
+
user: input.user,
|
|
67
|
+
merchant: input.merchant,
|
|
68
|
+
amount: input.amount,
|
|
69
|
+
psp: rec.psp,
|
|
70
|
+
request: {
|
|
71
|
+
url: input.request.url,
|
|
72
|
+
method: input.request.method,
|
|
73
|
+
headers: pickHeaders(input.request.headers, rec.passthroughHeaders),
|
|
74
|
+
body: input.request.body,
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
input.onApprovalUrl?.(created.approvalUrl);
|
|
78
|
+
const deadline = Date.now() + timeoutMs;
|
|
79
|
+
while (Date.now() < deadline) {
|
|
80
|
+
await sleep(this.pollIntervalMs);
|
|
81
|
+
const s = await this.get(`/v2/checkout/authorizations/${created.id}`);
|
|
82
|
+
if (s.status === 'approved')
|
|
83
|
+
return s.response;
|
|
84
|
+
if (s.status === 'declined')
|
|
85
|
+
throw new ApprovalDeclinedError(s.reason ?? 'no reason given');
|
|
86
|
+
}
|
|
87
|
+
throw new ApprovalTimeoutError(timeoutMs);
|
|
88
|
+
}
|
|
89
|
+
// --- auth: client_credentials, cached until just before it expires --------
|
|
90
|
+
token = null;
|
|
91
|
+
inflight = null;
|
|
92
|
+
/**
|
|
93
|
+
* Exchange client credentials for an access token, reusing the cached one
|
|
94
|
+
* until it is nearly expired. Concurrent callers share a single in-flight
|
|
95
|
+
* exchange rather than each minting their own token.
|
|
96
|
+
*/
|
|
97
|
+
async accessToken(force = false) {
|
|
98
|
+
if (!force && this.token && Date.now() < this.token.expiresAt)
|
|
99
|
+
return this.token.value;
|
|
100
|
+
if (!force && this.inflight)
|
|
101
|
+
return this.inflight;
|
|
102
|
+
this.inflight = (async () => {
|
|
103
|
+
const body = new URLSearchParams({
|
|
104
|
+
grant_type: 'client_credentials',
|
|
105
|
+
client_id: this.opts.clientId,
|
|
106
|
+
client_secret: this.opts.clientSecret,
|
|
107
|
+
});
|
|
108
|
+
const r = await this.fetch(`${this.baseUrl}/api/v1/oauth/token`, {
|
|
109
|
+
method: 'POST',
|
|
110
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
111
|
+
body: body.toString(),
|
|
112
|
+
});
|
|
113
|
+
if (!r.ok) {
|
|
114
|
+
// RFC 6749 error shape, which real OAuth clients expect verbatim.
|
|
115
|
+
const d = await r.json().catch(() => ({}));
|
|
116
|
+
throw new Error(`agentcard auth failed: ${d.error ?? r.status} ${d.error_description ?? ''}`.trim());
|
|
117
|
+
}
|
|
118
|
+
const d = (await r.json());
|
|
119
|
+
// Renew a minute early so a token never expires mid-checkout.
|
|
120
|
+
const ttl = Math.max(60, (d.expires_in ?? 3600) - 60);
|
|
121
|
+
this.token = { value: d.access_token, expiresAt: Date.now() + ttl * 1000 };
|
|
122
|
+
return this.token.value;
|
|
123
|
+
})().finally(() => { this.inflight = null; });
|
|
124
|
+
return this.inflight;
|
|
125
|
+
}
|
|
126
|
+
/** Authenticated request that retries ONCE on a 401 with a fresh token. */
|
|
127
|
+
async call(path, init = {}, retried = false) {
|
|
128
|
+
const token = await this.accessToken();
|
|
129
|
+
const r = await this.fetch(`${this.baseUrl}${path}`, {
|
|
130
|
+
...init,
|
|
131
|
+
headers: { ...(init.headers ?? {}), authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
132
|
+
});
|
|
133
|
+
// A token can be revoked or expire early; one forced refresh, then give up.
|
|
134
|
+
if (r.status === 401 && !retried) {
|
|
135
|
+
await this.accessToken(true);
|
|
136
|
+
return this.call(path, init, true);
|
|
137
|
+
}
|
|
138
|
+
if (!r.ok)
|
|
139
|
+
throw new Error(`agentcard ${path} -> ${r.status} ${await r.text()}`);
|
|
140
|
+
return r.json();
|
|
141
|
+
}
|
|
142
|
+
post(path, body) {
|
|
143
|
+
return this.call(path, { method: 'POST', body: JSON.stringify(body) });
|
|
144
|
+
}
|
|
145
|
+
get(path) {
|
|
146
|
+
return this.call(path);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Forward only the headers the merchant needs to accept the replay. Everything
|
|
151
|
+
* else (cookies, UA, tracing) is dropped so we transmit as little as possible.
|
|
152
|
+
*/
|
|
153
|
+
function pickHeaders(headers, allow) {
|
|
154
|
+
const out = {};
|
|
155
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
156
|
+
const lk = k.toLowerCase();
|
|
157
|
+
if (lk === 'content-type' || allow.some((re) => re.test(lk)))
|
|
158
|
+
out[lk] = v;
|
|
159
|
+
}
|
|
160
|
+
if (!out['content-type'])
|
|
161
|
+
out['content-type'] = 'application/json';
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { VaultClient, CardEncryptedError, ApprovalTimeoutError, ApprovalDeclinedError } from './client.js';
|
|
2
|
+
export type { PausedRequest, ReplayResponse, AuthorizeInput, VaultClientOptions } from './client.js';
|
|
3
|
+
export { attachToCdp, attachToPlaywright } from './cdp.js';
|
|
4
|
+
export type { CdpLike, AttachOptions } from './cdp.js';
|
|
5
|
+
export { BUILTIN_REGISTRY, findRecognizer } from './registry.js';
|
|
6
|
+
export type { Recognizer } from './registry.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which outbound requests carry a raw card, and where the card sits inside them.
|
|
3
|
+
*
|
|
4
|
+
* This is the only merchant-specific knowledge in the system, and it is keyed by
|
|
5
|
+
* PSP rather than by merchant: every Shopify store shares one entry, every Stripe
|
|
6
|
+
* site shares another. The list is served from the Agentcard API at runtime so
|
|
7
|
+
* integrators pick up new processors without shipping a release.
|
|
8
|
+
*/
|
|
9
|
+
export type Encoding = 'json' | 'form';
|
|
10
|
+
export interface Recognizer {
|
|
11
|
+
/** Stable id, e.g. "shopify" */
|
|
12
|
+
psp: string;
|
|
13
|
+
/** Matches the tokenization endpoint. */
|
|
14
|
+
match: RegExp;
|
|
15
|
+
encoding: Encoding;
|
|
16
|
+
/**
|
|
17
|
+
* Request headers that must be replayed verbatim for the merchant to accept
|
|
18
|
+
* the call (signatures, client tokens). Matched case-insensitively.
|
|
19
|
+
*/
|
|
20
|
+
passthroughHeaders: RegExp[];
|
|
21
|
+
/**
|
|
22
|
+
* True when the card is encrypted inside the page before the request leaves.
|
|
23
|
+
* A digit swap is useless here; the vault must re-run the PSP's client-side
|
|
24
|
+
* crypto instead. Kept in the registry so callers can fail loudly.
|
|
25
|
+
*/
|
|
26
|
+
clientSideEncrypted?: boolean;
|
|
27
|
+
}
|
|
28
|
+
export declare const BUILTIN_REGISTRY: Recognizer[];
|
|
29
|
+
export declare function findRecognizer(url: string, registry?: Recognizer[]): Recognizer | null;
|
package/dist/registry.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which outbound requests carry a raw card, and where the card sits inside them.
|
|
3
|
+
*
|
|
4
|
+
* This is the only merchant-specific knowledge in the system, and it is keyed by
|
|
5
|
+
* PSP rather than by merchant: every Shopify store shares one entry, every Stripe
|
|
6
|
+
* site shares another. The list is served from the Agentcard API at runtime so
|
|
7
|
+
* integrators pick up new processors without shipping a release.
|
|
8
|
+
*/
|
|
9
|
+
export const BUILTIN_REGISTRY = [
|
|
10
|
+
{
|
|
11
|
+
psp: 'shopify',
|
|
12
|
+
match: /(checkout\.pci\.shopifyinc\.com|deposit\.[a-z0-9-]+\.shopifycs\.com)\/sessions/i,
|
|
13
|
+
encoding: 'json',
|
|
14
|
+
passthroughHeaders: [/^shopify-identification-signature$/i],
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
psp: 'stripe',
|
|
18
|
+
match: /api\.stripe\.com\/v1\/(payment_methods|tokens)/i,
|
|
19
|
+
encoding: 'form',
|
|
20
|
+
// Elements' own surface markers; without these Stripe rejects the surface.
|
|
21
|
+
passthroughHeaders: [/^authorization$/i, /^stripe-version$/i, /^x-stripe-client-user-agent$/i],
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
psp: 'braintree',
|
|
25
|
+
match: /payments(\.sandbox)?\.braintree-api\.com\/graphql/i,
|
|
26
|
+
encoding: 'json',
|
|
27
|
+
passthroughHeaders: [/^authorization$/i, /^braintree-version$/i],
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
psp: 'checkout_com',
|
|
31
|
+
match: /api(\.sandbox)?\.checkout\.com\/tokens/i,
|
|
32
|
+
encoding: 'json',
|
|
33
|
+
passthroughHeaders: [/^authorization$/i],
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
psp: 'adyen',
|
|
37
|
+
match: /(checkoutshopper-[a-z]+\.adyen\.com|adyenpayments\.com)/i,
|
|
38
|
+
encoding: 'json',
|
|
39
|
+
passthroughHeaders: [],
|
|
40
|
+
clientSideEncrypted: true,
|
|
41
|
+
},
|
|
42
|
+
];
|
|
43
|
+
export function findRecognizer(url, registry = BUILTIN_REGISTRY) {
|
|
44
|
+
return registry.find((r) => r.match.test(url)) ?? null;
|
|
45
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@agent-cards/checkout",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Let browser agents pay with the user's own card, without your infrastructure ever touching card data.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./dist/index.js",
|
|
10
|
+
"./cdp": "./dist/cdp.js",
|
|
11
|
+
"./playwright": "./dist/cdp.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"_comment_build": "TypeScript is fetched rather than declared as a devDependency ON PURPOSE. This package ships zero dependencies, which is why pnpm writes no importer for it in the workspace lockfile; adding any dep here creates one, and an importer the lockfile has not been regenerated for fails every Vercel build with ERR_PNPM_OUTDATED_LOCKFILE. Pinned so the published output is reproducible.",
|
|
22
|
+
"build": "npx -y -p typescript@5.9.3 tsc",
|
|
23
|
+
"prepublishOnly": "pnpm build",
|
|
24
|
+
"test": "node test.mjs"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"payments",
|
|
28
|
+
"agents",
|
|
29
|
+
"browser-automation",
|
|
30
|
+
"pci",
|
|
31
|
+
"checkout"
|
|
32
|
+
],
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=22"
|
|
35
|
+
},
|
|
36
|
+
"license": "UNLICENSED"
|
|
37
|
+
}
|