@agent-cards/checkout 0.3.0 → 0.4.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 +15 -0
- package/README.md +34 -5
- package/dist/cdp.js +162 -6
- package/dist/client.d.ts +60 -0
- package/dist/client.js +323 -113
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/lifecycle.d.ts +17 -3
- package/dist/lifecycle.js +44 -2
- package/dist/preparation.d.ts +25 -0
- package/dist/preparation.js +150 -0
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
- Add `controller.prepare({ psp: 'square', environment: 'production' | 'sandbox' })` to both browser adapters. The caller awaits cardholder consent and unlock before starting its first native Pay action. This requires the matching preparation API and Vault deployment.
|
|
6
|
+
- Bind one fresh request to the selected card, declared merchant, merchant origin, amount, currency and Square environment. Readiness expires after at most 30 seconds; preparation failure, expiry, cancellation, navigation and reuse fail closed. Binding creates no second approval link or SMS. Square token amounts remain display-only.
|
|
7
|
+
- Preserve native request deadlines and merchant-abort protections. The SDK never clicks Pay, changes Square timers or retries an abandoned prepared checkout. The post-submit relay and token handoff must still fit Square's native deadline; a disconnected or slow cardholder device can miss it.
|
|
8
|
+
- Recover a lost bind acknowledgement through preparation metadata for cancellation and reconciliation only. Started replay or unconfirmed cleanup remains unknown. A parent CDP page disconnect now also stops a pending request in a child iframe.
|
|
9
|
+
- Add preparation lifecycle tests and an isolated Chromium fixture that waits more than ten seconds before any card request, then permits one fresh request. These fixtures use no real processor and do not establish native Square or production checkout acceptance.
|
|
10
|
+
|
|
11
|
+
## 0.3.1
|
|
12
|
+
|
|
13
|
+
- Detect a merchant request abort or owning frame/page closure while approval is pending. The attachment holds an unknown outcome and blocks automatic retries; an expired request is never reported as an authorized handoff.
|
|
14
|
+
- Retire pending authorizations through the org-scoped cancellation endpoint when the merchant request disappears. Cancellation can win only before the processor-send boundary; started or completed replay remains unknown to this operation. A late creation response is drained so its authorization ID can be cancelled without exposing another approval link.
|
|
15
|
+
- Square token mode uses the vault's verified browser TLS relay. The relay keeps TLS termination and card plaintext on the cardholder device and Square. SDK 0.2.1 can complete a prompt approval but does not contain the native-timeout lifecycle fixes; upgrade to 0.3.1 for Square checkout.
|
|
16
|
+
- Square's native tokenization request still expires after about 10 seconds, including approval and token handoff. Delayed approval cannot complete that checkout; a later SCA challenge has its own lifetime after handoff. This release does not extend the deadline or retry a payment after timeout. Reconcile the merchant outcome and explicitly begin another checkout when required.
|
|
17
|
+
|
|
3
18
|
## 0.3.0
|
|
4
19
|
|
|
5
20
|
### Browser checkout lifecycle
|
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@ your agent ──drives──> merchant checkout
|
|
|
30
30
|
npm i @agent-cards/checkout
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
Upgrading from 0.2.x? Read the [
|
|
33
|
+
Upgrading from 0.2.x? Read the [migration notes](./CHANGELOG.md), especially
|
|
34
34
|
the unknown-outcome, cancellation and browser-context requirements.
|
|
35
35
|
|
|
36
36
|
## Use it
|
|
@@ -79,8 +79,11 @@ Stripe's minor units, so the approval screen, the notifications and every
|
|
|
79
79
|
read show one amount. Tokenization requests carry no amount, so there the
|
|
80
80
|
pair is shown and reported (`amountAuthority: 'display_only'`), not enforced.
|
|
81
81
|
|
|
82
|
-
Then let your agent click "Pay" like it always does. `attachToCdp`
|
|
83
|
-
request
|
|
82
|
+
Then let your agent click "Pay" like it always does. `attachToCdp` pauses the
|
|
83
|
+
request for approval and resumes it only while the merchant request remains
|
|
84
|
+
live. Merchant timeouts still apply: Square's observed tokenization deadline
|
|
85
|
+
is about 10 seconds for approval and token handoff. For human approval, use
|
|
86
|
+
`controller.prepare()` before the first Pay action as shown below.
|
|
84
87
|
|
|
85
88
|
Playwright:
|
|
86
89
|
|
|
@@ -389,6 +392,29 @@ do not reuse that token in a new attachment as a workaround. Configuration and
|
|
|
389
392
|
unsupported-mode failures require fixing the integration. Bank flows requiring
|
|
390
393
|
another confirmation and other stored-token chains remain unverified.
|
|
391
394
|
|
|
395
|
+
Square's observed native tokenization request expires after about 10 seconds. SDK 0.4.0 adds approval before submission, requiring the matching preparation API and Vault deployment. Start human approval before the caller's first Pay action:
|
|
396
|
+
|
|
397
|
+
```ts
|
|
398
|
+
const checkout = await attachToPlaywright(page, {
|
|
399
|
+
vault, user: 'your-user-id', merchant: 'Example merchant',
|
|
400
|
+
amountCents: 100, currency: 'USD',
|
|
401
|
+
onApprovalUrl: deliverPrivatelyToCardholder,
|
|
402
|
+
});
|
|
403
|
+
const preparation = await checkout.prepare({
|
|
404
|
+
psp: 'square',
|
|
405
|
+
environment: 'production', // explicit; use 'sandbox' for Square Sandbox
|
|
406
|
+
});
|
|
407
|
+
// The cardholder has consented and unlocked the same approval document.
|
|
408
|
+
// No processor request or payment has started.
|
|
409
|
+
await page.getByRole('button', { name: 'Pay', exact: true }).click();
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
`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 and Square environment bind one fresh request. The amount remains `display_only`; a Square token does not enforce the merchant's eventual charge amount.
|
|
413
|
+
|
|
414
|
+
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, pauses Square timers, or automatically retries a failed prepared checkout.
|
|
415
|
+
|
|
416
|
+
After Pay, Square's native deadline still covers fresh authorization binding, device replay, relay and token handoff. A disconnected/backgrounded phone or slow transport can still miss it. A subsequent SCA challenge has its own lifetime after token handoff. If the merchant request aborts or its frame closes, the attachment blocks further requests and tries to retire the pre-replay authorization; a started replay or unconfirmed cancellation remains unknown. Without `prepare()`, approval loading and human interaction still share the native deadline, so delayed approval cannot finish that request.
|
|
417
|
+
|
|
392
418
|
Lost authorization polling, local approval timeouts, or interrupted browser
|
|
393
419
|
handoffs produce `outcome_unknown` and block automatic retry. The thrown
|
|
394
420
|
`PaymentOutcomeUnknownError` carries `authorizationId` when creation was
|
|
@@ -434,8 +460,11 @@ pnpm test:browser
|
|
|
434
460
|
The browser fixtures never contact a payment service. The general suite uses
|
|
435
461
|
`psp.invalid`; the Stripe continuation suite forces `api.stripe.com` through an
|
|
436
462
|
allowlisted loopback proxy and a temporary self-signed TLS stub (requires the
|
|
437
|
-
`openssl` CLI). All other proxy destinations are rejected.
|
|
438
|
-
in-process Agentcard API fixture and loopback merchant pages.
|
|
463
|
+
`openssl` CLI). All other proxy destinations are rejected. These suites use an
|
|
464
|
+
in-process Agentcard API fixture and loopback merchant pages. The preparation
|
|
465
|
+
fixture denies all external traffic, waits more than ten seconds before any
|
|
466
|
+
card request, and then checks one fresh request with an unchanged ten-second
|
|
467
|
+
fixture abort timer. It exercises SDK ordering, not the native Square SDK. It proves nested-frame pause/resume, agent control during approval,
|
|
439
468
|
post-payment tasks in the same page, decline/expiry/cancel, unknown-outcome retry
|
|
440
469
|
blocking, explicit unsupported endpoint behavior, and blocking an immediate real-browser
|
|
441
470
|
Stripe token-to-intent fetch chain, including unrelated first intents, changed
|
package/dist/cdp.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { BUILTIN_REGISTRY, cardUrlPatterns } from './registry.js';
|
|
2
2
|
import { ApprovalDeclinedError, ApprovalTimeoutError, CardEncryptedError, CheckoutApiError, PaymentOutcomeUnknownError, UnsupportedModeError, redactUrl, } from './client.js';
|
|
3
|
+
import { PreparationGate } from './preparation.js';
|
|
3
4
|
import { substituteEncryptedFields } from './substitute.js';
|
|
4
5
|
import { hostedFormSubmittedPage } from './hosted-form.js';
|
|
5
6
|
import { CheckoutLifecycle, paymentEndpointGuards } from './lifecycle.js';
|
|
@@ -284,6 +285,22 @@ function failureSummary(error) {
|
|
|
284
285
|
return `${error.name}: ${error.code ?? `http_${error.status}`}`;
|
|
285
286
|
return error instanceof Error ? error.name : 'CheckoutError';
|
|
286
287
|
}
|
|
288
|
+
/** Separate from explicit cancellation: the client can drain a late create ID. */
|
|
289
|
+
function merchantAttempt(lifecycle) {
|
|
290
|
+
const controller = new AbortController();
|
|
291
|
+
const error = () => new PaymentOutcomeUnknownError(lifecycle.getState().authorizationId, 'merchant_request_aborted');
|
|
292
|
+
return {
|
|
293
|
+
signal: controller.signal,
|
|
294
|
+
stop() {
|
|
295
|
+
if (controller.signal.aborted)
|
|
296
|
+
return;
|
|
297
|
+
lifecycle.merchantRequestAborted();
|
|
298
|
+
controller.abort(error());
|
|
299
|
+
},
|
|
300
|
+
assertLive() { if (controller.signal.aborted)
|
|
301
|
+
throw error(); },
|
|
302
|
+
};
|
|
303
|
+
}
|
|
287
304
|
/**
|
|
288
305
|
* Take over card tokenization for a page.
|
|
289
306
|
*
|
|
@@ -304,6 +321,14 @@ function failureSummary(error) {
|
|
|
304
321
|
export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
305
322
|
opts = safeOptions(opts);
|
|
306
323
|
const lifecycle = new CheckoutLifecycle(opts);
|
|
324
|
+
let preparationFrameId;
|
|
325
|
+
const preparationGate = new PreparationGate(opts, lifecycle, async () => {
|
|
326
|
+
const tree = await cdp.send('Page.getFrameTree', {}, pageSessionId);
|
|
327
|
+
if (typeof tree?.frameTree?.frame?.url !== 'string')
|
|
328
|
+
throw new Error('merchant_document_unavailable');
|
|
329
|
+
preparationFrameId = tree.frameTree.frame.id;
|
|
330
|
+
return tree.frameTree.frame.url;
|
|
331
|
+
});
|
|
307
332
|
const guards = paymentEndpointGuards(opts.paymentEndpoints);
|
|
308
333
|
const armed = new Set();
|
|
309
334
|
// Set once a failure proves that retrying cannot help; see isTerminal.
|
|
@@ -313,6 +338,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
313
338
|
let quietUntil = 0;
|
|
314
339
|
// One outstanding approval at a time; see the note above isApprovalOutcome.
|
|
315
340
|
let awaitingApproval = false;
|
|
341
|
+
let activeRequest = null;
|
|
316
342
|
// The hosted form the cardholder already submitted; see HOSTED_FORM_REPEAT_QUIET_MS.
|
|
317
343
|
const repeatQuietMs = opts.hostedFormRepeatQuietMs ?? HOSTED_FORM_REPEAT_QUIET_MS;
|
|
318
344
|
let lastSubmitted = null;
|
|
@@ -323,6 +349,10 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
323
349
|
const key = sessionId ?? '__root__';
|
|
324
350
|
if (armed.has(key))
|
|
325
351
|
return;
|
|
352
|
+
// Fetch's interception ID differs from Network's ID. Enable failure events
|
|
353
|
+
// before intercepting and bind each attempt to both its ID and CDP session.
|
|
354
|
+
await cdp.send('Network.enable', {}, sessionId);
|
|
355
|
+
await cdp.send('Page.enable', {}, sessionId).catch(() => { });
|
|
326
356
|
await cdp.send('Fetch.enable', {
|
|
327
357
|
patterns: urlPatterns.map((urlPattern) => ({ urlPattern, requestStage: 'Request' })),
|
|
328
358
|
}, sessionId);
|
|
@@ -333,6 +363,30 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
333
363
|
armed.add(key);
|
|
334
364
|
};
|
|
335
365
|
cdp.on(async (method, params, sessionId) => {
|
|
366
|
+
if (((method === 'Page.frameNavigated' && !params.frame?.parentId) || (method === 'Page.navigatedWithinDocument' && preparationFrameId && params.frameId === preparationFrameId)) && sessionId === pageSessionId) {
|
|
367
|
+
preparationGate.invalidate('merchant_document_changed');
|
|
368
|
+
if (preparationGate.isEngaged())
|
|
369
|
+
activeRequest?.attempt.stop();
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if ((method === 'Inspector.detached' && sessionId === pageSessionId)
|
|
373
|
+
|| (method === 'Target.detachedFromTarget' && params.sessionId === pageSessionId)) {
|
|
374
|
+
preparationGate.invalidate('merchant_document_closed');
|
|
375
|
+
// The root page owns every attached OOPIF; its loss ends child requests too.
|
|
376
|
+
activeRequest?.attempt.stop();
|
|
377
|
+
}
|
|
378
|
+
if (method === 'Network.loadingFailed') {
|
|
379
|
+
if (activeRequest && activeRequest.networkId === params.requestId && activeRequest.sessionId === sessionId)
|
|
380
|
+
activeRequest.attempt.stop();
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (method === 'Target.detachedFromTarget' || method === 'Inspector.detached' || method === 'Page.frameDetached') {
|
|
384
|
+
if (activeRequest && (method === 'Target.detachedFromTarget' ? activeRequest.sessionId === params.sessionId
|
|
385
|
+
: method === 'Inspector.detached' ? activeRequest.sessionId === sessionId
|
|
386
|
+
: activeRequest.sessionId === sessionId && activeRequest.frameId === params.frameId))
|
|
387
|
+
activeRequest.attempt.stop();
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
336
390
|
if (method === 'Target.attachedToTarget') {
|
|
337
391
|
const child = params.sessionId;
|
|
338
392
|
try {
|
|
@@ -351,9 +405,10 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
351
405
|
}
|
|
352
406
|
if (method !== 'Fetch.requestPaused')
|
|
353
407
|
return;
|
|
354
|
-
const { requestId, request, resourceType } = params;
|
|
408
|
+
const { requestId, request, resourceType, networkId, frameId } = params;
|
|
355
409
|
if (!opts.vault.isCardRequest(request.url, request.method)) {
|
|
356
410
|
if (guards.matches(request.url, request.method)) {
|
|
411
|
+
preparationGate.invalidate('unsupported_checkout');
|
|
357
412
|
lifecycle.unsupported();
|
|
358
413
|
opts.onEvent?.({ type: 'unsupported_checkout', detail: { url: redactUrl(request.url), method: request.method } });
|
|
359
414
|
await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
|
|
@@ -362,11 +417,22 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
362
417
|
await cdp.send('Fetch.continueRequest', { requestId }, sessionId).catch(() => { });
|
|
363
418
|
return;
|
|
364
419
|
}
|
|
420
|
+
let preparation;
|
|
421
|
+
try {
|
|
422
|
+
preparation = preparationGate.claim(request.url);
|
|
423
|
+
}
|
|
424
|
+
catch (error) {
|
|
425
|
+
opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
|
|
426
|
+
await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
365
429
|
// Same stop condition as the Playwright adapter: once a failure proves
|
|
366
430
|
// retrying is pointless, fail the request without calling the API again.
|
|
367
431
|
if (terminal || lifecycle.isBlocked() || awaitingApproval || Date.now() < quietUntil) {
|
|
368
432
|
const why = terminal ?? (lifecycle.isBlocked() ? lifecycle.getState().status : awaitingApproval ? 'an approval is already outstanding' : 'awaiting approval cooldown');
|
|
369
433
|
opts.onEvent?.({ type: 'blocked', detail: why instanceof Error ? failureSummary(why) : String(why) });
|
|
434
|
+
if (preparation)
|
|
435
|
+
preparationGate.retireUnboundClaim();
|
|
370
436
|
await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
|
|
371
437
|
return;
|
|
372
438
|
}
|
|
@@ -374,6 +440,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
374
440
|
// requests can never both clear the check above and raise two prompts for
|
|
375
441
|
// one checkout.
|
|
376
442
|
awaitingApproval = true;
|
|
443
|
+
const attempt = merchantAttempt(lifecycle);
|
|
377
444
|
let handoffStarted = false;
|
|
378
445
|
try {
|
|
379
446
|
const body = pausedBody(request);
|
|
@@ -395,19 +462,33 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
395
462
|
}
|
|
396
463
|
opts.onEvent?.({ type: 'card_request_paused', detail: { url: redactUrl(request.url), ...(resourceType ? { resourceType } : {}) } });
|
|
397
464
|
lifecycle.begin();
|
|
465
|
+
if (typeof networkId !== 'string' || !networkId) {
|
|
466
|
+
attempt.stop();
|
|
467
|
+
attempt.assertLive();
|
|
468
|
+
}
|
|
469
|
+
activeRequest = { attempt, networkId, sessionId, frameId };
|
|
470
|
+
if (preparation)
|
|
471
|
+
await preparationGate.assertDocument();
|
|
472
|
+
attempt.assertLive();
|
|
398
473
|
const replay = await opts.vault.authorize({
|
|
399
474
|
user: opts.user,
|
|
400
475
|
merchant: opts.merchant,
|
|
401
476
|
amount: opts.amount,
|
|
402
477
|
amountCents: opts.amountCents,
|
|
403
478
|
currency: opts.currency,
|
|
404
|
-
cardId: opts.cardId,
|
|
479
|
+
cardId: preparation?.cardId ?? opts.cardId,
|
|
480
|
+
preparation,
|
|
405
481
|
timeoutMs: opts.timeoutMs,
|
|
406
482
|
signal: lifecycle.abort.signal,
|
|
483
|
+
merchantSignal: attempt.signal,
|
|
407
484
|
onAuthorizationCreated: (id) => lifecycle.approvalCreated(id),
|
|
408
|
-
onApprovalUrl: (url) => {
|
|
485
|
+
onApprovalUrl: (url) => { if (!preparation && !attempt.signal.aborted) {
|
|
486
|
+
lifecycle.approvalUrl(url);
|
|
487
|
+
return opts.onApprovalUrl?.(url);
|
|
488
|
+
} },
|
|
409
489
|
request: { url: request.url, method: request.method, headers: request.headers, body },
|
|
410
490
|
});
|
|
491
|
+
attempt.assertLive();
|
|
411
492
|
if (lifecycle.isCancelled())
|
|
412
493
|
throw new Error('checkout cancelled locally after approval');
|
|
413
494
|
lifecycle.prepareHandoff(replay, request.url);
|
|
@@ -427,6 +508,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
427
508
|
responseHeaders: headerEntries(withCorsHeaders(page.headers, corsHeadersFor(request.url, request.headers))),
|
|
428
509
|
body: Buffer.from(page.body).toString('base64'),
|
|
429
510
|
}, sessionId);
|
|
511
|
+
attempt.assertLive();
|
|
430
512
|
lastSubmitted = { url: request.url, body, at: Date.now() };
|
|
431
513
|
// Named for what it is: a device-attested submission with no
|
|
432
514
|
// processor evidence, never an `authorized` event.
|
|
@@ -444,6 +526,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
444
526
|
requestId,
|
|
445
527
|
postData,
|
|
446
528
|
}, sessionId);
|
|
529
|
+
attempt.assertLive();
|
|
447
530
|
opts.onEvent?.({ type: 'authorized', detail: { mode: 'cse', authorizationId: replay.authorizationId, fields: Object.keys(replay.substitutions.fields) } });
|
|
448
531
|
}
|
|
449
532
|
else {
|
|
@@ -460,11 +543,15 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
460
543
|
responseHeaders: headerEntries(withCorsHeaders(replay.headers, cors.headers)),
|
|
461
544
|
body: Buffer.from(replay.body).toString('base64'),
|
|
462
545
|
}, sessionId);
|
|
546
|
+
attempt.assertLive();
|
|
463
547
|
opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId, amountVerified: replay.amountVerified ?? null, cors: cors.outcome } });
|
|
464
548
|
}
|
|
465
549
|
lifecycle.handedOff(replay);
|
|
466
550
|
}
|
|
467
551
|
catch (err) {
|
|
552
|
+
// Ignore the failure event caused by our own safe decline/error abort.
|
|
553
|
+
if (activeRequest?.attempt === attempt)
|
|
554
|
+
activeRequest = null;
|
|
468
555
|
lifecycle.failed(err, handoffStarted);
|
|
469
556
|
if (isTerminal(err))
|
|
470
557
|
terminal = err;
|
|
@@ -474,8 +561,12 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
474
561
|
await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
|
|
475
562
|
}
|
|
476
563
|
finally {
|
|
564
|
+
if (activeRequest?.attempt === attempt)
|
|
565
|
+
activeRequest = null;
|
|
477
566
|
awaitingApproval = false;
|
|
478
567
|
lifecycle.end();
|
|
568
|
+
if (preparation)
|
|
569
|
+
preparationGate.retireUnboundClaim();
|
|
479
570
|
}
|
|
480
571
|
});
|
|
481
572
|
await arm(pageSessionId);
|
|
@@ -500,6 +591,11 @@ export async function attachToPlaywright(page, opts) {
|
|
|
500
591
|
throw new Error('Service workers are active; use a checkout context created with serviceWorkers: "block".');
|
|
501
592
|
}
|
|
502
593
|
const lifecycle = new CheckoutLifecycle(opts);
|
|
594
|
+
const preparationGate = new PreparationGate(opts, lifecycle, async () => {
|
|
595
|
+
if (page.isClosed?.() || typeof page.url !== 'function')
|
|
596
|
+
throw new Error('merchant_document_unavailable');
|
|
597
|
+
return page.url();
|
|
598
|
+
});
|
|
503
599
|
const guards = paymentEndpointGuards(opts.paymentEndpoints);
|
|
504
600
|
// Playwright's own routing, NOT a hand-rolled CDP session.
|
|
505
601
|
//
|
|
@@ -520,6 +616,24 @@ export async function attachToPlaywright(page, opts) {
|
|
|
520
616
|
let quietUntil = 0;
|
|
521
617
|
// One outstanding approval at a time; see the note above isApprovalOutcome.
|
|
522
618
|
let awaitingApproval = false;
|
|
619
|
+
let activeRequest = null;
|
|
620
|
+
page.on?.('requestfailed', (request) => {
|
|
621
|
+
if (activeRequest && activeRequest.request === request)
|
|
622
|
+
activeRequest.attempt.stop();
|
|
623
|
+
});
|
|
624
|
+
page.on?.('close', () => { preparationGate.invalidate('merchant_document_closed'); activeRequest?.attempt.stop(); });
|
|
625
|
+
page.on?.('crash', () => { preparationGate.invalidate('merchant_document_closed'); activeRequest?.attempt.stop(); });
|
|
626
|
+
page.on?.('framenavigated', (frame) => {
|
|
627
|
+
if (frame === page.mainFrame?.()) {
|
|
628
|
+
preparationGate.invalidate('merchant_document_changed');
|
|
629
|
+
if (preparationGate.isEngaged())
|
|
630
|
+
activeRequest?.attempt.stop();
|
|
631
|
+
}
|
|
632
|
+
});
|
|
633
|
+
page.on?.('framedetached', (frame) => {
|
|
634
|
+
if (activeRequest?.frames.includes(frame))
|
|
635
|
+
activeRequest.attempt.stop();
|
|
636
|
+
});
|
|
523
637
|
// The hosted form the cardholder already submitted; see HOSTED_FORM_REPEAT_QUIET_MS.
|
|
524
638
|
const repeatQuietMs = opts.hostedFormRepeatQuietMs ?? HOSTED_FORM_REPEAT_QUIET_MS;
|
|
525
639
|
let lastSubmitted = null;
|
|
@@ -529,12 +643,21 @@ export async function attachToPlaywright(page, opts) {
|
|
|
529
643
|
// untouched or the browser's CORS check fails on our synthetic answer.
|
|
530
644
|
if (!opts.vault.isCardRequest(request.url(), request.method())) {
|
|
531
645
|
if (guards.matches(request.url(), request.method())) {
|
|
646
|
+
preparationGate.invalidate('unsupported_checkout');
|
|
532
647
|
lifecycle.unsupported();
|
|
533
648
|
opts.onEvent?.({ type: 'unsupported_checkout', detail: { url: redactUrl(request.url()), method: request.method() } });
|
|
534
649
|
return route.abort('aborted');
|
|
535
650
|
}
|
|
536
651
|
return route.fallback();
|
|
537
652
|
}
|
|
653
|
+
let preparation;
|
|
654
|
+
try {
|
|
655
|
+
preparation = preparationGate.claim(request.url());
|
|
656
|
+
}
|
|
657
|
+
catch (error) {
|
|
658
|
+
opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
|
|
659
|
+
return route.abort('aborted');
|
|
660
|
+
}
|
|
538
661
|
// Fail closed and stay quiet: no card may reach the PSP, but neither may
|
|
539
662
|
// the page's retry loop turn into a stream of doomed API calls. Every
|
|
540
663
|
// abort in this adapter is 'aborted' (ERR_ABORTED), the same code the
|
|
@@ -543,10 +666,24 @@ export async function attachToPlaywright(page, opts) {
|
|
|
543
666
|
if (terminal || lifecycle.isBlocked() || awaitingApproval || Date.now() < quietUntil) {
|
|
544
667
|
const why = terminal ?? (lifecycle.isBlocked() ? lifecycle.getState().status : awaitingApproval ? 'an approval is already outstanding' : 'awaiting approval cooldown');
|
|
545
668
|
opts.onEvent?.({ type: 'blocked', detail: why instanceof Error ? failureSummary(why) : String(why) });
|
|
669
|
+
if (preparation)
|
|
670
|
+
preparationGate.retireUnboundClaim();
|
|
546
671
|
return route.abort('aborted');
|
|
547
672
|
}
|
|
548
673
|
// Reserved before anything that could yield, matching attachToCdp.
|
|
549
674
|
awaitingApproval = true;
|
|
675
|
+
const attempt = merchantAttempt(lifecycle);
|
|
676
|
+
const frames = [];
|
|
677
|
+
try {
|
|
678
|
+
for (let frame = request.frame?.(); frame; frame = frame.parentFrame?.())
|
|
679
|
+
frames.push(frame);
|
|
680
|
+
}
|
|
681
|
+
catch { /* requestfailed/page close still cover unavailable frame metadata */ }
|
|
682
|
+
const assertRequestLive = () => {
|
|
683
|
+
if (request.failure?.() || page.isClosed?.() || frames.some(frame => frame.isDetached?.()))
|
|
684
|
+
attempt.stop();
|
|
685
|
+
attempt.assertLive();
|
|
686
|
+
};
|
|
550
687
|
let handoffStarted = false;
|
|
551
688
|
try {
|
|
552
689
|
const body = request.postData() ?? '';
|
|
@@ -556,19 +693,29 @@ export async function attachToPlaywright(page, opts) {
|
|
|
556
693
|
}
|
|
557
694
|
opts.onEvent?.({ type: 'card_request_paused', detail: { url: redactUrl(request.url()) } });
|
|
558
695
|
lifecycle.begin();
|
|
696
|
+
activeRequest = { request, frames, attempt };
|
|
697
|
+
if (preparation)
|
|
698
|
+
await preparationGate.assertDocument();
|
|
699
|
+
assertRequestLive();
|
|
559
700
|
const replay = await opts.vault.authorize({
|
|
560
701
|
user: opts.user,
|
|
561
702
|
merchant: opts.merchant,
|
|
562
703
|
amount: opts.amount,
|
|
563
704
|
amountCents: opts.amountCents,
|
|
564
705
|
currency: opts.currency,
|
|
565
|
-
cardId: opts.cardId,
|
|
706
|
+
cardId: preparation?.cardId ?? opts.cardId,
|
|
707
|
+
preparation,
|
|
566
708
|
timeoutMs: opts.timeoutMs,
|
|
567
709
|
signal: lifecycle.abort.signal,
|
|
710
|
+
merchantSignal: attempt.signal,
|
|
568
711
|
onAuthorizationCreated: (id) => lifecycle.approvalCreated(id),
|
|
569
|
-
onApprovalUrl: (url) => {
|
|
712
|
+
onApprovalUrl: (url) => { if (!preparation && !attempt.signal.aborted) {
|
|
713
|
+
lifecycle.approvalUrl(url);
|
|
714
|
+
return opts.onApprovalUrl?.(url);
|
|
715
|
+
} },
|
|
570
716
|
request: { url: request.url(), method: request.method(), headers: request.headers(), body },
|
|
571
717
|
});
|
|
718
|
+
assertRequestLive();
|
|
572
719
|
if (lifecycle.isCancelled())
|
|
573
720
|
throw new Error('checkout cancelled locally after approval');
|
|
574
721
|
lifecycle.prepareHandoff(replay, request.url());
|
|
@@ -579,6 +726,7 @@ export async function attachToPlaywright(page, opts) {
|
|
|
579
726
|
const synthetic = hostedFormSubmittedPage({ authorizationId: replay.authorizationId, merchant: opts.merchant, submittedAt: replay.submittedAt });
|
|
580
727
|
// Inert on a navigation (never CORS-checked); one path for every fulfill.
|
|
581
728
|
await route.fulfill({ status: synthetic.status, headers: withCorsHeaders(synthetic.headers, corsHeadersFor(request.url(), request.headers())), body: synthetic.body });
|
|
729
|
+
assertRequestLive();
|
|
582
730
|
lastSubmitted = { url: request.url(), body, at: Date.now() };
|
|
583
731
|
opts.onEvent?.({ type: 'submitted_on_device', detail: { authorizationId: replay.authorizationId, submittedAt: replay.submittedAt, outcome: replay.outcome } });
|
|
584
732
|
}
|
|
@@ -589,6 +737,7 @@ export async function attachToPlaywright(page, opts) {
|
|
|
589
737
|
const postData = cseBody(body, replay);
|
|
590
738
|
handoffStarted = true;
|
|
591
739
|
await route.continue({ postData });
|
|
740
|
+
assertRequestLive();
|
|
592
741
|
opts.onEvent?.({ type: 'authorized', detail: { mode: 'cse', authorizationId: replay.authorizationId, fields: Object.keys(replay.substitutions.fields) } });
|
|
593
742
|
}
|
|
594
743
|
else {
|
|
@@ -597,22 +746,29 @@ export async function attachToPlaywright(page, opts) {
|
|
|
597
746
|
// cross-origin answer is the same whichever adapter ran.
|
|
598
747
|
const cors = corsDecision(request.url(), request.headers());
|
|
599
748
|
await route.fulfill({ status: replay.status, headers: withCorsHeaders(replay.headers, cors.headers), body: replay.body });
|
|
749
|
+
assertRequestLive();
|
|
600
750
|
opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId, amountVerified: replay.amountVerified ?? null, cors: cors.outcome } });
|
|
601
751
|
}
|
|
602
752
|
lifecycle.handedOff(replay);
|
|
603
753
|
}
|
|
604
754
|
catch (err) {
|
|
755
|
+
if (activeRequest?.attempt === attempt)
|
|
756
|
+
activeRequest = null;
|
|
605
757
|
lifecycle.failed(err, handoffStarted);
|
|
606
758
|
if (isTerminal(err))
|
|
607
759
|
terminal = err;
|
|
608
760
|
else if (isApprovalOutcome(err))
|
|
609
761
|
quietUntil = Date.now() + cooldownMs;
|
|
610
762
|
opts.onEvent?.({ type: 'failed', detail: failureSummary(err) });
|
|
611
|
-
await route.abort('aborted');
|
|
763
|
+
await route.abort('aborted').catch(() => { });
|
|
612
764
|
}
|
|
613
765
|
finally {
|
|
766
|
+
if (activeRequest?.attempt === attempt)
|
|
767
|
+
activeRequest = null;
|
|
614
768
|
awaitingApproval = false;
|
|
615
769
|
lifecycle.end();
|
|
770
|
+
if (preparation)
|
|
771
|
+
preparationGate.retireUnboundClaim();
|
|
616
772
|
}
|
|
617
773
|
});
|
|
618
774
|
return lifecycle;
|
package/dist/client.d.ts
CHANGED
|
@@ -98,6 +98,46 @@ export interface HostedFormReplay {
|
|
|
98
98
|
}
|
|
99
99
|
/** What authorize() resolves with; branch on `mode` (absent means token). */
|
|
100
100
|
export type ReplayResponse = TokenReplay | CseReplay | HostedFormReplay;
|
|
101
|
+
export interface PrepareCheckoutOptions {
|
|
102
|
+
psp: 'square';
|
|
103
|
+
/** The processor environment, independent of your Agentcard client's mode. */
|
|
104
|
+
environment: 'production' | 'sandbox';
|
|
105
|
+
signal?: AbortSignal;
|
|
106
|
+
}
|
|
107
|
+
export interface PrepareCheckoutInput extends PrepareCheckoutOptions {
|
|
108
|
+
user: string;
|
|
109
|
+
merchant: string;
|
|
110
|
+
amountCents: number;
|
|
111
|
+
currency: string;
|
|
112
|
+
cardId?: string;
|
|
113
|
+
merchantOrigin: string;
|
|
114
|
+
checkoutKey: string;
|
|
115
|
+
timeoutMs?: number;
|
|
116
|
+
onPreparationCreated?: (id: string) => void;
|
|
117
|
+
onApprovalUrl?: (url: string) => void;
|
|
118
|
+
}
|
|
119
|
+
/** Real user consent and an unlocked device; no processor request or payment yet. */
|
|
120
|
+
export interface PreparedCheckout {
|
|
121
|
+
readonly id: string;
|
|
122
|
+
readonly status: 'ready';
|
|
123
|
+
readonly psp: 'square';
|
|
124
|
+
readonly environment: 'production' | 'sandbox';
|
|
125
|
+
readonly expiresAt: string;
|
|
126
|
+
readonly cardId: string;
|
|
127
|
+
readonly user: string;
|
|
128
|
+
readonly merchant: string;
|
|
129
|
+
readonly amountCents: number;
|
|
130
|
+
readonly currency: string;
|
|
131
|
+
readonly merchantOrigin: string;
|
|
132
|
+
readonly checkoutKey: string;
|
|
133
|
+
readonly paymentStatus: 'not_started';
|
|
134
|
+
readonly amountAuthority: 'display_only';
|
|
135
|
+
}
|
|
136
|
+
export declare class CheckoutPreparationError extends Error {
|
|
137
|
+
preparationId: string | null;
|
|
138
|
+
reason: string;
|
|
139
|
+
constructor(preparationId: string | null, reason: string);
|
|
140
|
+
}
|
|
101
141
|
export interface AuthorizeInput {
|
|
102
142
|
/** Your identifier for the person whose card should pay. */
|
|
103
143
|
user: string;
|
|
@@ -135,10 +175,14 @@ export interface AuthorizeInput {
|
|
|
135
175
|
timeoutMs?: number;
|
|
136
176
|
/** Stops local polling; it does not revoke a pending approval or undo a payment. */
|
|
137
177
|
signal?: AbortSignal;
|
|
178
|
+
/** Adapter-owned merchant request lifetime. A failed request retires a pending approval before replay when possible. */
|
|
179
|
+
merchantSignal?: AbortSignal;
|
|
138
180
|
/** Called before onApprovalUrl; lets a runtime reconcile an interrupted authorization. */
|
|
139
181
|
onAuthorizationCreated?: (authorizationId: string) => void;
|
|
140
182
|
/** Called once with the URL to surface to the user, if you deliver it yourself. */
|
|
141
183
|
onApprovalUrl?: (url: string) => void;
|
|
184
|
+
/** One-use preparation returned by this client. Never resumes an older request. */
|
|
185
|
+
preparation?: PreparedCheckout;
|
|
142
186
|
}
|
|
143
187
|
export declare class CardEncryptedError extends Error {
|
|
144
188
|
psp: string;
|
|
@@ -313,6 +357,8 @@ export declare class VaultClient {
|
|
|
313
357
|
private readonly pollIntervalMs;
|
|
314
358
|
private readonly unverifiableRetryDelaysMs;
|
|
315
359
|
private registry;
|
|
360
|
+
private readonly preparations;
|
|
361
|
+
private readonly usedPreparations;
|
|
316
362
|
constructor(opts: VaultClientOptions);
|
|
317
363
|
/** Refresh recognizers from the API so new PSPs work without a redeploy. */
|
|
318
364
|
syncRegistry(): Promise<void>;
|
|
@@ -330,12 +376,26 @@ export declare class VaultClient {
|
|
|
330
376
|
* these patterns pause.
|
|
331
377
|
*/
|
|
332
378
|
cardUrlPatterns(): string[];
|
|
379
|
+
/** Wait for real device approval before the caller starts native tokenization. */
|
|
380
|
+
prepareCheckout(input: PrepareCheckoutInput): Promise<PreparedCheckout>;
|
|
381
|
+
/** Cancel only an unconsumed preparation; a bound request is reconciled separately. */
|
|
382
|
+
cancelPreparation(id: string): Promise<void>;
|
|
333
383
|
/**
|
|
334
384
|
* Hand us a paused tokenization request. We ask the cardholder to approve,
|
|
335
385
|
* their device supplies the card and calls the merchant, and you get back the
|
|
336
386
|
* response to replay into the browser. Your process never sees a card.
|
|
337
387
|
*/
|
|
338
388
|
authorize(input: AuthorizeInput): Promise<ReplayResponse>;
|
|
389
|
+
/** A lost bind acknowledgement must never resume the request. Recover metadata only for safe cleanup. */
|
|
390
|
+
private retireUncertainPreparation;
|
|
391
|
+
/** Retire only a pre-replay authorization. A 409 or missing response remains unknown. */
|
|
392
|
+
cancelAuthorization(authorizationId: string): Promise<{
|
|
393
|
+
id: string;
|
|
394
|
+
status: 'declined';
|
|
395
|
+
reason: 'merchant_request_aborted';
|
|
396
|
+
cancelled: true;
|
|
397
|
+
processor_request_started: false;
|
|
398
|
+
}>;
|
|
339
399
|
/**
|
|
340
400
|
* POST the create, with two typed twists: a 502 `amount_unverifiable`
|
|
341
401
|
* (Stripe did not answer the read-back) is retried on a short backoff
|