@agent-cards/checkout 0.2.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/README.md +183 -10
- package/dist/cdp.d.ts +10 -5
- package/dist/cdp.js +187 -21
- package/dist/client.d.ts +26 -4
- package/dist/client.js +242 -98
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/dist/lifecycle.d.ts +94 -0
- package/dist/lifecycle.js +207 -0
- package/examples/existing-browser.mjs +63 -0
- package/package.json +6 -3
package/dist/cdp.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { BUILTIN_REGISTRY, cardUrlPatterns } from './registry.js';
|
|
2
|
-
import { ApprovalDeclinedError, ApprovalTimeoutError, CardEncryptedError, CheckoutApiError, UnsupportedModeError, redactUrl, } from './client.js';
|
|
2
|
+
import { ApprovalDeclinedError, ApprovalTimeoutError, CardEncryptedError, CheckoutApiError, PaymentOutcomeUnknownError, UnsupportedModeError, redactUrl, } from './client.js';
|
|
3
3
|
import { substituteEncryptedFields } from './substitute.js';
|
|
4
4
|
import { hostedFormSubmittedPage } from './hosted-form.js';
|
|
5
|
+
import { CheckoutLifecycle, paymentEndpointGuards } from './lifecycle.js';
|
|
5
6
|
// Every URL that leaves this module through onEvent is redacted to origin +
|
|
6
7
|
// path first. A paused PaymentIntent confirm can carry the client secret in
|
|
7
8
|
// its query string (Stripe.js puts it in the body; hand-rolled runtimes and
|
|
@@ -270,6 +271,35 @@ function headerEntries(headers) {
|
|
|
270
271
|
* that drifts from the registry is exactly the bug this adapter used to have.
|
|
271
272
|
*/
|
|
272
273
|
const FALLBACK_CARD_PATTERNS = cardUrlPatterns(BUILTIN_REGISTRY);
|
|
274
|
+
/** Observer exceptions and server error envelopes must not interrupt or leak a processor handoff. */
|
|
275
|
+
function safeOptions(opts) {
|
|
276
|
+
const observer = opts.onEvent;
|
|
277
|
+
return { ...opts, onEvent: (event) => { try {
|
|
278
|
+
Promise.resolve(observer?.(event)).catch(() => { });
|
|
279
|
+
}
|
|
280
|
+
catch { /* observer only */ } } };
|
|
281
|
+
}
|
|
282
|
+
function failureSummary(error) {
|
|
283
|
+
if (error instanceof CheckoutApiError)
|
|
284
|
+
return `${error.name}: ${error.code ?? `http_${error.status}`}`;
|
|
285
|
+
return error instanceof Error ? error.name : 'CheckoutError';
|
|
286
|
+
}
|
|
287
|
+
/** Separate from explicit cancellation: the client can drain a late create ID. */
|
|
288
|
+
function merchantAttempt(lifecycle) {
|
|
289
|
+
const controller = new AbortController();
|
|
290
|
+
const error = () => new PaymentOutcomeUnknownError(lifecycle.getState().authorizationId, 'merchant_request_aborted');
|
|
291
|
+
return {
|
|
292
|
+
signal: controller.signal,
|
|
293
|
+
stop() {
|
|
294
|
+
if (controller.signal.aborted)
|
|
295
|
+
return;
|
|
296
|
+
lifecycle.merchantRequestAborted();
|
|
297
|
+
controller.abort(error());
|
|
298
|
+
},
|
|
299
|
+
assertLive() { if (controller.signal.aborted)
|
|
300
|
+
throw error(); },
|
|
301
|
+
};
|
|
302
|
+
}
|
|
273
303
|
/**
|
|
274
304
|
* Take over card tokenization for a page.
|
|
275
305
|
*
|
|
@@ -288,6 +318,9 @@ const FALLBACK_CARD_PATTERNS = cardUrlPatterns(BUILTIN_REGISTRY);
|
|
|
288
318
|
* armed identically.
|
|
289
319
|
*/
|
|
290
320
|
export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
321
|
+
opts = safeOptions(opts);
|
|
322
|
+
const lifecycle = new CheckoutLifecycle(opts);
|
|
323
|
+
const guards = paymentEndpointGuards(opts.paymentEndpoints);
|
|
291
324
|
const armed = new Set();
|
|
292
325
|
// Set once a failure proves that retrying cannot help; see isTerminal.
|
|
293
326
|
let terminal = null;
|
|
@@ -296,45 +329,77 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
296
329
|
let quietUntil = 0;
|
|
297
330
|
// One outstanding approval at a time; see the note above isApprovalOutcome.
|
|
298
331
|
let awaitingApproval = false;
|
|
332
|
+
let activeRequest = null;
|
|
299
333
|
// The hosted form the cardholder already submitted; see HOSTED_FORM_REPEAT_QUIET_MS.
|
|
300
334
|
const repeatQuietMs = opts.hostedFormRepeatQuietMs ?? HOSTED_FORM_REPEAT_QUIET_MS;
|
|
301
335
|
let lastSubmitted = null;
|
|
302
336
|
const derived = typeof opts.vault?.cardUrlPatterns === 'function' ? opts.vault.cardUrlPatterns() : [];
|
|
303
|
-
const urlPatterns = derived.length > 0 ? derived : FALLBACK_CARD_PATTERNS;
|
|
337
|
+
const urlPatterns = [...new Set([...(derived.length > 0 ? derived : FALLBACK_CARD_PATTERNS), ...guards.patterns])];
|
|
304
338
|
opts.onEvent?.({ type: 'fetch_armed', detail: { patterns: urlPatterns } });
|
|
305
339
|
const arm = async (sessionId) => {
|
|
306
340
|
const key = sessionId ?? '__root__';
|
|
307
341
|
if (armed.has(key))
|
|
308
342
|
return;
|
|
309
|
-
|
|
343
|
+
// Fetch's interception ID differs from Network's ID. Enable failure events
|
|
344
|
+
// before intercepting and bind each attempt to both its ID and CDP session.
|
|
345
|
+
await cdp.send('Network.enable', {}, sessionId);
|
|
346
|
+
await cdp.send('Page.enable', {}, sessionId).catch(() => { });
|
|
310
347
|
await cdp.send('Fetch.enable', {
|
|
311
348
|
patterns: urlPatterns.map((urlPattern) => ({ urlPattern, requestStage: 'Request' })),
|
|
312
|
-
}, sessionId)
|
|
349
|
+
}, sessionId);
|
|
313
350
|
// Descend into this target's own children (iframes inside iframes).
|
|
314
351
|
await cdp.send('Target.setAutoAttach', {
|
|
315
352
|
autoAttach: true, waitForDebuggerOnStart: true, flatten: true,
|
|
316
|
-
}, sessionId)
|
|
353
|
+
}, sessionId);
|
|
354
|
+
armed.add(key);
|
|
317
355
|
};
|
|
318
356
|
cdp.on(async (method, params, sessionId) => {
|
|
357
|
+
if (method === 'Network.loadingFailed') {
|
|
358
|
+
if (activeRequest && activeRequest.networkId === params.requestId && activeRequest.sessionId === sessionId)
|
|
359
|
+
activeRequest.attempt.stop();
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (method === 'Target.detachedFromTarget' || method === 'Inspector.detached' || method === 'Page.frameDetached') {
|
|
363
|
+
if (activeRequest && (method === 'Target.detachedFromTarget' ? activeRequest.sessionId === params.sessionId
|
|
364
|
+
: method === 'Inspector.detached' ? activeRequest.sessionId === sessionId
|
|
365
|
+
: activeRequest.sessionId === sessionId && activeRequest.frameId === params.frameId))
|
|
366
|
+
activeRequest.attempt.stop();
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
319
369
|
if (method === 'Target.attachedToTarget') {
|
|
320
370
|
const child = params.sessionId;
|
|
321
|
-
|
|
371
|
+
try {
|
|
372
|
+
await arm(child);
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
terminal = new Error('browser_interception_unavailable');
|
|
376
|
+
lifecycle.failed(new PaymentOutcomeUnknownError(lifecycle.getState().authorizationId, 'browser_interception_unavailable'));
|
|
377
|
+
opts.onEvent?.({ type: 'failed', detail: 'browser_interception_unavailable' });
|
|
378
|
+
// Leave this target paused: resuming an unarmed card frame would silently bypass the vault.
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
322
381
|
// Child targets start paused when waitForDebuggerOnStart is set.
|
|
323
382
|
await cdp.send('Runtime.runIfWaitingForDebugger', {}, child).catch(() => { });
|
|
324
383
|
return;
|
|
325
384
|
}
|
|
326
385
|
if (method !== 'Fetch.requestPaused')
|
|
327
386
|
return;
|
|
328
|
-
const { requestId, request, resourceType } = params;
|
|
387
|
+
const { requestId, request, resourceType, networkId, frameId } = params;
|
|
329
388
|
if (!opts.vault.isCardRequest(request.url, request.method)) {
|
|
389
|
+
if (guards.matches(request.url, request.method)) {
|
|
390
|
+
lifecycle.unsupported();
|
|
391
|
+
opts.onEvent?.({ type: 'unsupported_checkout', detail: { url: redactUrl(request.url), method: request.method } });
|
|
392
|
+
await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
330
395
|
await cdp.send('Fetch.continueRequest', { requestId }, sessionId).catch(() => { });
|
|
331
396
|
return;
|
|
332
397
|
}
|
|
333
398
|
// Same stop condition as the Playwright adapter: once a failure proves
|
|
334
399
|
// retrying is pointless, fail the request without calling the API again.
|
|
335
|
-
if (terminal || awaitingApproval || Date.now() < quietUntil) {
|
|
336
|
-
const why = terminal ?? (awaitingApproval ? 'an approval is already outstanding' : 'awaiting approval cooldown');
|
|
337
|
-
opts.onEvent?.({ type: 'blocked', detail: String(why) });
|
|
400
|
+
if (terminal || lifecycle.isBlocked() || awaitingApproval || Date.now() < quietUntil) {
|
|
401
|
+
const why = terminal ?? (lifecycle.isBlocked() ? lifecycle.getState().status : awaitingApproval ? 'an approval is already outstanding' : 'awaiting approval cooldown');
|
|
402
|
+
opts.onEvent?.({ type: 'blocked', detail: why instanceof Error ? failureSummary(why) : String(why) });
|
|
338
403
|
await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
|
|
339
404
|
return;
|
|
340
405
|
}
|
|
@@ -342,6 +407,8 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
342
407
|
// requests can never both clear the check above and raise two prompts for
|
|
343
408
|
// one checkout.
|
|
344
409
|
awaitingApproval = true;
|
|
410
|
+
const attempt = merchantAttempt(lifecycle);
|
|
411
|
+
let handoffStarted = false;
|
|
345
412
|
try {
|
|
346
413
|
const body = pausedBody(request);
|
|
347
414
|
if (body === null) {
|
|
@@ -361,15 +428,34 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
361
428
|
return;
|
|
362
429
|
}
|
|
363
430
|
opts.onEvent?.({ type: 'card_request_paused', detail: { url: redactUrl(request.url), ...(resourceType ? { resourceType } : {}) } });
|
|
431
|
+
lifecycle.begin();
|
|
432
|
+
if (typeof networkId !== 'string' || !networkId) {
|
|
433
|
+
attempt.stop();
|
|
434
|
+
attempt.assertLive();
|
|
435
|
+
}
|
|
436
|
+
activeRequest = { attempt, networkId, sessionId, frameId };
|
|
364
437
|
const replay = await opts.vault.authorize({
|
|
365
438
|
user: opts.user,
|
|
366
439
|
merchant: opts.merchant,
|
|
367
440
|
amount: opts.amount,
|
|
368
441
|
amountCents: opts.amountCents,
|
|
369
442
|
currency: opts.currency,
|
|
370
|
-
|
|
443
|
+
cardId: opts.cardId,
|
|
444
|
+
timeoutMs: opts.timeoutMs,
|
|
445
|
+
signal: lifecycle.abort.signal,
|
|
446
|
+
merchantSignal: attempt.signal,
|
|
447
|
+
onAuthorizationCreated: (id) => lifecycle.approvalCreated(id),
|
|
448
|
+
onApprovalUrl: (url) => { if (!attempt.signal.aborted) {
|
|
449
|
+
lifecycle.approvalUrl(url);
|
|
450
|
+
return opts.onApprovalUrl?.(url);
|
|
451
|
+
} },
|
|
371
452
|
request: { url: request.url, method: request.method, headers: request.headers, body },
|
|
372
453
|
});
|
|
454
|
+
attempt.assertLive();
|
|
455
|
+
if (lifecycle.isCancelled())
|
|
456
|
+
throw new Error('checkout cancelled locally after approval');
|
|
457
|
+
lifecycle.prepareHandoff(replay, request.url);
|
|
458
|
+
handoffStarted = replay.mode !== 'cse';
|
|
373
459
|
if (replay.mode === 'hosted_form') {
|
|
374
460
|
// The device submitted the processor's own form; the processor
|
|
375
461
|
// answered the device. The paused NAVIGATION is fulfilled with a page
|
|
@@ -385,6 +471,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
385
471
|
responseHeaders: headerEntries(withCorsHeaders(page.headers, corsHeadersFor(request.url, request.headers))),
|
|
386
472
|
body: Buffer.from(page.body).toString('base64'),
|
|
387
473
|
}, sessionId);
|
|
474
|
+
attempt.assertLive();
|
|
388
475
|
lastSubmitted = { url: request.url, body, at: Date.now() };
|
|
389
476
|
// Named for what it is: a device-attested submission with no
|
|
390
477
|
// processor evidence, never an `authorized` event.
|
|
@@ -396,10 +483,13 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
396
483
|
// and cookies, and only the four ciphertext fields swapped in. Only
|
|
397
484
|
// postData rides on the command: no header override, ever (see
|
|
398
485
|
// cseBody for why a recomputed Content-Length is refused by Chromium).
|
|
486
|
+
const postData = Buffer.from(cseBody(body, replay)).toString('base64');
|
|
487
|
+
handoffStarted = true;
|
|
399
488
|
await cdp.send('Fetch.continueRequest', {
|
|
400
489
|
requestId,
|
|
401
|
-
postData
|
|
490
|
+
postData,
|
|
402
491
|
}, sessionId);
|
|
492
|
+
attempt.assertLive();
|
|
403
493
|
opts.onEvent?.({ type: 'authorized', detail: { mode: 'cse', authorizationId: replay.authorizationId, fields: Object.keys(replay.substitutions.fields) } });
|
|
404
494
|
}
|
|
405
495
|
else {
|
|
@@ -416,22 +506,32 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
416
506
|
responseHeaders: headerEntries(withCorsHeaders(replay.headers, cors.headers)),
|
|
417
507
|
body: Buffer.from(replay.body).toString('base64'),
|
|
418
508
|
}, sessionId);
|
|
509
|
+
attempt.assertLive();
|
|
419
510
|
opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId, amountVerified: replay.amountVerified ?? null, cors: cors.outcome } });
|
|
420
511
|
}
|
|
512
|
+
lifecycle.handedOff(replay);
|
|
421
513
|
}
|
|
422
514
|
catch (err) {
|
|
515
|
+
// Ignore the failure event caused by our own safe decline/error abort.
|
|
516
|
+
if (activeRequest?.attempt === attempt)
|
|
517
|
+
activeRequest = null;
|
|
518
|
+
lifecycle.failed(err, handoffStarted);
|
|
423
519
|
if (isTerminal(err))
|
|
424
520
|
terminal = err;
|
|
425
521
|
else if (isApprovalOutcome(err))
|
|
426
522
|
quietUntil = Date.now() + cooldownMs;
|
|
427
|
-
opts.onEvent?.({ type: 'failed', detail:
|
|
523
|
+
opts.onEvent?.({ type: 'failed', detail: failureSummary(err) });
|
|
428
524
|
await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
|
|
429
525
|
}
|
|
430
526
|
finally {
|
|
527
|
+
if (activeRequest?.attempt === attempt)
|
|
528
|
+
activeRequest = null;
|
|
431
529
|
awaitingApproval = false;
|
|
530
|
+
lifecycle.end();
|
|
432
531
|
}
|
|
433
532
|
});
|
|
434
533
|
await arm(pageSessionId);
|
|
534
|
+
return lifecycle;
|
|
435
535
|
}
|
|
436
536
|
/**
|
|
437
537
|
* Playwright convenience wrapper — the path for cloud browsers that hand you a
|
|
@@ -445,6 +545,14 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
445
545
|
* the note in the body for why a hand-rolled CDPSession does not work here.
|
|
446
546
|
*/
|
|
447
547
|
export async function attachToPlaywright(page, opts) {
|
|
548
|
+
opts = safeOptions(opts);
|
|
549
|
+
// Routing cannot see requests owned by a service worker. Existing controlled
|
|
550
|
+
// contexts must be recreated with serviceWorkers: 'block' before checkout.
|
|
551
|
+
if (page.context?.().serviceWorkers?.().length) {
|
|
552
|
+
throw new Error('Service workers are active; use a checkout context created with serviceWorkers: "block".');
|
|
553
|
+
}
|
|
554
|
+
const lifecycle = new CheckoutLifecycle(opts);
|
|
555
|
+
const guards = paymentEndpointGuards(opts.paymentEndpoints);
|
|
448
556
|
// Playwright's own routing, NOT a hand-rolled CDP session.
|
|
449
557
|
//
|
|
450
558
|
// A CDPSession from `newCDPSession(page)` is bound to the PAGE target and its
|
|
@@ -464,14 +572,30 @@ export async function attachToPlaywright(page, opts) {
|
|
|
464
572
|
let quietUntil = 0;
|
|
465
573
|
// One outstanding approval at a time; see the note above isApprovalOutcome.
|
|
466
574
|
let awaitingApproval = false;
|
|
575
|
+
let activeRequest = null;
|
|
576
|
+
page.on?.('requestfailed', (request) => {
|
|
577
|
+
if (activeRequest && activeRequest.request === request)
|
|
578
|
+
activeRequest.attempt.stop();
|
|
579
|
+
});
|
|
580
|
+
page.on?.('close', () => activeRequest?.attempt.stop());
|
|
581
|
+
page.on?.('crash', () => activeRequest?.attempt.stop());
|
|
582
|
+
page.on?.('framedetached', (frame) => {
|
|
583
|
+
if (activeRequest?.frames.includes(frame))
|
|
584
|
+
activeRequest.attempt.stop();
|
|
585
|
+
});
|
|
467
586
|
// The hosted form the cardholder already submitted; see HOSTED_FORM_REPEAT_QUIET_MS.
|
|
468
587
|
const repeatQuietMs = opts.hostedFormRepeatQuietMs ?? HOSTED_FORM_REPEAT_QUIET_MS;
|
|
469
588
|
let lastSubmitted = null;
|
|
470
|
-
await page.route((url) => opts.vault.isCardRequest(url.toString()), async (route) => {
|
|
589
|
+
await page.route((url) => opts.vault.isCardRequest(url.toString()) || guards.matches(url.toString()), async (route) => {
|
|
471
590
|
const request = route.request();
|
|
472
591
|
// The matcher only sees the URL; a preflight or a GET must pass through
|
|
473
592
|
// untouched or the browser's CORS check fails on our synthetic answer.
|
|
474
593
|
if (!opts.vault.isCardRequest(request.url(), request.method())) {
|
|
594
|
+
if (guards.matches(request.url(), request.method())) {
|
|
595
|
+
lifecycle.unsupported();
|
|
596
|
+
opts.onEvent?.({ type: 'unsupported_checkout', detail: { url: redactUrl(request.url()), method: request.method() } });
|
|
597
|
+
return route.abort('aborted');
|
|
598
|
+
}
|
|
475
599
|
return route.fallback();
|
|
476
600
|
}
|
|
477
601
|
// Fail closed and stay quiet: no card may reach the PSP, but neither may
|
|
@@ -479,13 +603,26 @@ export async function attachToPlaywright(page, opts) {
|
|
|
479
603
|
// abort in this adapter is 'aborted' (ERR_ABORTED), the same code the
|
|
480
604
|
// CDP adapter's Fetch.failRequest uses, so a refused navigation
|
|
481
605
|
// resolves identically whichever adapter is attached.
|
|
482
|
-
if (terminal || awaitingApproval || Date.now() < quietUntil) {
|
|
483
|
-
const why = terminal ?? (awaitingApproval ? 'an approval is already outstanding' : 'awaiting approval cooldown');
|
|
484
|
-
opts.onEvent?.({ type: 'blocked', detail: String(why) });
|
|
606
|
+
if (terminal || lifecycle.isBlocked() || awaitingApproval || Date.now() < quietUntil) {
|
|
607
|
+
const why = terminal ?? (lifecycle.isBlocked() ? lifecycle.getState().status : awaitingApproval ? 'an approval is already outstanding' : 'awaiting approval cooldown');
|
|
608
|
+
opts.onEvent?.({ type: 'blocked', detail: why instanceof Error ? failureSummary(why) : String(why) });
|
|
485
609
|
return route.abort('aborted');
|
|
486
610
|
}
|
|
487
611
|
// Reserved before anything that could yield, matching attachToCdp.
|
|
488
612
|
awaitingApproval = true;
|
|
613
|
+
const attempt = merchantAttempt(lifecycle);
|
|
614
|
+
const frames = [];
|
|
615
|
+
try {
|
|
616
|
+
for (let frame = request.frame?.(); frame; frame = frame.parentFrame?.())
|
|
617
|
+
frames.push(frame);
|
|
618
|
+
}
|
|
619
|
+
catch { /* requestfailed/page close still cover unavailable frame metadata */ }
|
|
620
|
+
const assertRequestLive = () => {
|
|
621
|
+
if (request.failure?.() || page.isClosed?.() || frames.some(frame => frame.isDetached?.()))
|
|
622
|
+
attempt.stop();
|
|
623
|
+
attempt.assertLive();
|
|
624
|
+
};
|
|
625
|
+
let handoffStarted = false;
|
|
489
626
|
try {
|
|
490
627
|
const body = request.postData() ?? '';
|
|
491
628
|
if (isRepeatOfSubmitted(lastSubmitted, request.url(), body, repeatQuietMs)) {
|
|
@@ -493,21 +630,38 @@ export async function attachToPlaywright(page, opts) {
|
|
|
493
630
|
return await route.abort('aborted');
|
|
494
631
|
}
|
|
495
632
|
opts.onEvent?.({ type: 'card_request_paused', detail: { url: redactUrl(request.url()) } });
|
|
633
|
+
lifecycle.begin();
|
|
634
|
+
activeRequest = { request, frames, attempt };
|
|
635
|
+
assertRequestLive();
|
|
496
636
|
const replay = await opts.vault.authorize({
|
|
497
637
|
user: opts.user,
|
|
498
638
|
merchant: opts.merchant,
|
|
499
639
|
amount: opts.amount,
|
|
500
640
|
amountCents: opts.amountCents,
|
|
501
641
|
currency: opts.currency,
|
|
502
|
-
|
|
642
|
+
cardId: opts.cardId,
|
|
643
|
+
timeoutMs: opts.timeoutMs,
|
|
644
|
+
signal: lifecycle.abort.signal,
|
|
645
|
+
merchantSignal: attempt.signal,
|
|
646
|
+
onAuthorizationCreated: (id) => lifecycle.approvalCreated(id),
|
|
647
|
+
onApprovalUrl: (url) => { if (!attempt.signal.aborted) {
|
|
648
|
+
lifecycle.approvalUrl(url);
|
|
649
|
+
return opts.onApprovalUrl?.(url);
|
|
650
|
+
} },
|
|
503
651
|
request: { url: request.url(), method: request.method(), headers: request.headers(), body },
|
|
504
652
|
});
|
|
653
|
+
assertRequestLive();
|
|
654
|
+
if (lifecycle.isCancelled())
|
|
655
|
+
throw new Error('checkout cancelled locally after approval');
|
|
656
|
+
lifecycle.prepareHandoff(replay, request.url());
|
|
657
|
+
handoffStarted = replay.mode !== 'cse';
|
|
505
658
|
if (replay.mode === 'hosted_form') {
|
|
506
659
|
// Same as the CDP path: the paused navigation resolves to the
|
|
507
660
|
// synthetic page, and a re-post of this form is refused.
|
|
508
661
|
const synthetic = hostedFormSubmittedPage({ authorizationId: replay.authorizationId, merchant: opts.merchant, submittedAt: replay.submittedAt });
|
|
509
662
|
// Inert on a navigation (never CORS-checked); one path for every fulfill.
|
|
510
663
|
await route.fulfill({ status: synthetic.status, headers: withCorsHeaders(synthetic.headers, corsHeadersFor(request.url(), request.headers())), body: synthetic.body });
|
|
664
|
+
assertRequestLive();
|
|
511
665
|
lastSubmitted = { url: request.url(), body, at: Date.now() };
|
|
512
666
|
opts.onEvent?.({ type: 'submitted_on_device', detail: { authorizationId: replay.authorizationId, submittedAt: replay.submittedAt, outcome: replay.outcome } });
|
|
513
667
|
}
|
|
@@ -515,7 +669,10 @@ export async function attachToPlaywright(page, opts) {
|
|
|
515
669
|
// Same as the CDP path: the request continues from this browser
|
|
516
670
|
// with the ciphertext swapped in and no header override; Playwright
|
|
517
671
|
// recomputes the length itself.
|
|
518
|
-
|
|
672
|
+
const postData = cseBody(body, replay);
|
|
673
|
+
handoffStarted = true;
|
|
674
|
+
await route.continue({ postData });
|
|
675
|
+
assertRequestLive();
|
|
519
676
|
opts.onEvent?.({ type: 'authorized', detail: { mode: 'cse', authorizationId: replay.authorizationId, fields: Object.keys(replay.substitutions.fields) } });
|
|
520
677
|
}
|
|
521
678
|
else {
|
|
@@ -524,19 +681,28 @@ export async function attachToPlaywright(page, opts) {
|
|
|
524
681
|
// cross-origin answer is the same whichever adapter ran.
|
|
525
682
|
const cors = corsDecision(request.url(), request.headers());
|
|
526
683
|
await route.fulfill({ status: replay.status, headers: withCorsHeaders(replay.headers, cors.headers), body: replay.body });
|
|
684
|
+
assertRequestLive();
|
|
527
685
|
opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId, amountVerified: replay.amountVerified ?? null, cors: cors.outcome } });
|
|
528
686
|
}
|
|
687
|
+
lifecycle.handedOff(replay);
|
|
529
688
|
}
|
|
530
689
|
catch (err) {
|
|
690
|
+
if (activeRequest?.attempt === attempt)
|
|
691
|
+
activeRequest = null;
|
|
692
|
+
lifecycle.failed(err, handoffStarted);
|
|
531
693
|
if (isTerminal(err))
|
|
532
694
|
terminal = err;
|
|
533
695
|
else if (isApprovalOutcome(err))
|
|
534
696
|
quietUntil = Date.now() + cooldownMs;
|
|
535
|
-
opts.onEvent?.({ type: 'failed', detail:
|
|
536
|
-
await route.abort('aborted');
|
|
697
|
+
opts.onEvent?.({ type: 'failed', detail: failureSummary(err) });
|
|
698
|
+
await route.abort('aborted').catch(() => { });
|
|
537
699
|
}
|
|
538
700
|
finally {
|
|
701
|
+
if (activeRequest?.attempt === attempt)
|
|
702
|
+
activeRequest = null;
|
|
539
703
|
awaitingApproval = false;
|
|
704
|
+
lifecycle.end();
|
|
540
705
|
}
|
|
541
706
|
});
|
|
707
|
+
return lifecycle;
|
|
542
708
|
}
|
package/dist/client.d.ts
CHANGED
|
@@ -133,6 +133,12 @@ export interface AuthorizeInput {
|
|
|
133
133
|
request: PausedRequest;
|
|
134
134
|
/** Abort if the user has not approved within this many ms. Default 15 min. */
|
|
135
135
|
timeoutMs?: number;
|
|
136
|
+
/** Stops local polling; it does not revoke a pending approval or undo a payment. */
|
|
137
|
+
signal?: AbortSignal;
|
|
138
|
+
/** Adapter-owned merchant request lifetime. A failed request retires a pending approval before replay when possible. */
|
|
139
|
+
merchantSignal?: AbortSignal;
|
|
140
|
+
/** Called before onApprovalUrl; lets a runtime reconcile an interrupted authorization. */
|
|
141
|
+
onAuthorizationCreated?: (authorizationId: string) => void;
|
|
136
142
|
/** Called once with the URL to surface to the user, if you deliver it yourself. */
|
|
137
143
|
onApprovalUrl?: (url: string) => void;
|
|
138
144
|
}
|
|
@@ -141,10 +147,9 @@ export declare class CardEncryptedError extends Error {
|
|
|
141
147
|
constructor(psp: string);
|
|
142
148
|
}
|
|
143
149
|
/**
|
|
144
|
-
* The
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
* same page could only raise more prompts for the same dead end.
|
|
150
|
+
* The registry requests a mode this SDK build cannot finish, before creation.
|
|
151
|
+
* A response in an unexpected mode after creation has an unknown payment
|
|
152
|
+
* outcome instead and raises PaymentOutcomeUnknownError.
|
|
148
153
|
*/
|
|
149
154
|
export declare class UnsupportedModeError extends Error {
|
|
150
155
|
mode: string;
|
|
@@ -153,6 +158,15 @@ export declare class UnsupportedModeError extends Error {
|
|
|
153
158
|
export declare class ApprovalTimeoutError extends Error {
|
|
154
159
|
constructor(ms: number);
|
|
155
160
|
}
|
|
161
|
+
export declare class CheckoutCancelledError extends Error {
|
|
162
|
+
constructor();
|
|
163
|
+
}
|
|
164
|
+
/** The payment may have reached the processor. Reconcile the merchant order before any new attempt. */
|
|
165
|
+
export declare class PaymentOutcomeUnknownError extends Error {
|
|
166
|
+
authorizationId: string | null;
|
|
167
|
+
reason: string;
|
|
168
|
+
constructor(authorizationId: string | null, reason: string);
|
|
169
|
+
}
|
|
156
170
|
export declare class ApprovalDeclinedError extends Error {
|
|
157
171
|
constructor(reason: string);
|
|
158
172
|
}
|
|
@@ -324,6 +338,14 @@ export declare class VaultClient {
|
|
|
324
338
|
* response to replay into the browser. Your process never sees a card.
|
|
325
339
|
*/
|
|
326
340
|
authorize(input: AuthorizeInput): Promise<ReplayResponse>;
|
|
341
|
+
/** Retire only a pre-replay authorization. A 409 or missing response remains unknown. */
|
|
342
|
+
cancelAuthorization(authorizationId: string): Promise<{
|
|
343
|
+
id: string;
|
|
344
|
+
status: 'declined';
|
|
345
|
+
reason: 'merchant_request_aborted';
|
|
346
|
+
cancelled: true;
|
|
347
|
+
processor_request_started: false;
|
|
348
|
+
}>;
|
|
327
349
|
/**
|
|
328
350
|
* POST the create, with two typed twists: a 502 `amount_unverifiable`
|
|
329
351
|
* (Stripe did not answer the read-back) is retried on a short backoff
|