@visa/cli 4.1.0-rc.13 → 4.1.0-rc.131

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.
Files changed (63) hide show
  1. package/README.md +188 -232
  2. package/dist/checkout-engine/adapters/generic.d.ts +4 -0
  3. package/dist/checkout-engine/adapters/generic.js +28 -13
  4. package/dist/checkout-engine/adapters/index.d.ts +4 -1
  5. package/dist/checkout-engine/adapters/index.js +10 -3
  6. package/dist/checkout-engine/adapters/shopify.d.ts +31 -0
  7. package/dist/checkout-engine/adapters/shopify.js +423 -0
  8. package/dist/checkout-engine/amount.d.ts +15 -0
  9. package/dist/checkout-engine/amount.js +72 -0
  10. package/dist/checkout-engine/cli-engine.d.ts +207 -2
  11. package/dist/checkout-engine/cli-engine.js +677 -27
  12. package/dist/checkout-engine/detect.d.ts +1 -1
  13. package/dist/checkout-engine/detect.js +26 -0
  14. package/dist/checkout-engine/evidence.d.ts +4 -1
  15. package/dist/checkout-engine/evidence.js +51 -6
  16. package/dist/checkout-engine/executor.d.ts +34 -4
  17. package/dist/checkout-engine/executor.js +266 -115
  18. package/dist/checkout-engine/hosted-approval.d.ts +133 -8
  19. package/dist/checkout-engine/hosted-approval.js +400 -49
  20. package/dist/checkout-engine/index.d.ts +4 -1
  21. package/dist/checkout-engine/index.js +3 -0
  22. package/dist/checkout-engine/instrument.d.ts +7 -0
  23. package/dist/checkout-engine/instrument.js +4 -0
  24. package/dist/checkout-engine/live-fill-approval.d.ts +0 -20
  25. package/dist/checkout-engine/live-fill-approval.js +15 -51
  26. package/dist/checkout-engine/mandate/card-mandate.d.ts +121 -0
  27. package/dist/checkout-engine/mandate/card-mandate.js +227 -0
  28. package/dist/checkout-engine/mandate/mandate-ledger.d.ts +165 -0
  29. package/dist/checkout-engine/mandate/mandate-ledger.js +373 -0
  30. package/dist/checkout-engine/outcome.d.ts +2 -2
  31. package/dist/checkout-engine/outcome.js +36 -1
  32. package/dist/checkout-engine/owner-only-file.d.ts +9 -0
  33. package/dist/checkout-engine/owner-only-file.js +20 -1
  34. package/dist/checkout-engine/trace-handles.d.ts +8 -0
  35. package/dist/checkout-engine/trace-handles.js +12 -0
  36. package/dist/checkout-engine/types.d.ts +20 -2
  37. package/dist/checkout-engine/vgs-gateway/server-mint-client.d.ts +82 -0
  38. package/dist/checkout-engine/vgs-gateway/server-mint-client.js +180 -0
  39. package/dist/checkout-engine/vgs-live-instrument.d.ts +38 -0
  40. package/dist/checkout-engine/vgs-live-instrument.js +52 -8
  41. package/dist/checkout-engine/vic-confirmation.js +2 -2
  42. package/dist/cli.js +580 -494
  43. package/dist/mcp-server/index.js +442 -176
  44. package/dist/skills/pair-visa-agent/RUNTIMES.md +92 -0
  45. package/dist/skills/pair-visa-agent/SKILL.md +467 -0
  46. package/dist/skills/pair-visa-agent/scripts/setup.mjs +48 -0
  47. package/install.ps1 +3 -41
  48. package/install.sh +4 -36
  49. package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
  50. package/package.json +16 -12
  51. package/server.json +3 -3
  52. package/dist/checkout-engine/inline-target.d.ts +0 -13
  53. package/dist/checkout-engine/inline-target.js +0 -37
  54. package/dist/checkout-engine/pay-args.d.ts +0 -14
  55. package/dist/checkout-engine/pay-args.js +0 -44
  56. package/dist/checkout-engine/pay.d.ts +0 -1
  57. package/dist/checkout-engine/pay.js +0 -13
  58. package/dist/checkout-engine/repo-env.d.ts +0 -11
  59. package/dist/checkout-engine/repo-env.js +0 -23
  60. package/dist/checkout-engine/run-live-fill.d.ts +0 -1
  61. package/dist/checkout-engine/run-live-fill.js +0 -443
  62. package/dist/checkout-engine/vgs-gateway/fetch-credential.d.mts +0 -74
  63. package/dist/checkout-engine/vgs-gateway/fetch-credential.mjs +0 -240
@@ -2,7 +2,8 @@
2
2
  // prepareCheckout(): navigate -> stabilize -> mandate gate -> resolve the
3
3
  // review facts. No credential is requested or filled in this phase.
4
4
  // submitApprovedCheckout(): verify the approval is bound to that review ->
5
- // revalidate -> mint credential -> fill -> revalidate -> submit.
5
+ // revalidate -> in dry-run stop without requesting a credential; in submit
6
+ // mode mint -> fill -> revalidate -> submit.
6
7
  //
7
8
  // runCheckout() remains the one-shot, auto-approved compatibility wrapper.
8
9
  //
@@ -11,16 +12,20 @@
11
12
  // runs before instrument.getCredential(). No credential is minted and no
12
13
  // field is filled on a page the mandate does not cover.
13
14
  // - The PRE-SUBMIT gate re-runs the full check with the resolved amount and
14
- // currency. No submit ever happens without it passing. Dry-run never
15
- // clicks submit.
15
+ // currency. No submit ever happens without it passing. Dry-run requests no
16
+ // credential and neither fills fields nor clicks submit.
16
17
  import { randomUUID } from 'node:crypto';
17
18
  import { mkdir } from 'node:fs/promises';
18
19
  import { join } from 'node:path';
19
20
  import { detectFields } from './detect.js';
20
21
  import { checkMandate, checkMandatePreFill } from './mandate.js';
21
- import { EvidenceLog } from './evidence.js';
22
+ import { EvidenceLog, maskOtp } from './evidence.js';
22
23
  import { observeOutcome } from './outcome.js';
23
24
  import { selectAdapter } from './adapters/index.js';
25
+ import { traceHandleFields } from './trace-handles.js';
26
+ import { readGenericPageAmount } from './amount.js';
27
+ import { detectShopifyChallenge, isShopifyCheckoutPage, readShopifyAmount, readStableShopifyAmount, } from './adapters/shopify.js';
28
+ export { minorFromDecimal, pageCurrency } from './amount.js';
24
29
  const SUBMIT_TEXT = /pay|place order|complete|buy|submit|checkout/i;
25
30
  const REVEAL_TEXT = /continue|next|proceed|review|go to payment/i;
26
31
  // Post-submit confirmed/declined/challenge signals live in outcome.ts
@@ -35,92 +40,6 @@ async function settle(page) {
35
40
  await page.waitForTimeout(200);
36
41
  await page.waitForLoadState('networkidle', { timeout: 1500 }).catch(() => { });
37
42
  }
38
- // Convert a human decimal like "$50.00" to integer minor units without
39
- // floats. Fail-closed on separator ambiguity: only layouts with exactly one
40
- // reading are parsed; anything else returns null and the caller refuses. The
41
- // dangerous direction is UNDER-reading (an EU "1.234,56" read as 1.23 lets an
42
- // over-cap total pass the gate), so no layout is ever guessed.
43
- // Only 2-decimal currencies are supported (see pageCurrency's ISO allowlist).
44
- export function minorFromDecimal(text) {
45
- const m = text.match(/\d[\d.,]*/);
46
- if (!m)
47
- return null;
48
- const token = m[0].replace(/[.,]+$/, '');
49
- // "1234" — plain integer major units.
50
- if (/^\d+$/.test(token))
51
- return Number.parseInt(token, 10) * 100;
52
- // "1,234.56" — thousands groups of exactly 3 plus a 2-digit decimal.
53
- if (/^\d{1,3}(,\d{3})+\.\d{2}$/.test(token)) {
54
- const [whole, frac] = token.replace(/,/g, '').split('.');
55
- return Number.parseInt(whole, 10) * 100 + Number.parseInt(frac, 10);
56
- }
57
- // "1.234,56" — the EU mirror: dot thousands, comma decimal.
58
- if (/^\d{1,3}(\.\d{3})+,\d{2}$/.test(token)) {
59
- const [whole, frac] = token.replace(/\./g, '').split(',');
60
- return Number.parseInt(whole, 10) * 100 + Number.parseInt(frac, 10);
61
- }
62
- // "49.99" / "49.9" — dot decimal. A 3-digit dot group ("1.234") is EU
63
- // thousands, not a decimal, so only 1-2 fraction digits qualify.
64
- if (/^\d+\.\d{1,2}$/.test(token)) {
65
- const [whole, frac] = token.split('.');
66
- return Number.parseInt(whole, 10) * 100 + Number.parseInt(frac.padEnd(2, '0'), 10);
67
- }
68
- // "49,99" / "49,9" — comma decimal. Unambiguous: a thousands group is
69
- // always exactly 3 digits, so a 1-2 digit comma tail can only be a decimal.
70
- if (/^\d+,\d{1,2}$/.test(token)) {
71
- const [whole, frac] = token.split(',');
72
- return Number.parseInt(whole, 10) * 100 + Number.parseInt(frac.padEnd(2, '0'), 10);
73
- }
74
- // Everything else ("1,234" thousands-or-3-decimals, "1.2.3", ...) is
75
- // ambiguous: refuse rather than guess.
76
- return null;
77
- }
78
- // Currency stated by the page's total text, when unambiguous. "$" is shared
79
- // by USD/CAD/AUD/... and never qualifies. The ISO allowlist is 2-decimal
80
- // currencies only, matching minorFromDecimal's scaling.
81
- const ISO_CURRENCIES = ['USD', 'EUR', 'GBP', 'CAD', 'AUD', 'CHF', 'NZD'];
82
- export function pageCurrency(text) {
83
- const iso = text.match(/\b([A-Z]{3})\b/);
84
- if (iso && ISO_CURRENCIES.includes(iso[1]))
85
- return iso[1];
86
- if (text.includes('€'))
87
- return 'EUR';
88
- if (text.includes('£'))
89
- return 'GBP';
90
- return null;
91
- }
92
- // Read the order total from the page. Prefers an explicit data-total-minor
93
- // attribute (machine-readable), else parses a labelled total from the page.
94
- // Either way the currency comes from the element's text, or stays null. A
95
- // total that is present but ambiguous is 'unreadable' — the executor refuses
96
- // rather than falling back to the caller amount, because the page is showing
97
- // the user a number we cannot verify against the mandate.
98
- async function readPageAmount(page) {
99
- const explicitLoc = page.locator('[data-total-minor]').first();
100
- if ((await explicitLoc.count().catch(() => 0)) > 0) {
101
- const explicit = await explicitLoc.getAttribute('data-total-minor').catch(() => null);
102
- if (explicit && /^\d+$/.test(explicit)) {
103
- const text = (await explicitLoc.textContent().catch(() => null)) ?? '';
104
- return {
105
- kind: 'ok',
106
- amountMinor: Number.parseInt(explicit, 10),
107
- currency: pageCurrency(text),
108
- source: 'page-attr',
109
- };
110
- }
111
- }
112
- const totalLoc = page.locator('#order-total, .order-total, [data-testid="order-total"]').first();
113
- if ((await totalLoc.count().catch(() => 0)) > 0) {
114
- const totalText = await totalLoc.textContent().catch(() => null);
115
- if (totalText && /\d/.test(totalText)) {
116
- const amountMinor = minorFromDecimal(totalText);
117
- if (amountMinor == null)
118
- return { kind: 'unreadable' };
119
- return { kind: 'ok', amountMinor, currency: pageCurrency(totalText), source: 'page-text' };
120
- }
121
- }
122
- return { kind: 'none' };
123
- }
124
43
  async function tryReveal(page, evidence, clicked) {
125
44
  // 1) A payment-method radio for card/credit/debit (accordion layouts).
126
45
  const radios = page.locator('input[type="radio"]');
@@ -226,6 +145,19 @@ async function findSubmit(page) {
226
145
  }
227
146
  return null;
228
147
  }
148
+ // The diagnostic snapshot records the page ORIGIN only, never the full URL: a
149
+ // payment-session path/query (e.g. a live Stripe `cs_live_...` checkout-session
150
+ // id) must not be retained in the local receipt, which elsewhere promises
151
+ // "hostname only" (#7101). Falls back to the raw value only if it does not parse
152
+ // as a URL (never a real page.url()).
153
+ export function snapshotOrigin(rawUrl) {
154
+ try {
155
+ return new URL(rawUrl).origin;
156
+ }
157
+ catch {
158
+ return '';
159
+ }
160
+ }
229
161
  async function snapshotSummary(page) {
230
162
  const info = await page
231
163
  .evaluate(() => {
@@ -234,7 +166,7 @@ async function snapshotSummary(page) {
234
166
  return { title: document.title, heading: heading?.textContent?.trim() || '', body };
235
167
  })
236
168
  .catch(() => ({ title: '', heading: '', body: '' }));
237
- return `url=${page.url()} title="${info.title}" heading="${info.heading}" body="${info.body}"`;
169
+ return `url=${snapshotOrigin(page.url())} title="${info.title}" heading="${info.heading}" body="${info.body}"`;
238
170
  }
239
171
  async function readConfirmationRef(page) {
240
172
  const ref = await page
@@ -444,8 +376,19 @@ function makeResult(outcome, fields, evidence, requiresAdapter, detail, confirma
444
376
  ...(confirmationRef ? { confirmationRef } : {}),
445
377
  };
446
378
  }
447
- async function readTransactionFacts(page, opts) {
448
- const pageAmount = await readPageAmount(page);
379
+ async function readTransactionFacts(page, opts, phase) {
380
+ const shopify = await isShopifyCheckoutPage(page);
381
+ const amountRead = shopify
382
+ ? phase === 'review'
383
+ ? await readStableShopifyAmount(page)
384
+ : await readShopifyAmount(page, true)
385
+ : await readGenericPageAmount(page);
386
+ const pageAmount = shopify && amountRead.kind === 'none'
387
+ ? {
388
+ kind: 'unreadable',
389
+ reason: 'Shopify final tax and total summary is not available',
390
+ }
391
+ : amountRead;
449
392
  const amountMinor = pageAmount.kind === 'ok'
450
393
  ? pageAmount.amountMinor
451
394
  : pageAmount.kind === 'none'
@@ -474,10 +417,10 @@ async function readTransactionFacts(page, opts) {
474
417
  currency,
475
418
  source,
476
419
  reason: pageAmount.kind === 'unreadable'
477
- ? 'page total is displayed but cannot be parsed unambiguously'
420
+ ? (pageAmount.reason ?? 'page total is displayed but cannot be parsed unambiguously')
478
421
  : 'transaction amount could not be determined',
479
422
  detail: pageAmount.kind === 'unreadable'
480
- ? 'transaction amount could not be determined (page total present but ambiguous, e.g. separator layout); refusing fail-closed'
423
+ ? `transaction amount could not be determined (${pageAmount.reason ?? 'page total present but ambiguous'}); refusing fail-closed`
481
424
  : 'transaction amount could not be determined (no readable page total, no amountMinor provided); refusing fail-closed',
482
425
  };
483
426
  }
@@ -630,11 +573,17 @@ async function captureDebugShot(page, dir, reviewId, label, evidence, fields) {
630
573
  export function isStripeLinkConsumerRequest(url) {
631
574
  return /(?:^|\/\/)([a-z0-9.-]*\.)?stripe\.com\/v1\/consumers\/sessions\/(?:lookup|start_verification)\b/i.test(url);
632
575
  }
633
- async function suppressStripeLink(page, evidence) {
634
- let suppressed = 0;
576
+ // Keyed by Page so the tracker installed at prepare time is reachable from the
577
+ // approved-submit leg without threading through the session store types.
578
+ const linkSuppressionByPage = new WeakMap();
579
+ // Exported for the link-quiet unit tests (a fake Page captures the route
580
+ // handler); production callers stay inside this module.
581
+ export async function suppressStripeLink(page, evidence) {
582
+ const state = { suppressed: 0, waiters: [] };
583
+ linkSuppressionByPage.set(page, state);
635
584
  await page.route((u) => isStripeLinkConsumerRequest(typeof u === 'string' ? u : u.href), (route) => {
636
- suppressed += 1;
637
- if (suppressed === 1) {
585
+ state.suppressed += 1;
586
+ if (state.suppressed === 1) {
638
587
  // origin + pathname only — never the full URL. The lookup carries the
639
588
  // email in the POST body today, but keep an operator email out of the
640
589
  // evidence log even if Stripe moves a param to the query string (#5708).
@@ -649,9 +598,45 @@ async function suppressStripeLink(page, evidence) {
649
598
  }
650
599
  evidence.step('note', { linkSuppressed: safe });
651
600
  }
601
+ for (const wake of state.waiters.splice(0))
602
+ wake();
652
603
  return route.abort();
653
604
  });
654
605
  }
606
+ /**
607
+ * Wait for the suppressed Stripe Link lookup to fire and settle BEFORE the
608
+ * submit click. Stripe debounces its consumer-session lookup ~300ms after the
609
+ * email input changes; our fill→click gap is single-digit ms, so the (aborted)
610
+ * lookup used to land INSIDE Stripe's in-flight submit chain and kill it
611
+ * silently — the click looked accepted but tokenization never ran and the page
612
+ * sat on the form until the outcome deadline (#5879: three identical live
613
+ * stalls at donate.stripe.com). Verified live A/B on that page: instant click →
614
+ * dead submit, no /v1/payment_methods; lookup settled first → tokenization and
615
+ * the confirm step both reached.
616
+ *
617
+ * If the lookup already fired, only the short settle applies (lets Stripe's
618
+ * abort handling unwind). If it never fires — non-Link page variants, no email
619
+ * field — the bound expires and the click proceeds as before.
620
+ */
621
+ export async function waitForLinkLookupQuiet(page, opts = {}) {
622
+ const boundMs = opts.boundMs ?? 1500;
623
+ const settleMs = opts.settleMs ?? 250;
624
+ const delay = opts.delay ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
625
+ const state = linkSuppressionByPage.get(page);
626
+ if (!state)
627
+ return { fired: false, waitedMs: 0 };
628
+ const started = Date.now();
629
+ if (state.suppressed === 0) {
630
+ await Promise.race([
631
+ new Promise((resolve) => state.waiters.push(resolve)),
632
+ delay(boundMs),
633
+ ]);
634
+ }
635
+ const fired = state.suppressed > 0;
636
+ if (fired)
637
+ await delay(settleMs);
638
+ return { fired, waitedMs: Date.now() - started };
639
+ }
655
640
  // Payer-chosen amount inputs. Deliberately payment-link-specific (Stripe's
656
641
  // customUnitAmount): on an ordinary checkout the total is merchant-controlled
657
642
  // and typing into anything price-like must never happen.
@@ -745,7 +730,62 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
745
730
  }
746
731
  // Detection is read-only here. In particular, no adapter fill and no
747
732
  // Instrument.getCredential() call can occur before an explicit approval.
748
- const detected = await detectFields(page);
733
+ let detected = await detectFields(page);
734
+ const shopifyPage = await isShopifyCheckoutPage(page);
735
+ let adapter = selectAdapter(detected, { shopify: shopifyPage });
736
+ if (options.contact && adapter.prepareContact) {
737
+ const preparedContact = await adapter.prepareContact(page, options.contact);
738
+ for (const field of preparedContact.filled) {
739
+ evidence.step('contact-prefill', {
740
+ role: field.role,
741
+ confidence: field.confidence,
742
+ source: field.source,
743
+ frame: field.frame,
744
+ value: field.value,
745
+ ok: field.ok,
746
+ error: field.error,
747
+ });
748
+ }
749
+ const challenge = await detectShopifyChallenge(page);
750
+ if (challenge) {
751
+ evidence.step('outcome', {
752
+ outcome: 'action-required',
753
+ signal: challenge.signal,
754
+ phase: 'contact-prefill',
755
+ });
756
+ evidence.setSnapshotSummary(await snapshotSummary(page));
757
+ return {
758
+ status: 'finished',
759
+ result: makeResult('action-required', fields, evidence, requiresAdapter, `Shop Pay verification requires a human before review (${challenge.signal}); no payment credential was requested`),
760
+ };
761
+ }
762
+ if (!preparedContact.ok) {
763
+ evidence.setSnapshotSummary(await snapshotSummary(page));
764
+ return {
765
+ status: 'finished',
766
+ result: makeResult('failed', fields, evidence, requiresAdapter, preparedContact.detail ??
767
+ 'Shopify contact prefill did not complete; no payment credential was requested'),
768
+ };
769
+ }
770
+ await settle(page);
771
+ const prefillHost = new URL(page.url()).hostname;
772
+ if (prefillHost !== merchantHost) {
773
+ const reason = `merchant changed during contact prefill: ${merchantHost} -> ${prefillHost}`;
774
+ evidence.step('mandate-verdict', {
775
+ phase: 'contact-prefill',
776
+ ok: false,
777
+ reason,
778
+ });
779
+ evidence.setSnapshotSummary(await snapshotSummary(page));
780
+ return {
781
+ status: 'finished',
782
+ result: makeResult('blocked-by-mandate', fields, evidence, requiresAdapter, reason),
783
+ };
784
+ }
785
+ detected = await detectFields(page);
786
+ adapter = selectAdapter(detected, { shopify: shopifyPage });
787
+ evidence.step('adapter-selected', { adapter: adapter.name, phase: 'review' });
788
+ }
749
789
  fields = detected.fields;
750
790
  evidence.step('detect', {
751
791
  phase: 'review',
@@ -759,7 +799,7 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
759
799
  evidence.step('psp-detected', { psp: p.psp, requiresAdapter: p.requiresAdapter });
760
800
  }
761
801
  }
762
- const facts = await readTransactionFacts(page, options);
802
+ const facts = await readTransactionFacts(page, options, 'review');
763
803
  recordTransactionFacts(evidence, 'review', facts);
764
804
  if (!facts.ok) {
765
805
  evidence.step('mandate-verdict', { phase: 'review', ok: false, reason: facts.reason });
@@ -870,7 +910,7 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
870
910
  evidence.setSnapshotSummary(await snapshotSummary(page));
871
911
  return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, preFill.reason);
872
912
  }
873
- const approvedFacts = await readTransactionFacts(page, options);
913
+ const approvedFacts = await readTransactionFacts(page, options, 'approval');
874
914
  recordTransactionFacts(evidence, 'approval', approvedFacts);
875
915
  if (!approvedFacts.ok) {
876
916
  evidence.step('mandate-verdict', {
@@ -925,6 +965,16 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
925
965
  return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, reason);
926
966
  }
927
967
  evidence.step('approval', { approved: true, reviewId: checkout.review.id });
968
+ if (opts.mode === 'dry-run') {
969
+ evidence.step('credential-skipped', {
970
+ reason: 'dry-run stops before credential mint or merchant-page disclosure',
971
+ });
972
+ evidence.step('submit', { would: true, target: approvalSubmit?.desc ?? 'none found' });
973
+ evidence.setSnapshotSummary(await snapshotSummary(page));
974
+ return makeResult('reviewed-dry-run', state.fields, evidence, requiresAdapter, approvalSubmit
975
+ ? `validated the reviewed checkout; would click ${approvalSubmit.desc}`
976
+ : 'validated the reviewed checkout; no submit control detected');
977
+ }
928
978
  const credential = await opts.instrument.getCredential({
929
979
  merchantHost,
930
980
  amountMinor: approvedFacts.amountMinor,
@@ -934,11 +984,14 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
934
984
  ...(credential.credentialExpiresAt
935
985
  ? { credentialExpiresAt: credential.credentialExpiresAt }
936
986
  : {}),
987
+ ...traceHandleFields(credential),
937
988
  });
938
989
  // Reveal + fill loop: fills whatever is present, then reveals the next
939
990
  // surface (card radio / next step) until the card number is filled.
940
991
  const clicked = new Set();
941
992
  let adapterName = null;
993
+ let adapterFillOk = true;
994
+ let adapterFillDetail = null;
942
995
  for (let attempt = 0; attempt < 4; attempt++) {
943
996
  // A reveal/continue action can navigate between attempts. Never expose
944
997
  // the credential to a host other than the one the human reviewed.
@@ -967,12 +1020,16 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
967
1020
  evidence.step('psp-detected', { psp: p.psp, requiresAdapter: p.requiresAdapter });
968
1021
  }
969
1022
  }
970
- const adapter = selectAdapter(detected);
1023
+ const adapter = selectAdapter(detected, {
1024
+ shopify: await isShopifyCheckoutPage(page),
1025
+ });
971
1026
  if (adapter.name !== adapterName) {
972
1027
  adapterName = adapter.name;
973
1028
  evidence.step('adapter-selected', { adapter: adapter.name });
974
1029
  }
975
1030
  const fill = await adapter.fill(page, detected.fields, credential, opts.contact);
1031
+ adapterFillOk = fill.ok;
1032
+ adapterFillDetail = fill.detail ?? null;
976
1033
  for (const f of fill.filled) {
977
1034
  evidence.step('field-fill', {
978
1035
  role: f.role,
@@ -1014,7 +1071,7 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
1014
1071
  // Re-run the full gate after fill as well. Contact/shipping fields can
1015
1072
  // change the total; any drift from the approved review refuses before a
1016
1073
  // submit click and requires the caller to prepare a new review.
1017
- const submitFacts = await readTransactionFacts(page, options);
1074
+ const submitFacts = await readTransactionFacts(page, options, 'pre-submit');
1018
1075
  recordTransactionFacts(evidence, 'pre-submit', submitFacts);
1019
1076
  if (!submitFacts.ok) {
1020
1077
  evidence.step('mandate-verdict', {
@@ -1057,16 +1114,9 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
1057
1114
  evidence.setSnapshotSummary(await snapshotSummary(page));
1058
1115
  return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, submitChangedBeforeClick);
1059
1116
  }
1060
- if (opts.mode === 'dry-run') {
1061
- evidence.step('submit', { would: true, target: submit?.desc ?? 'none found' });
1117
+ if (!adapterFillOk) {
1062
1118
  evidence.setSnapshotSummary(await snapshotSummary(page));
1063
- if (missingCredentialRoles.length > 0) {
1064
- const adapterRequired = requiresAdapter.size > 0;
1065
- return makeResult(adapterRequired ? 'adapter-required' : 'partial-fill', state.fields, evidence, requiresAdapter, adapterRequired
1066
- ? `credential fields require adapter: ${[...requiresAdapter].join(', ')}; missing ${missingCredentialRoles.join(', ')}`
1067
- : `credential fill incomplete: missing ${missingCredentialRoles.join(', ')}`);
1068
- }
1069
- return makeResult('filled-dry-run', state.fields, evidence, requiresAdapter, submit ? `would click ${submit.desc}` : 'no submit control detected');
1119
+ return makeResult('failed', state.fields, evidence, requiresAdapter, adapterFillDetail ?? `${adapterName ?? 'checkout'} adapter fill incomplete`);
1070
1120
  }
1071
1121
  if (missingCredentialRoles.length > 0) {
1072
1122
  evidence.setSnapshotSummary(await snapshotSummary(page));
@@ -1076,6 +1126,41 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
1076
1126
  evidence.setSnapshotSummary(await snapshotSummary(page));
1077
1127
  return makeResult('failed', state.fields, evidence, requiresAdapter, 'no submit control detected');
1078
1128
  }
1129
+ // Capture the OTP poll watermark BEFORE the click. The click is what
1130
+ // triggers the merchant's verification email, so the watermark must precede
1131
+ // it — otherwise a fast OTP could arrive before we start looking (the 5s
1132
+ // waitForMessage skew is a second line of defence, but ordering matters).
1133
+ // This is just a timestamp — no PII — so it is safe to record.
1134
+ const otpWatermark = new Date().toISOString();
1135
+ evidence.step('note', { otpWatermarkCaptured: true });
1136
+ // The suppressed Link lookup must settle before the click or it breaks
1137
+ // Stripe's submit chain mid-flight (#5879) — see waitForLinkLookupQuiet.
1138
+ const linkQuiet = await waitForLinkLookupQuiet(page);
1139
+ evidence.step('note', { linkQuiet });
1140
+ // This is deliberately the last await before the irreversible click.
1141
+ // Mint-time validation is not enough: reveal/fill and Link suppression can
1142
+ // consume a short-lived DAVV. Refuse malformed or <60s credentials so an
1143
+ // expiry decline cannot masquerade as a form-fill failure.
1144
+ const credentialExpiresAt = credential.credentialExpiresAt;
1145
+ const expiryMissing = opts.instrument.kind === 'agentic-token' && credentialExpiresAt === undefined;
1146
+ const expiresMs = credentialExpiresAt === undefined ? Number.NaN : Date.parse(credentialExpiresAt);
1147
+ if (expiryMissing ||
1148
+ (credentialExpiresAt !== undefined &&
1149
+ (!Number.isFinite(expiresMs) || expiresMs - Date.now() < 60_000))) {
1150
+ evidence.step('credential-expiry-check', {
1151
+ ok: false,
1152
+ reason: expiryMissing ? 'missing' : 'invalid-or-expiring',
1153
+ ...(credentialExpiresAt !== undefined ? { credentialExpiresAt } : {}),
1154
+ });
1155
+ evidence.setSnapshotSummary(await snapshotSummary(page));
1156
+ return makeResult('failed', state.fields, evidence, requiresAdapter, 'credential expired before submit; obtain a fresh intent and re-review');
1157
+ }
1158
+ if (credentialExpiresAt !== undefined) {
1159
+ evidence.step('credential-expiry-check', {
1160
+ ok: true,
1161
+ credentialExpiresAt,
1162
+ });
1163
+ }
1079
1164
  evidence.step('submit', { clicked: true, target: submit.desc });
1080
1165
  await submit.click();
1081
1166
  await settle(page);
@@ -1096,6 +1181,57 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
1096
1181
  });
1097
1182
  observed = reconcileHeldOutcome(observed, held);
1098
1183
  }
1184
+ // Agent-resolvable email OTP subroutine (SINGLE-USE). Fires only on a
1185
+ // 'verification-required' verdict (the merchant emailed a code to the
1186
+ // agent's own inbox) AND when a resolver is injected. Fills the code EXACTLY
1187
+ // ONCE, re-submits, and re-observes. Merchants invalidate a code on first
1188
+ // use, so a stale code is NEVER retried. On no resolver / timeout / missing
1189
+ // code field it falls through to the action-required (human) path below —
1190
+ // it never hangs and never re-fills credential material.
1191
+ if (observed.status === 'verification-required' && opts.resolveEmailOtp) {
1192
+ const otpDetect = await detectFields(page);
1193
+ const codeField = otpDetect.fields.oneTimeCode;
1194
+ if (!codeField) {
1195
+ evidence.step('note', { emailOtp: 'no one-time-code field detected' });
1196
+ }
1197
+ else {
1198
+ const resolution = await opts.resolveEmailOtp({
1199
+ after: otpWatermark,
1200
+ merchantHost: submitMerchantHost,
1201
+ });
1202
+ if (!resolution) {
1203
+ // Fail CLEAN: no code retrieved before the resolver's timeout.
1204
+ evidence.step('note', { emailOtp: 'not retrieved before timeout' });
1205
+ }
1206
+ else {
1207
+ // Fill once. maskOtp() ensures the code NEVER enters the evidence log
1208
+ // (receipt.ts's PAN backstop does not catch a 4-8 digit OTP). The
1209
+ // sender domain is a non-PII trust signal, safe to record.
1210
+ await page.locator(codeField.locator).fill(resolution.code);
1211
+ evidence.step('field-fill', {
1212
+ role: 'oneTimeCode',
1213
+ confidence: codeField.confidence,
1214
+ source: codeField.source,
1215
+ frame: codeField.frame,
1216
+ value: maskOtp(),
1217
+ ok: true,
1218
+ fromDomain: resolution.fromDomain,
1219
+ });
1220
+ const otpSubmit = await findSubmit(page);
1221
+ if (!otpSubmit) {
1222
+ evidence.step('note', { emailOtp: 'code filled but no submit control found' });
1223
+ }
1224
+ else {
1225
+ const otpLinkQuiet = await waitForLinkLookupQuiet(page);
1226
+ evidence.step('note', { linkQuiet: otpLinkQuiet, phase: 'post-otp' });
1227
+ evidence.step('submit', { clicked: true, target: otpSubmit.desc, phase: 'post-otp' });
1228
+ await otpSubmit.click();
1229
+ await settle(page);
1230
+ observed = await observeOutcome(page, { deadlineMs: opts.outcomeDeadlineMs });
1231
+ }
1232
+ }
1233
+ }
1234
+ }
1099
1235
  evidence.setSnapshotSummary(await snapshotSummary(page));
1100
1236
  if (options.debugShotsDir) {
1101
1237
  await captureDebugShot(page, options.debugShotsDir, checkout.review.id, '3-outcome', evidence, state.fields);
@@ -1121,6 +1257,20 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
1121
1257
  });
1122
1258
  return makeResult('action-required', state.fields, evidence, requiresAdapter, `issuer verification required (${observed.signal}) — a human must complete the challenge; no charge exists until it is completed`);
1123
1259
  }
1260
+ if (observed.status === 'verification-required') {
1261
+ // Still needing an email code after the subroutine (no resolver injected,
1262
+ // the code never arrived, or no code field) — hand off to a human. Mapped
1263
+ // to the same action-required outcome; the code is single-use so we never
1264
+ // retry here.
1265
+ evidence.step('outcome', {
1266
+ outcome: 'action-required',
1267
+ signal: observed.signal,
1268
+ reason: 'email verification code required but not auto-resolved',
1269
+ attempts: observed.attempts,
1270
+ elapsedMs: observed.elapsedMs,
1271
+ });
1272
+ return makeResult('action-required', state.fields, evidence, requiresAdapter, `email verification required (${observed.signal}) — a human must enter the code sent to the inbox`);
1273
+ }
1124
1274
  if (observed.status === 'confirmed') {
1125
1275
  const confirmationRef = await readConfirmationRef(page);
1126
1276
  evidence.step('outcome', {
@@ -1173,8 +1323,8 @@ export async function cancelPreparedCheckout(reviewId, detail = 'checkout cancel
1173
1323
  // approves that exact review ID. Human-in-the-loop callers should use the
1174
1324
  // explicit prepareCheckout()/submitApprovedCheckout() pair instead.
1175
1325
  export async function runCheckout(opts, store = defaultPreparedCheckoutStore) {
1176
- const { instrument, contact, mode, outcomeDeadlineMs, ...prepareOptions } = opts;
1177
- const preparation = await prepareCheckout(prepareOptions, store);
1326
+ const { instrument, contact, mode, outcomeDeadlineMs, resolveEmailOtp, ...prepareOptions } = opts;
1327
+ const preparation = await prepareCheckout({ ...prepareOptions, contact }, store);
1178
1328
  if (preparation.status === 'finished')
1179
1329
  return preparation.result;
1180
1330
  return submitApprovedCheckout(preparation.checkout.review.id, {
@@ -1183,5 +1333,6 @@ export async function runCheckout(opts, store = defaultPreparedCheckoutStore) {
1183
1333
  contact,
1184
1334
  mode,
1185
1335
  outcomeDeadlineMs,
1336
+ ...(resolveEmailOtp ? { resolveEmailOtp } : {}),
1186
1337
  }, store);
1187
1338
  }