@agent-cards/sdk 0.23.6 → 0.23.7

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.
@@ -28,10 +28,14 @@
28
28
  * and holds, as every unsupported refusal does;
29
29
  * - no card_number, or an empty one, as in a payment with a stored payment_token: continue untouched;
30
30
  * - a body the adapter could not read: stop that request alone, since nothing proves it carries no card.
31
- * A request the synced registry recognizes as a card request belongs to that recognizer instead; no
32
- * build serves one today. Hosted Checkout, the same hosts without /silent/, takes the card on
33
- * CyberSource's own page, and neither adapter arms it.
31
+ * A request the synced registry recognizes as a card request belongs to the Secure Acceptance lane
32
+ * instead (below): the API serves that recognizer only to a build that declares
33
+ * `cybersource_sa_token_create` and only for a company Agentcard turned the lane on for, and it covers
34
+ * exactly the token create, POST https://<host>/silent/embedded/token/create on the production or the
35
+ * test host. Every other company, and every other /silent/ path, keeps the stop above. Hosted Checkout,
36
+ * the same hosts without /silent/, takes the card on CyberSource's own page, and neither adapter arms it.
34
37
  */
38
+ import { SECURE_ACCEPTANCE_MERCHANTS, SECURE_ACCEPTANCE_PAGE_CONTENT_TYPE, isSecureAcceptanceTokenRequest, secureAcceptanceMerchantEntry, secureAcceptanceReplyOutcome, } from './cybersource-secure-acceptance.generated.js';
35
39
  export const CYBERSOURCE_SECURE_ACCEPTANCE_REASON = 'unsupported_cybersource_secure_acceptance';
36
40
  /** Where the stopped page says the card was going. */
37
41
  export const CYBERSOURCE_SECURE_ACCEPTANCE_RECIPIENT = 'CyberSource';
@@ -212,3 +216,240 @@ function formFields(body) {
212
216
  fields.push(pair);
213
217
  return fields;
214
218
  }
219
+ // ---- The Secure Acceptance lane (dark launch) ----
220
+ //
221
+ // For a company Agentcard turned it on for, the API serves one recognizer (payment-core definitions.js,
222
+ // capability cybersource_sa_token_create) for one request: POST /silent/embedded/token/create on
223
+ // secureacceptance.cybersource.com (production) or testsecureacceptance.cybersource.com (sandbox), a
224
+ // create_payment_token at a signed amount of 0, with the merchant's signed fields and the four unsigned
225
+ // card fields. The rules that judge that request and CyberSource's reply live in payment-core and reach
226
+ // this SDK as cybersource-secure-acceptance.generated.ts; the API and the cardholder's device run the same
227
+ // ones. The lane pays the request only through a prepared checkout, approved before the agent clicks
228
+ // Save, and only as the merchant's hidden frame sends it:
229
+ // - the pre-claim gate holds a card-bearing post to that URL to a navigation of a frame (pre-claim.ts);
230
+ // a top navigation, a fetch or an XHR is stopped as an unrecognized post is;
231
+ // - the claim binds the preparation only to the request payment-core admits for the preparation's
232
+ // environment, profile id and page origin (prepared-processor.ts);
233
+ // - the cardholder's device sends the real card to CyberSource through the checkout relay, and the API
234
+ // hands back CyberSource's own reply page once neither it nor the device finds a card in it;
235
+ // - the adapters answer the paused frame navigation with that page, status 200 and its one content type
236
+ // (secureAcceptanceFrameAnswer), so Chrome commits it as CyberSource's own document and its auto-post
237
+ // reaches the merchant's receipt page in the agent's own session, as it would with no Agentcard.
238
+ // The clocks fit Arrow's page, which asks its frame 30 times, a second apart, from Save: reads every
239
+ // 500 ms for 30 s after the create; a pause with nothing being handed over 20 s after it is retired (the
240
+ // send window, amadeus.ts armSendWindow); a reply not read 45 s after the pause is an unknown outcome;
241
+ // and once the page is delivered, a merchant whose reviewed entry names its next request is watched for
242
+ // it for 10 s.
243
+ /** The processor id, as the registry and the API name it. */
244
+ export const SECURE_ACCEPTANCE_PSP = 'cybersource_secure_acceptance';
245
+ /** The dark-launch capability the API serves the Secure Acceptance recognizer behind (payment-core definitions.js). */
246
+ export const SECURE_ACCEPTANCE_CAPABILITY = 'cybersource_sa_token_create';
247
+ /** How long after the pause a paused token request may still be sent; past it, nothing being handed over, it is retired. */
248
+ export const SECURE_ACCEPTANCE_SEND_WINDOW_MS = 20_000;
249
+ /** How long after the pause CyberSource's reply may still be read; past it the outcome is unknown. */
250
+ export const SECURE_ACCEPTANCE_REPLY_WINDOW_MS = 45_000;
251
+ /** How long after the create the authorization is read every 500 ms instead of at the caller's interval. */
252
+ export const SECURE_ACCEPTANCE_FAST_POLL_MS = 30_000;
253
+ /** How long after the page is delivered the merchant's follow-up request is watched for. */
254
+ export const SECURE_ACCEPTANCE_FOLLOW_UP_MS = 10_000;
255
+ /** The one content type the paused frame's answer carries, as CyberSource serves its page. */
256
+ export const SECURE_ACCEPTANCE_ANSWER_CONTENT_TYPE = SECURE_ACCEPTANCE_PAGE_CONTENT_TYPE;
257
+ /** A CyberSource Secure Acceptance profile id: a UUID shape, 36 characters, hex in either case. */
258
+ const PROFILE_ID = /^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$/;
259
+ /** This build's reviewed entries, or the ones a test passes. */
260
+ function reviewedEntries(entries) {
261
+ return entries ?? SECURE_ACCEPTANCE_MERCHANTS;
262
+ }
263
+ /** The reviewed entry that lists this profile id for this origin, through payment-core's own lookup, or null. */
264
+ function entryFor(profileId, merchantOrigin, entries) {
265
+ try {
266
+ return secureAcceptanceMerchantEntry(profileId, merchantOrigin, entries) ?? null;
267
+ }
268
+ catch {
269
+ return null;
270
+ }
271
+ }
272
+ /**
273
+ * The profile id a Secure Acceptance checkout prepares, or null when it can name none. A profile id the
274
+ * caller passes must have CyberSource's UUID shape, and is the one the bound request must carry. On the
275
+ * production host the caller may leave it out when exactly one entry this build reviewed lists the
276
+ * checkout page's origin, under exactly one profile id: that one is used, so the agent names nothing it
277
+ * cannot see. The test host has no entries (it takes only CyberSource's test cards), so a sandbox
278
+ * checkout always names its own. `entries` is for tests.
279
+ */
280
+ export function secureAcceptanceCheckoutProfile(input, entries) {
281
+ if (input.profileId !== undefined)
282
+ return typeof input.profileId === 'string' && PROFILE_ID.test(input.profileId) ? input.profileId : null;
283
+ if (input.environment !== 'production')
284
+ return null;
285
+ const listing = reviewedEntries(entries).filter((entry) => entry.environment === 'production' && entry.origins.includes(input.merchantOrigin));
286
+ if (listing.length !== 1 || listing[0].profileIds.length !== 1)
287
+ return null;
288
+ const [profileId] = listing[0].profileIds;
289
+ return typeof profileId === 'string' && PROFILE_ID.test(profileId) ? profileId : null;
290
+ }
291
+ /**
292
+ * Why a Secure Acceptance checkout may not be prepared under `merchant`, or null when it may:
293
+ * `secure_acceptance_profile_id_invalid` when it names no profile id this build can stand behind
294
+ * (secureAcceptanceCheckoutProfile), and, on the production host, `merchant_profile_mismatch` when the
295
+ * entry this build reviewed for that profile id and origin names another merchant, the one the approval
296
+ * shows. A profile id this build has not reviewed is the API's to judge (unsupported_checkout), as an
297
+ * eProtect paypageId is. `entries` is for tests.
298
+ */
299
+ export function secureAcceptanceCheckoutRefusal(input, entries) {
300
+ const profileId = secureAcceptanceCheckoutProfile(input, entries);
301
+ if (profileId === null)
302
+ return 'secure_acceptance_profile_id_invalid';
303
+ if (input.environment !== 'production')
304
+ return null;
305
+ const entry = entryFor(profileId, input.merchantOrigin, entries);
306
+ return entry && entry.merchant !== input.merchant ? 'merchant_profile_mismatch' : null;
307
+ }
308
+ /**
309
+ * Whether a paused request is the one shape the lane pays: a POST to the exact token-create URL on one of
310
+ * the two hosts (payment-core isSecureAcceptanceTokenRequest), sent as a navigation of a frame, as a
311
+ * merchant's form targeted at its hidden iframe sends it. A top navigation, a fetch and an XHR are not.
312
+ */
313
+ export function isSecureAcceptanceFramePost(request) {
314
+ if (request.navigation !== 'frame')
315
+ return false;
316
+ try {
317
+ return isSecureAcceptanceTokenRequest(request.url, String(request.method).toUpperCase()) === true;
318
+ }
319
+ catch {
320
+ return false;
321
+ }
322
+ }
323
+ /**
324
+ * The answer a paused token request gets, or null when the approved reply is not one to hand the frame:
325
+ * CyberSource's own page, byte for byte as the API stored it, with status 200 and the one content type
326
+ * CyberSource serves it with, and no other header. The page must pass payment-core's reply rule for the
327
+ * paused request (secureAcceptanceReplyOutcome: one hidden-input form posting to the receipt page the
328
+ * request signed, the request's own echoes, a masked number, a token, no card), run here with no card and
329
+ * no last four: the device and the API checked those.
330
+ */
331
+ export function secureAcceptanceFrameAnswer(request, response) {
332
+ if (!request || typeof request.url !== 'string' || typeof request.body !== 'string' || !response || typeof response.body !== 'string')
333
+ return null;
334
+ let verdict;
335
+ try {
336
+ // No card and no last four: the device and the API checked the page against the card.
337
+ verdict = secureAcceptanceReplyOutcome({ status: response.status, headers: { ...(response.headers ?? {}) }, bodyText: response.body }, { url: request.url, body: request.body }, undefined, undefined);
338
+ }
339
+ catch {
340
+ return null;
341
+ }
342
+ if (verdict?.outcome !== 'token' || verdict.page !== response.body)
343
+ return null;
344
+ return { status: 200, headers: { 'content-type': SECURE_ACCEPTANCE_ANSWER_CONTENT_TYPE }, body: response.body };
345
+ }
346
+ /**
347
+ * The request a reviewed merchant's page sends once its receipt page answered (Arrow: the payment-method
348
+ * set), for a production preparation whose profile id and origin an entry lists with one; null otherwise,
349
+ * the test host included. `entries` is for tests.
350
+ */
351
+ export function secureAcceptanceFollowUp(preparation, entries) {
352
+ if (preparation.environment !== 'production' || typeof preparation.secureAcceptanceProfileId !== 'string')
353
+ return null;
354
+ const followUp = entryFor(preparation.secureAcceptanceProfileId, preparation.merchantOrigin, entries)?.followUp;
355
+ if (!followUp || typeof followUp.method !== 'string' || typeof followUp.url !== 'string')
356
+ return null;
357
+ try {
358
+ return { method: followUp.method.toUpperCase(), url: new URL(followUp.url).href };
359
+ }
360
+ catch {
361
+ return null;
362
+ }
363
+ }
364
+ const INPUT_TAG = /<input\b((?:[^>"']|"[^"]*"|'[^']*')*)>/gi;
365
+ const ATTRIBUTE = /([^\s=/>"']+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
366
+ const NAMED_ENTITIES = { amp: '&', quot: '"', apos: "'", lt: '<', gt: '>' };
367
+ function decodeEntities(value) {
368
+ return value.replace(/&(#x[0-9a-f]{1,6}|#[0-9]{1,7}|[a-z]+);/gi, (whole, entity) => {
369
+ if (entity[0] === '#') {
370
+ const code = entity[1] === 'x' || entity[1] === 'X' ? parseInt(entity.slice(2), 16) : parseInt(entity.slice(1), 10);
371
+ return Number.isInteger(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : whole;
372
+ }
373
+ return NAMED_ENTITIES[entity.toLowerCase()] ?? whole;
374
+ });
375
+ }
376
+ /**
377
+ * The last four digits of the masked number CyberSource's reply page echoes (req_card_number, masked as
378
+ * x's and the last four), or null when the page carries no single such field. The page passed the reply
379
+ * rule before it was delivered, so this only reads it back for the follow-up event; it never carries more
380
+ * than those four digits.
381
+ */
382
+ export function secureAcceptanceReplyLast4(page) {
383
+ if (typeof page !== 'string')
384
+ return null;
385
+ const values = [];
386
+ for (const tag of page.matchAll(INPUT_TAG)) {
387
+ const attributes = new Map();
388
+ for (const attribute of tag[1].matchAll(ATTRIBUTE)) {
389
+ const name = attribute[1].toLowerCase();
390
+ if (!attributes.has(name))
391
+ attributes.set(name, decodeEntities(attribute[2] ?? attribute[3] ?? attribute[4] ?? ''));
392
+ }
393
+ if (attributes.get('name') === 'req_card_number')
394
+ values.push(attributes.get('value') ?? '');
395
+ }
396
+ return values.length === 1 ? /^[xX]{9,15}([0-9]{4})$/.exec(values[0])?.[1] ?? null : null;
397
+ }
398
+ /** A URL's origin and path, the part a follow-up request is matched on (its query is the page's own). */
399
+ function originAndPath(url) {
400
+ try {
401
+ const parsed = new URL(url);
402
+ return `${parsed.origin}${parsed.pathname}`;
403
+ }
404
+ catch {
405
+ return null;
406
+ }
407
+ }
408
+ /**
409
+ * One attachment's watch for the merchant's follow-up request after a delivered reply. Read-only: it
410
+ * pauses, changes and delays nothing. The adapters report every request the page sends (CDP
411
+ * Network.requestWillBeSent, Playwright page.on('request')) through saw(); when none matches the
412
+ * merchant's follow-up within SECURE_ACCEPTANCE_FOLLOW_UP_MS, the event `secure_acceptance_reply_unused`
413
+ * names the authorization and the masked number's last four, so the agent can find the card the merchant
414
+ * may have kept (in its saved cards, ending in those four) before it ever asks for a new checkout.
415
+ */
416
+ export class SecureAcceptanceFollowUpWatch {
417
+ onEvent;
418
+ ms;
419
+ watching = null;
420
+ constructor(onEvent, ms = SECURE_ACCEPTANCE_FOLLOW_UP_MS) {
421
+ this.onEvent = onEvent;
422
+ this.ms = ms;
423
+ }
424
+ /** Start watching for `followUp` after the reply for `authorizationId` was delivered; a watch already running ends unreported. */
425
+ begin(followUp, detail) {
426
+ this.end();
427
+ const target = originAndPath(followUp.url);
428
+ if (target === null)
429
+ return;
430
+ const timer = setTimeout(() => {
431
+ if (this.watching?.timer !== timer)
432
+ return;
433
+ this.watching = null;
434
+ this.onEvent?.({ type: 'secure_acceptance_reply_unused', detail: { authorizationId: detail.authorizationId, last4: detail.last4 } });
435
+ }, this.ms);
436
+ timer.unref?.();
437
+ this.watching = { target, method: String(followUp.method).toUpperCase(), timer };
438
+ }
439
+ /** A request the page sent: the follow-up, when it is the one being watched for, ends the watch. */
440
+ saw(method, url) {
441
+ if (!this.watching || typeof method !== 'string' || typeof url !== 'string')
442
+ return;
443
+ if (method.toUpperCase() === this.watching.method && originAndPath(url) === this.watching.target)
444
+ this.end();
445
+ }
446
+ /** True while a watch runs. */
447
+ get active() { return this.watching !== null; }
448
+ /** End the watch with no event. */
449
+ end() {
450
+ if (!this.watching)
451
+ return;
452
+ clearTimeout(this.watching.timer);
453
+ this.watching = null;
454
+ }
455
+ }
package/dist/index.d.ts CHANGED
@@ -9,6 +9,7 @@ export { CONVERGE_REFUSAL_REASONS } from './elavon-converge.js';
9
9
  export type { ConvergeRefusalReason } from './elavon-converge.js';
10
10
  export type { WspayRefusalReason } from './wspay.js';
11
11
  export type { ShijiStsUnsupportedReason } from './shiji-sts.js';
12
+ export type { SecureAcceptanceCheckoutRefusal, SecureAcceptanceUnknownReason } from './cybersource-secure-acceptance.js';
12
13
  export type { PausedRequest, ReplayResponse, TokenReplay, CseReplay, MerchantHostedReplay, VerifoneReplay, HostedFormReplay, CardEndpointReplay, DeviceEncryptReplay, AmountAuthority, AuthorizeInput, VaultClientOptions, AgentcardOptions, AdyenTestPlatformRefusal, MerchantTotalRefusal, PrepareCheckoutOptions, PrepareCheckoutInput, PreparedCheckout, RazorpayProcessorError, RuntimeCancelReason, ExecutionMetadata, PlaceholderCard, } from './client.js';
13
14
  export type { MicroformField, MicroformSidecar } from './cybersource-flex.js';
14
15
  export { attachToCdp, attachToPlaywright, corsHeadersFor, corsDecision, withCorsHeaders } from './cdp.js';
@@ -20,7 +20,7 @@ declare function juspayTokenizeAnswer(token: any): {
20
20
  };
21
21
  body: string;
22
22
  };
23
- declare function juspayTxnsTemplateRefusal(url: any, headers: any, body: any): "card_token" | "format" | "merchant_id" | "card_security_code" | "merchant_mismatch" | "juspay_endpoint" | "content_type" | "unreadable_body" | "unsupported_field" | "missing_field" | "order_id" | "routing_id" | "payment_method_type" | "redirect_after_payment" | "save_to_locker" | "tokenize" | "card_nickname" | null;
23
+ declare function juspayTxnsTemplateRefusal(url: any, headers: any, body: any): "content_type" | "card_token" | "format" | "merchant_id" | "card_security_code" | "merchant_mismatch" | "juspay_endpoint" | "unreadable_body" | "unsupported_field" | "missing_field" | "order_id" | "routing_id" | "payment_method_type" | "redirect_after_payment" | "save_to_locker" | "tokenize" | "card_nickname" | null;
24
24
  declare function assertJuspayTxnsTemplate(url: any, headers: any, body: any): Readonly<{
25
25
  environment: "production" | "sandbox" | undefined;
26
26
  host: any;
@@ -35,7 +35,8 @@ export type PreClaimDecision = {
35
35
  } | {
36
36
  kind: 'answer';
37
37
  }
38
- /** `payrails`: the session key a bound Payrails card save carries onto the create. */
38
+ /** `payrails`: the session key a bound Payrails card save carries onto the create. `secureAcceptance`: a CyberSource
39
+ * Secure Acceptance token create the lane held, a navigation of a frame, answered with a page whatever its outcome. */
39
40
  | {
40
41
  kind: 'pause';
41
42
  stripeStep: StripeStep;
@@ -43,6 +44,7 @@ export type PreClaimDecision = {
43
44
  cardEndpoint?: CardEndpointProfile;
44
45
  payrails?: PayrailsSidecar;
45
46
  navigation?: 'top' | 'frame';
47
+ secureAcceptance?: true;
46
48
  };
47
49
  /** The adapter mechanics a gate performs. Each is awaited where the inline code
48
50
  * awaited it; none swallow their own transport failure (a gate adds a .catch
package/dist/pre-claim.js CHANGED
@@ -76,7 +76,7 @@ import { PAYRAILS_HOLDING_REASONS, PAYRAILS_SESSION_MESSAGE, isPayrailsHost, isP
76
76
  import { classifyVerifoneProfileRequest, verifoneTemplatedUrl } from './verifone.js';
77
77
  import { ekashuEntryUrl, isEkashuEntry, isEkashuHost } from './ekashu.js';
78
78
  import { PAYPAL_HOSTED_FIELDS_REASON, isPaypalHostedFieldsConfirm, paypalHostedFieldsBody } from './paypal-hosted-fields.js';
79
- import { CYBERSOURCE_SECURE_ACCEPTANCE_REASON, CYBERSOURCE_SECURE_ACCEPTANCE_RECIPIENT, cybersourceSecureAcceptanceRequest, isCybersourceSecureAcceptanceUrl, } from './cybersource-secure-acceptance.js';
79
+ import { CYBERSOURCE_SECURE_ACCEPTANCE_REASON, CYBERSOURCE_SECURE_ACCEPTANCE_RECIPIENT, cybersourceSecureAcceptanceRequest, isCybersourceSecureAcceptanceUrl, isSecureAcceptanceFramePost, } from './cybersource-secure-acceptance.js';
80
80
  /**
81
81
  * The URL an event may name for a request. For an armed merchant profile's
82
82
  * endpoint or a sibling of it: the endpoint origin, the path TEMPLATE and the
@@ -1550,20 +1550,99 @@ async function paypalHostedFieldsUnreadable(ctx) {
1550
1550
  // proves it carries no card;
1551
1551
  // - any other request (a payment with a stored payment_token and no card, a GET, a CORS preflight)
1552
1552
  // defers, and the arbiter continues it untouched.
1553
- // A request the synced registry recognizes as a card request defers too, so the arbiter pauses it for
1554
- // that recognizer; no build serves one today. A path outside /silent/ is not this gate's. Synchronous
1555
- // until it must stop a request, like every gate. Exported for the gate registry's tests.
1553
+ // A request the synced registry recognizes as a card request is the Secure Acceptance lane's
1554
+ // (cybersource-secure-acceptance.ts): the API serves that recognizer only to a build that declares
1555
+ // cybersource_sa_token_create, for a company Agentcard turned the lane on for, and only for the exact token
1556
+ // create on the production or the test host. Before it pauses, the gate holds such a request to the one
1557
+ // shape the lane pays:
1558
+ // - a card post that a merchant's form submits into a frame (a navigation of a frame, as Arrow's hidden
1559
+ // secureAcceptanceiFrame is) pauses for the claim flow, carrying the mark that answers it with a page
1560
+ // whatever its outcome. With no preparation on this attachment it is not paused: it gets the stopped
1561
+ // page, `stopped_before_send`, with a `blocked` event, `preparation_required`, and nothing reaches the
1562
+ // API, since only a prepared checkout pays it;
1563
+ // - a card post as the page's own navigation, from a fetch or from an XHR, or one whose card field the
1564
+ // recognizer reads under no name of its own (another spelling of card_number, which would otherwise
1565
+ // continue as card-free), is stopped as the post to an unrecognized /silent/ path is;
1566
+ // - a body the adapter could not read is stopped alone, as above;
1567
+ // - a body with no card number defers, and continues untouched (the recognizer's passWithoutCard).
1568
+ // A path outside /silent/ is not this gate's. Synchronous until it must read the frame or stop a request,
1569
+ // like every gate. Exported for the gate registry's tests.
1556
1570
  export const cybersourceSecureAcceptancePreClaim = (ctx) => {
1557
1571
  const { url, method } = ctx.request;
1558
- if (!isCybersourceSecureAcceptanceUrl(url) || ctx.vault.isCardRequest(url, method))
1572
+ if (!isCybersourceSecureAcceptanceUrl(url))
1559
1573
  return undefined;
1574
+ const recognized = ctx.vault.isCardRequest(url, method);
1560
1575
  // A reader that throws (Playwright's postData() can) reads as a body the adapter could not read.
1561
1576
  const { body, contentType } = cardEndpointPausedBody(ctx);
1562
1577
  const carried = cybersourceSecureAcceptanceRequest(url, method, body, contentType);
1563
1578
  if (carried === 'no_card')
1564
1579
  return undefined;
1565
- return carried === 'card' ? cybersourceSecureAcceptanceUnsupported(ctx) : cybersourceSecureAcceptanceUnreadable(ctx);
1580
+ if (carried === 'unreadable')
1581
+ return cybersourceSecureAcceptanceUnreadable(ctx);
1582
+ if (!recognized)
1583
+ return cybersourceSecureAcceptanceUnsupported(ctx);
1584
+ // The recognizer's own reading must find the card this gate found, or the adapter would continue the
1585
+ // post as card-free (passWithoutCard) with the agent's card in it.
1586
+ if ((ctx.vault.withoutCard?.(url, body) ?? null) !== null)
1587
+ return cybersourceSecureAcceptanceUnsupported(ctx);
1588
+ return secureAcceptanceHold(ctx);
1566
1589
  };
1590
+ /**
1591
+ * The lane's hold on a recognized card post: a navigation of a frame pauses, marked, unless no
1592
+ * preparation exists on an idle attachment; anything else is stopped as unsupported.
1593
+ */
1594
+ async function secureAcceptanceHold(ctx) {
1595
+ let navigation = null;
1596
+ try {
1597
+ navigation = ctx.request.navigation === true ? await boundedNavigation(ctx) : null;
1598
+ }
1599
+ catch {
1600
+ navigation = null;
1601
+ }
1602
+ if (!isSecureAcceptanceFramePost({ url: ctx.request.url, method: ctx.request.method, navigation }))
1603
+ return cybersourceSecureAcceptanceUnsupported(ctx);
1604
+ const idle = ctx.attachmentReady() && !ctx.stripeAttachmentBusy() && !ctx.interceptionAborted() && !ctx.terminal()
1605
+ && !ctx.lifecycle.isBlocked() && !ctx.awaitingApproval();
1606
+ if (idle && !ctx.preparationGate.isEngaged())
1607
+ return secureAcceptancePreparationRequired(ctx);
1608
+ return { kind: 'pause', stripeStep: ctx.stripeStep, navigation: 'frame', secureAcceptance: true };
1609
+ }
1610
+ /** How long the lane waits for the adapter to say which frame a paused navigation is for. */
1611
+ const SECURE_ACCEPTANCE_NAVIGATION_READ_MS = 2_000;
1612
+ /**
1613
+ * Which frame a paused navigation is for, as the adapter reads it, or null when it cannot say within
1614
+ * SECURE_ACCEPTANCE_NAVIGATION_READ_MS. A read that does not answer (Chromium does not answer a raw CDP
1615
+ * frame tree read on a session whose own top-level navigation is paused) stops the post as not the
1616
+ * reviewed shape, rather than leaving it paused.
1617
+ */
1618
+ async function boundedNavigation(ctx) {
1619
+ if (typeof ctx.navigation !== 'function')
1620
+ return null;
1621
+ let timer;
1622
+ const late = new Promise((resolve) => { timer = setTimeout(() => resolve(null), SECURE_ACCEPTANCE_NAVIGATION_READ_MS); });
1623
+ try {
1624
+ return (await Promise.race([ctx.navigation().catch(() => null), late])) ?? null;
1625
+ }
1626
+ finally {
1627
+ clearTimeout(timer);
1628
+ }
1629
+ }
1630
+ /**
1631
+ * A held frame post with no preparation on this attachment: refused before any pause, prompt or
1632
+ * authorization, as a Fiserv capture is, and answered with the stopped page naming CyberSource. A later
1633
+ * prepare() is refused too, as after any first card request.
1634
+ */
1635
+ async function secureAcceptancePreparationRequired(ctx) {
1636
+ ctx.preparationGate.observeUnprepared();
1637
+ ctx.lifecycle.preparationRequired();
1638
+ ctx.onEvent?.({ type: 'blocked', detail: 'preparation_required' });
1639
+ try {
1640
+ return await stopNavigationBeforeSend(ctx, CYBERSOURCE_SECURE_ACCEPTANCE_RECIPIENT, 'not_approved');
1641
+ }
1642
+ catch { /* the page could not be answered: fail the request below */ }
1643
+ await ctx.effects.abort().catch(() => { });
1644
+ return { kind: 'abort' };
1645
+ }
1567
1646
  async function cybersourceSecureAcceptanceUnsupported(ctx) {
1568
1647
  const { url, method } = ctx.request;
1569
1648
  ctx.preparationGate.invalidate('unsupported_checkout');
@@ -1,6 +1,7 @@
1
1
  import { CheckoutPreparationError } from './client.js';
2
2
  import { amountRefusal, validAmountInput } from './client.js';
3
3
  import { matchesPreparation, validPreparationEnvironment, preparationEndpoint, preparationBindsByShape, preparationRequestMethod, preparedRequestRefusal } from './prepared-processor.js';
4
+ import { SECURE_ACCEPTANCE_PSP, secureAcceptanceCheckoutProfile } from './cybersource-secure-acceptance.js';
4
5
  /** A local, one-use rendezvous. It never starts or retries a merchant request. */
5
6
  export class PreparationGate {
6
7
  opts;
@@ -106,6 +107,13 @@ export class PreparationGate {
106
107
  const page = new URL(this.documentUrl);
107
108
  if (!(page.protocol === 'https:' || (page.protocol === 'http:' && page.hostname === 'localhost')) || page.username || page.password)
108
109
  throw new CheckoutPreparationError(null, 'merchant_origin_invalid');
110
+ // A CyberSource Secure Acceptance checkout names the profile id its token create must carry: the one
111
+ // passed, or on the production host the one this build's reviewed entry for the page's origin pins
112
+ // (secureAcceptanceCheckoutProfile). prepareCheckout refuses a checkout that can name none, and the
113
+ // handle it returns must carry exactly this one.
114
+ const secureAcceptanceProfileId = options.psp === SECURE_ACCEPTANCE_PSP
115
+ ? secureAcceptanceCheckoutProfile({ environment: options.environment, profileId: options.secureAcceptanceProfileId, merchantOrigin: page.origin }) ?? undefined
116
+ : undefined;
109
117
  const prepared = await this.opts.vault.prepareCheckout({
110
118
  ...options, user: this.opts.user, merchant: this.opts.merchant,
111
119
  amount: this.opts.amount, currency: this.opts.currency, cardId: this.opts.cardId,
@@ -125,8 +133,10 @@ export class PreparationGate {
125
133
  this.prepared = prepared;
126
134
  // A Fiserv preparation also names the key pin the cardholder approves (merchantProfile).
127
135
  // One that tokenizes before Pay names the capture context its cardholder approved under.
136
+ // A Secure Acceptance preparation names the profile id its cardholder approved for.
128
137
  if (prepared.psp !== options.psp || prepared.environment !== options.environment
129
138
  || prepared.merchantProfile !== options.merchantProfile
139
+ || prepared.secureAcceptanceProfileId !== secureAcceptanceProfileId
130
140
  || (prepared.cybersourceFlex?.captureContext ?? null) !== (cybersourceFlex?.captureContext ?? null))
131
141
  throw new CheckoutPreparationError(prepared.id, 'checkout_changed');
132
142
  if (signal.aborted || this.state !== 'preparing') {
@@ -169,11 +179,14 @@ export class PreparationGate {
169
179
  // A Fiserv preparation claims only a capture under the key pin it names.
170
180
  // A CreditGuard preparation claims only a submit to the page it framed.
171
181
  // A Worldpay eProtect preparation claims only its registration GET naming the paypageId it carries.
182
+ // A CyberSource Secure Acceptance preparation claims only its token create naming the profile id it
183
+ // carries, sent from the page it approved (the request's own Origin header names it).
172
184
  if (Date.parse(prepared.expiresAt) <= Date.now() || !matchesPreparation(prepared, requestUrl, requestMethod, requestBody, requestHeaders)) {
173
185
  // An Amadeus request payment-core refuses names its own reason (preparedRequestRefusal), so
174
- // Air France's split expiry reads amadeus_merchant_held_expiry rather than a changed checkout.
186
+ // Air France's split expiry reads amadeus_merchant_held_expiry rather than a changed checkout,
187
+ // and a Secure Acceptance frame post with no Origin reads secure_acceptance_origin_header.
175
188
  const reason = Date.parse(prepared.expiresAt) <= Date.now() ? 'expired'
176
- : preparedRequestRefusal(prepared.psp, prepared.environment, requestUrl, requestBody, requestHeaders, prepared.merchantOrigin);
189
+ : preparedRequestRefusal(prepared.psp, prepared.environment, requestUrl, requestBody, requestHeaders, prepared.merchantOrigin, prepared.currency);
177
190
  this.invalidate(reason);
178
191
  throw new CheckoutPreparationError(prepared.id, reason);
179
192
  }
@@ -11,8 +11,13 @@
11
11
  * amadeus_checkout: prepared only. Its SDK tunnel abandons the add after 30 seconds, too soon
12
12
  * for an approval after Pay. Its environments are its hosts: paypages (production) and
13
13
  * paypages.test (sandbox).
14
+ *
15
+ * cybersource_secure_acceptance: prepared only. The merchant's page asks its hidden frame for
16
+ * CyberSource's reply 30 times, a second apart, from Save (Arrow Electronics), too soon for an
17
+ * approval after Save. Its environments are its two hosts: secureacceptance (production) and
18
+ * testsecureacceptance (sandbox).
14
19
  */
15
- export type PreparationProcessor = 'square' | 'braintree' | 'worldpay' | 'bambora' | 'mercado_pago' | 'recurly' | 'spreedly' | 'adyen' | 'checkout_com' | 'paysafe' | 'fiserv' | 'cybersource_flex' | 'aci' | 'creditguard' | 'worldpay_eprotect' | 'amadeus_checkout' | 'verifone';
20
+ export type PreparationProcessor = 'square' | 'braintree' | 'worldpay' | 'bambora' | 'mercado_pago' | 'recurly' | 'spreedly' | 'adyen' | 'checkout_com' | 'paysafe' | 'fiserv' | 'cybersource_flex' | 'aci' | 'creditguard' | 'worldpay_eprotect' | 'amadeus_checkout' | 'verifone' | 'cybersource_secure_acceptance';
16
21
  export type PreparationEnvironment = 'production' | 'sandbox' | 'shared';
17
22
  /**
18
23
  * How the approved card reaches the processor: the device's own request (token), or
@@ -30,7 +35,8 @@ export declare function preparationRequestMethod(psp: string): 'GET' | 'POST';
30
35
  * hosts too: connect (production) and connect-cert (sandbox). So are CyberSource
31
36
  * Flex's: flex.cybersource.com is live, testflex.cybersource.com is test, and each has
32
37
  * its own signing keys. A Verifone preparation pays in the environment of the key its reviewed
33
- * profile pins.
38
+ * profile pins. CyberSource Secure Acceptance's are separate hosts too: secureacceptance is
39
+ * live, testsecureacceptance is test.
34
40
  */
35
41
  export declare function validPreparationEnvironment(psp: string, environment: string): boolean;
36
42
  /**
@@ -59,13 +65,16 @@ export declare function preparationEndpoint(psp: PreparationProcessor, environme
59
65
  * What a preparation binds beyond its processor and environment. CreditGuard: the merchant page the
60
66
  * cardholder approved (the submit's ppDomain names it) and the hosted page the checkout framed (the
61
67
  * submit must pay that page's own transaction). Worldpay eProtect: the paypageId naming the Worldpay
62
- * merchant the registration is for, fixed before its page sent anything. Every other processor
63
- * ignores all three.
68
+ * merchant the registration is for, fixed before its page sent anything. CyberSource Secure
69
+ * Acceptance: the profile id naming the CyberSource merchant profile the token is created for, and the
70
+ * page the cardholder approved, which the request's Origin, Referer and receipt page must be on. Every
71
+ * other processor ignores all four.
64
72
  */
65
73
  export interface PreparedRequestBinding {
66
74
  merchantOrigin?: string;
67
75
  paymentPageUrl?: string;
68
76
  paypageId?: string | null;
77
+ secureAcceptanceProfileId?: string | null;
69
78
  }
70
79
  /**
71
80
  * The approved merchant page's host, with its port if it has one: what the Amadeus SDK sends in its
@@ -100,6 +109,9 @@ export declare function matchesPreparedMerchantRequest(profileId: string, reques
100
109
  * A Worldpay eProtect preparation names the paypageId its registration must carry.
101
110
  * An Amadeus preparation binds its SDK's Host header to the page the cardholder approved
102
111
  * (merchantOrigin), as the API does.
112
+ * A CyberSource Secure Acceptance preparation names the profile id its token create must carry and
113
+ * the page it must come from, and its signed currency must be the approved one, as the API's create
114
+ * holds it.
103
115
  */
104
116
  export declare function matchesPreparation(preparation: {
105
117
  psp: PreparationProcessor;
@@ -111,6 +123,8 @@ export declare function matchesPreparation(preparation: {
111
123
  merchantOrigin?: string;
112
124
  paymentPageUrl?: string;
113
125
  paypageId?: string;
126
+ secureAcceptanceProfileId?: string;
127
+ currency?: string;
114
128
  }, requestUrl: string, method: string, body?: string | null, headers?: Record<string, string>): boolean;
115
129
  /**
116
130
  * The reason a claim reports for a request its preparation does not match. An Amadeus preparation
@@ -121,4 +135,4 @@ export declare function matchesPreparation(preparation: {
121
135
  * header that does not name the approved page is `amadeus_header_binding`. Everything else, another
122
136
  * processor's request or the other environment's host included, is `checkout_changed`.
123
137
  */
124
- export declare function preparedRequestRefusal(psp: PreparationProcessor, environment: PreparationEnvironment, requestUrl: string, body?: string | null, headers?: Record<string, string>, merchantOrigin?: string | null): string;
138
+ export declare function preparedRequestRefusal(psp: PreparationProcessor, environment: PreparationEnvironment, requestUrl: string, body?: string | null, headers?: Record<string, string>, merchantOrigin?: string | null, currency?: string | null): string;