@agent-cards/sdk 0.20.0 → 0.21.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/dist/cdp.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import { BUILTIN_REGISTRY, cardUrlPatterns } from './registry.js';
2
- import { AdyenTestPlatformRefusedError, ApprovalDeclinedError, ApprovalTimeoutError, CardEncryptedError, CheckoutApiError, CheckoutCancelledError, MerchantTotalError, PaymentOutcomeUnknownError, PreparationRequiredError, ProcessorRefusedError, UnsupportedModeError, } from './client.js';
2
+ import { AdyenTestPlatformRefusedError, AmountRequiredError, ApprovalDeclinedError, ApprovalTimeoutError, CardEncryptedError, CheckoutApiError, CheckoutCancelledError, MerchantTotalError, PaymentOutcomeUnknownError, PreparationRequiredError, ProcessorRefusedError, UnsupportedCapabilityError, UnsupportedModeError, } from './client.js';
3
3
  import { PreparationGate } from './preparation.js';
4
4
  import { StripeCheckoutGate } from './stripe-checkout.js';
5
5
  import { MercadoCheckoutGate } from './mercado-checkout.js';
6
6
  import { MERCADO_CHECKOUT_PATTERN } from './mercado-checkout.generated.js';
7
7
  import { dispatchPreClaim, eventUrl } from './pre-claim.js';
8
+ import { GMO_TOKEN_V1_PATTERNS, gmoRequestShop, isGmoTokenV1Request, recognizesGmo } from './gmo.js';
8
9
  import { CheckoutAttachmentError, attachmentDeadline, attachmentFailure, withinAttachmentDeadline } from './attachment.js';
9
10
  import { cseBody } from './cse-body.js';
10
11
  import { replayTemplatedUrl, reviewedMerchantProfileUrlPatterns } from './merchant-hosted.js';
@@ -12,6 +13,15 @@ import { MerchantHandoffWatch } from './merchant-handoff.js';
12
13
  import { MERCHANT_PAGE_WAIT_MS, MerchantTotalWatch } from './merchant-total-watch.js';
13
14
  import { hostedFormSubmittedPage } from './hosted-form.js';
14
15
  import { CheckoutLifecycle, paymentEndpointGuards } from './lifecycle.js';
16
+ import { CaptureContexts, MicroformFrames, judgeMicroformFrameUrls, refusedRequestFailure } from './cybersource-flex.js';
17
+ /**
18
+ * Fetch.failRequest's errorReason for a paused request the CDP adapter will not let through: the
19
+ * CDP form of refusedRequestFailure ('Failed' for a CyberSource Flex request, so the Microform field
20
+ * reports it to the page; 'Aborted' for every other request, as before).
21
+ */
22
+ function cdpFailure(url) {
23
+ return refusedRequestFailure(url) === 'failed' ? 'Failed' : 'Aborted';
24
+ }
15
25
  // Every URL that leaves this module through onEvent is redacted to origin +
16
26
  // path first. A paused PaymentIntent confirm can carry the client secret in
17
27
  // its query string (Stripe.js puts it in the body; hand-rolled runtimes and
@@ -144,7 +154,8 @@ function isApprovalOutcome(err) {
144
154
  *
145
155
  * Terminal means "will answer identically next time no matter who does what":
146
156
  * a misconfiguration or an unsupported PSP. Nothing a person does changes
147
- * those, so asking again is pure waste.
157
+ * those, so asking again is pure waste. A GMO request with no `amount`
158
+ * (AmountRequiredError) is one: the attachment's options do not change.
148
159
  *
149
160
  * Other errors may be retryable unless the lifecycle holds the attachment
150
161
  * for merchant reconciliation, as it does after a processor refusal.
@@ -161,7 +172,8 @@ function isTerminal(err) {
161
172
  // Refused at create for Adyen's test platform: the page's next request is refused the same way.
162
173
  if (err instanceof AdyenTestPlatformRefusedError)
163
174
  return err.stage === 'create';
164
- return err instanceof CardEncryptedError || err instanceof UnsupportedModeError || err instanceof PreparationRequiredError;
175
+ return err instanceof CardEncryptedError || err instanceof UnsupportedModeError || err instanceof PreparationRequiredError
176
+ || err instanceof AmountRequiredError || err instanceof UnsupportedCapabilityError;
165
177
  }
166
178
  /**
167
179
  * cseBody, retiring a merchant-hosted approval it refuses. The refusal (a live body
@@ -734,12 +746,26 @@ function bodyShape(body) {
734
746
  }
735
747
  return { shape: `raw:${body}`, namesAmount: false };
736
748
  }
737
- /** The identity of a request from its parts; see CheckoutRequestIdentity. */
738
- function requestIdentity(opts, url, method, body, documentUrl, pageAmount) {
739
- const shaped = bodyShape(body);
740
- return { url, method, documentUrl, pageAmount, body: shaped.shape,
749
+ /**
750
+ * The identity of a request from its parts; see CheckoutRequestIdentity. A CyberSource Flex
751
+ * body is a fresh JWE every time the card field seals, and it names no amount, so its shape is
752
+ * the one the Flex gate read from the field iframe (cybersource-flex.ts: microform type, fields,
753
+ * the capture context's key id and the context itself), never the ciphertext.
754
+ */
755
+ function requestIdentity(opts, url, method, body, documentUrl, pageAmount, flexShape) {
756
+ const shaped = flexShape !== undefined ? { shape: flexShape, namesAmount: false } : bodyShape(body);
757
+ // A GMO token is minted for the one shop its request names, and the body's shape leaves that value
758
+ // out, so the shop joins the identity: a retry that names another shop needs its own approval.
759
+ const shop = gmoRequestShop(url, body);
760
+ return { url, method, documentUrl, pageAmount, body: shop === undefined ? shaped.shape : `${shaped.shape};gmo_shop:${JSON.stringify(shop)}`,
741
761
  amountEvidence: opts.amount != null || pageAmount !== undefined || shaped.namesAmount };
742
762
  }
763
+ /** A CDP frame or request URL with its fragment, which CDP reports apart (urlFragment). */
764
+ function withFragment(value) {
765
+ if (typeof value?.url !== 'string')
766
+ return undefined;
767
+ return value.url + (typeof value.urlFragment === 'string' ? value.urlFragment : '');
768
+ }
743
769
  /**
744
770
  * May this request be answered from the approval the other one raised?
745
771
  *
@@ -1115,6 +1141,39 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1115
1141
  const lifecycle = new CheckoutLifecycle(opts);
1116
1142
  const stripeCheckout = new StripeCheckoutGate(opts);
1117
1143
  const mercadoCheckout = new MercadoCheckoutGate();
1144
+ // CyberSource Flex card field iframes and their capture contexts, recorded from the CDP
1145
+ // events that report a frame's committed document with its fragment (cybersource-flex.ts).
1146
+ const microforms = new MicroformFrames();
1147
+ /**
1148
+ * One bounded read of a session's frame tree, for a Flex request whose frame is not on record:
1149
+ * records every frame it finds and resolves to the committed URL of `frameId`, or undefined
1150
+ * when that frame is not in the tree.
1151
+ */
1152
+ const rereadMicroforms = async (sessionId, frameId) => {
1153
+ if (!sessionId)
1154
+ return undefined;
1155
+ let timer;
1156
+ try {
1157
+ const tree = await Promise.race([cdp.send('Page.getFrameTree', {}, sessionId),
1158
+ new Promise((_resolve, reject) => { timer = setTimeout(() => reject(new Error('frame_tree_unavailable')), 1_000); })]);
1159
+ let issuing;
1160
+ const walk = (node, depth) => {
1161
+ if (!node?.frame || depth > 16)
1162
+ return;
1163
+ const url = withFragment(node.frame);
1164
+ microforms.observe(node.frame.id, url);
1165
+ if (node.frame.id === frameId)
1166
+ issuing = url;
1167
+ for (const child of Array.isArray(node.childFrames) ? node.childFrames : [])
1168
+ walk(child, depth + 1);
1169
+ };
1170
+ walk(tree?.frameTree, 0);
1171
+ return issuing;
1172
+ }
1173
+ finally {
1174
+ clearTimeout(timer);
1175
+ }
1176
+ };
1118
1177
  let preparationFrameId;
1119
1178
  const readDocumentUrl = async () => {
1120
1179
  const tree = await cdp.send('Page.getFrameTree', {}, pageSessionId);
@@ -1293,8 +1352,12 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1293
1352
  // that the vault names no profile for.
1294
1353
  const merchantPatterns = [...reviewedMerchantProfileUrlPatterns(),
1295
1354
  ...(typeof opts.vault?.merchantProfileUrlPatterns === 'function' ? opts.vault.merchantProfileUrlPatterns() : [])];
1355
+ // GMO token v1's JSONP GET is armed only when the synced registry recognizes
1356
+ // GMO (an org the API served the dark entry to), so the gate can refuse it;
1357
+ // for every other org it is never intercepted, as before (see gmo.ts).
1296
1358
  const urlPatterns = [...new Set([...(derived.length > 0 ? derived : FALLBACK_CARD_PATTERNS), ...guards.patterns,
1297
- ...(stripeCheckout.isEnabled() ? ['https://api.stripe.com/*'] : []), MERCADO_CHECKOUT_PATTERN, ...merchantPatterns])];
1359
+ ...(stripeCheckout.isEnabled() ? ['https://api.stripe.com/*'] : []), MERCADO_CHECKOUT_PATTERN, ...merchantPatterns,
1360
+ ...(recognizesGmo(opts.vault) ? GMO_TOKEN_V1_PATTERNS : [])])];
1298
1361
  const arm = async (sessionId, resume = false, worker = false) => {
1299
1362
  const key = sessionId ?? '__root__';
1300
1363
  if (armed.has(key))
@@ -1421,6 +1484,12 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1421
1484
  // Its Fetch events, children and commands remain permanently ignored.
1422
1485
  if (sessionDetached(sessionId) && !matchesActive)
1423
1486
  return;
1487
+ // A frame's document request carries its URL fragment (for an out-of-process card field
1488
+ // iframe, on the PARENT session before the child target exists), but a navigation can be
1489
+ // stopped before it commits: it only starts the capture context's check. The frame is
1490
+ // recorded once its document commits (Page.frameNavigated below).
1491
+ if (params.type === 'Document')
1492
+ microforms.warm(withFragment(params.request));
1424
1493
  const key = requestKey(sessionId, params.requestId);
1425
1494
  const record = { sessionId, networkId: params.requestId, request: params.request };
1426
1495
  if (sessionDetached(sessionId) || retiredSources.has(key) || hasRetiredSummary(owner, params.requestId))
@@ -1446,6 +1515,12 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1446
1515
  }
1447
1516
  if (sessionDetached(sessionId))
1448
1517
  return;
1518
+ if (method === 'Page.frameNavigated') {
1519
+ // A child frame committed a document: an out-of-process iframe reports its own on its
1520
+ // session, an in-process one on its parent's (the page's top frame is handled above).
1521
+ microforms.observe(params.frame?.id, withFragment(params.frame));
1522
+ return;
1523
+ }
1449
1524
  if (method === 'Network.loadingFinished') {
1450
1525
  const key = requestKey(sessionId, params.requestId);
1451
1526
  requestSources.delete(key);
@@ -1507,6 +1582,10 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1507
1582
  return;
1508
1583
  }
1509
1584
  if (method === 'Target.detachedFromTarget' || method === 'Inspector.detached' || method === 'Page.frameDetached') {
1585
+ // A frame that moves to its own process ('swap') keeps its document; any other detach ends it.
1586
+ if (!(method === 'Page.frameDetached' && params.reason === 'swap')) {
1587
+ microforms.forget(method === 'Page.frameDetached' ? params.frameId : method === 'Target.detachedFromTarget' ? params.targetId : undefined);
1588
+ }
1510
1589
  if (activeRequest && (method === 'Target.detachedFromTarget' ? activeRequest.sessionId === params.sessionId
1511
1590
  : method === 'Inspector.detached' ? activeRequest.sessionId === sessionId
1512
1591
  : activeRequest.sessionId === sessionId && activeRequest.frameId === params.frameId))
@@ -1579,6 +1658,10 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1579
1658
  workerOwners.set(child, owner);
1580
1659
  ownersWithWorkers.add(owner);
1581
1660
  }
1661
+ // An iframe target's id is its frame id, and its url keeps the fragment: the one source
1662
+ // for a card field iframe that rendered before this attachment (empty on a fresh one).
1663
+ if (params.targetInfo.type === 'iframe')
1664
+ microforms.observe(params.targetInfo.targetId, params.targetInfo.url);
1582
1665
  await arm(child, true, params.targetInfo.type === 'worker');
1583
1666
  }
1584
1667
  catch (error) {
@@ -1595,6 +1678,9 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1595
1678
  const { requestId, request, resourceType, networkId, frameId } = params;
1596
1679
  // When this request paused: the merchant's total is read as of this moment.
1597
1680
  const pausedAt = Date.now();
1681
+ // A paused document request has not committed either: it only starts the context's check.
1682
+ if (resourceType === 'Document')
1683
+ microforms.warm(withFragment(request));
1598
1684
  // One pre-claim gate registry decides and performs the mechanics through
1599
1685
  // these effects; the state accessors read the same closure variables the
1600
1686
  // inline gates did. It resolves synchronously for an ordinary card request,
@@ -1611,7 +1697,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1611
1697
  responseHeaders: Object.entries(withCorsHeaders(response.headers, corsHeadersFor(request.url, request.headers)))
1612
1698
  .map(([name, value]) => ({ name, value })),
1613
1699
  body: Buffer.from(response.body).toString('base64') }, sessionId),
1614
- abort: () => cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { }),
1700
+ abort: () => cdp.send('Fetch.failRequest', { requestId, errorReason: cdpFailure(request.url) }, sessionId).catch(() => { }),
1615
1701
  },
1616
1702
  attachmentReady: () => attachmentReady,
1617
1703
  interceptionAborted: () => setupStop.signal.aborted,
@@ -1623,14 +1709,18 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1623
1709
  awaitingApproval: () => awaitingApproval,
1624
1710
  stripeStep: null,
1625
1711
  merchantHandoff: { retried: (profile) => merchantHandoff.retried(profile, (judge) => awaitRetryReport(sessionId, networkId, judge)) },
1712
+ // The card field iframe that sent a Flex request is the paused request's own frame.
1713
+ cybersourceFlex: { judge: (flexRequest) => microforms.judge(flexRequest, frameId, () => rereadMicroforms(sessionId, frameId)) },
1626
1714
  });
1627
1715
  const preClaim = preClaimResult instanceof Promise ? await preClaimResult : preClaimResult;
1628
1716
  if (preClaim.kind !== 'pause')
1629
1717
  return;
1630
1718
  const stripeStep = preClaim.stripeStep;
1719
+ // The capture context and fields a CyberSource Flex create carries, and its retry shape.
1720
+ const flexPause = preClaim.cybersourceFlex;
1631
1721
  if (!attachmentReady || arming.has(sessionId ?? '__root__') || setupStop.signal.aborted) {
1632
1722
  opts.onEvent?.({ type: 'blocked', detail: 'checkout_interception_not_ready' });
1633
- await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
1723
+ await cdp.send('Fetch.failRequest', { requestId, errorReason: cdpFailure(request.url) }, sessionId).catch(() => { });
1634
1724
  return;
1635
1725
  }
1636
1726
  let preparation;
@@ -1639,7 +1729,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1639
1729
  }
1640
1730
  catch (error) {
1641
1731
  opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
1642
- await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
1732
+ await cdp.send('Fetch.failRequest', { requestId, errorReason: cdpFailure(request.url) }, sessionId).catch(() => { });
1643
1733
  return;
1644
1734
  }
1645
1735
  // The page asking again for the purchase an outstanding approval is for:
@@ -1650,7 +1740,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1650
1740
  if (awaitingApproval && activeRequest?.attempt.lost && !terminal && !setupStop.signal.aborted && !lifecycle.isCancelled()
1651
1741
  && typeof networkId === 'string' && networkId) {
1652
1742
  const retryBody = pausedBody(request);
1653
- const retry = retryBody === null ? null : requestIdentity(opts, request.url, request.method, retryBody, documentKey(await documentUrlOf(readDocumentUrl, lifecycle.abort.signal)), await pageAmountOf(opts));
1743
+ const retry = retryBody === null ? null : requestIdentity(opts, request.url, request.method, retryBody, documentKey(await documentUrlOf(readDocumentUrl, lifecycle.abort.signal)), await pageAmountOf(opts), flexPause?.shape);
1654
1744
  if (retry && activeRequest?.attempt.lost && sameCheckoutRequest(activeRequest.identity, retry)) {
1655
1745
  if (preparation)
1656
1746
  preparationGate.retireUnboundClaim();
@@ -1708,7 +1798,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1708
1798
  opts.onEvent?.({ type: 'blocked', detail: why instanceof Error ? failureSummary(why) : String(why) });
1709
1799
  if (preparation)
1710
1800
  preparationGate.retireUnboundClaim();
1711
- await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
1801
+ await cdp.send('Fetch.failRequest', { requestId, errorReason: cdpFailure(request.url) }, sessionId).catch(() => { });
1712
1802
  if (repeatedAuthorizationId)
1713
1803
  void opts.vault.reportDuplicateGuard?.(repeatedAuthorizationId);
1714
1804
  return;
@@ -1742,7 +1832,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1742
1832
  // about the page's configuration, so the next request is judged
1743
1833
  // afresh (no latch, no cooldown).
1744
1834
  opts.onEvent?.({ type: 'failed', detail: BODY_UNREADABLE_REASON });
1745
- await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
1835
+ await cdp.send('Fetch.failRequest', { requestId, errorReason: cdpFailure(request.url) }, sessionId).catch(() => { });
1746
1836
  return;
1747
1837
  }
1748
1838
  // The body is needed to tell a re-post of the form the cardholder
@@ -1750,7 +1840,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1750
1840
  // and before any API call: no second prompt, no round trip.
1751
1841
  if (isRepeatOfSubmitted(lastSubmitted, request.url, body, repeatQuietMs)) {
1752
1842
  opts.onEvent?.({ type: 'blocked', detail: HOSTED_FORM_REPEAT_REASON });
1753
- await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
1843
+ await cdp.send('Fetch.failRequest', { requestId, errorReason: cdpFailure(request.url) }, sessionId).catch(() => { });
1754
1844
  if (lastSubmitted)
1755
1845
  void opts.vault.reportDuplicateGuard?.(lastSubmitted.authorizationId);
1756
1846
  return;
@@ -1765,7 +1855,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1765
1855
  }
1766
1856
  attempt.bind({ requestId, sessionId, request });
1767
1857
  activeRequest = { attempt, requestId, networkId, sessionId, frameId, request,
1768
- identity: requestIdentity(opts, request.url, request.method, body, undefined, undefined) };
1858
+ identity: requestIdentity(opts, request.url, request.method, body, undefined, undefined, flexPause?.shape) };
1769
1859
  await awaitRequestSource(activeRequest);
1770
1860
  if (preparation)
1771
1861
  await preparationGate.assertDocument();
@@ -1821,7 +1911,8 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1821
1911
  } },
1822
1912
  request: stripeStep?.phase === 'final' ? stripeStep.request : { url: request.url, method: request.method, headers: request.headers, body,
1823
1913
  ...(mercadoCheckout.requiresDocument(request.url, request.method)
1824
- ? { mercado_checkout: mercadoCheckout.claimToken(request.url, request.method, await readDocumentUrl()) } : {}) },
1914
+ ? { mercado_checkout: mercadoCheckout.claimToken(request.url, request.method, await readDocumentUrl()) } : {}),
1915
+ ...(flexPause ? flexPause.sidecar : {}) },
1825
1916
  });
1826
1917
  attempt.assertLive();
1827
1918
  approvedId = replay.authorizationId;
@@ -1956,7 +2047,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
1956
2047
  quietUntil = Date.now() + cooldownMs;
1957
2048
  opts.onEvent?.({ type: 'failed', detail: failureSummary(err) });
1958
2049
  }
1959
- await cdp.send('Fetch.failRequest', { requestId: dead.requestId, errorReason: 'Aborted' }, dead.sessionId).catch(() => { });
2050
+ await cdp.send('Fetch.failRequest', { requestId: dead.requestId, errorReason: cdpFailure(request.url) }, dead.sessionId).catch(() => { });
1960
2051
  settlePause();
1961
2052
  }
1962
2053
  finally {
@@ -2041,6 +2132,18 @@ export async function attachToPlaywright(page, opts) {
2041
2132
  const lifecycle = new CheckoutLifecycle(opts);
2042
2133
  const stripeCheckout = new StripeCheckoutGate(opts);
2043
2134
  const mercadoCheckout = new MercadoCheckoutGate();
2135
+ // CyberSource Flex capture contexts, verified once each (cybersource-flex.ts). Playwright
2136
+ // keeps a frame's fragment in frame.url(), so the field iframes are read when a request pauses.
2137
+ const microformContexts = new CaptureContexts();
2138
+ const frameUrlOf = (frame) => {
2139
+ try {
2140
+ const url = frame?.url?.();
2141
+ return typeof url === 'string' ? url : undefined;
2142
+ }
2143
+ catch {
2144
+ return undefined;
2145
+ }
2146
+ };
2044
2147
  const readDocumentUrl = async () => {
2045
2148
  if (page.isClosed?.() || typeof page.url !== 'function')
2046
2149
  throw new Error('merchant_document_unavailable');
@@ -2162,6 +2265,9 @@ export async function attachToPlaywright(page, opts) {
2162
2265
  page.on?.('crash', () => { if (!attachmentReady)
2163
2266
  setupStop.abort(new CheckoutAttachmentError('closed')); preparationGate.invalidate('merchant_document_closed'); stripeCheckout.invalidate(); mercadoCheckout.invalidate(); activeRequest?.attempt.stop(); handoffAnswer?.closed(); });
2164
2267
  page.on?.('framenavigated', (frame) => {
2268
+ // A CyberSource Flex card field iframe loaded: start its capture-context check now, so its
2269
+ // card request pauses without waiting on it (cybersource-flex.ts).
2270
+ microformContexts.warm(frameUrlOf(frame));
2165
2271
  if (frame === page.mainFrame?.()) {
2166
2272
  if (documentRequest) {
2167
2273
  documentRequest = null;
@@ -2204,7 +2310,9 @@ export async function attachToPlaywright(page, opts) {
2204
2310
  }
2205
2311
  await page.route((url) => opts.vault.isCardRequest(url.toString()) || guards.matches(url.toString()) || stripeCheckout.matches(url.toString()) || mercadoCheckout.matches(url.toString())
2206
2312
  // An armed merchant profile's endpoint or a sibling of it (merchant-hosted.ts), as attachToCdp arms it.
2207
- || (typeof opts.vault.merchantProfileOf === 'function' && opts.vault.merchantProfileOf(url.toString()) !== null), async (route) => {
2313
+ || (typeof opts.vault.merchantProfileOf === 'function' && opts.vault.merchantProfileOf(url.toString()) !== null)
2314
+ // GMO token v1's JSONP GET, routed only for an org the API served GMO to (see attachToCdp).
2315
+ || (isGmoTokenV1Request(url.toString()) && recognizesGmo(opts.vault)), async (route) => {
2208
2316
  const request = route.request();
2209
2317
  // When this request paused, and the page it came from: the merchant's total is read as of now.
2210
2318
  const pausedAt = Date.now();
@@ -2226,7 +2334,7 @@ export async function attachToPlaywright(page, opts) {
2226
2334
  continueRequest: (postData) => postData !== undefined ? route.continue({ postData }) : route.fallback(),
2227
2335
  answer: (response) => route.fulfill({ ...response,
2228
2336
  headers: withCorsHeaders(response.headers, corsHeadersFor(request.url(), request.headers())) }),
2229
- abort: () => route.abort('aborted'),
2337
+ abort: () => route.abort(refusedRequestFailure(request.url())),
2230
2338
  },
2231
2339
  attachmentReady: () => attachmentReady,
2232
2340
  interceptionAborted: () => setupStop.signal.aborted,
@@ -2239,14 +2347,34 @@ export async function attachToPlaywright(page, opts) {
2239
2347
  stripeStep: null,
2240
2348
  // This route arrives after every answer the page read before sending it; only an unfollowed 3xx is left to settle.
2241
2349
  merchantHandoff: { retried: (profile) => merchantHandoff.retried(profile, () => { handoffAnswer?.settle(); return false; }) },
2350
+ // The card field iframe that sent a Flex request, and every frame beside it on the page.
2351
+ cybersourceFlex: { judge: (flexRequest) => {
2352
+ let issuing;
2353
+ try {
2354
+ issuing = request.frame?.();
2355
+ }
2356
+ catch {
2357
+ issuing = undefined;
2358
+ }
2359
+ let frames = [];
2360
+ try {
2361
+ frames = page.frames?.() ?? [];
2362
+ }
2363
+ catch {
2364
+ frames = [];
2365
+ }
2366
+ return judgeMicroformFrameUrls(flexRequest, frameUrlOf(issuing), frames.map(frameUrlOf), microformContexts);
2367
+ } },
2242
2368
  });
2243
2369
  const preClaim = preClaimResult instanceof Promise ? await preClaimResult : preClaimResult;
2244
2370
  if (preClaim.kind !== 'pause')
2245
2371
  return;
2246
2372
  const stripeStep = preClaim.stripeStep;
2373
+ // The capture context and fields a CyberSource Flex create carries, and its retry shape.
2374
+ const flexPause = preClaim.cybersourceFlex;
2247
2375
  if (!attachmentReady || setupStop.signal.aborted) {
2248
2376
  opts.onEvent?.({ type: 'blocked', detail: 'checkout_interception_not_ready' });
2249
- return route.abort('aborted');
2377
+ return route.abort(refusedRequestFailure(request.url()));
2250
2378
  }
2251
2379
  let preparation;
2252
2380
  try {
@@ -2254,13 +2382,13 @@ export async function attachToPlaywright(page, opts) {
2254
2382
  }
2255
2383
  catch (error) {
2256
2384
  opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
2257
- return route.abort('aborted');
2385
+ return route.abort(refusedRequestFailure(request.url()));
2258
2386
  }
2259
2387
  // The page asking again for the purchase an outstanding approval is
2260
2388
  // for, after its own request timed out: bind it to that approval (see
2261
2389
  // merchantAttempt and the same check in attachToCdp).
2262
2390
  if (awaitingApproval && activeRequest?.attempt.lost && !terminal && !setupStop.signal.aborted && !lifecycle.isCancelled()) {
2263
- const retry = requestIdentity(opts, request.url(), request.method(), request.postData() ?? '', documentKey(await documentUrlOf(readDocumentUrl, lifecycle.abort.signal)), await pageAmountOf(opts));
2391
+ const retry = requestIdentity(opts, request.url(), request.method(), request.postData() ?? '', documentKey(await documentUrlOf(readDocumentUrl, lifecycle.abort.signal)), await pageAmountOf(opts), flexPause?.shape);
2264
2392
  if (activeRequest?.attempt.lost && sameCheckoutRequest(activeRequest.identity, retry)) {
2265
2393
  if (preparation)
2266
2394
  preparationGate.retireUnboundClaim();
@@ -2297,9 +2425,10 @@ export async function attachToPlaywright(page, opts) {
2297
2425
  }
2298
2426
  // Fail closed and stay quiet: no card may reach the PSP, but neither may
2299
2427
  // the page's retry loop turn into a stream of doomed API calls. Every
2300
- // abort in this adapter is 'aborted' (ERR_ABORTED), the same code the
2301
- // CDP adapter's Fetch.failRequest uses, so a refused navigation
2302
- // resolves identically whichever adapter is attached.
2428
+ // abort in this adapter is 'aborted' (ERR_ABORTED), and a CyberSource
2429
+ // Flex request's is 'failed' (refusedRequestFailure), the same codes the
2430
+ // CDP adapter's Fetch.failRequest uses, so a refused request resolves
2431
+ // identically whichever adapter is attached.
2303
2432
  if (terminal || lifecycle.isBlocked() || awaitingApproval || Date.now() < quietUntil) {
2304
2433
  let repeatedAuthorizationId = null;
2305
2434
  if (lastSubmitted) {
@@ -2316,7 +2445,7 @@ export async function attachToPlaywright(page, opts) {
2316
2445
  opts.onEvent?.({ type: 'blocked', detail: why instanceof Error ? failureSummary(why) : String(why) });
2317
2446
  if (preparation)
2318
2447
  preparationGate.retireUnboundClaim();
2319
- const refused = await route.abort('aborted');
2448
+ const refused = await route.abort(refusedRequestFailure(request.url()));
2320
2449
  if (repeatedAuthorizationId)
2321
2450
  void opts.vault.reportDuplicateGuard?.(repeatedAuthorizationId);
2322
2451
  return refused;
@@ -2362,7 +2491,7 @@ export async function attachToPlaywright(page, opts) {
2362
2491
  const body = request.postData() ?? '';
2363
2492
  if (isRepeatOfSubmitted(lastSubmitted, request.url(), body, repeatQuietMs)) {
2364
2493
  opts.onEvent?.({ type: 'blocked', detail: HOSTED_FORM_REPEAT_REASON });
2365
- const refused = await route.abort('aborted');
2494
+ const refused = await route.abort(refusedRequestFailure(request.url()));
2366
2495
  if (lastSubmitted)
2367
2496
  void opts.vault.reportDuplicateGuard?.(lastSubmitted.authorizationId);
2368
2497
  return refused;
@@ -2373,7 +2502,7 @@ export async function attachToPlaywright(page, opts) {
2373
2502
  const payToInterceptMs = readPayToInterceptMs(opts.payClickedAt, approvalStartedAt);
2374
2503
  attempt.bind({ route, request, frames });
2375
2504
  activeRequest = { request, frames, attempt,
2376
- identity: requestIdentity(opts, request.url(), request.method(), body, undefined, undefined) };
2505
+ identity: requestIdentity(opts, request.url(), request.method(), body, undefined, undefined, flexPause?.shape) };
2377
2506
  if (preparation)
2378
2507
  await preparationGate.assertDocument();
2379
2508
  // One bounded read serves the merchant origin and the retry identity; see attachToCdp.
@@ -2415,7 +2544,8 @@ export async function attachToPlaywright(page, opts) {
2415
2544
  } },
2416
2545
  request: stripeStep?.phase === 'final' ? stripeStep.request : { url: request.url(), method: request.method(), headers: request.headers(), body,
2417
2546
  ...(mercadoCheckout.requiresDocument(request.url(), request.method())
2418
- ? { mercado_checkout: mercadoCheckout.claimToken(request.url(), request.method(), await readDocumentUrl()) } : {}) },
2547
+ ? { mercado_checkout: mercadoCheckout.claimToken(request.url(), request.method(), await readDocumentUrl()) } : {}),
2548
+ ...(flexPause ? flexPause.sidecar : {}) },
2419
2549
  });
2420
2550
  assertRequestLive();
2421
2551
  approvedId = replay.authorizationId;
@@ -2507,7 +2637,7 @@ export async function attachToPlaywright(page, opts) {
2507
2637
  quietUntil = Date.now() + cooldownMs;
2508
2638
  opts.onEvent?.({ type: 'failed', detail: failureSummary(err) });
2509
2639
  }
2510
- await dead.abort('aborted').catch(() => { });
2640
+ await dead.abort(refusedRequestFailure(request.url())).catch(() => { });
2511
2641
  }
2512
2642
  finally {
2513
2643
  if (activeRequest?.attempt === attempt)
package/dist/client.d.ts CHANGED
@@ -7,6 +7,7 @@ import { VoiceModule, type VoiceOptions } from './voice.js';
7
7
  import { BuyModule } from './buy.js';
8
8
  import { RequestRegistry } from './requests.js';
9
9
  import type { ApiTransport } from './api-module.js';
10
+ import { type MicroformSidecar } from './cybersource-flex.js';
10
11
  export interface PausedRequest {
11
12
  url: string;
12
13
  method: string;
@@ -14,6 +15,14 @@ export interface PausedRequest {
14
15
  body: string;
15
16
  /** Captured native Checkout Pro initialization, validated before use. */
16
17
  mercado_checkout?: unknown;
18
+ /**
19
+ * CyberSource Flex only: the capture context the card field iframe was loaded with, byte for
20
+ * byte from its URL fragment. The cardholder's device seals the real card under it, so the API
21
+ * verifies it before anything exists. The adapters read it; refused on any other processor.
22
+ */
23
+ capture_context?: string;
24
+ /** CyberSource Flex only: the Microform fields that loaded and the one that sent this request. */
25
+ microform?: MicroformSidecar;
17
26
  }
18
27
  /**
19
28
  * The modes this SDK can finish. Asked for on syncRegistry (the API serves
@@ -34,12 +43,22 @@ export declare const SUPPORTED_REGISTRY_FEATURES: readonly string[];
34
43
  /**
35
44
  * Dark-launch capabilities this build can finish. Sent on syncRegistry as
36
45
  * ?capabilities= so the API also serves the recognizers gated behind one of
37
- * these. `fiserv_card_capture`: Fiserv Commerce Hub's Secure Data Capture card
46
+ * these, and only to an org Agentcard enabled for the processor.
47
+ * `fiserv_card_capture`: Fiserv Commerce Hub's Secure Data Capture card
38
48
  * capture, which this build pauses and pays only through a prepared checkout
39
49
  * (pre-claim.ts, fiserv.ts). The API serves Fiserv's recognizer only to a build
40
50
  * that lists it and only for a company Agentcard turned Fiserv on for, so an older
41
51
  * SDK never pauses a capture it cannot finish and no SDK pauses one for a company
42
52
  * Fiserv is off for; the API also refuses a Fiserv payment for every such company.
53
+ * `gmo_shared_pk`: GMO token v2's getToken on the shared-key path, which this
54
+ * build judges before it pauses (gmo.ts) and pays only for the amount the
55
+ * agent passes. A gated recognizer never sits in BUILTIN_REGISTRY, so it is
56
+ * paused only after a sync.
57
+ * `capture_context`: CyberSource Flex Microform, whose create carries the capture
58
+ * context this build reads from the card field iframe (cybersource-flex.ts); the API
59
+ * serves Flex's recognizer the same way, only for a company Agentcard turned it on for.
60
+ * A gated recognizer is never in BUILTIN_REGISTRY, so a build reaches it only through a
61
+ * sync that declares its capability.
43
62
  */
44
63
  export declare const SUPPORTED_CAPABILITIES: readonly string[];
45
64
  /**
@@ -273,6 +292,9 @@ export interface AuthorizeInput extends ExecutionMetadata {
273
292
  * smallest unit is refused with nothing charged (an AmountMismatchError;
274
293
  * `stage` says which check). Agentcard derives the display string; you
275
294
  * never send one. Both or neither: one without the other is refused.
295
+ * Required when the processor's request names no amount at all (GMO): then
296
+ * this pair is what the cardholder approves, and a request without it
297
+ * throws AmountRequiredError before anything is created.
276
298
  */
277
299
  amount?: number | string;
278
300
  currency?: string;
@@ -337,6 +359,51 @@ export declare class UnsupportedModeError extends Error {
337
359
  mode: string;
338
360
  constructor(mode: string);
339
361
  }
362
+ /**
363
+ * The recognizer is gated behind a dark-launch capability this SDK build does not
364
+ * declare (SUPPORTED_CAPABILITIES), so it cannot send what that processor's create
365
+ * needs. Thrown locally before any create or prompt, and terminal. A registry the
366
+ * API served never holds such an entry (it serves a gated one only to a build that
367
+ * declares it); a hand-built one can.
368
+ */
369
+ export declare class UnsupportedCapabilityError extends Error {
370
+ capability: string;
371
+ constructor(capability: string);
372
+ }
373
+ /**
374
+ * placeholderCard() found no stored card to describe (`card_not_found`), the stored card's
375
+ * network has no documented placeholder number (`brand_unsupported`), or the stored card
376
+ * carries no usable expiry (`expiry_unknown`). Nothing was created and nothing was charged.
377
+ */
378
+ export declare class PlaceholderCardError extends Error {
379
+ reason: 'card_not_found' | 'brand_unsupported' | 'expiry_unknown';
380
+ constructor(reason: 'card_not_found' | 'brand_unsupported' | 'expiry_unknown');
381
+ }
382
+ /**
383
+ * What the agent types into a checkout so the page's own card facts match the stored card
384
+ * that pays: a documented test number of the same network, a placeholder security code of that
385
+ * network's length, and the stored card's real expiry. CyberSource Flex merchants post the brand
386
+ * the card field detected from the typed number and the expiry typed into their own fields beside
387
+ * the token (Sonos, Amtrak), so a mismatch there fails the merchant's payment.
388
+ */
389
+ export interface PlaceholderCard {
390
+ /**
391
+ * The stored card these facts describe (GET /api/v2/vault_cards). Pass it as `cardId` to
392
+ * attachToCdp, attachToPlaywright or authorize(): the approval page then preselects this card.
393
+ * Without it the page preselects its own choice, which can be another card.
394
+ */
395
+ readonly cardId: string;
396
+ /** Its network, as GET /api/v2/vault_cards names it. */
397
+ readonly brand: string;
398
+ /** A documented test card number of that network. Never a real card: the device supplies the real one. */
399
+ readonly number: string;
400
+ /** A placeholder security code of the length that network uses. */
401
+ readonly securityCode: string;
402
+ /** The stored card's expiry month, two digits ("04"). */
403
+ readonly expiryMonth: string;
404
+ /** The stored card's expiry year, four digits ("2031"). */
405
+ readonly expiryYear: string;
406
+ }
340
407
  /**
341
408
  * The recognizer says this processor is preparation-required, and no preparation
342
409
  * was passed. Its card may be sent only after the cardholder approved a
@@ -349,6 +416,19 @@ export declare class PreparationRequiredError extends Error {
349
416
  psp: string;
350
417
  constructor(psp: string);
351
418
  }
419
+ /**
420
+ * The paused request names no amount and none was passed. A GMO card token
421
+ * request carries no amount (the merchant's server charges the token later),
422
+ * so the `amount` and `currency` you pass are what the cardholder approves.
423
+ * Thrown locally before any create or prompt, and terminal: the same request
424
+ * without an amount fails the same way. The API answers 400 amount_required
425
+ * for the same case. A page total (`pageAmount`) does not count.
426
+ */
427
+ export declare class AmountRequiredError extends Error {
428
+ psp: string;
429
+ readonly code: "amount_required";
430
+ constructor(psp: string);
431
+ }
352
432
  export declare class ApprovalTimeoutError extends Error {
353
433
  constructor(ms: number);
354
434
  }
@@ -812,6 +892,22 @@ export declare class VaultClient {
812
892
  * these patterns pause.
813
893
  */
814
894
  cardUrlPatterns(): string[];
895
+ /**
896
+ * What to type into a checkout so the page's own card facts match the stored card that
897
+ * pays (see PlaceholderCard): read from GET /api/v2/vault_cards, which lists each stored card's
898
+ * network and expiry. `cardId` names the stored card; omitted, the user's most recently added
899
+ * card. Pin the returned `cardId` on the attachment or authorize(), so the approval page
900
+ * preselects that card: without a pin the page preselects its own choice (its recommended
901
+ * card, or the first card the cardholder's device can open), which can be another card. After
902
+ * prepare(), pass the prepared checkout's `cardId`, the card the cardholder already approved.
903
+ * The facts hold only while the cardholder pays with that card. Throws PlaceholderCardError
904
+ * when there is no such card, its network has no placeholder, or it carries no expiry.
905
+ */
906
+ placeholderCard(input: {
907
+ user: string;
908
+ cardId?: string;
909
+ signal?: AbortSignal;
910
+ }): Promise<PlaceholderCard>;
815
911
  /** Wait for real device approval before the caller starts native tokenization. */
816
912
  prepareCheckout(input: PrepareCheckoutInput): Promise<PreparedCheckout>;
817
913
  /** Cancel only an unconsumed preparation; a bound request is reconciled separately. */