@agent-cards/checkout 0.2.1 → 0.3.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 ADDED
@@ -0,0 +1,25 @@
1
+ # Changelog
2
+
3
+ ## 0.3.0
4
+
5
+ ### Browser checkout lifecycle
6
+
7
+ - `attachToPlaywright` and `attachToCdp` now return a `CheckoutController` with state, cancellation, merchant reconciliation, user-action hooks and explicit retry after a merchant-confirmed failure. Existing callers may continue to ignore the return value.
8
+ - `requireMerchantResult: true` holds further card requests after handoff until the application's merchant integration confirms the outcome. Approval or tokenization alone does not establish a successful order.
9
+ - Stripe `/v1/payment_methods` and `/v1/tokens` handoffs hold further recognized card requests, even without `requireMerchantResult`. Tokenization does not bind a specific PaymentIntent, amount or currency, so automatic token-to-intent continuation is unsupported. Direct card-bearing PaymentIntent confirms retain their existing backend amount verification. Unknown merchant-server endpoints require explicit `paymentEndpoints` guards; server-side charges remain outside this browser guard.
10
+ - Exact `paymentEndpoints` guards abort unsupported payment endpoints identified by the integrator. Unknown endpoints outside those guards remain untouched.
11
+ - An existing-browser example covers the direct SDK connection used with Browserbase, Kernel or a compatible custom Chromium/CDP session. Cloud-provider sessions and real merchant/3DS flows still require separate validation; Kernel's native Vault integration is a separate path.
12
+
13
+ ### Migration from 0.2.x
14
+
15
+ - Handle `PaymentOutcomeUnknownError` separately from decline or safe expiry. Lost create/poll responses, malformed post-create responses and local deadlines can leave an approval or payment outstanding. Reconcile the merchant order before starting another attempt; do not retry because a local timer elapsed.
16
+ - `timeoutMs` now bounds authentication, authorization creation and polling together. `cancel()` stops this attachment locally; it does not revoke an approval link or cancel a processor payment. A cancelled attachment cannot restart.
17
+ - An attachment that issued an unbound Stripe token cannot reset with `retryAfterMerchantFailure`, including when browser delivery is uncertain. Reconcile the merchant outcome and use a separately validated flow; do not reuse the token in a new attachment.
18
+ - Hosted-form submissions remain blocked until an explicit merchant-confirmed failure permits retry. The short duplicate-request cooldown is not proof that retry is safe.
19
+ - Initial raw-CDP interception setup errors now reject attachment. A child target that cannot be armed stays paused for operator recovery. Supply a browser-level, session-aware CDP connection for raw CDP; a page-scoped Playwright `CDPSession` is insufficient.
20
+ - Playwright attachment rejects contexts with active service workers. Create checkout contexts with `serviceWorkers: 'block'`; checking an existing context cannot prevent later worker registration.
21
+ - Observer callback failures no longer interrupt payment handoff. Event failure summaries omit processor/API response bodies and request query strings.
22
+
23
+ ### Verification
24
+
25
+ Deterministic transport and lifecycle tests cover failure recovery and retry guards. Local Chromium fixtures cover nested cross-origin frames, same-page post-payment work, and blocking immediate Stripe payment-method-to-intent fetch chains for both adapters, including an unrelated first intent and changed amount/currency. All payment endpoints in those fixtures are local stubs; they do not establish production provider or processor coverage.
package/README.md CHANGED
@@ -30,6 +30,9 @@ your agent ──drives──> merchant checkout
30
30
  npm i @agent-cards/checkout
31
31
  ```
32
32
 
33
+ Upgrading from 0.2.x? Read the [0.3.0 migration notes](./CHANGELOG.md), especially
34
+ the unknown-outcome, cancellation and browser-context requirements.
35
+
33
36
  ## Use it
34
37
 
35
38
  Two lines against a CDP session you already have:
@@ -118,12 +121,12 @@ before you put that in writing.
118
121
 
119
122
  ## Supported processors
120
123
 
121
- Merchants inherit their processor, so one entry covers every store on it.
124
+ Coverage is specific to the processor request format, merchant setup, browser transport and follow-up flow. A recognized endpoint is not proof that every store using that processor completes checkout.
122
125
 
123
126
  | Processor | Status |
124
127
  |---|---|
125
128
  | Shopify | supported, verified end to end |
126
- | Stripe | supported, verified end to end |
129
+ | 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 |
127
130
  | Braintree / PayPal | supported, verified end to end |
128
131
  | Checkout.com | supported |
129
132
  | 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 |
@@ -213,8 +216,9 @@ three; the difference matters if you drive `authorize()` yourself.
213
216
  `syncRegistry()` asks the API for `SUPPORTED_MODES` only
214
217
  (`token,cse,hosted_form`), so a processor whose flow this build cannot finish
215
218
  is never paused; the API serves `hosted_form` entries only to callers that ask.
216
- An approval in some other mode throws `UnsupportedModeError`, which is
217
- terminal for that page (upgrade the SDK). The `authorized` event's detail names
219
+ A registry mode this SDK cannot finish throws `UnsupportedModeError` before
220
+ creation. An approval returned in an unexpected mode has an unknown outcome
221
+ and holds the attachment for reconciliation. The `authorized` event's detail names
218
222
  the `mode`, the `authorizationId` and, for `cse`, the `fields` that were
219
223
  substituted; it never carries ciphertext. The `submitted_on_device` event's
220
224
  detail names the `authorizationId`, `submittedAt` and `outcome:
@@ -224,8 +228,7 @@ one. `amountAuthority` on every replay is `stripe_payment_intent`,
224
228
 
225
229
  ## Errors worth handling
226
230
 
227
- - `ApprovalTimeoutError` — the user never approved. Default window is 15 minutes;
228
- we have completed checkouts after a 5.5 minute approval delay.
231
+ - `ApprovalTimeoutError` — the server confirms the authorization expired without a replay attempt. A local deadline is different: `PaymentOutcomeUnknownError` means the approval link may still be valid, so reconcile the merchant order before another attempt.
229
232
  - `ApprovalDeclinedError` — the user said no.
230
233
  - `AmountMismatchError`: the processor's amount did not match the amount the
231
234
  user was (or would have been) asked to approve. Nothing was charged. An
@@ -276,8 +279,9 @@ one. `amountAuthority` on every replay is `stripe_payment_intent`,
276
279
  - `CardEncryptedError`: this processor encrypts the card in-page and its
277
280
  registry entry does not (yet) say the vault can produce that ciphertext;
278
281
  route the purchase to an Agentcard-issued card instead.
279
- - `UnsupportedModeError`: the approval came back in a mode this build of the
280
- SDK cannot finish. Terminal for the page; upgrade.
282
+ - `UnsupportedModeError`: the registry requests a mode this SDK cannot finish
283
+ before an authorization exists. Upgrade. An unexpected approved mode instead
284
+ raises `PaymentOutcomeUnknownError` and requires reconciliation.
281
285
  - `SubstitutionError`: a `cse` approval could not be written into the paused
282
286
  body (the four encrypted fields were not there). The request is failed and
283
287
  the next one is judged afresh.
@@ -292,3 +296,167 @@ unrelated transitive versions). Build it with the workspace TypeScript:
292
296
  ```bash
293
297
  cd packages/checkout && pnpm build && pnpm test
294
298
  ```
299
+
300
+
301
+ ## Browser integration and merchant outcomes
302
+
303
+ `attachToPlaywright` works with an existing Chromium page reached through
304
+ `chromium.connectOverCDP`. Browserbase supplies `session.connectUrl`; Kernel
305
+ supplies `browser.cdp_ws_url`; a custom browser must expose a compatible CDP
306
+ endpoint. This is Agentcard's **direct SDK** path. Kernel's native Vault alias
307
+ integration is a separate provider adapter with its own coverage and lifecycle;
308
+ do not install both interceptors on the same checkout without validating how
309
+ those routes interact.
310
+
311
+ The local browser suite validates Chromium and nested cross-origin frames over
312
+ both Playwright routing and a raw, session-aware CDP connection. It does not
313
+ establish live Browserbase, Kernel, 3DS or merchant coverage. A raw page-scoped
314
+ Playwright `CDPSession` is not the `CdpLike` interface. Raw CDP must preserve the
315
+ `sessionId` on every command/event and allow recursive target attachment.
316
+ Initial arming errors reject `attachToCdp`; a child that cannot be armed remains
317
+ paused and reports `browser_interception_unavailable` for operator recovery.
318
+
319
+ Use a checkout context created with `serviceWorkers: 'block'`. Playwright cannot
320
+ route requests intercepted by a service worker. The SDK rejects already active
321
+ service workers, but that check cannot prevent a site from registering one
322
+ later in an existing context configured to allow them. Attach before entering
323
+ card fields; keep the existing checkout tab. Separate popup tabs need their own
324
+ attachment. A page route does not cover a popup's first navigation; a popup
325
+ which submits payment on that navigation requires a separately validated
326
+ context/browser-level integration. Existing `page.route` handlers must call
327
+ `route.fallback()` when they do not handle a request; later routes have priority.
328
+
329
+ Both adapters now return a controller; existing code that ignores the return
330
+ value continues to work. Choose `requireMerchantResult: true` for a pilot:
331
+
332
+ ```ts
333
+ const checkout = await attachToPlaywright(page, {
334
+ vault, user, merchant, amountCents, currency,
335
+ requireMerchantResult: true,
336
+ onStateChange: state => recordState(state),
337
+ onUserAction: action => deliverPrivatelyToUser(action),
338
+ resolveMerchantResult: async state => readMerchantOrder(state),
339
+ paymentEndpoints: [
340
+ { origin: 'https://payments.example.com', pathname: '/submit', methods: ['POST'] },
341
+ ],
342
+ });
343
+
344
+ // Your existing agent dispatches checkout. Later, once the paused request resumes:
345
+ const state = await checkout.reconcile();
346
+ if (state.status === 'completed' && state.orderId) await finishAgentTask(state.orderId);
347
+ ```
348
+
349
+ `resolveMerchantResult` must read an authoritative merchant order/receipt tied
350
+ to this attempt. It returns one of:
351
+
352
+ - `{ status: 'completed', orderId }`: merchant-confirmed success.
353
+ - `{ status: 'failed' }`: merchant confirmed the attempt failed; no successful payment/order exists.
354
+ - `{ status: 'pending' }` or `{ status: 'unknown' }`: keep waiting or reconcile; never click Pay again.
355
+ - `{ status: 'requires_user_action', reason: '3ds' | 'redirect' | 'other' }`: deliver your own browser live view or supported challenge UI to the user.
356
+
357
+ The SDK does not infer order success from `authorized` or a tokenization reply,
358
+ and does not claim to detect or solve arbitrary 3DS challenges. Your merchant
359
+ resolver (or `checkout.requestUserAction('3ds')` when your browser observes it)
360
+ drives that hook. Deliver `onUserAction` approval URLs privately: they are
361
+ capabilities and never belong in general telemetry. Observer exceptions are
362
+ isolated from the payment handoff.
363
+
364
+ With `requireMerchantResult`, subsequent card requests stay blocked after
365
+ handoff. Stripe `/v1/payment_methods` and `/v1/tokens` handoffs always hold further
366
+ recognized card requests, even when that option is false. A tokenization approval has no
367
+ authoritative binding to a specific PaymentIntent, amount or currency. The first
368
+ observed confirm cannot supply that binding. The SDK therefore blocks every
369
+ follow-up confirm on that attachment, including the same token, an unrelated
370
+ intent, changed amounts and retries. It reports `awaiting_merchant` with reason
371
+ `stripe_tokenization_unbound`, while ordinary browser traffic stays available.
372
+ There is no automatic token-to-intent continuation or merchant-continuation hook.
373
+ Unrecognized merchant-server endpoints remain outside this guard unless listed
374
+ in `paymentEndpoints`; this is not a guarantee against a merchant charging a
375
+ saved token on its own server.
376
+ A direct card-bearing PaymentIntent confirm remains supported with the backend's
377
+ existing amount verification when `amountCents` and `currency` are supplied.
378
+
379
+ Hosted-form submissions also always stay blocked because their payment outcome
380
+ is unverified. `reconcile()` calls the resolver once, coalescing concurrent calls.
381
+ After an explicit merchant-confirmed failure, the application may call
382
+ `checkout.retryAfterMerchantFailure({ status: 'failed' })` to permit another
383
+ attempt where the attachment permits recovery. This is an assertion from your
384
+ merchant integration, not a timeout or a best guess. Completed orders, cancelled
385
+ attachments and attachments that issued an unbound Stripe token cannot reset
386
+ this way, including when the token's browser delivery acknowledgement was lost.
387
+ Reconcile the merchant outcome and use a separately validated checkout flow;
388
+ do not reuse that token in a new attachment as a workaround. Configuration and
389
+ unsupported-mode failures require fixing the integration. Bank flows requiring
390
+ another confirmation and other stored-token chains remain unverified.
391
+
392
+ Lost authorization polling, local approval timeouts, or interrupted browser
393
+ handoffs produce `outcome_unknown` and block automatic retry. The thrown
394
+ `PaymentOutcomeUnknownError` carries `authorizationId` when creation was
395
+ acknowledged. `checkout.cancel()` stops the local attachment and polling; it
396
+ does not revoke a pending approval link or undo a processor payment. Cancellation
397
+ after an attempt starts is therefore unknown until reconciled. A cancelled
398
+ attachment cannot restart.
399
+
400
+ ### Unsupported endpoints
401
+
402
+ `paymentEndpoints` is an explicit list supplied by the integrator after observing
403
+ the site's payment requests. Each guard uses a canonical origin, exact path and
404
+ mutation methods; it never examines or logs card bodies. If a guarded endpoint
405
+ is not recognized, the SDK aborts it and reports `unsupported_checkout` /
406
+ `unsupported` without creating an approval. Preflights and ordinary page traffic
407
+ continue. There is no wildcard or intercept-all fallback, and no automatic
408
+ conversion to an issued card. Unknown endpoints absent from these guards remain
409
+ untouched; the SDK cannot identify every payment request from its URL.
410
+
411
+ ### Runnable integration and local verification
412
+
413
+ `examples/existing-browser.mjs` runs against an existing provider session, using
414
+ an application-owned driver module for the agent's actions, user communication
415
+ and merchant-result resolver. Set `CHECKOUT_DRIVER` to that module's absolute
416
+ path and `CHECKOUT_CDP_URL` to the provider connection URL; optionally select the
417
+ existing tab with `CHECKOUT_PAGE_INDEX`. The module must export
418
+ `prepareCheckout(page)`, `submitCheckout(page)`, `resolveMerchantResult({page,
419
+ state})`, `onUserAction(action, {page})`, and `finishAfterPayment({page, orderId})`.
420
+ `prepareCheckout` returns the checkout options above. `submitCheckout` dispatches
421
+ the existing agent's approved purchase and returns without waiting for approval.
422
+ Install `playwright-core` in the example's host project. The SDK itself keeps no
423
+ runtime dependencies. The example is integration scaffolding, not a universal
424
+ merchant driver and not evidence of a live provider checkout.
425
+
426
+ ```sh
427
+ pnpm build
428
+ pnpm test
429
+ # Uses installed playwright-core, falling back to the monorepo backend dependency.
430
+ # Set CHECKOUT_CHROME_PATH if Chromium is not installed in Playwright's cache.
431
+ pnpm test:browser
432
+ ```
433
+
434
+ The browser fixtures never contact a payment service. The general suite uses
435
+ `psp.invalid`; the Stripe continuation suite forces `api.stripe.com` through an
436
+ allowlisted loopback proxy and a temporary self-signed TLS stub (requires the
437
+ `openssl` CLI). All other proxy destinations are rejected. Both suites use an
438
+ in-process Agentcard API fixture and loopback merchant pages. It proves nested-frame pause/resume, agent control during approval,
439
+ post-payment tasks in the same page, decline/expiry/cancel, unknown-outcome retry
440
+ blocking, explicit unsupported endpoint behavior, and blocking an immediate real-browser
441
+ Stripe token-to-intent fetch chain, including unrelated first intents, changed
442
+ amounts/currencies and delayed CDP acknowledgement. It does not test card
443
+ cryptography, real bank authorization or a cloud-provider deployment.
444
+
445
+ Provider/API references checked for this integration:
446
+ [Playwright CDP](https://playwright.dev/docs/api/class-browsertype#browser-type-connect-over-cdp),
447
+ [Playwright routing limitations](https://playwright.dev/docs/api/class-page#page-route),
448
+ [Browserbase Playwright quickstart](https://docs.browserbase.com/welcome/quickstarts/playwright),
449
+ [Kernel native Agentcard integration](https://www.kernel.sh/docs/integrations/payments/agentcard).
450
+
451
+
452
+ The authenticated `GET /v2/checkout/coverage` endpoint describes direct-SDK
453
+ processor modes, limitations and verification levels. Use
454
+ `POST /v2/checkout/coverage/assess` with up to 1,000 uniquely identified cases:
455
+ `{ cases: [{ id, request_url, method: "POST", scenario: "one_time", weight: 1,
456
+ requires_3ds: false }] }`. Scenarios also include `save_card`,
457
+ `subscription_initial` and `subscription_renewal`. The endpoint assesses request
458
+ recognition, not purchases: `recognized`, `unsupported` and `unverified` are
459
+ coverage classifications, `recognized_traffic_share` is traffic-weighted, and
460
+ `purchase_success_rate` stays null without observed merchant outcomes. Do not
461
+ substitute the assessor for a browser/merchant validation run or use native
462
+ Kernel adapter coverage as evidence for this SDK's coverage.
package/dist/cdp.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { type VaultClient } from './client.js';
2
+ import { type CheckoutController, type LifecycleOptions, type PaymentEndpointGuard } from './lifecycle.js';
2
3
  /**
3
4
  * The CORS headers a fulfilled CROSS-ORIGIN request needs, or null when the
4
5
  * request is same-origin (or carries no Origin, so no CORS check applies).
@@ -63,14 +64,14 @@ export declare function corsDecision(url: string, requestHeaders: Record<string,
63
64
  */
64
65
  export declare function withCorsHeaders(headers: Record<string, string>, cors: Record<string, string> | null): Record<string, string>;
65
66
  /**
66
- * Minimal shape of a CDP connection. Works with a raw websocket client, a
67
- * Puppeteer CDPSession, or Playwright's CDPSession.
67
+ * Browser-level, session-aware CDP connection. A page-scoped Playwright or
68
+ * Puppeteer CDPSession is NOT this interface; use attachToPlaywright for those.
68
69
  */
69
70
  export interface CdpLike {
70
71
  send(method: string, params?: any, sessionId?: string): Promise<any>;
71
72
  on(handler: (method: string, params: any, sessionId?: string) => void): void;
72
73
  }
73
- export interface AttachOptions {
74
+ export interface AttachOptions extends LifecycleOptions {
74
75
  vault: VaultClient;
75
76
  user: string;
76
77
  merchant: string;
@@ -84,6 +85,10 @@ export interface AttachOptions {
84
85
  */
85
86
  amountCents?: number;
86
87
  currency?: string;
88
+ cardId?: string;
89
+ timeoutMs?: number;
90
+ /** Explicit payment endpoints to block if the registry cannot handle their method/format. Unlisted traffic is untouched. */
91
+ paymentEndpoints?: readonly PaymentEndpointGuard[];
87
92
  onApprovalUrl?: (url: string) => void;
88
93
  onEvent?: (e: {
89
94
  type: string;
@@ -111,7 +116,7 @@ export interface AttachOptions {
111
116
  * match. Patterns are resolved once, at attach, so every nested target ends up
112
117
  * armed identically.
113
118
  */
114
- export declare function attachToCdp(cdp: CdpLike, pageSessionId: string, opts: AttachOptions): Promise<void>;
119
+ export declare function attachToCdp(cdp: CdpLike, pageSessionId: string, opts: AttachOptions): Promise<CheckoutController>;
115
120
  /**
116
121
  * Playwright convenience wrapper — the path for cloud browsers that hand you a
117
122
  * CDP websocket (Kernel's `cdp_ws_url`, Browserbase, etc.):
@@ -123,4 +128,4 @@ export declare function attachToCdp(cdp: CdpLike, pageSessionId: string, opts: A
123
128
  * Uses Playwright's own request routing, which already spans subframes — see
124
129
  * the note in the body for why a hand-rolled CDPSession does not work here.
125
130
  */
126
- export declare function attachToPlaywright(page: any, opts: AttachOptions): Promise<void>;
131
+ export declare function attachToPlaywright(page: any, opts: AttachOptions): Promise<CheckoutController>;
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,19 @@ 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
+ }
273
287
  /**
274
288
  * Take over card tokenization for a page.
275
289
  *
@@ -288,6 +302,9 @@ const FALLBACK_CARD_PATTERNS = cardUrlPatterns(BUILTIN_REGISTRY);
288
302
  * armed identically.
289
303
  */
290
304
  export async function attachToCdp(cdp, pageSessionId, opts) {
305
+ opts = safeOptions(opts);
306
+ const lifecycle = new CheckoutLifecycle(opts);
307
+ const guards = paymentEndpointGuards(opts.paymentEndpoints);
291
308
  const armed = new Set();
292
309
  // Set once a failure proves that retrying cannot help; see isTerminal.
293
310
  let terminal = null;
@@ -300,25 +317,34 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
300
317
  const repeatQuietMs = opts.hostedFormRepeatQuietMs ?? HOSTED_FORM_REPEAT_QUIET_MS;
301
318
  let lastSubmitted = null;
302
319
  const derived = typeof opts.vault?.cardUrlPatterns === 'function' ? opts.vault.cardUrlPatterns() : [];
303
- const urlPatterns = derived.length > 0 ? derived : FALLBACK_CARD_PATTERNS;
320
+ const urlPatterns = [...new Set([...(derived.length > 0 ? derived : FALLBACK_CARD_PATTERNS), ...guards.patterns])];
304
321
  opts.onEvent?.({ type: 'fetch_armed', detail: { patterns: urlPatterns } });
305
322
  const arm = async (sessionId) => {
306
323
  const key = sessionId ?? '__root__';
307
324
  if (armed.has(key))
308
325
  return;
309
- armed.add(key);
310
326
  await cdp.send('Fetch.enable', {
311
327
  patterns: urlPatterns.map((urlPattern) => ({ urlPattern, requestStage: 'Request' })),
312
- }, sessionId).catch(() => { });
328
+ }, sessionId);
313
329
  // Descend into this target's own children (iframes inside iframes).
314
330
  await cdp.send('Target.setAutoAttach', {
315
331
  autoAttach: true, waitForDebuggerOnStart: true, flatten: true,
316
- }, sessionId).catch(() => { });
332
+ }, sessionId);
333
+ armed.add(key);
317
334
  };
318
335
  cdp.on(async (method, params, sessionId) => {
319
336
  if (method === 'Target.attachedToTarget') {
320
337
  const child = params.sessionId;
321
- await arm(child);
338
+ try {
339
+ await arm(child);
340
+ }
341
+ catch {
342
+ terminal = new Error('browser_interception_unavailable');
343
+ lifecycle.failed(new PaymentOutcomeUnknownError(lifecycle.getState().authorizationId, 'browser_interception_unavailable'));
344
+ opts.onEvent?.({ type: 'failed', detail: 'browser_interception_unavailable' });
345
+ // Leave this target paused: resuming an unarmed card frame would silently bypass the vault.
346
+ return;
347
+ }
322
348
  // Child targets start paused when waitForDebuggerOnStart is set.
323
349
  await cdp.send('Runtime.runIfWaitingForDebugger', {}, child).catch(() => { });
324
350
  return;
@@ -327,14 +353,20 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
327
353
  return;
328
354
  const { requestId, request, resourceType } = params;
329
355
  if (!opts.vault.isCardRequest(request.url, request.method)) {
356
+ if (guards.matches(request.url, request.method)) {
357
+ lifecycle.unsupported();
358
+ opts.onEvent?.({ type: 'unsupported_checkout', detail: { url: redactUrl(request.url), method: request.method } });
359
+ await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
360
+ return;
361
+ }
330
362
  await cdp.send('Fetch.continueRequest', { requestId }, sessionId).catch(() => { });
331
363
  return;
332
364
  }
333
365
  // Same stop condition as the Playwright adapter: once a failure proves
334
366
  // 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) });
367
+ if (terminal || lifecycle.isBlocked() || awaitingApproval || Date.now() < quietUntil) {
368
+ const why = terminal ?? (lifecycle.isBlocked() ? lifecycle.getState().status : awaitingApproval ? 'an approval is already outstanding' : 'awaiting approval cooldown');
369
+ opts.onEvent?.({ type: 'blocked', detail: why instanceof Error ? failureSummary(why) : String(why) });
338
370
  await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
339
371
  return;
340
372
  }
@@ -342,6 +374,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
342
374
  // requests can never both clear the check above and raise two prompts for
343
375
  // one checkout.
344
376
  awaitingApproval = true;
377
+ let handoffStarted = false;
345
378
  try {
346
379
  const body = pausedBody(request);
347
380
  if (body === null) {
@@ -361,15 +394,24 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
361
394
  return;
362
395
  }
363
396
  opts.onEvent?.({ type: 'card_request_paused', detail: { url: redactUrl(request.url), ...(resourceType ? { resourceType } : {}) } });
397
+ lifecycle.begin();
364
398
  const replay = await opts.vault.authorize({
365
399
  user: opts.user,
366
400
  merchant: opts.merchant,
367
401
  amount: opts.amount,
368
402
  amountCents: opts.amountCents,
369
403
  currency: opts.currency,
370
- onApprovalUrl: opts.onApprovalUrl,
404
+ cardId: opts.cardId,
405
+ timeoutMs: opts.timeoutMs,
406
+ signal: lifecycle.abort.signal,
407
+ onAuthorizationCreated: (id) => lifecycle.approvalCreated(id),
408
+ onApprovalUrl: (url) => { lifecycle.approvalUrl(url); return opts.onApprovalUrl?.(url); },
371
409
  request: { url: request.url, method: request.method, headers: request.headers, body },
372
410
  });
411
+ if (lifecycle.isCancelled())
412
+ throw new Error('checkout cancelled locally after approval');
413
+ lifecycle.prepareHandoff(replay, request.url);
414
+ handoffStarted = replay.mode !== 'cse';
373
415
  if (replay.mode === 'hosted_form') {
374
416
  // The device submitted the processor's own form; the processor
375
417
  // answered the device. The paused NAVIGATION is fulfilled with a page
@@ -396,9 +438,11 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
396
438
  // and cookies, and only the four ciphertext fields swapped in. Only
397
439
  // postData rides on the command: no header override, ever (see
398
440
  // cseBody for why a recomputed Content-Length is refused by Chromium).
441
+ const postData = Buffer.from(cseBody(body, replay)).toString('base64');
442
+ handoffStarted = true;
399
443
  await cdp.send('Fetch.continueRequest', {
400
444
  requestId,
401
- postData: Buffer.from(cseBody(body, replay)).toString('base64'),
445
+ postData,
402
446
  }, sessionId);
403
447
  opts.onEvent?.({ type: 'authorized', detail: { mode: 'cse', authorizationId: replay.authorizationId, fields: Object.keys(replay.substitutions.fields) } });
404
448
  }
@@ -418,20 +462,24 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
418
462
  }, sessionId);
419
463
  opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId, amountVerified: replay.amountVerified ?? null, cors: cors.outcome } });
420
464
  }
465
+ lifecycle.handedOff(replay);
421
466
  }
422
467
  catch (err) {
468
+ lifecycle.failed(err, handoffStarted);
423
469
  if (isTerminal(err))
424
470
  terminal = err;
425
471
  else if (isApprovalOutcome(err))
426
472
  quietUntil = Date.now() + cooldownMs;
427
- opts.onEvent?.({ type: 'failed', detail: String(err) });
473
+ opts.onEvent?.({ type: 'failed', detail: failureSummary(err) });
428
474
  await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
429
475
  }
430
476
  finally {
431
477
  awaitingApproval = false;
478
+ lifecycle.end();
432
479
  }
433
480
  });
434
481
  await arm(pageSessionId);
482
+ return lifecycle;
435
483
  }
436
484
  /**
437
485
  * Playwright convenience wrapper — the path for cloud browsers that hand you a
@@ -445,6 +493,14 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
445
493
  * the note in the body for why a hand-rolled CDPSession does not work here.
446
494
  */
447
495
  export async function attachToPlaywright(page, opts) {
496
+ opts = safeOptions(opts);
497
+ // Routing cannot see requests owned by a service worker. Existing controlled
498
+ // contexts must be recreated with serviceWorkers: 'block' before checkout.
499
+ if (page.context?.().serviceWorkers?.().length) {
500
+ throw new Error('Service workers are active; use a checkout context created with serviceWorkers: "block".');
501
+ }
502
+ const lifecycle = new CheckoutLifecycle(opts);
503
+ const guards = paymentEndpointGuards(opts.paymentEndpoints);
448
504
  // Playwright's own routing, NOT a hand-rolled CDP session.
449
505
  //
450
506
  // A CDPSession from `newCDPSession(page)` is bound to the PAGE target and its
@@ -467,11 +523,16 @@ export async function attachToPlaywright(page, opts) {
467
523
  // The hosted form the cardholder already submitted; see HOSTED_FORM_REPEAT_QUIET_MS.
468
524
  const repeatQuietMs = opts.hostedFormRepeatQuietMs ?? HOSTED_FORM_REPEAT_QUIET_MS;
469
525
  let lastSubmitted = null;
470
- await page.route((url) => opts.vault.isCardRequest(url.toString()), async (route) => {
526
+ await page.route((url) => opts.vault.isCardRequest(url.toString()) || guards.matches(url.toString()), async (route) => {
471
527
  const request = route.request();
472
528
  // The matcher only sees the URL; a preflight or a GET must pass through
473
529
  // untouched or the browser's CORS check fails on our synthetic answer.
474
530
  if (!opts.vault.isCardRequest(request.url(), request.method())) {
531
+ if (guards.matches(request.url(), request.method())) {
532
+ lifecycle.unsupported();
533
+ opts.onEvent?.({ type: 'unsupported_checkout', detail: { url: redactUrl(request.url()), method: request.method() } });
534
+ return route.abort('aborted');
535
+ }
475
536
  return route.fallback();
476
537
  }
477
538
  // Fail closed and stay quiet: no card may reach the PSP, but neither may
@@ -479,13 +540,14 @@ export async function attachToPlaywright(page, opts) {
479
540
  // abort in this adapter is 'aborted' (ERR_ABORTED), the same code the
480
541
  // CDP adapter's Fetch.failRequest uses, so a refused navigation
481
542
  // 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) });
543
+ if (terminal || lifecycle.isBlocked() || awaitingApproval || Date.now() < quietUntil) {
544
+ const why = terminal ?? (lifecycle.isBlocked() ? lifecycle.getState().status : awaitingApproval ? 'an approval is already outstanding' : 'awaiting approval cooldown');
545
+ opts.onEvent?.({ type: 'blocked', detail: why instanceof Error ? failureSummary(why) : String(why) });
485
546
  return route.abort('aborted');
486
547
  }
487
548
  // Reserved before anything that could yield, matching attachToCdp.
488
549
  awaitingApproval = true;
550
+ let handoffStarted = false;
489
551
  try {
490
552
  const body = request.postData() ?? '';
491
553
  if (isRepeatOfSubmitted(lastSubmitted, request.url(), body, repeatQuietMs)) {
@@ -493,15 +555,24 @@ export async function attachToPlaywright(page, opts) {
493
555
  return await route.abort('aborted');
494
556
  }
495
557
  opts.onEvent?.({ type: 'card_request_paused', detail: { url: redactUrl(request.url()) } });
558
+ lifecycle.begin();
496
559
  const replay = await opts.vault.authorize({
497
560
  user: opts.user,
498
561
  merchant: opts.merchant,
499
562
  amount: opts.amount,
500
563
  amountCents: opts.amountCents,
501
564
  currency: opts.currency,
502
- onApprovalUrl: opts.onApprovalUrl,
565
+ cardId: opts.cardId,
566
+ timeoutMs: opts.timeoutMs,
567
+ signal: lifecycle.abort.signal,
568
+ onAuthorizationCreated: (id) => lifecycle.approvalCreated(id),
569
+ onApprovalUrl: (url) => { lifecycle.approvalUrl(url); return opts.onApprovalUrl?.(url); },
503
570
  request: { url: request.url(), method: request.method(), headers: request.headers(), body },
504
571
  });
572
+ if (lifecycle.isCancelled())
573
+ throw new Error('checkout cancelled locally after approval');
574
+ lifecycle.prepareHandoff(replay, request.url());
575
+ handoffStarted = replay.mode !== 'cse';
505
576
  if (replay.mode === 'hosted_form') {
506
577
  // Same as the CDP path: the paused navigation resolves to the
507
578
  // synthetic page, and a re-post of this form is refused.
@@ -515,7 +586,9 @@ export async function attachToPlaywright(page, opts) {
515
586
  // Same as the CDP path: the request continues from this browser
516
587
  // with the ciphertext swapped in and no header override; Playwright
517
588
  // recomputes the length itself.
518
- await route.continue({ postData: cseBody(body, replay) });
589
+ const postData = cseBody(body, replay);
590
+ handoffStarted = true;
591
+ await route.continue({ postData });
519
592
  opts.onEvent?.({ type: 'authorized', detail: { mode: 'cse', authorizationId: replay.authorizationId, fields: Object.keys(replay.substitutions.fields) } });
520
593
  }
521
594
  else {
@@ -526,17 +599,21 @@ export async function attachToPlaywright(page, opts) {
526
599
  await route.fulfill({ status: replay.status, headers: withCorsHeaders(replay.headers, cors.headers), body: replay.body });
527
600
  opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId, amountVerified: replay.amountVerified ?? null, cors: cors.outcome } });
528
601
  }
602
+ lifecycle.handedOff(replay);
529
603
  }
530
604
  catch (err) {
605
+ lifecycle.failed(err, handoffStarted);
531
606
  if (isTerminal(err))
532
607
  terminal = err;
533
608
  else if (isApprovalOutcome(err))
534
609
  quietUntil = Date.now() + cooldownMs;
535
- opts.onEvent?.({ type: 'failed', detail: String(err) });
610
+ opts.onEvent?.({ type: 'failed', detail: failureSummary(err) });
536
611
  await route.abort('aborted');
537
612
  }
538
613
  finally {
539
614
  awaitingApproval = false;
615
+ lifecycle.end();
540
616
  }
541
617
  });
618
+ return lifecycle;
542
619
  }
package/dist/client.d.ts CHANGED
@@ -133,6 +133,10 @@ 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
+ /** Called before onApprovalUrl; lets a runtime reconcile an interrupted authorization. */
139
+ onAuthorizationCreated?: (authorizationId: string) => void;
136
140
  /** Called once with the URL to surface to the user, if you deliver it yourself. */
137
141
  onApprovalUrl?: (url: string) => void;
138
142
  }
@@ -141,10 +145,9 @@ export declare class CardEncryptedError extends Error {
141
145
  constructor(psp: string);
142
146
  }
143
147
  /**
144
- * The API approved an authorization in a mode this SDK build cannot finish.
145
- * Unreachable by construction (syncRegistry asks only for SUPPORTED_MODES and
146
- * every create names its mode), so it is treated as terminal: retrying the
147
- * same page could only raise more prompts for the same dead end.
148
+ * The registry requests a mode this SDK build cannot finish, before creation.
149
+ * A response in an unexpected mode after creation has an unknown payment
150
+ * outcome instead and raises PaymentOutcomeUnknownError.
148
151
  */
149
152
  export declare class UnsupportedModeError extends Error {
150
153
  mode: string;
@@ -153,6 +156,15 @@ export declare class UnsupportedModeError extends Error {
153
156
  export declare class ApprovalTimeoutError extends Error {
154
157
  constructor(ms: number);
155
158
  }
159
+ export declare class CheckoutCancelledError extends Error {
160
+ constructor();
161
+ }
162
+ /** The payment may have reached the processor. Reconcile the merchant order before any new attempt. */
163
+ export declare class PaymentOutcomeUnknownError extends Error {
164
+ authorizationId: string | null;
165
+ reason: string;
166
+ constructor(authorizationId: string | null, reason: string);
167
+ }
156
168
  export declare class ApprovalDeclinedError extends Error {
157
169
  constructor(reason: string);
158
170
  }