@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 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: Object.entries(page.headers).map(([name, value]) => ({ name, value })),
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: Object.entries(replay.headers).map(([name, value]) => ({ name, value: String(value) })),
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
- await route.fulfill({ status: synthetic.status, headers: synthetic.headers, body: synthetic.body });
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
- await route.fulfill({ status: replay.status, headers: replay.headers, body: replay.body });
391
- opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId, amountVerified: replay.amountVerified ?? null } });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-cards/checkout",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Let browser agents pay with the user's own card, without your infrastructure ever touching card data.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",