@agent-cards/checkout 0.2.0 → 0.2.1
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 +15 -1
- package/dist/cdp.d.ts +63 -0
- package/dist/cdp.js +142 -6
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -126,6 +126,7 @@ Merchants inherit their processor, so one entry covers every store on it.
|
|
|
126
126
|
| Stripe | supported, verified end to end |
|
|
127
127
|
| Braintree / PayPal | supported, verified end to end |
|
|
128
128
|
| Checkout.com | supported |
|
|
129
|
+
| VGS Collect (Very Good Security; Wolt) | not supported: VGS's proxy aliases only submissions from its own iframe, so a replay from the cardholder's device is refused by the merchant (verified on Wolt, 2026-09-03). Not recognized, so the agent's browser is not paused there |
|
|
129
130
|
| Adyen | supported (mode `cse`): the vault encrypts the card for Adyen on the cardholder's device and your browser sends it |
|
|
130
131
|
| Tranzila | supported (mode `hosted_form`): the cardholder finishes on Tranzila's own page; the paused form navigation resolves to a synthetic page, and you poll the merchant's order state |
|
|
131
132
|
|
|
@@ -146,7 +147,20 @@ three; the difference matters if you drive `authorize()` yourself.
|
|
|
146
147
|
|
|
147
148
|
- **`token`** (every processor but Adyen). The cardholder's device calls the
|
|
148
149
|
processor and reports its answer; `authorize()` resolves with `status`,
|
|
149
|
-
`headers` and `body` to fulfill the paused request with.
|
|
150
|
+
`headers` and `body` to fulfill the paused request with. The browser checks
|
|
151
|
+
a fulfilled answer exactly as it checks a real one, so when the page called
|
|
152
|
+
the processor cross-origin (Stripe always does: Checkout on
|
|
153
|
+
`checkout.stripe.com` and Elements in the `js.stripe.com` frame both fetch
|
|
154
|
+
`api.stripe.com`) the answer must carry `access-control-allow-origin` for
|
|
155
|
+
the request's own `Origin`, or the page's fetch rejects and the checkout
|
|
156
|
+
reports a connection error even though the cardholder approved. The
|
|
157
|
+
adapters add those headers (`corsHeadersFor` + `withCorsHeaders`, exported
|
|
158
|
+
for a runtime that fulfills by hand, and `corsDecision` when you also want
|
|
159
|
+
the reason) and report the decision on the `authorized` event as `cors`:
|
|
160
|
+
`echoed`, `same_origin`, or `none` (no usable Origin on the request, so
|
|
161
|
+
the page could not read the answer). Only what a browser serializes is
|
|
162
|
+
echoed: one canonical http(s) origin, or the opaque `null`. Shopify's
|
|
163
|
+
card iframe posts to its own origin, so it never needed them.
|
|
150
164
|
- **`cse`** (Adyen). Adyen's own page SDK encrypts the card before the request
|
|
151
165
|
leaves the browser, so the paused body carries ciphertext. The cardholder's
|
|
152
166
|
device produces the same ciphertext under the merchant's Adyen public key
|
package/dist/cdp.d.ts
CHANGED
|
@@ -1,4 +1,67 @@
|
|
|
1
1
|
import { type VaultClient } from './client.js';
|
|
2
|
+
/**
|
|
3
|
+
* The CORS headers a fulfilled CROSS-ORIGIN request needs, or null when the
|
|
4
|
+
* request is same-origin (or carries no Origin, so no CORS check applies).
|
|
5
|
+
*
|
|
6
|
+
* The browser checks a fulfilled response exactly as it checks a real one. A
|
|
7
|
+
* page that fetches a processor on another origin therefore needs
|
|
8
|
+
* `access-control-allow-origin` on the synthetic answer, or its fetch rejects
|
|
9
|
+
* with "Failed to fetch" and the page never sees the processor's reply, even
|
|
10
|
+
* though the cardholder approved and the processor answered the vault.
|
|
11
|
+
* Shopify never hit this on its current host: the checkout.pci.shopifyinc.com
|
|
12
|
+
* card iframe posts to its own origin (its older deposit.<region>.shopifycs.com
|
|
13
|
+
* host is called from the checkout.shopifycs.com frame, cross-origin, and gets
|
|
14
|
+
* the answer like everyone else). Stripe hits it on every surface (Checkout on
|
|
15
|
+
* checkout.stripe.com or a merchant domain, and Elements in the js.stripe.com
|
|
16
|
+
* frame, all call api.stripe.com), and so does every other processor whose
|
|
17
|
+
* card frame calls a separate API host. Observed live on
|
|
18
|
+
* 2026-09-03: the vault replayed a Stripe PaymentMethod into a raw-CDP
|
|
19
|
+
* runtime, the browser refused the answer for want of this header, and
|
|
20
|
+
* Stripe Checkout showed "We are experiencing connection issues".
|
|
21
|
+
*
|
|
22
|
+
* The exact Origin is echoed rather than `*`: a credentialed request refuses
|
|
23
|
+
* `*`, the echo satisfies both. The processor's own value can never reach
|
|
24
|
+
* this adapter (a browser does not expose that header to the page that
|
|
25
|
+
* replayed the call), so whatever the replay carries under these names is
|
|
26
|
+
* replaced by the one value that is right for THIS request. Playwright adds
|
|
27
|
+
* the same headers inside route.fulfill when a cross-origin fulfill carries
|
|
28
|
+
* none (microsoft/playwright#12929), which is why attachToPlaywright never
|
|
29
|
+
* needed this; it writes them itself anyway, replacing a stale value, so both
|
|
30
|
+
* adapters answer the vault's replays identically.
|
|
31
|
+
*
|
|
32
|
+
* This widens nothing. A tokenization endpoint is built for anonymous
|
|
33
|
+
* browsers and answers every origin (`access-control-allow-origin: *` on
|
|
34
|
+
* Stripe's and Shopify's own replies), so the page that made the request
|
|
35
|
+
* could always read the processor's answer to it; the replay is made exactly
|
|
36
|
+
* as visible, to exactly that page. Whether a card goes anywhere at all is
|
|
37
|
+
* decided by the cardholder on the approval screen, never by this header.
|
|
38
|
+
*
|
|
39
|
+
* Only what a browser serializes is ever echoed: one canonical http(s)
|
|
40
|
+
* origin (`new URL(origin).origin === origin`), or the opaque `null` a
|
|
41
|
+
* sandboxed or data: document sends, which Chrome matches against
|
|
42
|
+
* `access-control-allow-origin: null` and which Playwright echoes too. That
|
|
43
|
+
* refuses userinfo, a path, an explicit default port, several origins in one
|
|
44
|
+
* value, or a control character that would break the fulfill after the
|
|
45
|
+
* cardholder already approved. Anything refused simply gets no CORS answer,
|
|
46
|
+
* which is what every fulfill got before this existed.
|
|
47
|
+
*/
|
|
48
|
+
export declare function corsHeadersFor(url: string, requestHeaders: Record<string, string> | undefined): Record<string, string> | null;
|
|
49
|
+
/** How a fulfill was answered, reported on the `authorized` event so a silent CORS failure is diagnosable from events alone. */
|
|
50
|
+
export type CorsOutcome = 'echoed' | 'same_origin' | 'none';
|
|
51
|
+
/** The CORS answer and its reason: `none` when no usable Origin was sent (or the url is not http(s)), `same_origin` when no check applies. */
|
|
52
|
+
export declare function corsDecision(url: string, requestHeaders: Record<string, string> | undefined): {
|
|
53
|
+
headers: Record<string, string> | null;
|
|
54
|
+
outcome: CorsOutcome;
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* `headers` with the CORS answer for this request written in; the object
|
|
58
|
+
* itself when none is needed. Any header the answer names is replaced
|
|
59
|
+
* whatever its case, so a name is never sent twice. The answer varies by
|
|
60
|
+
* Origin, and a `vary` the replay already carries is extended rather than
|
|
61
|
+
* replaced (or left alone when it already covers Origin or is `*`); a `vary`
|
|
62
|
+
* the answer itself names is taken as given.
|
|
63
|
+
*/
|
|
64
|
+
export declare function withCorsHeaders(headers: Record<string, string>, cors: Record<string, string> | null): Record<string, string>;
|
|
2
65
|
/**
|
|
3
66
|
* Minimal shape of a CDP connection. Works with a raw websocket client, a
|
|
4
67
|
* Puppeteer CDPSession, or Playwright's CDPSession.
|
package/dist/cdp.js
CHANGED
|
@@ -141,6 +141,128 @@ function pausedBody(request) {
|
|
|
141
141
|
return Buffer.concat(entries.map((e) => Buffer.from(e.bytes, 'base64'))).toString('utf8');
|
|
142
142
|
}
|
|
143
143
|
const BODY_UNREADABLE_REASON = 'the paused request\'s body could not be read (no postData and no postDataEntries)';
|
|
144
|
+
/**
|
|
145
|
+
* The CORS headers a fulfilled CROSS-ORIGIN request needs, or null when the
|
|
146
|
+
* request is same-origin (or carries no Origin, so no CORS check applies).
|
|
147
|
+
*
|
|
148
|
+
* The browser checks a fulfilled response exactly as it checks a real one. A
|
|
149
|
+
* page that fetches a processor on another origin therefore needs
|
|
150
|
+
* `access-control-allow-origin` on the synthetic answer, or its fetch rejects
|
|
151
|
+
* with "Failed to fetch" and the page never sees the processor's reply, even
|
|
152
|
+
* though the cardholder approved and the processor answered the vault.
|
|
153
|
+
* Shopify never hit this on its current host: the checkout.pci.shopifyinc.com
|
|
154
|
+
* card iframe posts to its own origin (its older deposit.<region>.shopifycs.com
|
|
155
|
+
* host is called from the checkout.shopifycs.com frame, cross-origin, and gets
|
|
156
|
+
* the answer like everyone else). Stripe hits it on every surface (Checkout on
|
|
157
|
+
* checkout.stripe.com or a merchant domain, and Elements in the js.stripe.com
|
|
158
|
+
* frame, all call api.stripe.com), and so does every other processor whose
|
|
159
|
+
* card frame calls a separate API host. Observed live on
|
|
160
|
+
* 2026-09-03: the vault replayed a Stripe PaymentMethod into a raw-CDP
|
|
161
|
+
* runtime, the browser refused the answer for want of this header, and
|
|
162
|
+
* Stripe Checkout showed "We are experiencing connection issues".
|
|
163
|
+
*
|
|
164
|
+
* The exact Origin is echoed rather than `*`: a credentialed request refuses
|
|
165
|
+
* `*`, the echo satisfies both. The processor's own value can never reach
|
|
166
|
+
* this adapter (a browser does not expose that header to the page that
|
|
167
|
+
* replayed the call), so whatever the replay carries under these names is
|
|
168
|
+
* replaced by the one value that is right for THIS request. Playwright adds
|
|
169
|
+
* the same headers inside route.fulfill when a cross-origin fulfill carries
|
|
170
|
+
* none (microsoft/playwright#12929), which is why attachToPlaywright never
|
|
171
|
+
* needed this; it writes them itself anyway, replacing a stale value, so both
|
|
172
|
+
* adapters answer the vault's replays identically.
|
|
173
|
+
*
|
|
174
|
+
* This widens nothing. A tokenization endpoint is built for anonymous
|
|
175
|
+
* browsers and answers every origin (`access-control-allow-origin: *` on
|
|
176
|
+
* Stripe's and Shopify's own replies), so the page that made the request
|
|
177
|
+
* could always read the processor's answer to it; the replay is made exactly
|
|
178
|
+
* as visible, to exactly that page. Whether a card goes anywhere at all is
|
|
179
|
+
* decided by the cardholder on the approval screen, never by this header.
|
|
180
|
+
*
|
|
181
|
+
* Only what a browser serializes is ever echoed: one canonical http(s)
|
|
182
|
+
* origin (`new URL(origin).origin === origin`), or the opaque `null` a
|
|
183
|
+
* sandboxed or data: document sends, which Chrome matches against
|
|
184
|
+
* `access-control-allow-origin: null` and which Playwright echoes too. That
|
|
185
|
+
* refuses userinfo, a path, an explicit default port, several origins in one
|
|
186
|
+
* value, or a control character that would break the fulfill after the
|
|
187
|
+
* cardholder already approved. Anything refused simply gets no CORS answer,
|
|
188
|
+
* which is what every fulfill got before this existed.
|
|
189
|
+
*/
|
|
190
|
+
export function corsHeadersFor(url, requestHeaders) {
|
|
191
|
+
return corsDecision(url, requestHeaders).headers;
|
|
192
|
+
}
|
|
193
|
+
/** The CORS answer and its reason: `none` when no usable Origin was sent (or the url is not http(s)), `same_origin` when no check applies. */
|
|
194
|
+
export function corsDecision(url, requestHeaders) {
|
|
195
|
+
const none = { headers: null, outcome: 'none' };
|
|
196
|
+
const originEntry = Object.entries(requestHeaders ?? {}).find(([name]) => name.toLowerCase() === 'origin');
|
|
197
|
+
const origin = typeof originEntry?.[1] === 'string' ? originEntry[1].trim() : '';
|
|
198
|
+
if (!origin)
|
|
199
|
+
return none;
|
|
200
|
+
let target;
|
|
201
|
+
try {
|
|
202
|
+
target = new URL(url);
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return none;
|
|
206
|
+
}
|
|
207
|
+
if (target.protocol !== 'https:' && target.protocol !== 'http:')
|
|
208
|
+
return none;
|
|
209
|
+
if (origin !== 'null') {
|
|
210
|
+
let originUrl;
|
|
211
|
+
try {
|
|
212
|
+
originUrl = new URL(origin);
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return none;
|
|
216
|
+
}
|
|
217
|
+
if (originUrl.protocol !== 'https:' && originUrl.protocol !== 'http:')
|
|
218
|
+
return none;
|
|
219
|
+
if (originUrl.origin !== origin)
|
|
220
|
+
return none;
|
|
221
|
+
if (target.origin === origin)
|
|
222
|
+
return { headers: null, outcome: 'same_origin' };
|
|
223
|
+
}
|
|
224
|
+
return { headers: { 'access-control-allow-origin': origin, 'access-control-allow-credentials': 'true' }, outcome: 'echoed' };
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* `headers` with the CORS answer for this request written in; the object
|
|
228
|
+
* itself when none is needed. Any header the answer names is replaced
|
|
229
|
+
* whatever its case, so a name is never sent twice. The answer varies by
|
|
230
|
+
* Origin, and a `vary` the replay already carries is extended rather than
|
|
231
|
+
* replaced (or left alone when it already covers Origin or is `*`); a `vary`
|
|
232
|
+
* the answer itself names is taken as given.
|
|
233
|
+
*/
|
|
234
|
+
export function withCorsHeaders(headers, cors) {
|
|
235
|
+
if (!cors)
|
|
236
|
+
return headers;
|
|
237
|
+
const replaced = new Set(Object.keys(cors).map((name) => name.toLowerCase()));
|
|
238
|
+
const out = {};
|
|
239
|
+
let varyName = null;
|
|
240
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
241
|
+
const lower = name.toLowerCase();
|
|
242
|
+
if (replaced.has(lower))
|
|
243
|
+
continue;
|
|
244
|
+
if (lower === 'vary')
|
|
245
|
+
varyName = name;
|
|
246
|
+
out[name] = value;
|
|
247
|
+
}
|
|
248
|
+
Object.assign(out, cors);
|
|
249
|
+
if (replaced.has('vary'))
|
|
250
|
+
return out;
|
|
251
|
+
if (varyName === null) {
|
|
252
|
+
out.vary = 'Origin';
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
const existing = String(out[varyName]);
|
|
256
|
+
const members = existing.split(',').map((m) => m.trim().toLowerCase());
|
|
257
|
+
if (!members.includes('origin') && !members.includes('*'))
|
|
258
|
+
out[varyName] = `${existing}, Origin`;
|
|
259
|
+
}
|
|
260
|
+
return out;
|
|
261
|
+
}
|
|
262
|
+
/** CDP's header shape: `{ name, value }` entries. */
|
|
263
|
+
function headerEntries(headers) {
|
|
264
|
+
return Object.entries(headers).map(([name, value]) => ({ name, value: String(value) }));
|
|
265
|
+
}
|
|
144
266
|
/**
|
|
145
267
|
* Last-resort patterns: the built-in recognizers' hosts, derived the same way
|
|
146
268
|
* as everything else. Used only when the vault hands back nothing at all (a
|
|
@@ -255,10 +377,12 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
255
377
|
// why not a fake result page), and the same form is refused if the
|
|
256
378
|
// page posts it again.
|
|
257
379
|
const page = hostedFormSubmittedPage({ authorizationId: replay.authorizationId, merchant: opts.merchant, submittedAt: replay.submittedAt });
|
|
380
|
+
// A navigation response is never CORS-checked, so the CORS wrap is
|
|
381
|
+
// inert here; it is applied so every fulfill goes through one path.
|
|
258
382
|
await cdp.send('Fetch.fulfillRequest', {
|
|
259
383
|
requestId,
|
|
260
384
|
responseCode: page.status,
|
|
261
|
-
responseHeaders:
|
|
385
|
+
responseHeaders: headerEntries(withCorsHeaders(page.headers, corsHeadersFor(request.url, request.headers))),
|
|
262
386
|
body: Buffer.from(page.body).toString('base64'),
|
|
263
387
|
}, sessionId);
|
|
264
388
|
lastSubmitted = { url: request.url, body, at: Date.now() };
|
|
@@ -279,13 +403,20 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
279
403
|
opts.onEvent?.({ type: 'authorized', detail: { mode: 'cse', authorizationId: replay.authorizationId, fields: Object.keys(replay.substitutions.fields) } });
|
|
280
404
|
}
|
|
281
405
|
else {
|
|
406
|
+
// The processor's answer, handed to the page as if the processor had
|
|
407
|
+
// sent it. Cross-origin, the browser then runs its CORS check on it:
|
|
408
|
+
// see corsHeadersFor for why the answer has to carry the headers. The
|
|
409
|
+
// decision rides on the event, so a fulfill the page could not read
|
|
410
|
+
// (no usable Origin on a cross-origin request) is visible in telemetry
|
|
411
|
+
// rather than only as the page's own "connection error".
|
|
412
|
+
const cors = corsDecision(request.url, request.headers);
|
|
282
413
|
await cdp.send('Fetch.fulfillRequest', {
|
|
283
414
|
requestId,
|
|
284
415
|
responseCode: replay.status,
|
|
285
|
-
responseHeaders:
|
|
416
|
+
responseHeaders: headerEntries(withCorsHeaders(replay.headers, cors.headers)),
|
|
286
417
|
body: Buffer.from(replay.body).toString('base64'),
|
|
287
418
|
}, sessionId);
|
|
288
|
-
opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId, amountVerified: replay.amountVerified ?? null } });
|
|
419
|
+
opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId, amountVerified: replay.amountVerified ?? null, cors: cors.outcome } });
|
|
289
420
|
}
|
|
290
421
|
}
|
|
291
422
|
catch (err) {
|
|
@@ -375,7 +506,8 @@ export async function attachToPlaywright(page, opts) {
|
|
|
375
506
|
// Same as the CDP path: the paused navigation resolves to the
|
|
376
507
|
// synthetic page, and a re-post of this form is refused.
|
|
377
508
|
const synthetic = hostedFormSubmittedPage({ authorizationId: replay.authorizationId, merchant: opts.merchant, submittedAt: replay.submittedAt });
|
|
378
|
-
|
|
509
|
+
// Inert on a navigation (never CORS-checked); one path for every fulfill.
|
|
510
|
+
await route.fulfill({ status: synthetic.status, headers: withCorsHeaders(synthetic.headers, corsHeadersFor(request.url(), request.headers())), body: synthetic.body });
|
|
379
511
|
lastSubmitted = { url: request.url(), body, at: Date.now() };
|
|
380
512
|
opts.onEvent?.({ type: 'submitted_on_device', detail: { authorizationId: replay.authorizationId, submittedAt: replay.submittedAt, outcome: replay.outcome } });
|
|
381
513
|
}
|
|
@@ -387,8 +519,12 @@ export async function attachToPlaywright(page, opts) {
|
|
|
387
519
|
opts.onEvent?.({ type: 'authorized', detail: { mode: 'cse', authorizationId: replay.authorizationId, fields: Object.keys(replay.substitutions.fields) } });
|
|
388
520
|
}
|
|
389
521
|
else {
|
|
390
|
-
|
|
391
|
-
|
|
522
|
+
// Playwright adds these itself when a cross-origin fulfill carries
|
|
523
|
+
// none; written here anyway (replacing a stale value) so a
|
|
524
|
+
// cross-origin answer is the same whichever adapter ran.
|
|
525
|
+
const cors = corsDecision(request.url(), request.headers());
|
|
526
|
+
await route.fulfill({ status: replay.status, headers: withCorsHeaders(replay.headers, cors.headers), body: replay.body });
|
|
527
|
+
opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId, amountVerified: replay.amountVerified ?? null, cors: cors.outcome } });
|
|
392
528
|
}
|
|
393
529
|
}
|
|
394
530
|
catch (err) {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { VaultClient, CardEncryptedError, UnsupportedModeError, ApprovalTimeoutError, ApprovalDeclinedError, AmountMismatchError, IntentNotConfirmableError, ProcessorRefusedError, CheckoutApiError, redactUrl, SUPPORTED_MODES, } from './client.js';
|
|
2
2
|
export type { PausedRequest, ReplayResponse, TokenReplay, CseReplay, HostedFormReplay, AmountAuthority, AuthorizeInput, VaultClientOptions, } from './client.js';
|
|
3
|
-
export { attachToCdp, attachToPlaywright } from './cdp.js';
|
|
4
|
-
export type { CdpLike, AttachOptions } from './cdp.js';
|
|
3
|
+
export { attachToCdp, attachToPlaywright, corsHeadersFor, corsDecision, withCorsHeaders } from './cdp.js';
|
|
4
|
+
export type { CdpLike, AttachOptions, CorsOutcome } from './cdp.js';
|
|
5
5
|
export { substituteEncryptedFields, SubstitutionError } from './substitute.js';
|
|
6
6
|
export type { Substitutions } from './substitute.js';
|
|
7
7
|
export { hostedFormSubmittedPage, HOSTED_FORM_SUBMITTED_OUTCOME } from './hosted-form.js';
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { VaultClient, CardEncryptedError, UnsupportedModeError, ApprovalTimeoutError, ApprovalDeclinedError, AmountMismatchError, IntentNotConfirmableError, ProcessorRefusedError, CheckoutApiError, redactUrl, SUPPORTED_MODES, } from './client.js';
|
|
2
|
-
export { attachToCdp, attachToPlaywright } from './cdp.js';
|
|
2
|
+
export { attachToCdp, attachToPlaywright, corsHeadersFor, corsDecision, withCorsHeaders } from './cdp.js';
|
|
3
3
|
export { substituteEncryptedFields, SubstitutionError } from './substitute.js';
|
|
4
4
|
export { hostedFormSubmittedPage, HOSTED_FORM_SUBMITTED_OUTCOME } from './hosted-form.js';
|
|
5
5
|
export { BUILTIN_REGISTRY, cardUrlPatterns, findRecognizer } from './registry.js';
|
package/package.json
CHANGED