@agent-cards/checkout 0.5.0 → 0.6.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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0
4
+
5
+ - Recognize Paysafe Checkout 1.8's exact hosted tokenization endpoints and preserve its native credential and correlation headers. The matching API registry and Vault deployment are required.
6
+ - Recognize Checkout.com card tokenization at `card-acquisition-gateway.checkout.com/tokens` and its sandbox counterpart alongside the existing API hosts. The matching backend registry and Vault deployment are required; this entry does not establish native merchant payment completion.
7
+ - Prepare Worldpay, Bambora and Mercado Pago checkout before the merchant's first Pay action. Use `psp: 'worldpay'` with `environment: 'production' | 'sandbox'`, or `psp: 'bambora' | 'mercado_pago'` with `environment: 'shared'`. Shared endpoints do not establish processor test mode. The merchant's credentials and checkout configuration determine that mode.
8
+ - Keep the existing selected-card, merchant, document, amount, currency and one-use consent checks. The native request begins only after approval, and approval readiness lasts at most 30 seconds. The SDK does not extend processor deadlines or automatically retry after payment uncertainty. Matching API, Vault and preparation migration are required.
9
+ - Bound Playwright and CDP setup with `attachmentTimeoutMs`, defaulting to 30 seconds. A stalled setup throws a sanitized `CheckoutAttachmentError`; late acknowledgements cannot start approval or retry setup, and concurrent setup failures report once. Close the failed checkout page and start a fresh context before trying again.
10
+ - Preserve the original setup failure across attached frames: a transport failure reports `unavailable`, a deadline reports `timeout`, and an observed page closure reports `closed`.
11
+ - Reject processor URLs with credentials or nondefault ports consistently across SDK and API discovery. Registry synchronization updates endpoint recognition; upgrading the SDK is required for the new preparation and setup behavior.
12
+
13
+ The matching Vault/API release adds Mollie card-token and supported Airwallex intent-confirmation replay through browser-owned TLS, preserves Worldpay's native session media type and Bambora's `cvd` field, and reports Nuvei's unambiguous validation error as processor refusal. Local regression and Chromium fixture results do not establish native processor acceptance or a completed merchant purchase.
14
+
3
15
  ## 0.5.0
4
16
 
5
17
  - Prepare Braintree card checkout with `controller.prepare({ psp: 'braintree', environment: 'production' | 'sandbox' })` before the merchant's first Pay action. The cardholder approves and unlocks first; one fresh native request then uses that approval without another notification. The matching API and Vault release is required.
package/README.md CHANGED
@@ -130,7 +130,7 @@ Coverage is specific to the processor request format, merchant setup, browser tr
130
130
  |---|---|
131
131
  | Shopify | supported, verified end to end |
132
132
  | Stripe | tokenization replay and direct card-bearing PaymentIntent confirms are implemented; direct confirms with `amountCents` + `currency` use backend amount verification. Browser token-to-intent continuation is unsupported and held. Validate the exact merchant flow before pilot use |
133
- | Braintree card tokenization | prepared checkout supported; production paid-order validation pending |
133
+ | Braintree card tokenization | Prepared checkout supported; one live Haymarket Books ebook purchase with SDK `0.5.0` confirmed merchant fulfillment and SDK `completed` using a merchant receipt resolver. Independent processor capture/settlement, live 3DS and PayPal wallet flows remain unverified. |
134
134
  | Checkout.com | supported |
135
135
  | 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 |
136
136
  | Adyen | supported (mode `cse`): the vault encrypts the card for Adyen on the cardholder's device and your browser sends it |
@@ -327,6 +327,16 @@ Playwright `CDPSession` is not the `CdpLike` interface. Raw CDP must preserve th
327
327
  Initial arming errors reject `attachToCdp`; a child that cannot be armed remains
328
328
  paused and reports `browser_interception_unavailable` for operator recovery.
329
329
 
330
+ Await attachment before clicking Pay. Both adapters stop waiting for browser
331
+ setup after 30 seconds and throw `CheckoutAttachmentError` with
332
+ `code: 'checkout_attachment_failed'` and `reason: 'timeout'`, `'closed'` or
333
+ `'unavailable'`. Use `attachmentTimeoutMs` to choose a setup deadline from 1 to
334
+ 300000 milliseconds, separately from the approval's `timeoutMs`.
335
+ Close the failed checkout page and create a fresh browser context before
336
+ trying again. A late setup response cannot reopen the failed attachment or
337
+ request approval; intercepted card requests remain blocked. A setup failure
338
+ does not establish the status of any earlier purchase.
339
+
330
340
  Use a checkout context created with `serviceWorkers: 'block'`. Playwright cannot
331
341
  route requests intercepted by a service worker. The SDK rejects already active
332
342
  service workers, but that check cannot prevent a site from registering one
@@ -400,7 +410,9 @@ do not reuse that token in a new attachment as a workaround. Configuration and
400
410
  unsupported-mode failures require fixing the integration. Bank flows requiring
401
411
  another confirmation and other stored-token chains remain unverified.
402
412
 
403
- Prepare a Square or Braintree checkout before the first Pay action so the cardholder can approve before the native card request starts. The API and Vault deployments must support the selected processor:
413
+ Worldpay, Bambora and Mercado Pago preparation requires SDK 0.6.0 or later and the matching API and Vault release.
414
+
415
+ Prepare a Square, Braintree, Worldpay, Bambora or Mercado Pago checkout before the first Pay action so the cardholder can approve before the native card request starts. The API and Vault deployments must support the selected processor:
404
416
 
405
417
  ```ts
406
418
  const checkout = await attachToPlaywright(page, {
@@ -409,8 +421,8 @@ const checkout = await attachToPlaywright(page, {
409
421
  onApprovalUrl: deliverPrivatelyToCardholder,
410
422
  });
411
423
  const preparation = await checkout.prepare({
412
- psp: 'braintree', // use 'square' for Square
413
- environment: 'production', // explicit; use 'sandbox' for the processor's sandbox
424
+ psp: 'braintree',
425
+ environment: 'production', // Square, Braintree and Worldpay: production or sandbox
414
426
  });
415
427
  // The cardholder has consented and unlocked the same approval document.
416
428
  // No processor request or payment has started.
@@ -419,6 +431,16 @@ await page.getByRole('button', { name: 'Pay', exact: true }).click();
419
431
 
420
432
  `prepare()` is available on both Playwright and raw CDP controllers. It requires `amountCents` and `currency`, must precede the first recognized card request, and returns only when the cardholder's device is ready. It delivers the preparation URL through `onApprovalUrl` and `onUserAction`; binding the subsequent authorization sends no second approval link or SMS. The phone page must stay open. Its selected card, merchant origin, declared merchant, amount, currency, processor and environment bind one fresh request. The amount remains `display_only`; a card token does not enforce the merchant's eventual charge amount.
421
433
 
434
+ | Processor | `environment` | Fresh native request |
435
+ | --- | --- | --- |
436
+ | Square | `production` or `sandbox` | Matching Square `/v2/card-nonce` host |
437
+ | Braintree | `production` or `sandbox` | Matching Braintree GraphQL host and guest `TokenizeCreditCard` mutation |
438
+ | Worldpay | `production` or `sandbox` | Matching Access Worldpay host and `/sessions/card` |
439
+ | Bambora | `shared` | `/scripts/tokenization/tokens` on `api.bam.shift4api.net` or `api.na.bambora.com` |
440
+ | Mercado Pago | `shared` | `api.mercadopago.com/v1/card_tokens` with a fresh card body |
441
+
442
+ Use `environment: 'shared'` for Bambora and Mercado Pago because the same endpoint serves test and live requests. Agentcard cannot establish the processor's test mode from that URL or a credential prefix. Configure test mode through the merchant's processor account when testing. Agentcard's own `sandbox` flag remains separate. Prepared Worldpay, Bambora and Mercado Pago requests reject saved-card and recurring request bodies; a refused request retires the local preparation. Reconcile any existing merchant attempt before creating a new attachment.
443
+
422
444
  Braintree's native `ClientConfiguration` GraphQL query can run before, during or after preparation without using the approval. Only a single `TokenizeCreditCard` mutation can consume the prepared Braintree checkout. Prepared requests require guest card tokenization with explicit `options.validate: false`; omitted validation options, saved-card fields and `validate: true` are refused. Other GraphQL operations, batches and compound mutations are blocked. Braintree's legacy REST fallback cannot consume a prepared authorization.
423
445
 
424
446
  Readiness lasts up to 30 seconds (`preparation.expiresAt`) and appears as `ready_to_submit`, with `paymentStatus: 'not_started'`. Trigger the caller-owned Pay action immediately after the promise resolves. Expiry, navigation, cancellation, an early request or a changed checkout fails closed. A preparation and its attachment are single use; reconcile any bound authorization before creating a new attachment. The SDK never clicks Pay, reuses a stale request, changes native request deadlines, or automatically retries a failed prepared checkout.
@@ -472,9 +494,7 @@ The browser fixtures never contact a payment service. The general suite uses
472
494
  allowlisted loopback proxy and a temporary self-signed TLS stub (requires the
473
495
  `openssl` CLI). All other proxy destinations are rejected. These suites use an
474
496
  in-process Agentcard API fixture and loopback merchant pages. The preparation
475
- fixture denies all external traffic, waits more than ten seconds before any
476
- card request, and then checks one fresh request with an unchanged ten-second
477
- fixture abort timer. It exercises SDK ordering, not the native Square SDK. It proves nested-frame pause/resume, agent control during approval,
497
+ fixture denies all external traffic and waits beyond each modeled request deadline before any card request: eleven seconds for Square, sixty-one seconds for Braintree, and six and a half seconds for Worldpay, Bambora and Mercado Pago. Each fixture then checks one fresh request with an unchanged abort timer. The five-second timer matches the inspected Worldpay and Bambora source; the Mercado Pago timer is a test boundary, not a measured native deadline. The fixture exercises SDK ordering with native-shaped request bodies and synthetic responses, not processor acceptance. It proves nested-frame pause/resume, agent control during approval,
478
498
  post-payment tasks in the same page, decline/expiry/cancel, unknown-outcome retry
479
499
  blocking, explicit unsupported endpoint behavior, and blocking an immediate real-browser
480
500
  Stripe token-to-intent fetch chain, including unrelated first intents, changed
@@ -0,0 +1,11 @@
1
+ /** An attachment failure has no payment or browser credentials in its message. */
2
+ export declare class CheckoutAttachmentError extends Error {
3
+ readonly reason: 'timeout' | 'closed' | 'unavailable';
4
+ readonly code = "checkout_attachment_failed";
5
+ constructor(reason: 'timeout' | 'closed' | 'unavailable');
6
+ }
7
+ /** Preserve a known setup cause without exposing raw transport errors. */
8
+ export declare function attachmentFailure(error: unknown): CheckoutAttachmentError;
9
+ export declare function attachmentDeadline(value: number | undefined): number;
10
+ /** Stop waiting, then reject every late continuation before its next command. */
11
+ export declare function withinAttachmentDeadline(arm: (assertActive: () => void) => Promise<void>, timeoutMs: number, signal: AbortSignal): Promise<void>;
@@ -0,0 +1,50 @@
1
+ /** An attachment failure has no payment or browser credentials in its message. */
2
+ export class CheckoutAttachmentError extends Error {
3
+ reason;
4
+ code = 'checkout_attachment_failed';
5
+ constructor(reason) {
6
+ super('Could not attach checkout interception. Close this checkout page and retry in a fresh browser context.');
7
+ this.reason = reason;
8
+ this.name = 'CheckoutAttachmentError';
9
+ }
10
+ }
11
+ /** Preserve a known setup cause without exposing raw transport errors. */
12
+ export function attachmentFailure(error) {
13
+ return error instanceof CheckoutAttachmentError ? error : new CheckoutAttachmentError('unavailable');
14
+ }
15
+ export function attachmentDeadline(value) {
16
+ if (value === undefined)
17
+ return 30_000;
18
+ if (!Number.isSafeInteger(value) || value <= 0 || value > 300_000) {
19
+ throw new Error('attachmentTimeoutMs must be an integer between 1 and 300000.');
20
+ }
21
+ return value;
22
+ }
23
+ /** Stop waiting, then reject every late continuation before its next command. */
24
+ export async function withinAttachmentDeadline(arm, timeoutMs, signal) {
25
+ let stopped;
26
+ let timer;
27
+ let onAbort = () => { };
28
+ const assertActive = () => { if (stopped)
29
+ throw stopped; };
30
+ const deadline = new Promise((_resolve, reject) => {
31
+ const stop = (failure) => { stopped ??= failure; reject(stopped); };
32
+ onAbort = () => stop(attachmentFailure(signal.reason));
33
+ signal.addEventListener('abort', onAbort, { once: true });
34
+ if (signal.aborted)
35
+ onAbort();
36
+ else
37
+ timer = setTimeout(() => stop(new CheckoutAttachmentError('timeout')), timeoutMs);
38
+ });
39
+ try {
40
+ await Promise.race([deadline, Promise.resolve().then(() => { assertActive(); return arm(assertActive); })]);
41
+ }
42
+ catch (error) {
43
+ stopped ??= attachmentFailure(error);
44
+ throw stopped;
45
+ }
46
+ finally {
47
+ clearTimeout(timer);
48
+ signal.removeEventListener('abort', onAbort);
49
+ }
50
+ }
@@ -2,6 +2,8 @@
2
2
  export type BraintreeEnvironment = 'production' | 'sandbox';
3
3
  export type BraintreeRequestKind = 'configuration' | 'tokenization' | 'invalid';
4
4
  export declare function braintreeEnvironment(url: string): BraintreeEnvironment | undefined;
5
+ /** Bounded duplicate-aware JSON for fresh-card preparation bodies. */
6
+ export declare function readTokenizationJson(body: string | null): Record<string, unknown> | null;
5
7
  /** Undefined belongs to another processor; invalid Braintree requests stay blocked. */
6
8
  export declare function classifyBraintreeRequest(url: string, method: string, body: string | null): BraintreeRequestKind | undefined;
7
9
  /** Preparation accepts guest tokenization only; ordinary interception stays broader. */
package/dist/braintree.js CHANGED
@@ -115,6 +115,18 @@ class JsonReader {
115
115
  return value;
116
116
  }
117
117
  }
118
+ /** Bounded duplicate-aware JSON for fresh-card preparation bodies. */
119
+ export function readTokenizationJson(body) {
120
+ if (typeof body !== 'string' || body.length === 0 || body.length > MAX_BODY_LENGTH)
121
+ return null;
122
+ try {
123
+ const value = new JsonReader(body).read();
124
+ return record(value) ? value : null;
125
+ }
126
+ catch {
127
+ return null;
128
+ }
129
+ }
118
130
  /** Native operations need names, punctuation and variables, never literals. */
119
131
  function lex(query) {
120
132
  const tokens = [];
package/dist/cdp.d.ts CHANGED
@@ -87,6 +87,8 @@ export interface AttachOptions extends LifecycleOptions {
87
87
  currency?: string;
88
88
  cardId?: string;
89
89
  timeoutMs?: number;
90
+ /** Browser interception setup deadline, separate from approval. Defaults to 30000 ms; maximum 300000 ms. */
91
+ attachmentTimeoutMs?: number;
90
92
  /** Explicit payment endpoints to block if the registry cannot handle their method/format. Unlisted traffic is untouched. */
91
93
  paymentEndpoints?: readonly PaymentEndpointGuard[];
92
94
  onApprovalUrl?: (url: string) => void;
package/dist/cdp.js CHANGED
@@ -2,6 +2,7 @@ import { BUILTIN_REGISTRY, cardUrlPatterns } from './registry.js';
2
2
  import { ApprovalDeclinedError, ApprovalTimeoutError, CardEncryptedError, CheckoutApiError, PaymentOutcomeUnknownError, ProcessorRefusedError, UnsupportedModeError, redactUrl, } from './client.js';
3
3
  import { PreparationGate } from './preparation.js';
4
4
  import { classifyBraintreeRequest } from './braintree.js';
5
+ import { CheckoutAttachmentError, attachmentDeadline, attachmentFailure, withinAttachmentDeadline } from './attachment.js';
5
6
  import { substituteEncryptedFields } from './substitute.js';
6
7
  import { hostedFormSubmittedPage } from './hosted-form.js';
7
8
  import { CheckoutLifecycle, paymentEndpointGuards } from './lifecycle.js';
@@ -328,6 +329,10 @@ function merchantAttempt(lifecycle) {
328
329
  */
329
330
  export async function attachToCdp(cdp, pageSessionId, opts) {
330
331
  opts = safeOptions(opts);
332
+ const setupTimeoutMs = attachmentDeadline(opts.attachmentTimeoutMs);
333
+ const setupStop = new AbortController();
334
+ let attachmentReady = false;
335
+ let setupFailureReported = false;
331
336
  const lifecycle = new CheckoutLifecycle(opts);
332
337
  let preparationFrameId;
333
338
  const preparationGate = new PreparationGate(opts, lifecycle, async () => {
@@ -339,6 +344,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
339
344
  });
340
345
  const guards = paymentEndpointGuards(opts.paymentEndpoints);
341
346
  const armed = new Set();
347
+ const arming = new Map();
342
348
  // Set once a failure proves that retrying cannot help; see isTerminal.
343
349
  let terminal = null;
344
350
  // Silence window after a person declined or ignored one; see APPROVAL_COOLDOWN_MS.
@@ -352,23 +358,48 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
352
358
  let lastSubmitted = null;
353
359
  const derived = typeof opts.vault?.cardUrlPatterns === 'function' ? opts.vault.cardUrlPatterns() : [];
354
360
  const urlPatterns = [...new Set([...(derived.length > 0 ? derived : FALLBACK_CARD_PATTERNS), ...guards.patterns])];
355
- opts.onEvent?.({ type: 'fetch_armed', detail: { patterns: urlPatterns } });
356
- const arm = async (sessionId) => {
361
+ const arm = async (sessionId, resume = false) => {
357
362
  const key = sessionId ?? '__root__';
358
363
  if (armed.has(key))
359
364
  return;
365
+ if (arming.has(key))
366
+ return arming.get(key);
360
367
  // Fetch's interception ID differs from Network's ID. Enable failure events
361
368
  // before intercepting and bind each attempt to both its ID and CDP session.
362
- await cdp.send('Network.enable', {}, sessionId);
363
- await cdp.send('Page.enable', {}, sessionId).catch(() => { });
364
- await cdp.send('Fetch.enable', {
365
- patterns: urlPatterns.map((urlPattern) => ({ urlPattern, requestStage: 'Request' })),
366
- }, sessionId);
367
- // Descend into this target's own children (iframes inside iframes).
368
- await cdp.send('Target.setAutoAttach', {
369
- autoAttach: true, waitForDebuggerOnStart: true, flatten: true,
370
- }, sessionId);
371
- armed.add(key);
369
+ const pending = withinAttachmentDeadline(async (assertActive) => {
370
+ await cdp.send('Network.enable', {}, sessionId);
371
+ assertActive();
372
+ await cdp.send('Page.enable', {}, sessionId).catch(() => { });
373
+ assertActive();
374
+ await cdp.send('Fetch.enable', {
375
+ patterns: urlPatterns.map((urlPattern) => ({ urlPattern, requestStage: 'Request' })),
376
+ }, sessionId);
377
+ assertActive();
378
+ // Descend into this target's own children (iframes inside iframes).
379
+ await cdp.send('Target.setAutoAttach', {
380
+ autoAttach: true, waitForDebuggerOnStart: true, flatten: true,
381
+ }, sessionId);
382
+ assertActive();
383
+ // A child remains part of setup until Chrome acknowledges its resume.
384
+ // Deduplicate this command with arming, and bound the acknowledgement too.
385
+ if (resume) {
386
+ await cdp.send('Runtime.runIfWaitingForDebugger', {}, sessionId);
387
+ assertActive();
388
+ }
389
+ }, setupTimeoutMs, setupStop.signal);
390
+ arming.set(key, pending);
391
+ try {
392
+ await pending;
393
+ armed.add(key);
394
+ }
395
+ catch (error) {
396
+ const failure = attachmentFailure(error);
397
+ setupStop.abort(failure);
398
+ throw failure;
399
+ }
400
+ finally {
401
+ arming.delete(key);
402
+ }
372
403
  };
373
404
  cdp.on(async (method, params, sessionId) => {
374
405
  if (((method === 'Page.frameNavigated' && !params.frame?.parentId) || (method === 'Page.navigatedWithinDocument' && preparationFrameId && params.frameId === preparationFrameId)) && sessionId === pageSessionId) {
@@ -379,6 +410,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
379
410
  }
380
411
  if ((method === 'Inspector.detached' && sessionId === pageSessionId)
381
412
  || (method === 'Target.detachedFromTarget' && params.sessionId === pageSessionId)) {
413
+ setupStop.abort(new CheckoutAttachmentError('closed'));
382
414
  preparationGate.invalidate('merchant_document_closed');
383
415
  // The root page owns every attached OOPIF; its loss ends child requests too.
384
416
  activeRequest?.attempt.stop();
@@ -396,19 +428,25 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
396
428
  return;
397
429
  }
398
430
  if (method === 'Target.attachedToTarget') {
431
+ if (setupStop.signal.aborted)
432
+ return;
399
433
  const child = params.sessionId;
400
434
  try {
401
- await arm(child);
435
+ await arm(child, true);
402
436
  }
403
- catch {
437
+ catch (error) {
438
+ // Shared child setup and sibling cancellation can reject several
439
+ // handlers together. Publish the terminal setup failure only once.
440
+ if (setupFailureReported)
441
+ return;
442
+ setupFailureReported = true;
443
+ setupStop.abort(attachmentFailure(error));
404
444
  terminal = new Error('browser_interception_unavailable');
405
445
  lifecycle.failed(new PaymentOutcomeUnknownError(lifecycle.getState().authorizationId, 'browser_interception_unavailable'));
406
446
  opts.onEvent?.({ type: 'failed', detail: 'browser_interception_unavailable' });
407
447
  // Leave this target paused: resuming an unarmed card frame would silently bypass the vault.
408
448
  return;
409
449
  }
410
- // Child targets start paused when waitForDebuggerOnStart is set.
411
- await cdp.send('Runtime.runIfWaitingForDebugger', {}, child).catch(() => { });
412
450
  return;
413
451
  }
414
452
  if (method !== 'Fetch.requestPaused')
@@ -438,9 +476,14 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
438
476
  await cdp.send('Fetch.continueRequest', { requestId }, sessionId).catch(() => { });
439
477
  return;
440
478
  }
479
+ if (!attachmentReady || arming.has(sessionId ?? '__root__') || setupStop.signal.aborted) {
480
+ opts.onEvent?.({ type: 'blocked', detail: 'checkout_interception_not_ready' });
481
+ await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
482
+ return;
483
+ }
441
484
  let preparation;
442
485
  try {
443
- preparation = preparationGate.claim(request.url, braintree === 'tokenization' ? pausedBody(request) : undefined);
486
+ preparation = preparationGate.claim(request.url, pausedBody(request));
444
487
  }
445
488
  catch (error) {
446
489
  opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
@@ -590,7 +633,24 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
590
633
  preparationGate.retireUnboundClaim();
591
634
  }
592
635
  });
593
- await arm(pageSessionId);
636
+ try {
637
+ await arm(pageSessionId);
638
+ if (setupStop.signal.aborted)
639
+ throw attachmentFailure(setupStop.signal.reason);
640
+ attachmentReady = true;
641
+ opts.onEvent?.({ type: 'fetch_armed', detail: { patterns: urlPatterns } });
642
+ }
643
+ catch (error) {
644
+ const failure = attachmentFailure(error);
645
+ setupStop.abort(failure);
646
+ terminal = failure;
647
+ if (!setupFailureReported) {
648
+ setupFailureReported = true;
649
+ lifecycle.cancel();
650
+ opts.onEvent?.({ type: 'failed', detail: `checkout_attachment_${failure.reason}` });
651
+ }
652
+ throw failure;
653
+ }
594
654
  return lifecycle;
595
655
  }
596
656
  /**
@@ -606,6 +666,9 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
606
666
  */
607
667
  export async function attachToPlaywright(page, opts) {
608
668
  opts = safeOptions(opts);
669
+ const setupTimeoutMs = attachmentDeadline(opts.attachmentTimeoutMs);
670
+ const setupStop = new AbortController();
671
+ let attachmentReady = false;
609
672
  // Routing cannot see requests owned by a service worker. Existing controlled
610
673
  // contexts must be recreated with serviceWorkers: 'block' before checkout.
611
674
  if (page.context?.().serviceWorkers?.().length) {
@@ -642,8 +705,10 @@ export async function attachToPlaywright(page, opts) {
642
705
  if (activeRequest && activeRequest.request === request)
643
706
  activeRequest.attempt.stop();
644
707
  });
645
- page.on?.('close', () => { preparationGate.invalidate('merchant_document_closed'); activeRequest?.attempt.stop(); });
646
- page.on?.('crash', () => { preparationGate.invalidate('merchant_document_closed'); activeRequest?.attempt.stop(); });
708
+ page.on?.('close', () => { if (!attachmentReady)
709
+ setupStop.abort(new CheckoutAttachmentError('closed')); preparationGate.invalidate('merchant_document_closed'); activeRequest?.attempt.stop(); });
710
+ page.on?.('crash', () => { if (!attachmentReady)
711
+ setupStop.abort(new CheckoutAttachmentError('closed')); preparationGate.invalidate('merchant_document_closed'); activeRequest?.attempt.stop(); });
647
712
  page.on?.('framenavigated', (frame) => {
648
713
  if (frame === page.mainFrame?.()) {
649
714
  preparationGate.invalidate('merchant_document_changed');
@@ -658,147 +723,175 @@ export async function attachToPlaywright(page, opts) {
658
723
  // The hosted form the cardholder already submitted; see HOSTED_FORM_REPEAT_QUIET_MS.
659
724
  const repeatQuietMs = opts.hostedFormRepeatQuietMs ?? HOSTED_FORM_REPEAT_QUIET_MS;
660
725
  let lastSubmitted = null;
661
- await page.route((url) => opts.vault.isCardRequest(url.toString()) || guards.matches(url.toString()), async (route) => {
662
- const request = route.request();
663
- const braintree = request.method().toUpperCase() === 'POST'
664
- ? classifyBraintreeRequest(request.url(), request.method(), request.postData() ?? '') : undefined;
665
- if (braintree === 'configuration')
666
- return route.fallback();
667
- if (braintree === 'invalid') {
668
- opts.onEvent?.({ type: 'blocked', detail: 'unsupported_braintree_graphql_operation' });
669
- return route.abort('aborted');
726
+ const install = async (assertActive) => {
727
+ if (page.isClosed?.()) {
728
+ const failure = new CheckoutAttachmentError('closed');
729
+ setupStop.abort(failure);
730
+ throw failure;
670
731
  }
671
- // The matcher only sees the URL; a preflight or a GET must pass through
672
- // untouched or the browser's CORS check fails on our synthetic answer.
673
- if (!opts.vault.isCardRequest(request.url(), request.method())) {
674
- if (guards.matches(request.url(), request.method())) {
675
- preparationGate.invalidate('unsupported_checkout');
676
- lifecycle.unsupported();
677
- opts.onEvent?.({ type: 'unsupported_checkout', detail: { url: redactUrl(request.url()), method: request.method() } });
732
+ await page.route((url) => opts.vault.isCardRequest(url.toString()) || guards.matches(url.toString()), async (route) => {
733
+ const request = route.request();
734
+ const braintree = request.method().toUpperCase() === 'POST'
735
+ ? classifyBraintreeRequest(request.url(), request.method(), request.postData() ?? '') : undefined;
736
+ if (braintree === 'configuration')
737
+ return route.fallback();
738
+ if (braintree === 'invalid') {
739
+ opts.onEvent?.({ type: 'blocked', detail: 'unsupported_braintree_graphql_operation' });
678
740
  return route.abort('aborted');
679
741
  }
680
- return route.fallback();
681
- }
682
- let preparation;
683
- try {
684
- preparation = preparationGate.claim(request.url(), braintree === 'tokenization' ? request.postData() ?? '' : undefined);
685
- }
686
- catch (error) {
687
- opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
688
- return route.abort('aborted');
689
- }
690
- // Fail closed and stay quiet: no card may reach the PSP, but neither may
691
- // the page's retry loop turn into a stream of doomed API calls. Every
692
- // abort in this adapter is 'aborted' (ERR_ABORTED), the same code the
693
- // CDP adapter's Fetch.failRequest uses, so a refused navigation
694
- // resolves identically whichever adapter is attached.
695
- if (terminal || lifecycle.isBlocked() || awaitingApproval || Date.now() < quietUntil) {
696
- const why = terminal ?? (lifecycle.isBlocked() ? lifecycle.getState().status : awaitingApproval ? 'an approval is already outstanding' : 'awaiting approval cooldown');
697
- opts.onEvent?.({ type: 'blocked', detail: why instanceof Error ? failureSummary(why) : String(why) });
698
- if (preparation)
699
- preparationGate.retireUnboundClaim();
700
- return route.abort('aborted');
701
- }
702
- // Reserved before anything that could yield, matching attachToCdp.
703
- awaitingApproval = true;
704
- const attempt = merchantAttempt(lifecycle);
705
- const frames = [];
706
- try {
707
- for (let frame = request.frame?.(); frame; frame = frame.parentFrame?.())
708
- frames.push(frame);
709
- }
710
- catch { /* requestfailed/page close still cover unavailable frame metadata */ }
711
- const assertRequestLive = () => {
712
- if (request.failure?.() || page.isClosed?.() || frames.some(frame => frame.isDetached?.()))
713
- attempt.stop();
714
- attempt.assertLive();
715
- };
716
- let handoffStarted = false;
717
- try {
718
- const body = request.postData() ?? '';
719
- if (isRepeatOfSubmitted(lastSubmitted, request.url(), body, repeatQuietMs)) {
720
- opts.onEvent?.({ type: 'blocked', detail: HOSTED_FORM_REPEAT_REASON });
721
- return await route.abort('aborted');
742
+ // The matcher only sees the URL; a preflight or a GET must pass through
743
+ // untouched or the browser's CORS check fails on our synthetic answer.
744
+ if (!opts.vault.isCardRequest(request.url(), request.method())) {
745
+ if (guards.matches(request.url(), request.method())) {
746
+ preparationGate.invalidate('unsupported_checkout');
747
+ lifecycle.unsupported();
748
+ opts.onEvent?.({ type: 'unsupported_checkout', detail: { url: redactUrl(request.url()), method: request.method() } });
749
+ return route.abort('aborted');
750
+ }
751
+ return route.fallback();
722
752
  }
723
- opts.onEvent?.({ type: 'card_request_paused', detail: { url: redactUrl(request.url()) } });
724
- lifecycle.begin();
725
- activeRequest = { request, frames, attempt };
726
- if (preparation)
727
- await preparationGate.assertDocument();
728
- assertRequestLive();
729
- const replay = await opts.vault.authorize({
730
- user: opts.user,
731
- merchant: opts.merchant,
732
- amount: opts.amount,
733
- amountCents: opts.amountCents,
734
- currency: opts.currency,
735
- cardId: preparation?.cardId ?? opts.cardId,
736
- preparation,
737
- timeoutMs: opts.timeoutMs,
738
- signal: lifecycle.abort.signal,
739
- merchantSignal: attempt.signal,
740
- onAuthorizationCreated: (id) => lifecycle.approvalCreated(id),
741
- onApprovalUrl: (url) => { if (!preparation && !attempt.signal.aborted) {
742
- lifecycle.approvalUrl(url);
743
- return opts.onApprovalUrl?.(url);
744
- } },
745
- request: { url: request.url(), method: request.method(), headers: request.headers(), body },
746
- });
747
- assertRequestLive();
748
- if (lifecycle.isCancelled())
749
- throw new Error('checkout cancelled locally after approval');
750
- lifecycle.prepareHandoff(replay, request.url());
751
- handoffStarted = replay.mode !== 'cse';
752
- if (replay.mode === 'hosted_form') {
753
- // Same as the CDP path: the paused navigation resolves to the
754
- // synthetic page, and a re-post of this form is refused.
755
- const synthetic = hostedFormSubmittedPage({ authorizationId: replay.authorizationId, merchant: opts.merchant, submittedAt: replay.submittedAt });
756
- // Inert on a navigation (never CORS-checked); one path for every fulfill.
757
- await route.fulfill({ status: synthetic.status, headers: withCorsHeaders(synthetic.headers, corsHeadersFor(request.url(), request.headers())), body: synthetic.body });
758
- assertRequestLive();
759
- lastSubmitted = { url: request.url(), body, at: Date.now() };
760
- opts.onEvent?.({ type: 'submitted_on_device', detail: { authorizationId: replay.authorizationId, submittedAt: replay.submittedAt, outcome: replay.outcome } });
753
+ if (!attachmentReady || setupStop.signal.aborted) {
754
+ opts.onEvent?.({ type: 'blocked', detail: 'checkout_interception_not_ready' });
755
+ return route.abort('aborted');
761
756
  }
762
- else if (replay.mode === 'cse') {
763
- // Same as the CDP path: the request continues from this browser
764
- // with the ciphertext swapped in and no header override; Playwright
765
- // recomputes the length itself.
766
- const postData = cseBody(body, replay);
767
- handoffStarted = true;
768
- await route.continue({ postData });
769
- assertRequestLive();
770
- opts.onEvent?.({ type: 'authorized', detail: { mode: 'cse', authorizationId: replay.authorizationId, fields: Object.keys(replay.substitutions.fields) } });
757
+ let preparation;
758
+ try {
759
+ preparation = preparationGate.claim(request.url(), request.postData() ?? '');
771
760
  }
772
- else {
773
- // Playwright adds these itself when a cross-origin fulfill carries
774
- // none; written here anyway (replacing a stale value) so a
775
- // cross-origin answer is the same whichever adapter ran.
776
- const cors = corsDecision(request.url(), request.headers());
777
- await route.fulfill({ status: replay.status, headers: withCorsHeaders(replay.headers, cors.headers), body: replay.body });
761
+ catch (error) {
762
+ opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
763
+ return route.abort('aborted');
764
+ }
765
+ // Fail closed and stay quiet: no card may reach the PSP, but neither may
766
+ // the page's retry loop turn into a stream of doomed API calls. Every
767
+ // abort in this adapter is 'aborted' (ERR_ABORTED), the same code the
768
+ // CDP adapter's Fetch.failRequest uses, so a refused navigation
769
+ // resolves identically whichever adapter is attached.
770
+ if (terminal || lifecycle.isBlocked() || awaitingApproval || Date.now() < quietUntil) {
771
+ const why = terminal ?? (lifecycle.isBlocked() ? lifecycle.getState().status : awaitingApproval ? 'an approval is already outstanding' : 'awaiting approval cooldown');
772
+ opts.onEvent?.({ type: 'blocked', detail: why instanceof Error ? failureSummary(why) : String(why) });
773
+ if (preparation)
774
+ preparationGate.retireUnboundClaim();
775
+ return route.abort('aborted');
776
+ }
777
+ // Reserved before anything that could yield, matching attachToCdp.
778
+ awaitingApproval = true;
779
+ const attempt = merchantAttempt(lifecycle);
780
+ const frames = [];
781
+ try {
782
+ for (let frame = request.frame?.(); frame; frame = frame.parentFrame?.())
783
+ frames.push(frame);
784
+ }
785
+ catch { /* requestfailed/page close still cover unavailable frame metadata */ }
786
+ const assertRequestLive = () => {
787
+ if (request.failure?.() || page.isClosed?.() || frames.some(frame => frame.isDetached?.()))
788
+ attempt.stop();
789
+ attempt.assertLive();
790
+ };
791
+ let handoffStarted = false;
792
+ try {
793
+ const body = request.postData() ?? '';
794
+ if (isRepeatOfSubmitted(lastSubmitted, request.url(), body, repeatQuietMs)) {
795
+ opts.onEvent?.({ type: 'blocked', detail: HOSTED_FORM_REPEAT_REASON });
796
+ return await route.abort('aborted');
797
+ }
798
+ opts.onEvent?.({ type: 'card_request_paused', detail: { url: redactUrl(request.url()) } });
799
+ lifecycle.begin();
800
+ activeRequest = { request, frames, attempt };
801
+ if (preparation)
802
+ await preparationGate.assertDocument();
778
803
  assertRequestLive();
779
- opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId, amountVerified: replay.amountVerified ?? null, cors: cors.outcome } });
804
+ const replay = await opts.vault.authorize({
805
+ user: opts.user,
806
+ merchant: opts.merchant,
807
+ amount: opts.amount,
808
+ amountCents: opts.amountCents,
809
+ currency: opts.currency,
810
+ cardId: preparation?.cardId ?? opts.cardId,
811
+ preparation,
812
+ timeoutMs: opts.timeoutMs,
813
+ signal: lifecycle.abort.signal,
814
+ merchantSignal: attempt.signal,
815
+ onAuthorizationCreated: (id) => lifecycle.approvalCreated(id),
816
+ onApprovalUrl: (url) => { if (!preparation && !attempt.signal.aborted) {
817
+ lifecycle.approvalUrl(url);
818
+ return opts.onApprovalUrl?.(url);
819
+ } },
820
+ request: { url: request.url(), method: request.method(), headers: request.headers(), body },
821
+ });
822
+ assertRequestLive();
823
+ if (lifecycle.isCancelled())
824
+ throw new Error('checkout cancelled locally after approval');
825
+ lifecycle.prepareHandoff(replay, request.url());
826
+ handoffStarted = replay.mode !== 'cse';
827
+ if (replay.mode === 'hosted_form') {
828
+ // Same as the CDP path: the paused navigation resolves to the
829
+ // synthetic page, and a re-post of this form is refused.
830
+ const synthetic = hostedFormSubmittedPage({ authorizationId: replay.authorizationId, merchant: opts.merchant, submittedAt: replay.submittedAt });
831
+ // Inert on a navigation (never CORS-checked); one path for every fulfill.
832
+ await route.fulfill({ status: synthetic.status, headers: withCorsHeaders(synthetic.headers, corsHeadersFor(request.url(), request.headers())), body: synthetic.body });
833
+ assertRequestLive();
834
+ lastSubmitted = { url: request.url(), body, at: Date.now() };
835
+ opts.onEvent?.({ type: 'submitted_on_device', detail: { authorizationId: replay.authorizationId, submittedAt: replay.submittedAt, outcome: replay.outcome } });
836
+ }
837
+ else if (replay.mode === 'cse') {
838
+ // Same as the CDP path: the request continues from this browser
839
+ // with the ciphertext swapped in and no header override; Playwright
840
+ // recomputes the length itself.
841
+ const postData = cseBody(body, replay);
842
+ handoffStarted = true;
843
+ await route.continue({ postData });
844
+ assertRequestLive();
845
+ opts.onEvent?.({ type: 'authorized', detail: { mode: 'cse', authorizationId: replay.authorizationId, fields: Object.keys(replay.substitutions.fields) } });
846
+ }
847
+ else {
848
+ // Playwright adds these itself when a cross-origin fulfill carries
849
+ // none; written here anyway (replacing a stale value) so a
850
+ // cross-origin answer is the same whichever adapter ran.
851
+ const cors = corsDecision(request.url(), request.headers());
852
+ await route.fulfill({ status: replay.status, headers: withCorsHeaders(replay.headers, cors.headers), body: replay.body });
853
+ assertRequestLive();
854
+ opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId, amountVerified: replay.amountVerified ?? null, cors: cors.outcome } });
855
+ }
856
+ lifecycle.handedOff(replay);
780
857
  }
781
- lifecycle.handedOff(replay);
782
- }
783
- catch (err) {
784
- if (activeRequest?.attempt === attempt)
785
- activeRequest = null;
786
- lifecycle.failed(err, handoffStarted);
787
- if (isTerminal(err))
788
- terminal = err;
789
- else if (isApprovalOutcome(err))
790
- quietUntil = Date.now() + cooldownMs;
791
- opts.onEvent?.({ type: 'failed', detail: failureSummary(err) });
792
- await route.abort('aborted').catch(() => { });
793
- }
794
- finally {
795
- if (activeRequest?.attempt === attempt)
796
- activeRequest = null;
797
- awaitingApproval = false;
798
- lifecycle.end();
799
- if (preparation)
800
- preparationGate.retireUnboundClaim();
801
- }
802
- });
858
+ catch (err) {
859
+ if (activeRequest?.attempt === attempt)
860
+ activeRequest = null;
861
+ lifecycle.failed(err, handoffStarted);
862
+ if (isTerminal(err))
863
+ terminal = err;
864
+ else if (isApprovalOutcome(err))
865
+ quietUntil = Date.now() + cooldownMs;
866
+ opts.onEvent?.({ type: 'failed', detail: failureSummary(err) });
867
+ await route.abort('aborted').catch(() => { });
868
+ }
869
+ finally {
870
+ if (activeRequest?.attempt === attempt)
871
+ activeRequest = null;
872
+ awaitingApproval = false;
873
+ lifecycle.end();
874
+ if (preparation)
875
+ preparationGate.retireUnboundClaim();
876
+ }
877
+ });
878
+ assertActive();
879
+ };
880
+ try {
881
+ await withinAttachmentDeadline(install, setupTimeoutMs, setupStop.signal);
882
+ if (setupStop.signal.aborted)
883
+ throw attachmentFailure(setupStop.signal.reason);
884
+ attachmentReady = true;
885
+ }
886
+ catch (error) {
887
+ const failure = attachmentFailure(error);
888
+ setupStop.abort(failure);
889
+ terminal = failure;
890
+ lifecycle.cancel();
891
+ opts.onEvent?.({ type: 'failed', detail: `checkout_attachment_${failure.reason}` });
892
+ // A route registration may finish after this rejection. Keep its handler
893
+ // inert for card traffic; removing it would reopen the failed checkout.
894
+ throw failure;
895
+ }
803
896
  return lifecycle;
804
897
  }
package/dist/client.d.ts CHANGED
@@ -102,7 +102,7 @@ export type ReplayResponse = TokenReplay | CseReplay | HostedFormReplay;
102
102
  export interface PrepareCheckoutOptions {
103
103
  psp: PreparationProcessor;
104
104
  /** The processor environment, independent of your Agentcard client's mode. */
105
- environment: 'production' | 'sandbox';
105
+ environment: 'production' | 'sandbox' | 'shared';
106
106
  signal?: AbortSignal;
107
107
  }
108
108
  export interface PrepareCheckoutInput extends PrepareCheckoutOptions {
@@ -122,7 +122,7 @@ export interface PreparedCheckout {
122
122
  readonly id: string;
123
123
  readonly status: 'ready';
124
124
  readonly psp: PreparationProcessor;
125
- readonly environment: 'production' | 'sandbox';
125
+ readonly environment: 'production' | 'sandbox' | 'shared';
126
126
  readonly expiresAt: string;
127
127
  readonly cardId: string;
128
128
  readonly user: string;
package/dist/client.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { BUILTIN_REGISTRY, cardUrlPatterns as deriveCardUrlPatterns, findRecognizer, } from './registry.js';
2
- import { matchesPreparedRequest } from './prepared-processor.js';
2
+ import { matchesPreparedRequest, validPreparationEnvironment } from './prepared-processor.js';
3
3
  /**
4
4
  * The modes this SDK can finish. Asked for on syncRegistry (the API serves
5
5
  * only recognizers in these modes, so a request this build cannot complete
@@ -315,7 +315,7 @@ export class VaultClient {
315
315
  async prepareCheckout(input) {
316
316
  input = { ...input };
317
317
  const fail = (reason, id = null) => new CheckoutPreparationError(id, reason);
318
- if (!['square', 'braintree'].includes(input.psp) || !['production', 'sandbox'].includes(input.environment))
318
+ if (!validPreparationEnvironment(input.psp, input.environment))
319
319
  throw fail('unsupported_processor');
320
320
  if (!Number.isSafeInteger(input.amountCents) || input.amountCents <= 0 || typeof input.currency !== 'string' || !/^[a-z]{3}$/i.test(input.currency))
321
321
  throw fail('amount_required');
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { VaultClient, CardEncryptedError, UnsupportedModeError, ApprovalTimeoutError, ApprovalDeclinedError, AmountMismatchError, IntentNotConfirmableError, ProcessorRefusedError, CheckoutApiError, redactUrl, SUPPORTED_MODES, PaymentOutcomeUnknownError, CheckoutCancelledError, CheckoutPreparationError, } from './client.js';
2
2
  export type { PausedRequest, ReplayResponse, TokenReplay, CseReplay, HostedFormReplay, AmountAuthority, AuthorizeInput, VaultClientOptions, PrepareCheckoutOptions, PrepareCheckoutInput, PreparedCheckout, RazorpayProcessorError, } from './client.js';
3
3
  export { attachToCdp, attachToPlaywright, corsHeadersFor, corsDecision, withCorsHeaders } from './cdp.js';
4
+ export { CheckoutAttachmentError } from './attachment.js';
4
5
  export type { CdpLike, AttachOptions, CorsOutcome } from './cdp.js';
5
6
  export { substituteEncryptedFields, SubstitutionError } from './substitute.js';
6
7
  export type { Substitutions } from './substitute.js';
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export { VaultClient, CardEncryptedError, UnsupportedModeError, ApprovalTimeoutError, ApprovalDeclinedError, AmountMismatchError, IntentNotConfirmableError, ProcessorRefusedError, CheckoutApiError, redactUrl, SUPPORTED_MODES, PaymentOutcomeUnknownError, CheckoutCancelledError, CheckoutPreparationError, } from './client.js';
2
2
  export { attachToCdp, attachToPlaywright, corsHeadersFor, corsDecision, withCorsHeaders } from './cdp.js';
3
+ export { CheckoutAttachmentError } from './attachment.js';
3
4
  export { substituteEncryptedFields, SubstitutionError } from './substitute.js';
4
5
  export { hostedFormSubmittedPage, HOSTED_FORM_SUBMITTED_OUTCOME } from './hosted-form.js';
5
6
  export { BUILTIN_REGISTRY, cardUrlPatterns, findRecognizer } from './registry.js';
@@ -1,5 +1,5 @@
1
1
  import { CheckoutPreparationError } from './client.js';
2
- import { matchesPreparedRequest, preparationEndpoint } from './prepared-processor.js';
2
+ import { matchesPreparedRequest, validPreparationEnvironment, preparationEndpoint } from './prepared-processor.js';
3
3
  /** A local, one-use rendezvous. It never starts or retries a merchant request. */
4
4
  export class PreparationGate {
5
5
  opts;
@@ -34,7 +34,7 @@ export class PreparationGate {
34
34
  const onAbort = () => this.invalidate('cancelled');
35
35
  signal.addEventListener('abort', onAbort, { once: true });
36
36
  try {
37
- if (!options || !['square', 'braintree'].includes(options.psp) || !['production', 'sandbox'].includes(options.environment))
37
+ if (!options || !validPreparationEnvironment(options.psp, options.environment))
38
38
  throw new CheckoutPreparationError(null, 'unsupported_processor');
39
39
  const tokenizer = preparationEndpoint(options.psp, options.environment);
40
40
  if (!this.opts.vault.isCardRequest(tokenizer, 'POST'))
@@ -1,5 +1,7 @@
1
- export type PreparationProcessor = 'square' | 'braintree';
2
- export type PreparationEnvironment = 'production' | 'sandbox';
1
+ export type PreparationProcessor = 'square' | 'braintree' | 'worldpay' | 'bambora' | 'mercado_pago';
2
+ export type PreparationEnvironment = 'production' | 'sandbox' | 'shared';
3
+ /** Shared endpoint processors cannot attest test/live mode from their URL or key prefix. */
4
+ export declare function validPreparationEnvironment(psp: string, environment: string): boolean;
3
5
  export declare function preparationEndpoint(psp: PreparationProcessor, environment: PreparationEnvironment): string;
4
6
  /** Processor identity and environment are part of the device's prior consent. */
5
7
  export declare function matchesPreparedRequest(psp: PreparationProcessor, environment: PreparationEnvironment, requestUrl: string, method: string, body?: string | null): boolean;
@@ -1,23 +1,91 @@
1
- import { braintreeEnvironment, isPreparedBraintreeRequest } from './braintree.js';
1
+ import { braintreeEnvironment, isPreparedBraintreeRequest, readTokenizationJson } from './braintree.js';
2
+ /** Shared endpoint processors cannot attest test/live mode from their URL or key prefix. */
3
+ export function validPreparationEnvironment(psp, environment) {
4
+ if (psp === 'bambora' || psp === 'mercado_pago')
5
+ return environment === 'shared';
6
+ return ['square', 'braintree', 'worldpay'].includes(psp) && ['production', 'sandbox'].includes(environment);
7
+ }
2
8
  export function preparationEndpoint(psp, environment) {
9
+ if (!validPreparationEnvironment(psp, environment))
10
+ throw new Error('unsupported_preparation_processor');
11
+ if (psp === 'bambora')
12
+ return 'https://api.bam.shift4api.net/scripts/tokenization/tokens';
13
+ if (psp === 'mercado_pago')
14
+ return 'https://api.mercadopago.com/v1/card_tokens';
15
+ if (psp === 'worldpay')
16
+ return environment === 'production'
17
+ ? 'https://access.worldpay.com/sessions/card' : 'https://try.access.worldpay.com/sessions/card';
3
18
  if (psp === 'braintree')
4
19
  return environment === 'production'
5
20
  ? 'https://payments.braintree-api.com/graphql' : 'https://payments.sandbox.braintree-api.com/graphql';
6
21
  return environment === 'production'
7
22
  ? 'https://pci-connect.squareup.com/v2/card-nonce' : 'https://pci-connect.squareupsandbox.com/v2/card-nonce';
8
23
  }
24
+ function record(value) {
25
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
26
+ }
27
+ const keys = (value, allowed) => Object.keys(value).every(key => allowed.includes(key));
28
+ const string = (value, max = 1024) => typeof value === 'string' && value.length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value);
29
+ const digits = (value, min, max) => typeof value === 'string' && value.length >= min && value.length <= max && !/[^0-9]/.test(value);
30
+ const month = (value) => (typeof value === 'number' && Number.isInteger(value) || digits(value, 1, 2)) && Number(value) >= 1 && Number(value) <= 12;
31
+ const year = (value) => (typeof value === 'number' && Number.isInteger(value) || digits(value, 2, 4)) && (Number(value) >= 0 && Number(value) <= 99 || Number(value) >= 2000 && Number(value) <= 9999);
32
+ /** Native fresh-card shapes only. Saved-card, charge and recurring siblings never consume consent. */
33
+ function freshCardBody(psp, body) {
34
+ const value = readTokenizationJson(body);
35
+ if (!value)
36
+ return false;
37
+ if (psp === 'worldpay')
38
+ return keys(value, ['identity', 'cardNumber', 'cardExpiryDate', 'cvc'])
39
+ && string(value.identity) && digits(value.cardNumber, 12, 19)
40
+ && record(value.cardExpiryDate) && keys(value.cardExpiryDate, ['month', 'year'])
41
+ && month(value.cardExpiryDate.month) && year(value.cardExpiryDate.year)
42
+ && (value.cvc === undefined || digits(value.cvc, 3, 4));
43
+ if (psp === 'bambora')
44
+ return keys(value, ['number', 'expiry_month', 'expiry_year', 'cvd'])
45
+ && digits(value.number, 12, 19) && month(value.expiry_month) && year(value.expiry_year)
46
+ && (value.cvd === undefined || digits(value.cvd, 3, 4));
47
+ if (psp !== 'mercado_pago' || !keys(value, ['card_number', 'expiration_month', 'expiration_year', 'security_code', 'cardholder', 'device'])
48
+ || !digits(value.card_number, 12, 19) || !month(value.expiration_month) || !year(value.expiration_year)
49
+ || (value.security_code !== undefined && value.security_code !== '' && !digits(value.security_code, 3, 4)) || !record(value.cardholder)
50
+ || !keys(value.cardholder, ['name', 'identification']))
51
+ return false;
52
+ const holder = value.cardholder;
53
+ if (holder.name !== undefined && holder.name !== '' && !string(holder.name))
54
+ return false;
55
+ if (holder.identification !== undefined && (!record(holder.identification) || !keys(holder.identification, ['type', 'number'])
56
+ || Object.values(holder.identification).some(v => v !== '' && !string(v))))
57
+ return false;
58
+ if (value.device !== undefined && (!record(value.device) || !keys(value.device, ['meli'])
59
+ || !record(value.device.meli) || !keys(value.device.meli, ['session_id']) || !string(value.device.meli.session_id, 4096)))
60
+ return false;
61
+ return true;
62
+ }
9
63
  /** Processor identity and environment are part of the device's prior consent. */
10
64
  export function matchesPreparedRequest(psp, environment, requestUrl, method, body) {
11
- if (method.toUpperCase() !== 'POST')
65
+ if (method.toUpperCase() !== 'POST' || !validPreparationEnvironment(psp, environment))
12
66
  return false;
13
- if (psp === 'braintree') {
67
+ if (psp === 'braintree')
14
68
  return braintreeEnvironment(requestUrl) === environment && isPreparedBraintreeRequest(body ?? null);
15
- }
16
- if (psp !== 'square')
17
- return false;
18
69
  try {
19
70
  const request = new URL(requestUrl), endpoint = new URL(preparationEndpoint(psp, environment));
20
- return request.origin === endpoint.origin && request.pathname === endpoint.pathname && !request.username && !request.password;
71
+ if (request.username || request.password || request.hash)
72
+ return false;
73
+ if (psp === 'bambora') {
74
+ if (![endpoint.href, 'https://api.na.bambora.com/scripts/tokenization/tokens'].includes(requestUrl))
75
+ return false;
76
+ }
77
+ else if (psp === 'worldpay') {
78
+ if (requestUrl !== endpoint.href)
79
+ return false;
80
+ }
81
+ else if (request.origin !== endpoint.origin || request.pathname !== endpoint.pathname)
82
+ return false;
83
+ if (psp === 'mercado_pago') {
84
+ const names = [...request.searchParams.keys()];
85
+ if (new Set(names).size !== names.length || names.some(name => !['public_key', 'locale', 'js_version', 'referer'].includes(name)))
86
+ return false;
87
+ }
88
+ return psp === 'square' || freshCardBody(psp, body ?? null);
21
89
  }
22
90
  catch {
23
91
  return false;
package/dist/registry.js CHANGED
@@ -48,8 +48,12 @@ export const BUILTIN_REGISTRY = [
48
48
  },
49
49
  {
50
50
  psp: 'checkout_com',
51
- match: /api(\.sandbox)?\.checkout\.com\/tokens/i,
52
- hosts: ["^api\\.checkout\\.com$", "^api\\.sandbox\\.checkout\\.com$"],
51
+ // Flow's native card controller can use either tokenization host.
52
+ match: /(api|card-acquisition-gateway)(\.sandbox)?\.checkout\.com\/tokens/i,
53
+ hosts: [
54
+ "^api\\.checkout\\.com$", "^api\\.sandbox\\.checkout\\.com$",
55
+ "^card-acquisition-gateway\\.checkout\\.com$", "^card-acquisition-gateway\\.sandbox\\.checkout\\.com$",
56
+ ],
53
57
  encoding: 'json',
54
58
  passthroughHeaders: [/^authorization$/i],
55
59
  },
@@ -156,10 +160,10 @@ export const BUILTIN_REGISTRY = [
156
160
  },
157
161
  {
158
162
  psp: 'paysafe',
159
- match: /api(\.test)?\.paysafe\.com\/(?:paymenthub\/v1\/singleusepaymenthandles|js\/api\/v1\/tokenize)(?![\w\/-])/i,
160
- hosts: ["^api\\.paysafe\\.com$", "^api\\.test\\.paysafe\\.com$"],
163
+ match: /(?:api(\.test)?\.paysafe\.com\/(?:paymenthub\/v1\/singleusepaymenthandles|js\/api\/v1\/tokenize)|hosted(\.test)?\.paysafe\.com\/checkout\/api\/v1\/tokenize)(?![\w\/-])/i,
164
+ hosts: ["^api\\.paysafe\\.com$", "^api\\.test\\.paysafe\\.com$", "^hosted\\.paysafe\\.com$", "^hosted\\.test\\.paysafe\\.com$"],
161
165
  encoding: 'json',
162
- passthroughHeaders: [/^authorization$/i, /^x-paysafe-credentials$/i],
166
+ passthroughHeaders: [/^authorization$/i, /^x-paysafe-credentials$/i, /^correlationid$/i],
163
167
  },
164
168
  {
165
169
  psp: 'recurly',
@@ -211,7 +215,7 @@ export function findRecognizer(url, registry = BUILTIN_REGISTRY) {
211
215
  catch {
212
216
  return null;
213
217
  }
214
- if (parsed.protocol !== 'https:')
218
+ if (parsed.protocol !== 'https:' || parsed.port || parsed.username || parsed.password)
215
219
  return null;
216
220
  const hostPath = `${parsed.hostname.toLowerCase()}${parsed.pathname}`;
217
221
  return (registry.find((r) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-cards/checkout",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
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",
@@ -23,7 +23,7 @@
23
23
  "_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.",
24
24
  "build": "npx -y -p typescript@5.9.3 tsc",
25
25
  "prepublishOnly": "pnpm build",
26
- "test": "node test.mjs && node --test lifecycle.test.mjs merchant-abort.test.mjs preparation.test.mjs braintree.test.mjs minimum-delay.test.mjs",
26
+ "test": "node test.mjs && node --test lifecycle.test.mjs merchant-abort.test.mjs preparation.test.mjs braintree.test.mjs prepared-processor.test.mjs minimum-delay.test.mjs paysafe.test.mjs attachment.test.mjs",
27
27
  "test:browser": "node browser.test.mjs && node stripe-browser.test.mjs && node preparation-browser.test.mjs"
28
28
  },
29
29
  "keywords": [