@visa/cli 4.1.0-rc.229 → 4.1.0-rc.230

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.
@@ -26,7 +26,7 @@ import { summarizeFillFailure } from './adapters/generic.js';
26
26
  import { traceHandleFields } from './trace-handles.js';
27
27
  import { readGenericPageAmount } from './amount.js';
28
28
  import { webBotAuthHeadersOrNone } from './web-bot-auth.js';
29
- import { detectShopifyChallenge, isShopifyCheckoutPage, readShopifyAmount, readStableShopifyAmount, shopifyEnglishCheckoutUrl, } from './adapters/shopify.js';
29
+ import { assertShopifyGuestCheckout, ensureShopifyGuestCheckout, isShopifyCheckoutPage, readShopifyAmount, readStableShopifyAmount, shopifyEnglishCheckoutUrl, } from './adapters/shopify.js';
30
30
  export { minorFromDecimal, pageCurrency } from './amount.js';
31
31
  const SUBMIT_TEXT = /pay|place order|complete|buy|submit|checkout/i;
32
32
  const REVEAL_TEXT = /continue|next|proceed|review|go to payment/i;
@@ -499,7 +499,10 @@ function recordTransactionFacts(evidence, phase, facts) {
499
499
  currency: facts.currency,
500
500
  });
501
501
  }
502
- function reviewChangeReason(review, merchantHost, facts) {
502
+ function reviewChangeReason(review, merchantHost, facts, merchantOrigin) {
503
+ if (review.merchantOrigin && merchantOrigin !== review.merchantOrigin) {
504
+ return `merchant origin changed after review: ${review.merchantOrigin} -> ${merchantOrigin ?? 'invalid'}`;
505
+ }
503
506
  if (merchantHost !== review.merchantHost) {
504
507
  return `merchant changed after review: ${review.merchantHost} -> ${merchantHost}`;
505
508
  }
@@ -511,6 +514,40 @@ function reviewChangeReason(review, merchantHost, facts) {
511
514
  }
512
515
  return null;
513
516
  }
517
+ function exactOrigin(value) {
518
+ try {
519
+ const url = new URL(value);
520
+ if (url.protocol !== 'https:' || url.username || url.password)
521
+ return null;
522
+ return url.origin.toLowerCase();
523
+ }
524
+ catch {
525
+ return null;
526
+ }
527
+ }
528
+ export function trustedMerchantOriginRefusal(options, pageUrl, expectedOrigin) {
529
+ const identity = options.trustedMerchantIdentity;
530
+ if (!identity)
531
+ return null;
532
+ if (Date.parse(identity.expiresAt) <= Date.now())
533
+ return 'trusted UCP checkout handoff expired';
534
+ const origin = exactOrigin(pageUrl);
535
+ if (!origin)
536
+ return 'trusted UCP checkout reached a non-HTTPS or credentialed origin';
537
+ if (expectedOrigin) {
538
+ return origin === expectedOrigin
539
+ ? null
540
+ : `merchant origin changed after review: ${expectedOrigin} -> ${origin}`;
541
+ }
542
+ return identity.allowedOrigins.includes(origin)
543
+ ? null
544
+ : `trusted UCP checkout reached undeclared origin ${origin}`;
545
+ }
546
+ function mandateForPage(options, pageUrl) {
547
+ if (!options.trustedMerchantIdentity)
548
+ return options.mandate;
549
+ return { ...options.mandate, merchantHost: new URL(pageUrl).hostname };
550
+ }
514
551
  function submitTargetChangeReason(review, current) {
515
552
  const reviewed = review.submitTargetFingerprint;
516
553
  if (!reviewed && !current)
@@ -727,6 +764,14 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
727
764
  const options = {
728
765
  ...opts,
729
766
  mandate: { ...opts.mandate },
767
+ ...(opts.trustedMerchantIdentity
768
+ ? {
769
+ trustedMerchantIdentity: Object.freeze({
770
+ ...opts.trustedMerchantIdentity,
771
+ allowedOrigins: Object.freeze([...opts.trustedMerchantIdentity.allowedOrigins]),
772
+ }),
773
+ }
774
+ : {}),
730
775
  };
731
776
  const evidence = new EvidenceLog();
732
777
  // Pin an English locale: amount reconciliation reads the order summary by
@@ -757,12 +802,30 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
757
802
  let fields = {};
758
803
  let keepOpen = false;
759
804
  try {
760
- evidence.step('navigation', { url: options.url });
805
+ evidence.step('navigation', {
806
+ url: options.trustedMerchantIdentity ? exactOrigin(options.url) : options.url,
807
+ });
761
808
  await page.goto(options.url, { waitUntil: 'domcontentloaded' });
762
809
  await waitForStableDom(page);
763
- evidence.step('dom-stable', { url: page.url() });
764
- const merchantHost = new URL(page.url()).hostname;
765
- const preFill = checkMandatePreFill(options.mandate, {
810
+ evidence.step('dom-stable', {
811
+ url: options.trustedMerchantIdentity ? exactOrigin(page.url()) : page.url(),
812
+ });
813
+ let merchantHost = new URL(page.url()).hostname;
814
+ const initialOriginRefusal = trustedMerchantOriginRefusal(options, page.url());
815
+ if (initialOriginRefusal) {
816
+ evidence.step('mandate-verdict', {
817
+ phase: 'trusted-origin',
818
+ ok: false,
819
+ reason: initialOriginRefusal,
820
+ });
821
+ evidence.setSnapshotSummary(await snapshotSummary(page));
822
+ return {
823
+ status: 'finished',
824
+ result: makeResult('blocked-by-mandate', fields, evidence, requiresAdapter, initialOriginRefusal),
825
+ };
826
+ }
827
+ let reviewedOrigin;
828
+ const preFill = checkMandatePreFill(mandateForPage(options, page.url()), {
766
829
  merchantHost,
767
830
  currency: options.currency ?? null,
768
831
  });
@@ -809,37 +872,76 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
809
872
  await page.goto(englishUrl, { waitUntil: 'domcontentloaded' }).catch(() => { });
810
873
  await waitForStableDom(page);
811
874
  if (await isShopifyCheckoutPage(page)) {
812
- evidence.step('navigation', { url: page.url(), reason: 'shopify-locale-normalized' });
875
+ evidence.step('navigation', {
876
+ url: options.trustedMerchantIdentity ? exactOrigin(page.url()) : page.url(),
877
+ reason: 'shopify-locale-normalized',
878
+ });
813
879
  detected = await detectFields(page);
814
880
  }
815
881
  }
816
882
  }
817
883
  let adapter = selectAdapter(detected, { shopify: shopifyPage });
818
884
  if (options.contact && adapter.prepareContact) {
819
- const preparedContact = await adapter.prepareContact(page, options.contact);
820
- for (const field of preparedContact.filled) {
821
- evidence.step('contact-prefill', {
822
- role: field.role,
823
- confidence: field.confidence,
824
- source: field.source,
825
- frame: field.frame,
826
- value: field.value,
827
- ok: field.ok,
828
- error: field.error,
829
- });
830
- }
831
- const challenge = await detectShopifyChallenge(page);
832
- if (challenge) {
833
- evidence.step('outcome', {
834
- outcome: 'action-required',
835
- signal: challenge.signal,
885
+ const recordPrefill = async (contact) => {
886
+ const result = await adapter.prepareContact(page, contact);
887
+ for (const field of result.filled) {
888
+ evidence.step('contact-prefill', {
889
+ role: field.role,
890
+ confidence: field.confidence,
891
+ source: field.source,
892
+ frame: field.frame,
893
+ value: field.value,
894
+ ok: field.ok,
895
+ error: field.error,
896
+ });
897
+ }
898
+ return result;
899
+ };
900
+ let preparedContact = await recordPrefill(options.contact);
901
+ if (shopifyPage && options.checkoutRoute === 'guest-card') {
902
+ const guest = await ensureShopifyGuestCheckout(page);
903
+ evidence.step('note', {
836
904
  phase: 'contact-prefill',
905
+ checkoutRoute: options.checkoutRoute,
906
+ shopifyGuestStatus: guest.status,
907
+ signal: guest.signal,
837
908
  });
838
- evidence.setSnapshotSummary(await snapshotSummary(page));
839
- return {
840
- status: 'finished',
841
- result: makeResult('action-required', fields, evidence, requiresAdapter, `Shop Pay verification requires a human before review (${challenge.signal}); no payment credential was requested`),
842
- };
909
+ if (guest.status === 'action-required') {
910
+ evidence.step('outcome', {
911
+ outcome: 'action-required',
912
+ signal: guest.signal,
913
+ phase: 'contact-prefill',
914
+ });
915
+ evidence.setSnapshotSummary(await snapshotSummary(page));
916
+ return {
917
+ status: 'finished',
918
+ result: makeResult('action-required', fields, evidence, requiresAdapter, guest.detail, undefined, 'human-action-required'),
919
+ };
920
+ }
921
+ if (guest.status === 'transitioned') {
922
+ // Do not write the recognized email a second time: Shopify can open
923
+ // the same modal on every email input event. Only retry contact fill
924
+ // when the takeover interrupted it, and omit email on that retry.
925
+ if (!preparedContact.ok) {
926
+ await settle(page);
927
+ detected = await detectFields(page);
928
+ adapter = selectAdapter(detected, { shopify: true });
929
+ preparedContact = await recordPrefill({ ...options.contact, email: undefined });
930
+ }
931
+ const verifiedGuest = await assertShopifyGuestCheckout(page);
932
+ if (verifiedGuest.status === 'action-required') {
933
+ evidence.step('outcome', {
934
+ outcome: 'action-required',
935
+ signal: verifiedGuest.signal,
936
+ phase: 'contact-prefill-guest-verification',
937
+ });
938
+ evidence.setSnapshotSummary(await snapshotSummary(page));
939
+ return {
940
+ status: 'finished',
941
+ result: makeResult('action-required', fields, evidence, requiresAdapter, verifiedGuest.detail, undefined, 'human-action-required'),
942
+ };
943
+ }
944
+ }
843
945
  }
844
946
  if (!preparedContact.ok) {
845
947
  evidence.setSnapshotSummary(await snapshotSummary(page));
@@ -851,8 +953,10 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
851
953
  }
852
954
  await settle(page);
853
955
  const prefillHost = new URL(page.url()).hostname;
854
- if (prefillHost !== merchantHost) {
855
- const reason = `merchant changed during contact prefill: ${merchantHost} -> ${prefillHost}`;
956
+ const trustedPrefillRefusal = trustedMerchantOriginRefusal(options, page.url());
957
+ if (trustedPrefillRefusal || prefillHost !== merchantHost) {
958
+ const reason = trustedPrefillRefusal ??
959
+ `merchant changed during contact prefill: ${merchantHost} -> ${prefillHost}`;
856
960
  evidence.step('mandate-verdict', {
857
961
  phase: 'contact-prefill',
858
962
  ok: false,
@@ -901,6 +1005,26 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
901
1005
  result: makeResult('failed', fields, evidence, requiresAdapter, reason, undefined, 'card-number-field-unavailable'),
902
1006
  };
903
1007
  }
1008
+ // Contact/shipping and Shopify locale normalization may legitimately move
1009
+ // between the exact service and business origins authorized by UCP. Freeze
1010
+ // whichever declared origin is actually on-screen only after those
1011
+ // credential-free steps, then require that exact origin for approval,
1012
+ // credential fill, and submit.
1013
+ const reviewOriginRefusal = trustedMerchantOriginRefusal(options, page.url());
1014
+ if (reviewOriginRefusal) {
1015
+ evidence.step('mandate-verdict', {
1016
+ phase: 'review-origin',
1017
+ ok: false,
1018
+ reason: reviewOriginRefusal,
1019
+ });
1020
+ evidence.setSnapshotSummary(await snapshotSummary(page));
1021
+ return {
1022
+ status: 'finished',
1023
+ result: makeResult('blocked-by-mandate', fields, evidence, requiresAdapter, reviewOriginRefusal),
1024
+ };
1025
+ }
1026
+ merchantHost = new URL(page.url()).hostname;
1027
+ reviewedOrigin = options.trustedMerchantIdentity ? exactOrigin(page.url()) : undefined;
904
1028
  const facts = await readTransactionFacts(page, options, 'review');
905
1029
  recordTransactionFacts(evidence, 'review', facts);
906
1030
  if (!facts.ok) {
@@ -911,7 +1035,7 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
911
1035
  result: makeResult('blocked-by-mandate', fields, evidence, requiresAdapter, facts.detail),
912
1036
  };
913
1037
  }
914
- const verdict = checkMandate(options.mandate, {
1038
+ const verdict = checkMandate(mandateForPage(options, page.url()), {
915
1039
  merchantHost,
916
1040
  amountMinor: facts.amountMinor,
917
1041
  currency: facts.currency,
@@ -930,8 +1054,12 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
930
1054
  const submit = await findSubmit(page, { allowDeferred: true });
931
1055
  const review = Object.freeze({
932
1056
  id: randomUUID(),
933
- url: page.url(),
1057
+ // A UCP continuation may contain a bearer-like path/query. The live page
1058
+ // stays in the process-bound store; serializable review facts expose only
1059
+ // the exact reviewed origin.
1060
+ url: reviewedOrigin ?? page.url(),
934
1061
  merchantHost,
1062
+ ...(reviewedOrigin ? { merchantOrigin: reviewedOrigin } : {}),
935
1063
  amountMinor: facts.amountMinor,
936
1064
  currency: facts.currency,
937
1065
  mandateMaxAmountMinor: options.mandate.maxAmountMinor,
@@ -939,6 +1067,7 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
939
1067
  submitTarget: submit?.desc ?? null,
940
1068
  submitTargetFingerprint: submit ? Object.freeze({ ...submit.fingerprint }) : null,
941
1069
  detectedRoles: Object.freeze(Object.keys(fields)),
1070
+ checkoutRoute: options.checkoutRoute,
942
1071
  });
943
1072
  evidence.step('review', {
944
1073
  reviewId: review.id,
@@ -948,12 +1077,14 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
948
1077
  submitTarget: review.submitTarget,
949
1078
  submitTargetFingerprint: review.submitTargetFingerprint,
950
1079
  detectedRoles: review.detectedRoles,
1080
+ checkoutRoute: review.checkoutRoute,
951
1081
  });
952
1082
  const checkout = Object.freeze({
953
1083
  review,
954
1084
  fields: Object.freeze({ ...fields }),
955
1085
  evidence,
956
1086
  requiresAdapter: Object.freeze([...requiresAdapter]),
1087
+ checkoutRoute: options.checkoutRoute,
957
1088
  });
958
1089
  store.put(review.id, {
959
1090
  checkout,
@@ -1005,7 +1136,17 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
1005
1136
  // the credential boundary. A changed checkout requires a fresh review.
1006
1137
  await waitForStableDom(page);
1007
1138
  const merchantHost = new URL(page.url()).hostname;
1008
- const preFill = checkMandatePreFill(options.mandate, {
1139
+ const approvalOriginRefusal = trustedMerchantOriginRefusal(options, page.url(), checkout.review.merchantOrigin);
1140
+ if (approvalOriginRefusal) {
1141
+ evidence.step('mandate-verdict', {
1142
+ phase: 'approval-origin',
1143
+ ok: false,
1144
+ reason: approvalOriginRefusal,
1145
+ });
1146
+ evidence.setSnapshotSummary(await snapshotSummary(page));
1147
+ return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, approvalOriginRefusal);
1148
+ }
1149
+ const preFill = checkMandatePreFill(mandateForPage(options, page.url()), {
1009
1150
  merchantHost,
1010
1151
  currency: options.currency ?? null,
1011
1152
  });
@@ -1027,7 +1168,7 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
1027
1168
  evidence.setSnapshotSummary(await snapshotSummary(page));
1028
1169
  return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, approvedFacts.detail);
1029
1170
  }
1030
- const approvalVerdict = checkMandate(options.mandate, {
1171
+ const approvalVerdict = checkMandate(mandateForPage(options, page.url()), {
1031
1172
  merchantHost,
1032
1173
  amountMinor: approvedFacts.amountMinor,
1033
1174
  currency: approvedFacts.currency,
@@ -1038,7 +1179,7 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
1038
1179
  evidence.setSnapshotSummary(await snapshotSummary(page));
1039
1180
  return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, approvalVerdict.reason);
1040
1181
  }
1041
- const changedAtApproval = reviewChangeReason(checkout.review, merchantHost, approvedFacts);
1182
+ const changedAtApproval = reviewChangeReason(checkout.review, merchantHost, approvedFacts, exactOrigin(page.url()) ?? undefined);
1042
1183
  if (changedAtApproval) {
1043
1184
  evidence.step('approval', {
1044
1185
  approved: false,
@@ -1083,6 +1224,24 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
1083
1224
  ? `validated the reviewed checkout; would click ${approvalSubmit.desc}`
1084
1225
  : 'validated the reviewed checkout; no submit control detected');
1085
1226
  }
1227
+ if (options.checkoutRoute === 'guest-card' && (await isShopifyCheckoutPage(page))) {
1228
+ const guest = await assertShopifyGuestCheckout(page);
1229
+ evidence.step('note', {
1230
+ phase: 'pre-credential',
1231
+ checkoutRoute: options.checkoutRoute,
1232
+ shopifyGuestStatus: guest.status,
1233
+ signal: guest.signal,
1234
+ });
1235
+ if (guest.status === 'action-required') {
1236
+ evidence.step('outcome', {
1237
+ outcome: 'action-required',
1238
+ signal: guest.signal,
1239
+ phase: 'pre-credential',
1240
+ });
1241
+ evidence.setSnapshotSummary(await snapshotSummary(page));
1242
+ return makeResult('action-required', state.fields, evidence, requiresAdapter, guest.detail, undefined, 'human-action-required');
1243
+ }
1244
+ }
1086
1245
  const credential = await opts.instrument.getCredential({
1087
1246
  merchantHost,
1088
1247
  amountMinor: approvedFacts.amountMinor,
@@ -1104,8 +1263,11 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
1104
1263
  // A reveal/continue action can navigate between attempts. Never expose
1105
1264
  // the credential to a host other than the one the human reviewed.
1106
1265
  const fillHost = new URL(page.url()).hostname;
1107
- if (fillHost !== checkout.review.merchantHost) {
1108
- const reason = `merchant changed after review: ${checkout.review.merchantHost} -> ${fillHost}`;
1266
+ const fillOriginRefusal = trustedMerchantOriginRefusal(options, page.url(), checkout.review.merchantOrigin);
1267
+ if (fillOriginRefusal ||
1268
+ (!checkout.review.merchantOrigin && fillHost !== checkout.review.merchantHost)) {
1269
+ const reason = fillOriginRefusal ??
1270
+ `merchant changed after review: ${checkout.review.merchantHost} -> ${fillHost}`;
1109
1271
  evidence.step('mandate-verdict', {
1110
1272
  phase: 'approved-submit',
1111
1273
  ok: false,
@@ -1213,7 +1375,17 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
1213
1375
  return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, submitFacts.detail);
1214
1376
  }
1215
1377
  const submitMerchantHost = new URL(page.url()).hostname;
1216
- const verdict = checkMandate(options.mandate, {
1378
+ const submitOriginRefusal = trustedMerchantOriginRefusal(options, page.url(), checkout.review.merchantOrigin);
1379
+ if (submitOriginRefusal) {
1380
+ evidence.step('mandate-verdict', {
1381
+ phase: 'pre-submit-origin',
1382
+ ok: false,
1383
+ reason: submitOriginRefusal,
1384
+ });
1385
+ evidence.setSnapshotSummary(await snapshotSummary(page));
1386
+ return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, submitOriginRefusal);
1387
+ }
1388
+ const verdict = checkMandate(mandateForPage(options, page.url()), {
1217
1389
  merchantHost: submitMerchantHost,
1218
1390
  amountMinor: submitFacts.amountMinor,
1219
1391
  currency: submitFacts.currency,
@@ -1223,7 +1395,7 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
1223
1395
  evidence.setSnapshotSummary(await snapshotSummary(page));
1224
1396
  return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, verdict.reason);
1225
1397
  }
1226
- const changedBeforeSubmit = reviewChangeReason(checkout.review, submitMerchantHost, submitFacts);
1398
+ const changedBeforeSubmit = reviewChangeReason(checkout.review, submitMerchantHost, submitFacts, exactOrigin(page.url()) ?? undefined);
1227
1399
  if (changedBeforeSubmit) {
1228
1400
  evidence.step('mandate-verdict', {
1229
1401
  phase: 'pre-submit',
@@ -61,8 +61,11 @@ function defaultOpenUrl(url) {
61
61
  // `CHECKOUT_SKIP_BROWSER_OPEN=1` or `VISA_SUPPRESS_BROWSER=true|1` disables it
62
62
  // (headless/agent hosts where launching a browser on the WRONG machine is pointless or noisy).
63
63
  if (process.env.CHECKOUT_SKIP_BROWSER_OPEN === '1' ||
64
+ process.env.CHECKOUT_SKIP_BROWSER_OPEN === 'true' ||
64
65
  process.env.VISA_SUPPRESS_BROWSER === '1' ||
65
- process.env.VISA_SUPPRESS_BROWSER === 'true')
66
+ process.env.VISA_SUPPRESS_BROWSER === 'true' ||
67
+ process.env.VISA_CLI_NO_BROWSER === '1' ||
68
+ process.env.VISA_CLI_NO_BROWSER === 'true')
66
69
  return;
67
70
  // Platform-appropriate opener; unknown platforms just skip (the URL is logged).
68
71
  const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start ""' : 'xdg-open';
@@ -1,9 +1,10 @@
1
- export { createCliCheckoutEngine, type CliReviewInput, type CliReviewFacts, type CliPayInput, type CliReceiptFacts, type CliStartMandateInput, type CliMandateFacts, type CliEngineDeps, CheckoutReviewRefusedError, } from './cli-engine.js';
1
+ export { createCliCheckoutEngine, type CliReviewInput, type CliReviewFacts, type CliPayInput, type CliReceiptFacts, type CliStartMandateInput, type CliMandateFacts, type CliEngineDeps, type ReceiptWriteObservation, CheckoutReviewRefusedError, } from './cli-engine.js';
2
2
  export { prepareCheckout, submitApprovedCheckout, runCheckout, InMemoryPreparedCheckoutStore, } from './executor.js';
3
- export type { CheckoutResult, CheckoutReview, CheckoutOutcome, CheckoutFailureCode, } from './executor.js';
3
+ export type { CheckoutResult, CheckoutReview, CheckoutOutcome, CheckoutFailureCode, CheckoutRoute, } from './executor.js';
4
4
  export { readConfirmedMerchants, type ConfirmedMerchant, type ConfirmedCharge, } from './confirmed-merchants.js';
5
5
  export { KNOWN_MERCHANT_IDENTITIES, type MerchantIdentity } from './known-merchants.js';
6
6
  export { RECEIPT_DIR } from './receipt-dir.js';
7
7
  export { HostedApprovalDeclinedError, sanitizeApprovalIntent, APPROVAL_INTENT_MAX_CHARS, } from './hosted-approval.js';
8
8
  export { createCardMandate, drawFromMandate, MandateDrawDeclinedError, DEFAULT_MANDATE_MAX_DRAWS, type CreateCardMandateInput, type CreateCardMandateDeps, type CardMandateFacts, type DrawFromMandateInput, type DrawFromMandateDeps, type DrawResult, type CardMandateMerchant, } from './mandate/card-mandate.js';
9
9
  export { MandateLedger, remainingMinor, defaultLedgerPath, CARD_MANDATE_LEDGER_VERSION, type CardMandateRecord, type CardMandateLedgerFile, type CardMandateDraw, type CardMandateReservation, } from './mandate/mandate-ledger.js';
10
+ export { WEB_BOT_AUTH_MAX_TTL_SECONDS, WEB_BOT_AUTH_DEFAULT_TTL_SECONDS, WEB_BOT_AUTH_SIGNATURE_LABEL, WEB_BOT_AUTH_TAG, WEB_BOT_AUTH_COVERED_COMPONENTS, buildWebBotAuthHeaders, buildWebBotAuthHeadersAsync, webBotAuthHeadersOrNone, webBotAuthHeadersOrNoneAsync, resolveWebBotAuthConfig, type WebBotAuthPrivateJwk, type WebBotAuthSigningKey, type WebBotAuthConfig, type WebBotAuthHeaders, type BuildHeadersParams, } from './web-bot-auth.js';
@@ -9,3 +9,4 @@ export { RECEIPT_DIR } from './receipt-dir.js';
9
9
  export { HostedApprovalDeclinedError, sanitizeApprovalIntent, APPROVAL_INTENT_MAX_CHARS, } from './hosted-approval.js';
10
10
  export { createCardMandate, drawFromMandate, MandateDrawDeclinedError, DEFAULT_MANDATE_MAX_DRAWS, } from './mandate/card-mandate.js';
11
11
  export { MandateLedger, remainingMinor, defaultLedgerPath, CARD_MANDATE_LEDGER_VERSION, } from './mandate/mandate-ledger.js';
12
+ export { WEB_BOT_AUTH_MAX_TTL_SECONDS, WEB_BOT_AUTH_DEFAULT_TTL_SECONDS, WEB_BOT_AUTH_SIGNATURE_LABEL, WEB_BOT_AUTH_TAG, WEB_BOT_AUTH_COVERED_COMPONENTS, buildWebBotAuthHeaders, buildWebBotAuthHeadersAsync, webBotAuthHeadersOrNone, webBotAuthHeadersOrNoneAsync, resolveWebBotAuthConfig, } from './web-bot-auth.js';
@@ -36,6 +36,10 @@ export type CheckoutReceiptV1 = {
36
36
  export type CheckoutReceiptV2 = {
37
37
  schema: 'checkout-agent-receipt/v2';
38
38
  recordedAt: string;
39
+ /** Correlates terminal pre-review observations that have no reviewId yet. */
40
+ observationId?: string;
41
+ /** Explicitly distinguishes pre-submit review observations from payment attempts. */
42
+ phase?: 'review';
39
43
  merchant: {
40
44
  name: string;
41
45
  host: string;
@@ -57,6 +61,15 @@ export type CheckoutReceiptV2 = {
57
61
  network: {
58
62
  confirmation: 'APPROVED' | 'DECLINED' | null;
59
63
  };
64
+ /**
65
+ * Capability evidence for unsuccessful checkout surfaces. Optional so
66
+ * previously published v2 receipt producers remain source-compatible.
67
+ */
68
+ capability?: {
69
+ failureCode: string | null;
70
+ detectedRoles: string[];
71
+ requiresAdapter: string[];
72
+ };
60
73
  /** Local reviewed-attempt identifier. Keeps filenames stable across schema versions. */
61
74
  receiptId: string | null;
62
75
  /** Merchant confirmation reference only; never substituted with an internal review id. */
@@ -80,6 +93,7 @@ export type ReceiptWriteReport = {
80
93
  export declare function buildReceipt(input: {
81
94
  mode: CheckoutMode;
82
95
  reviewId: string | null;
96
+ observationId?: string;
83
97
  merchant: {
84
98
  name: string;
85
99
  host: string;
@@ -6,7 +6,7 @@ import { submitClickedWithoutConfirmation } from './live-fill-approval.js';
6
6
  * What the operator still owes after this run. Empty for a clean dry-run fill
7
7
  * and for a definitive submit outcome whose VIC confirmation posted.
8
8
  */
9
- function reconciliationFor(result, vicConfirmation) {
9
+ function reconciliationFor(result, vicConfirmation, mode) {
10
10
  const reasons = [];
11
11
  if (submitClickedWithoutConfirmation(result)) {
12
12
  reasons.push('the pay control was clicked but no definitive outcome was observed — ' +
@@ -22,7 +22,7 @@ function reconciliationFor(result, vicConfirmation) {
22
22
  reasons.push('confirmed without a merchant confirmation reference — ' +
23
23
  'record the order number from the merchant dashboard');
24
24
  }
25
- if (result.outcome === 'action-required') {
25
+ if (mode === 'submit' && result.outcome === 'action-required') {
26
26
  reasons.push('an issuer verification challenge was shown and not completed — ' +
27
27
  'finish the purchase manually if still wanted, or confirm with the merchant that no order exists');
28
28
  }
@@ -30,7 +30,7 @@ function reconciliationFor(result, vicConfirmation) {
30
30
  }
31
31
  export function buildReceipt(input) {
32
32
  const { result } = input;
33
- const reconciliation = reconciliationFor(result, input.vicConfirmation);
33
+ const reconciliation = reconciliationFor(result, input.vicConfirmation, input.mode);
34
34
  const fallbackAction = input.mode === 'dry-run'
35
35
  ? 'Dry run only — nothing was submitted; safe to run again.'
36
36
  : result.outcome === 'confirmed'
@@ -55,15 +55,25 @@ export function buildReceipt(input) {
55
55
  const cardLast4 = input.cardLast4 && /^\d{4}$/.test(input.cardLast4) ? input.cardLast4 : null;
56
56
  const checkoutUrl = input.merchant.url && KNOWN_MERCHANT_IDENTITIES[input.merchant.url] ? input.merchant.url : null;
57
57
  const networkConfirmation = input.vicConfirmation?.posted === true ? input.vicConfirmation.transactionStatus : null;
58
+ const capability = result.failureCode || result.requiresAdapter.length > 0
59
+ ? {
60
+ failureCode: result.failureCode ?? null,
61
+ detectedRoles: Object.keys(result.fields).sort(),
62
+ requiresAdapter: [...result.requiresAdapter].sort(),
63
+ }
64
+ : null;
58
65
  return {
59
66
  schema: 'checkout-agent-receipt/v2',
60
67
  recordedAt: (input.recordedAt ?? new Date()).toISOString(),
68
+ ...(input.observationId ? { observationId: input.observationId } : {}),
69
+ ...(input.observationId && input.reviewId === null ? { phase: 'review' } : {}),
61
70
  merchant: { name: input.merchant.name, host: input.merchant.host, checkoutUrl },
62
71
  transaction: input.transaction,
63
72
  outcome: result.outcome,
64
73
  agent: input.agentName ? { name: input.agentName } : null,
65
74
  rail: { type: 'card', cardLast4 },
66
75
  network: { confirmation: networkConfirmation },
76
+ ...(capability ? { capability } : {}),
67
77
  receiptId: input.reviewId,
68
78
  reference: result.confirmationRef ?? null,
69
79
  recovery: {
@@ -61,6 +61,15 @@ function parseUnresolved(json, receiptFile) {
61
61
  receipt.schema !== 'checkout-agent-receipt/v2') {
62
62
  return null;
63
63
  }
64
+ // Terminal review observations are useful capability evidence, but no pay
65
+ // control, credential, or submission exists yet. They must never poison the
66
+ // double-charge ledger even when the observed page showed a Shop Pay or 3DS
67
+ // action-required surface.
68
+ if (receipt.schema === 'checkout-agent-receipt/v2' &&
69
+ (receipt.phase === 'review' ||
70
+ (typeof receipt.observationId === 'string' && receipt.receiptId === null))) {
71
+ return null;
72
+ }
64
73
  if (typeof receipt.outcome !== 'string' || !MAY_HAVE_CHARGED_OUTCOMES.has(receipt.outcome)) {
65
74
  return null;
66
75
  }
@@ -20,7 +20,8 @@ export interface WebBotAuthPrivateJwk {
20
20
  export interface WebBotAuthSigningKey {
21
21
  /** RFC 7638 thumbprint. Must match a `kid` published in the directory. */
22
22
  keyId: string;
23
- privateJwk: WebBotAuthPrivateJwk;
23
+ privateJwk?: WebBotAuthPrivateJwk;
24
+ signFn?: (signingInput: Uint8Array) => Promise<Uint8Array>;
24
25
  }
25
26
  export interface WebBotAuthConfig {
26
27
  /** Absolute https URL of the operator-hosted signature directory. */
@@ -81,6 +82,11 @@ export declare function buildWebBotAuthHeaders(params: BuildHeadersParams): WebB
81
82
  * module existed — rather than throwing into a live checkout.
82
83
  */
83
84
  export declare function webBotAuthHeadersOrNone(config: WebBotAuthConfig | null, targetUrl: string, nowSeconds: number): WebBotAuthHeaders | null;
85
+ /**
86
+ * Async version of buildWebBotAuthHeaders, supporting Turnkey or vault signFn signers.
87
+ */
88
+ export declare function buildWebBotAuthHeadersAsync(params: BuildHeadersParams): Promise<WebBotAuthHeaders>;
89
+ export declare function webBotAuthHeadersOrNoneAsync(config: WebBotAuthConfig | null, targetUrl: string, nowSeconds: number): Promise<WebBotAuthHeaders | null>;
84
90
  /**
85
91
  * Resolve signing config from the environment. Returns `null` — meaning "send
86
92
  * unsigned" — unless a directory URL and a usable key are BOTH present.
@@ -88,6 +88,15 @@ export function buildWebBotAuthHeaders(params) {
88
88
  };
89
89
  const base = buildSignatureBase(baseParams);
90
90
  const signatureParams = buildSignatureParams(baseParams);
91
+ // privateJwk is optional on the key so a vault-held signer can supply signFn
92
+ // instead. Only the async builder can await such a signer, so refuse here with
93
+ // the same explicit message rather than handing undefined to createPrivateKey
94
+ // and surfacing an opaque node:crypto error.
95
+ if (!config.key.privateJwk) {
96
+ throw new Error(config.key.signFn
97
+ ? 'WebBotAuthSigningKey with signFn requires buildWebBotAuthHeadersAsync'
98
+ : 'WebBotAuthSigningKey requires either privateJwk or signFn');
99
+ }
91
100
  const privateKey = createPrivateKey({
92
101
  key: config.key.privateJwk,
93
102
  format: 'jwk',
@@ -121,6 +130,56 @@ export function webBotAuthHeadersOrNone(config, targetUrl, nowSeconds) {
121
130
  return null;
122
131
  }
123
132
  }
133
+ /**
134
+ * Async version of buildWebBotAuthHeaders, supporting Turnkey or vault signFn signers.
135
+ */
136
+ export async function buildWebBotAuthHeadersAsync(params) {
137
+ const { authority, config, nowSeconds } = params;
138
+ const ttl = Math.min(Math.max(1, Math.floor(config.ttlSeconds ?? WEB_BOT_AUTH_DEFAULT_TTL_SECONDS)), WEB_BOT_AUTH_MAX_TTL_SECONDS);
139
+ const created = Math.floor(nowSeconds);
140
+ const expires = created + ttl;
141
+ const baseParams = {
142
+ authority,
143
+ directoryUrl: config.directoryUrl,
144
+ keyId: config.key.keyId,
145
+ created,
146
+ expires,
147
+ };
148
+ const base = buildSignatureBase(baseParams);
149
+ const signatureParams = buildSignatureParams(baseParams);
150
+ let signature;
151
+ if (config.key.signFn) {
152
+ signature = await config.key.signFn(Buffer.from(base, 'utf8'));
153
+ }
154
+ else if (config.key.privateJwk) {
155
+ const privateKey = createPrivateKey({
156
+ key: config.key.privateJwk,
157
+ format: 'jwk',
158
+ });
159
+ signature = cryptoSign(null, Buffer.from(base, 'utf8'), privateKey);
160
+ }
161
+ else {
162
+ throw new Error('WebBotAuthSigningKey requires either privateJwk or signFn');
163
+ }
164
+ return {
165
+ 'Signature-Input': `${WEB_BOT_AUTH_SIGNATURE_LABEL}=${signatureParams}`,
166
+ Signature: `${WEB_BOT_AUTH_SIGNATURE_LABEL}=:${Buffer.from(signature).toString('base64')}:`,
167
+ 'Signature-Agent': sfString(config.directoryUrl),
168
+ };
169
+ }
170
+ export async function webBotAuthHeadersOrNoneAsync(config, targetUrl, nowSeconds) {
171
+ if (!config)
172
+ return null;
173
+ try {
174
+ const authority = new URL(targetUrl).host;
175
+ if (!authority)
176
+ return null;
177
+ return await buildWebBotAuthHeadersAsync({ authority, config, nowSeconds });
178
+ }
179
+ catch {
180
+ return null;
181
+ }
182
+ }
124
183
  /**
125
184
  * Resolve signing config from the environment. Returns `null` — meaning "send
126
185
  * unsigned" — unless a directory URL and a usable key are BOTH present.
@@ -151,7 +210,7 @@ export function resolveWebBotAuthConfig(env, loadKey) {
151
210
  catch {
152
211
  return null;
153
212
  }
154
- if (!key?.keyId || key.privateJwk?.crv !== 'Ed25519' || !key.privateJwk.d)
213
+ if (!key?.keyId || (!key.signFn && (key.privateJwk?.crv !== 'Ed25519' || !key.privateJwk.d)))
155
214
  return null;
156
215
  const rawTtl = Number(env.VISA_WEB_BOT_AUTH_TTL_SECONDS);
157
216
  const ttlSeconds = Number.isFinite(rawTtl) && rawTtl > 0 ? rawTtl : undefined;