@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 +12 -0
- package/README.md +27 -7
- package/dist/attachment.d.ts +11 -0
- package/dist/attachment.js +50 -0
- package/dist/braintree.d.ts +2 -0
- package/dist/braintree.js +12 -0
- package/dist/cdp.d.ts +2 -0
- package/dist/cdp.js +247 -154
- package/dist/client.d.ts +2 -2
- package/dist/client.js +2 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/preparation.js +2 -2
- package/dist/prepared-processor.d.ts +4 -2
- package/dist/prepared-processor.js +75 -7
- package/dist/registry.js +10 -6
- package/package.json +2 -2
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 |
|
|
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
|
-
|
|
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',
|
|
413
|
-
environment: 'production', //
|
|
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
|
|
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
|
+
}
|
package/dist/braintree.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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,
|
|
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
|
-
|
|
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', () => {
|
|
646
|
-
|
|
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
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
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
|
-
|
|
724
|
-
|
|
725
|
-
|
|
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
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
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
|
-
|
|
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
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
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 (!
|
|
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';
|
package/dist/preparation.js
CHANGED
|
@@ -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 || !
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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.
|
|
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": [
|