@visa/cli 4.1.0-rc.26 → 4.1.0-rc.261

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 (83) hide show
  1. package/README.md +202 -46
  2. package/dist/checkout-engine/adapters/generic.d.ts +69 -0
  3. package/dist/checkout-engine/adapters/generic.js +383 -58
  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 +80 -0
  7. package/dist/checkout-engine/adapters/shopify.js +688 -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/browser-launch.d.ts +9 -4
  11. package/dist/checkout-engine/browser-launch.js +19 -4
  12. package/dist/checkout-engine/browserbase-browser.d.ts +24 -0
  13. package/dist/checkout-engine/browserbase-browser.js +186 -0
  14. package/dist/checkout-engine/cli-engine.d.ts +206 -32
  15. package/dist/checkout-engine/cli-engine.js +772 -216
  16. package/dist/checkout-engine/confirmed-merchants.d.ts +31 -0
  17. package/dist/checkout-engine/confirmed-merchants.js +165 -0
  18. package/dist/checkout-engine/detect.d.ts +1 -1
  19. package/dist/checkout-engine/detect.js +6 -0
  20. package/dist/checkout-engine/evidence.d.ts +1 -1
  21. package/dist/checkout-engine/executor.d.ts +61 -3
  22. package/dist/checkout-engine/executor.js +550 -144
  23. package/dist/checkout-engine/hosted-approval.d.ts +69 -9
  24. package/dist/checkout-engine/hosted-approval.js +211 -21
  25. package/dist/checkout-engine/index.d.ts +6 -2
  26. package/dist/checkout-engine/index.js +5 -1
  27. package/dist/checkout-engine/instrument.d.ts +6 -0
  28. package/dist/checkout-engine/known-merchants.d.ts +10 -0
  29. package/dist/checkout-engine/known-merchants.js +38 -0
  30. package/dist/checkout-engine/live-fill-approval.d.ts +5 -11
  31. package/dist/checkout-engine/live-fill-approval.js +20 -34
  32. package/dist/checkout-engine/mandate/card-mandate.d.ts +6 -2
  33. package/dist/checkout-engine/mandate/card-mandate.js +10 -5
  34. package/dist/checkout-engine/mandate/mandate-ledger.d.ts +63 -23
  35. package/dist/checkout-engine/mandate/mandate-ledger.js +124 -17
  36. package/dist/checkout-engine/receipt-dir.d.ts +6 -0
  37. package/dist/checkout-engine/receipt-dir.js +8 -0
  38. package/dist/checkout-engine/receipt.d.ts +56 -2
  39. package/dist/checkout-engine/receipt.js +55 -16
  40. package/dist/checkout-engine/shopify-primary-domain.d.ts +25 -0
  41. package/dist/checkout-engine/shopify-primary-domain.js +96 -0
  42. package/dist/checkout-engine/trace-handles.d.ts +8 -0
  43. package/dist/checkout-engine/trace-handles.js +12 -0
  44. package/dist/checkout-engine/types.d.ts +15 -2
  45. package/dist/checkout-engine/unresolved-charges.d.ts +34 -0
  46. package/dist/checkout-engine/unresolved-charges.js +134 -0
  47. package/dist/checkout-engine/vgs-gateway/server-mint-client.d.ts +26 -7
  48. package/dist/checkout-engine/vgs-gateway/server-mint-client.js +43 -3
  49. package/dist/checkout-engine/vgs-live-instrument.d.ts +11 -35
  50. package/dist/checkout-engine/vgs-live-instrument.js +14 -74
  51. package/dist/checkout-engine/vic-confirmation.d.ts +18 -0
  52. package/dist/checkout-engine/vic-confirmation.js +9 -3
  53. package/dist/checkout-engine/web-bot-auth.d.ts +98 -0
  54. package/dist/checkout-engine/web-bot-auth.js +218 -0
  55. package/dist/cli.js +899 -387
  56. package/dist/mcp-apps/ucp-checkout.html +280 -0
  57. package/dist/mcp-server/index.js +725 -254
  58. package/dist/merchant-ucp-mcp/index.js +6 -0
  59. package/dist/skills/pair-visa-agent/RUNTIMES.md +122 -79
  60. package/dist/skills/pair-visa-agent/SKILL.md +433 -318
  61. package/dist/skills/pair-visa-agent/scripts/__tests__/setup.test.mjs +407 -0
  62. package/dist/skills/pair-visa-agent/scripts/setup.mjs +310 -30
  63. package/dist/skills/visa-shopify-checkout/SKILL.md +122 -0
  64. package/dist/skills/visa-shopify-checkout/references/evidence-and-states.md +37 -0
  65. package/dist/skills/visa-ucp-shopping/SKILL.md +86 -0
  66. package/dist/subway-direct.mjs +1 -0
  67. package/install.ps1 +7 -6
  68. package/install.sh +3 -3
  69. package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
  70. package/package.json +31 -28
  71. package/server.json +4 -4
  72. package/dist/checkout-engine/inline-target.d.ts +0 -13
  73. package/dist/checkout-engine/inline-target.js +0 -37
  74. package/dist/checkout-engine/pay-args.d.ts +0 -14
  75. package/dist/checkout-engine/pay-args.js +0 -44
  76. package/dist/checkout-engine/pay.d.ts +0 -1
  77. package/dist/checkout-engine/pay.js +0 -13
  78. package/dist/checkout-engine/repo-env.d.ts +0 -11
  79. package/dist/checkout-engine/repo-env.js +0 -23
  80. package/dist/checkout-engine/run-live-fill.d.ts +0 -1
  81. package/dist/checkout-engine/run-live-fill.js +0 -493
  82. package/dist/checkout-engine/vgs-gateway/fetch-credential.d.mts +0 -74
  83. package/dist/checkout-engine/vgs-gateway/fetch-credential.mjs +0 -248
@@ -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,8 +12,8 @@
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';
@@ -21,6 +22,13 @@ import { checkMandate, checkMandatePreFill } from './mandate.js';
21
22
  import { EvidenceLog, maskOtp } from './evidence.js';
22
23
  import { observeOutcome } from './outcome.js';
23
24
  import { selectAdapter } from './adapters/index.js';
25
+ import { summarizeFillFailure } from './adapters/generic.js';
26
+ import { traceHandleFields } from './trace-handles.js';
27
+ import { readGenericPageAmount } from './amount.js';
28
+ import { webBotAuthHeadersOrNone } from './web-bot-auth.js';
29
+ import { assertShopifyGuestCheckout, ensureShopifyGuestCheckout, isShopifyCheckoutPage, readShopifyAmount, readStableShopifyAmount, shopifyEnglishCheckoutUrl, } from './adapters/shopify.js';
30
+ import { navigationRedirectEvidence, shopifyPrimaryDomainAlias } from './shopify-primary-domain.js';
31
+ export { minorFromDecimal, pageCurrency } from './amount.js';
24
32
  const SUBMIT_TEXT = /pay|place order|complete|buy|submit|checkout/i;
25
33
  const REVEAL_TEXT = /continue|next|proceed|review|go to payment/i;
26
34
  // Post-submit confirmed/declined/challenge signals live in outcome.ts
@@ -35,92 +43,6 @@ async function settle(page) {
35
43
  await page.waitForTimeout(200);
36
44
  await page.waitForLoadState('networkidle', { timeout: 1500 }).catch(() => { });
37
45
  }
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
46
  async function tryReveal(page, evidence, clicked) {
125
47
  // 1) A payment-method radio for card/credit/debit (accordion layouts).
126
48
  const radios = page.locator('input[type="radio"]');
@@ -203,17 +125,63 @@ async function fingerprintSubmitTarget(locator, kind, fallbackLabel) {
203
125
  };
204
126
  }, { targetKind: kind, targetFallbackLabel: fallbackLabel });
205
127
  }
206
- async function findSubmit(page) {
207
- const submitBtn = page.locator('button[type="submit"], input[type="submit"]').first();
208
- if ((await submitBtn.count().catch(() => 0)) > 0) {
209
- const label = ((await submitBtn.textContent().catch(() => '')) || '').trim() ||
210
- (await submitBtn.getAttribute('value').catch(() => '')) ||
211
- 'submit';
212
- return {
213
- desc: `submit button ("${label}")`,
214
- fingerprint: await fingerprintSubmitTarget(submitBtn, 'submit-control', 'submit'),
215
- click: () => submitBtn.click(),
216
- };
128
+ // Shopify checkouts keep INERT duplicates of the pay control in the DOM —
129
+ // aria-hidden="true", tabindex="-1", and/or zero-size. Playwright still reports
130
+ // those as "visible, enabled and stable", so a bare `.first()` resolves to one
131
+ // and then every click is swallowed by whatever paints on top of it
132
+ // (observed live: `<h3 id="billingAddress"> intercepts pointer events`, retried
133
+ // until the 6s timeout, deterministically, on casper.com). Rank the real
134
+ // controls ahead of the inert ones and prefer a pay-labelled control.
135
+ export function preferredSubmitIndex(cands, opts = {}) {
136
+ const indexed = cands.map((c, i) => ({ c, i }));
137
+ const usable = indexed.filter(({ c }) => c.visible && !c.ariaHidden && c.tabIndex !== -1 && c.area > 0);
138
+ const usablePayLike = usable.find(({ c }) => PAY_LABEL.test(c.label));
139
+ if (usablePayLike)
140
+ return usablePayLike.i;
141
+ if (!opts.allowDeferred)
142
+ return usable[0]?.i ?? -1;
143
+ // A genuine multi-step checkout can keep its final submit control inside a
144
+ // hidden payment section until a safe, non-submit Continue button advances
145
+ // the page. The human still needs that exact control bound into the review
146
+ // before approval. Accept it for fingerprinting only when it is not marked
147
+ // inert; the pre-click lookup remains strict and will refuse unless the same
148
+ // control becomes visible and has non-zero area after reveal.
149
+ const deferred = indexed.filter(({ c }) => !c.ariaHidden && c.tabIndex !== -1);
150
+ if (deferred.length === 0)
151
+ return -1;
152
+ const deferredPayLike = deferred.find(({ c }) => PAY_LABEL.test(c.label));
153
+ return (deferredPayLike ?? usable[0] ?? deferred[0]).i;
154
+ }
155
+ const PAY_LABEL = /pay|place order|complete order|submit order|buy now/i;
156
+ async function findSubmit(page, opts = {}) {
157
+ const controls = page.locator('button[type="submit"], input[type="submit"]');
158
+ const handles = await controls.all().catch(() => []);
159
+ if (handles.length > 0) {
160
+ const metas = await Promise.all(handles.map(async (h) => {
161
+ const text = ((await h.textContent().catch(() => '')) || '').trim();
162
+ const value = text || (await h.getAttribute('value').catch(() => '')) || '';
163
+ const ariaHidden = await h.getAttribute('aria-hidden').catch(() => null);
164
+ const tabIndexRaw = await h.getAttribute('tabindex').catch(() => null);
165
+ const visible = await h.isVisible().catch(() => false);
166
+ const box = await h.boundingBox().catch(() => null);
167
+ return {
168
+ label: value,
169
+ ariaHidden: ariaHidden === 'true',
170
+ tabIndex: tabIndexRaw === null ? null : Number(tabIndexRaw),
171
+ visible,
172
+ area: box ? box.width * box.height : 0,
173
+ };
174
+ }));
175
+ const idx = preferredSubmitIndex(metas, opts);
176
+ if (idx >= 0) {
177
+ const chosen = handles[idx];
178
+ const label = metas[idx].label || 'submit';
179
+ return {
180
+ desc: `submit button ("${label}")`,
181
+ fingerprint: await fingerprintSubmitTarget(chosen, 'submit-control', 'submit'),
182
+ click: () => chosen.click(),
183
+ };
184
+ }
217
185
  }
218
186
  const byText = page.getByRole('button', { name: SUBMIT_TEXT }).first();
219
187
  if ((await byText.count().catch(() => 0)) > 0) {
@@ -226,6 +194,19 @@ async function findSubmit(page) {
226
194
  }
227
195
  return null;
228
196
  }
197
+ // The diagnostic snapshot records the page ORIGIN only, never the full URL: a
198
+ // payment-session path/query (e.g. a live Stripe `cs_live_...` checkout-session
199
+ // id) must not be retained in the local receipt, which elsewhere promises
200
+ // "hostname only" (#7101). Falls back to the raw value only if it does not parse
201
+ // as a URL (never a real page.url()).
202
+ export function snapshotOrigin(rawUrl) {
203
+ try {
204
+ return new URL(rawUrl).origin;
205
+ }
206
+ catch {
207
+ return '';
208
+ }
209
+ }
229
210
  async function snapshotSummary(page) {
230
211
  const info = await page
231
212
  .evaluate(() => {
@@ -234,7 +215,7 @@ async function snapshotSummary(page) {
234
215
  return { title: document.title, heading: heading?.textContent?.trim() || '', body };
235
216
  })
236
217
  .catch(() => ({ title: '', heading: '', body: '' }));
237
- return `url=${page.url()} title="${info.title}" heading="${info.heading}" body="${info.body}"`;
218
+ return `url=${snapshotOrigin(page.url())} title="${info.title}" heading="${info.heading}" body="${info.body}"`;
238
219
  }
239
220
  async function readConfirmationRef(page) {
240
221
  const ref = await page
@@ -408,7 +389,7 @@ function unknownPreparedCheckoutResult(reviewId) {
408
389
  evidence.step('approval', { approved: false, reviewId, reason: detail });
409
390
  return makeResult('failed', {}, evidence, [], detail);
410
391
  }
411
- function makeResult(outcome, fields, evidence, requiresAdapter, detail, confirmationRef) {
392
+ function makeResult(outcome, fields, evidence, requiresAdapter, detail, confirmationRef, failureCode) {
412
393
  const steps = evidence.getSteps();
413
394
  const approved = steps.find((step) => step.type === 'approval' && step.data.approved === true);
414
395
  const minted = steps.find((step) => step.type === 'credential-minted');
@@ -426,6 +407,12 @@ function makeResult(outcome, fields, evidence, requiresAdapter, detail, confirma
426
407
  : filledRoles.has('number') || filledRoles.has('cvc')
427
408
  ? 'partially-exposed'
428
409
  : 'minted-not-exposed';
410
+ const terminalFailureCode = failureCode ??
411
+ (outcome === 'action-required'
412
+ ? 'human-action-required'
413
+ : outcome === 'blocked-by-mandate'
414
+ ? 'mandate-blocked'
415
+ : undefined);
429
416
  return {
430
417
  outcome,
431
418
  fields,
@@ -440,12 +427,24 @@ function makeResult(outcome, fields, evidence, requiresAdapter, detail, confirma
440
427
  : {}),
441
428
  ...(completed ? { fillCompletedAt: completed.ts } : {}),
442
429
  },
430
+ ...(terminalFailureCode ? { failureCode: terminalFailureCode } : {}),
443
431
  ...(detail ? { detail } : {}),
444
432
  ...(confirmationRef ? { confirmationRef } : {}),
445
433
  };
446
434
  }
447
- async function readTransactionFacts(page, opts) {
448
- const pageAmount = await readPageAmount(page);
435
+ async function readTransactionFacts(page, opts, phase) {
436
+ const shopify = await isShopifyCheckoutPage(page);
437
+ const amountRead = shopify
438
+ ? phase === 'review'
439
+ ? await readStableShopifyAmount(page)
440
+ : await readShopifyAmount(page, true)
441
+ : await readGenericPageAmount(page);
442
+ const pageAmount = shopify && amountRead.kind === 'none'
443
+ ? {
444
+ kind: 'unreadable',
445
+ reason: 'Shopify final tax and total summary is not available',
446
+ }
447
+ : amountRead;
449
448
  const amountMinor = pageAmount.kind === 'ok'
450
449
  ? pageAmount.amountMinor
451
450
  : pageAmount.kind === 'none'
@@ -474,10 +473,10 @@ async function readTransactionFacts(page, opts) {
474
473
  currency,
475
474
  source,
476
475
  reason: pageAmount.kind === 'unreadable'
477
- ? 'page total is displayed but cannot be parsed unambiguously'
476
+ ? (pageAmount.reason ?? 'page total is displayed but cannot be parsed unambiguously')
478
477
  : 'transaction amount could not be determined',
479
478
  detail: pageAmount.kind === 'unreadable'
480
- ? 'transaction amount could not be determined (page total present but ambiguous, e.g. separator layout); refusing fail-closed'
479
+ ? `transaction amount could not be determined (${pageAmount.reason ?? 'page total present but ambiguous'}); refusing fail-closed`
481
480
  : 'transaction amount could not be determined (no readable page total, no amountMinor provided); refusing fail-closed',
482
481
  };
483
482
  }
@@ -501,7 +500,10 @@ function recordTransactionFacts(evidence, phase, facts) {
501
500
  currency: facts.currency,
502
501
  });
503
502
  }
504
- function reviewChangeReason(review, merchantHost, facts) {
503
+ function reviewChangeReason(review, merchantHost, facts, merchantOrigin) {
504
+ if (review.merchantOrigin && merchantOrigin !== review.merchantOrigin) {
505
+ return `merchant origin changed after review: ${review.merchantOrigin} -> ${merchantOrigin ?? 'invalid'}`;
506
+ }
505
507
  if (merchantHost !== review.merchantHost) {
506
508
  return `merchant changed after review: ${review.merchantHost} -> ${merchantHost}`;
507
509
  }
@@ -513,6 +515,40 @@ function reviewChangeReason(review, merchantHost, facts) {
513
515
  }
514
516
  return null;
515
517
  }
518
+ function exactOrigin(value) {
519
+ try {
520
+ const url = new URL(value);
521
+ if (url.protocol !== 'https:' || url.username || url.password)
522
+ return null;
523
+ return url.origin.toLowerCase();
524
+ }
525
+ catch {
526
+ return null;
527
+ }
528
+ }
529
+ export function trustedMerchantOriginRefusal(options, pageUrl, expectedOrigin) {
530
+ const identity = options.trustedMerchantIdentity;
531
+ if (!identity)
532
+ return null;
533
+ if (Date.parse(identity.expiresAt) <= Date.now())
534
+ return 'trusted UCP checkout handoff expired';
535
+ const origin = exactOrigin(pageUrl);
536
+ if (!origin)
537
+ return 'trusted UCP checkout reached a non-HTTPS or credentialed origin';
538
+ if (expectedOrigin) {
539
+ return origin === expectedOrigin
540
+ ? null
541
+ : `merchant origin changed after review: ${expectedOrigin} -> ${origin}`;
542
+ }
543
+ return identity.allowedOrigins.includes(origin)
544
+ ? null
545
+ : `trusted UCP checkout reached undeclared origin ${origin}`;
546
+ }
547
+ function mandateForPage(options, pageUrl) {
548
+ if (!options.trustedMerchantIdentity)
549
+ return options.mandate;
550
+ return { ...options.mandate, merchantHost: new URL(pageUrl).hostname };
551
+ }
516
552
  function submitTargetChangeReason(review, current) {
517
553
  const reviewed = review.submitTargetFingerprint;
518
554
  if (!reviewed && !current)
@@ -729,9 +765,31 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
729
765
  const options = {
730
766
  ...opts,
731
767
  mandate: { ...opts.mandate },
768
+ ...(opts.trustedMerchantIdentity
769
+ ? {
770
+ trustedMerchantIdentity: Object.freeze({
771
+ ...opts.trustedMerchantIdentity,
772
+ allowedOrigins: Object.freeze([...opts.trustedMerchantIdentity.allowedOrigins]),
773
+ }),
774
+ }
775
+ : {}),
732
776
  };
733
777
  const evidence = new EvidenceLog();
734
- const context = await options.browser.newContext();
778
+ // Pin an English locale: amount reconciliation reads the order summary by
779
+ // its visible labels (Subtotal/Taxes/Total), and merchants localize by
780
+ // Accept-Language (observed live 2026-08-16: a Shopify checkout redirected
781
+ // to /es-us and rendered "Impuestos estimados", so the total never parsed
782
+ // and the review refused fail-closed on a perfectly good checkout).
783
+ // Present Web Bot Auth (RFC 9421) credentials when, and only when, an
784
+ // operator directory is configured to resolve them. Off by default: an
785
+ // unresolvable signature fails verification and is worse than none. The
786
+ // signature covers @authority, so it is bound to the checkout host — a
787
+ // cross-origin redirect simply arrives unverified, never wrongly verified.
788
+ const webBotAuthHeaders = webBotAuthHeadersOrNone(options.webBotAuth ?? null, options.url, Date.now() / 1000);
789
+ const context = await options.browser.newContext({
790
+ locale: 'en-US',
791
+ ...(webBotAuthHeaders ? { extraHTTPHeaders: webBotAuthHeaders } : {}),
792
+ });
735
793
  // Bound every action so a mis-detected or hidden element fails fast instead
736
794
  // of stalling on Playwright's long default timeout.
737
795
  context.setDefaultTimeout(6000);
@@ -745,12 +803,49 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
745
803
  let fields = {};
746
804
  let keepOpen = false;
747
805
  try {
748
- evidence.step('navigation', { url: options.url });
749
- await page.goto(options.url, { waitUntil: 'domcontentloaded' });
806
+ evidence.step('navigation', {
807
+ url: options.trustedMerchantIdentity ? exactOrigin(options.url) : options.url,
808
+ });
809
+ const navigationResponse = await page.goto(options.url, { waitUntil: 'domcontentloaded' });
750
810
  await waitForStableDom(page);
751
- evidence.step('dom-stable', { url: page.url() });
752
- const merchantHost = new URL(page.url()).hostname;
753
- const preFill = checkMandatePreFill(options.mandate, {
811
+ evidence.step('dom-stable', {
812
+ url: options.trustedMerchantIdentity ? exactOrigin(page.url()) : page.url(),
813
+ });
814
+ let merchantHost = new URL(page.url()).hostname;
815
+ const initialOriginRefusal = trustedMerchantOriginRefusal(options, page.url());
816
+ if (initialOriginRefusal) {
817
+ evidence.step('mandate-verdict', {
818
+ phase: 'trusted-origin',
819
+ ok: false,
820
+ reason: initialOriginRefusal,
821
+ });
822
+ evidence.setSnapshotSummary(await snapshotSummary(page));
823
+ return {
824
+ status: 'finished',
825
+ result: makeResult('blocked-by-mandate', fields, evidence, requiresAdapter, initialOriginRefusal),
826
+ };
827
+ }
828
+ if (!options.trustedMerchantIdentity) {
829
+ const shopifyAlias = shopifyPrimaryDomainAlias({
830
+ mandateHost: options.mandate.merchantHost,
831
+ initialUrl: options.url,
832
+ finalUrl: page.url(),
833
+ redirects: await navigationRedirectEvidence(navigationResponse),
834
+ });
835
+ if (shopifyAlias) {
836
+ evidence.step('note', {
837
+ kind: 'shopify-primary-domain-alias',
838
+ mandateHost: options.mandate.merchantHost,
839
+ checkoutHost: shopifyAlias,
840
+ });
841
+ // The proof is collected before any credential is minted. Bind this
842
+ // prepared session to Shopify's primary storefront host so every later
843
+ // approval and pre-submit revalidation stays strict on that host.
844
+ options.mandate = { ...options.mandate, merchantHost: shopifyAlias };
845
+ }
846
+ }
847
+ let reviewedOrigin;
848
+ const preFill = checkMandatePreFill(mandateForPage(options, page.url()), {
754
849
  merchantHost,
755
850
  currency: options.currency ?? null,
756
851
  });
@@ -787,7 +882,116 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
787
882
  }
788
883
  // Detection is read-only here. In particular, no adapter fill and no
789
884
  // Instrument.getCredential() call can occur before an explicit approval.
790
- const detected = await detectFields(page);
885
+ let detected = await detectFields(page);
886
+ const shopifyPage = await isShopifyCheckoutPage(page);
887
+ if (shopifyPage) {
888
+ // Same checkout session, English presentation — the amount reader needs
889
+ // the English summary labels (see shopifyEnglishCheckoutUrl).
890
+ const englishUrl = shopifyEnglishCheckoutUrl(page.url());
891
+ if (englishUrl) {
892
+ await page.goto(englishUrl, { waitUntil: 'domcontentloaded' }).catch(() => { });
893
+ await waitForStableDom(page);
894
+ if (await isShopifyCheckoutPage(page)) {
895
+ evidence.step('navigation', {
896
+ url: options.trustedMerchantIdentity ? exactOrigin(page.url()) : page.url(),
897
+ reason: 'shopify-locale-normalized',
898
+ });
899
+ detected = await detectFields(page);
900
+ }
901
+ }
902
+ }
903
+ let adapter = selectAdapter(detected, { shopify: shopifyPage });
904
+ if (options.contact && adapter.prepareContact) {
905
+ const recordPrefill = async (contact) => {
906
+ const result = await adapter.prepareContact(page, contact);
907
+ for (const field of result.filled) {
908
+ evidence.step('contact-prefill', {
909
+ role: field.role,
910
+ confidence: field.confidence,
911
+ source: field.source,
912
+ frame: field.frame,
913
+ value: field.value,
914
+ ok: field.ok,
915
+ error: field.error,
916
+ });
917
+ }
918
+ return result;
919
+ };
920
+ let preparedContact = await recordPrefill(options.contact);
921
+ if (shopifyPage && options.checkoutRoute === 'guest-card') {
922
+ const guest = await ensureShopifyGuestCheckout(page);
923
+ evidence.step('note', {
924
+ phase: 'contact-prefill',
925
+ checkoutRoute: options.checkoutRoute,
926
+ shopifyGuestStatus: guest.status,
927
+ signal: guest.signal,
928
+ });
929
+ if (guest.status === 'action-required') {
930
+ evidence.step('outcome', {
931
+ outcome: 'action-required',
932
+ signal: guest.signal,
933
+ phase: 'contact-prefill',
934
+ });
935
+ evidence.setSnapshotSummary(await snapshotSummary(page));
936
+ return {
937
+ status: 'finished',
938
+ result: makeResult('action-required', fields, evidence, requiresAdapter, guest.detail, undefined, 'human-action-required'),
939
+ };
940
+ }
941
+ if (guest.status === 'transitioned') {
942
+ // Do not write the recognized email a second time: Shopify can open
943
+ // the same modal on every email input event. Only retry contact fill
944
+ // when the takeover interrupted it, and omit email on that retry.
945
+ if (!preparedContact.ok) {
946
+ await settle(page);
947
+ detected = await detectFields(page);
948
+ adapter = selectAdapter(detected, { shopify: true });
949
+ preparedContact = await recordPrefill({ ...options.contact, email: undefined });
950
+ }
951
+ const verifiedGuest = await assertShopifyGuestCheckout(page);
952
+ if (verifiedGuest.status === 'action-required') {
953
+ evidence.step('outcome', {
954
+ outcome: 'action-required',
955
+ signal: verifiedGuest.signal,
956
+ phase: 'contact-prefill-guest-verification',
957
+ });
958
+ evidence.setSnapshotSummary(await snapshotSummary(page));
959
+ return {
960
+ status: 'finished',
961
+ result: makeResult('action-required', fields, evidence, requiresAdapter, verifiedGuest.detail, undefined, 'human-action-required'),
962
+ };
963
+ }
964
+ }
965
+ }
966
+ if (!preparedContact.ok) {
967
+ evidence.setSnapshotSummary(await snapshotSummary(page));
968
+ return {
969
+ status: 'finished',
970
+ result: makeResult('failed', fields, evidence, requiresAdapter, preparedContact.detail ??
971
+ 'Shopify contact prefill did not complete; no payment credential was requested'),
972
+ };
973
+ }
974
+ await settle(page);
975
+ const prefillHost = new URL(page.url()).hostname;
976
+ const trustedPrefillRefusal = trustedMerchantOriginRefusal(options, page.url());
977
+ if (trustedPrefillRefusal || prefillHost !== merchantHost) {
978
+ const reason = trustedPrefillRefusal ??
979
+ `merchant changed during contact prefill: ${merchantHost} -> ${prefillHost}`;
980
+ evidence.step('mandate-verdict', {
981
+ phase: 'contact-prefill',
982
+ ok: false,
983
+ reason,
984
+ });
985
+ evidence.setSnapshotSummary(await snapshotSummary(page));
986
+ return {
987
+ status: 'finished',
988
+ result: makeResult('blocked-by-mandate', fields, evidence, requiresAdapter, reason),
989
+ };
990
+ }
991
+ detected = await detectFields(page);
992
+ adapter = selectAdapter(detected, { shopify: shopifyPage });
993
+ evidence.step('adapter-selected', { adapter: adapter.name, phase: 'review' });
994
+ }
791
995
  fields = detected.fields;
792
996
  evidence.step('detect', {
793
997
  phase: 'review',
@@ -801,7 +1005,47 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
801
1005
  evidence.step('psp-detected', { psp: p.psp, requiresAdapter: p.requiresAdapter });
802
1006
  }
803
1007
  }
804
- const facts = await readTransactionFacts(page, options);
1008
+ // A checkout the runner cannot put a card INTO is not reviewable. Without a
1009
+ // detected card-number field a later pay would fill nothing and dispatch
1010
+ // whatever control the review happened to bind (observed live 2026-08-16:
1011
+ // a Payhip storefront SEARCH form, a FastSpring "PayPal Checkout" label,
1012
+ // and a Shopify discount-form "Submit" all reviewed clean this way — the
1013
+ // real card fields sat in unreachable PSP iframes or an unrendered payment
1014
+ // section). Every adapter fills from this same detection, so a missing
1015
+ // number field here means no pay can ever succeed: refuse while it is
1016
+ // still free.
1017
+ if (!fields.number) {
1018
+ const found = Object.keys(fields);
1019
+ const reason = `no card number field detected (roles found: ${found.length ? found.join(', ') : 'none'}) — ` +
1020
+ 'the card form is likely inside a PSP iframe or behind a later step, so a credential cannot be entered on this page';
1021
+ evidence.step('detect', { phase: 'review', missingCardNumber: true, reason });
1022
+ evidence.setSnapshotSummary(await snapshotSummary(page));
1023
+ return {
1024
+ status: 'finished',
1025
+ result: makeResult('failed', fields, evidence, requiresAdapter, reason, undefined, 'card-number-field-unavailable'),
1026
+ };
1027
+ }
1028
+ // Contact/shipping and Shopify locale normalization may legitimately move
1029
+ // between the exact service and business origins authorized by UCP. Freeze
1030
+ // whichever declared origin is actually on-screen only after those
1031
+ // credential-free steps, then require that exact origin for approval,
1032
+ // credential fill, and submit.
1033
+ const reviewOriginRefusal = trustedMerchantOriginRefusal(options, page.url());
1034
+ if (reviewOriginRefusal) {
1035
+ evidence.step('mandate-verdict', {
1036
+ phase: 'review-origin',
1037
+ ok: false,
1038
+ reason: reviewOriginRefusal,
1039
+ });
1040
+ evidence.setSnapshotSummary(await snapshotSummary(page));
1041
+ return {
1042
+ status: 'finished',
1043
+ result: makeResult('blocked-by-mandate', fields, evidence, requiresAdapter, reviewOriginRefusal),
1044
+ };
1045
+ }
1046
+ merchantHost = new URL(page.url()).hostname;
1047
+ reviewedOrigin = options.trustedMerchantIdentity ? exactOrigin(page.url()) : undefined;
1048
+ const facts = await readTransactionFacts(page, options, 'review');
805
1049
  recordTransactionFacts(evidence, 'review', facts);
806
1050
  if (!facts.ok) {
807
1051
  evidence.step('mandate-verdict', { phase: 'review', ok: false, reason: facts.reason });
@@ -811,7 +1055,7 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
811
1055
  result: makeResult('blocked-by-mandate', fields, evidence, requiresAdapter, facts.detail),
812
1056
  };
813
1057
  }
814
- const verdict = checkMandate(options.mandate, {
1058
+ const verdict = checkMandate(mandateForPage(options, page.url()), {
815
1059
  merchantHost,
816
1060
  amountMinor: facts.amountMinor,
817
1061
  currency: facts.currency,
@@ -824,11 +1068,18 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
824
1068
  result: makeResult('blocked-by-mandate', fields, evidence, requiresAdapter, verdict.reason),
825
1069
  };
826
1070
  }
827
- const submit = await findSubmit(page);
1071
+ // Multi-step pages may expose the final submit control in a hidden section
1072
+ // before a safe Continue reveals it. Fingerprint that exact control for the
1073
+ // human review; the eventual click path still requires it to be usable.
1074
+ const submit = await findSubmit(page, { allowDeferred: true });
828
1075
  const review = Object.freeze({
829
1076
  id: randomUUID(),
830
- url: page.url(),
1077
+ // A UCP continuation may contain a bearer-like path/query. The live page
1078
+ // stays in the process-bound store; serializable review facts expose only
1079
+ // the exact reviewed origin.
1080
+ url: reviewedOrigin ?? page.url(),
831
1081
  merchantHost,
1082
+ ...(reviewedOrigin ? { merchantOrigin: reviewedOrigin } : {}),
832
1083
  amountMinor: facts.amountMinor,
833
1084
  currency: facts.currency,
834
1085
  mandateMaxAmountMinor: options.mandate.maxAmountMinor,
@@ -836,6 +1087,7 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
836
1087
  submitTarget: submit?.desc ?? null,
837
1088
  submitTargetFingerprint: submit ? Object.freeze({ ...submit.fingerprint }) : null,
838
1089
  detectedRoles: Object.freeze(Object.keys(fields)),
1090
+ checkoutRoute: options.checkoutRoute,
839
1091
  });
840
1092
  evidence.step('review', {
841
1093
  reviewId: review.id,
@@ -845,12 +1097,14 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
845
1097
  submitTarget: review.submitTarget,
846
1098
  submitTargetFingerprint: review.submitTargetFingerprint,
847
1099
  detectedRoles: review.detectedRoles,
1100
+ checkoutRoute: review.checkoutRoute,
848
1101
  });
849
1102
  const checkout = Object.freeze({
850
1103
  review,
851
1104
  fields: Object.freeze({ ...fields }),
852
1105
  evidence,
853
1106
  requiresAdapter: Object.freeze([...requiresAdapter]),
1107
+ checkoutRoute: options.checkoutRoute,
854
1108
  });
855
1109
  store.put(review.id, {
856
1110
  checkout,
@@ -902,7 +1156,17 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
902
1156
  // the credential boundary. A changed checkout requires a fresh review.
903
1157
  await waitForStableDom(page);
904
1158
  const merchantHost = new URL(page.url()).hostname;
905
- const preFill = checkMandatePreFill(options.mandate, {
1159
+ const approvalOriginRefusal = trustedMerchantOriginRefusal(options, page.url(), checkout.review.merchantOrigin);
1160
+ if (approvalOriginRefusal) {
1161
+ evidence.step('mandate-verdict', {
1162
+ phase: 'approval-origin',
1163
+ ok: false,
1164
+ reason: approvalOriginRefusal,
1165
+ });
1166
+ evidence.setSnapshotSummary(await snapshotSummary(page));
1167
+ return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, approvalOriginRefusal);
1168
+ }
1169
+ const preFill = checkMandatePreFill(mandateForPage(options, page.url()), {
906
1170
  merchantHost,
907
1171
  currency: options.currency ?? null,
908
1172
  });
@@ -912,7 +1176,7 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
912
1176
  evidence.setSnapshotSummary(await snapshotSummary(page));
913
1177
  return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, preFill.reason);
914
1178
  }
915
- const approvedFacts = await readTransactionFacts(page, options);
1179
+ const approvedFacts = await readTransactionFacts(page, options, 'approval');
916
1180
  recordTransactionFacts(evidence, 'approval', approvedFacts);
917
1181
  if (!approvedFacts.ok) {
918
1182
  evidence.step('mandate-verdict', {
@@ -924,7 +1188,7 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
924
1188
  evidence.setSnapshotSummary(await snapshotSummary(page));
925
1189
  return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, approvedFacts.detail);
926
1190
  }
927
- const approvalVerdict = checkMandate(options.mandate, {
1191
+ const approvalVerdict = checkMandate(mandateForPage(options, page.url()), {
928
1192
  merchantHost,
929
1193
  amountMinor: approvedFacts.amountMinor,
930
1194
  currency: approvedFacts.currency,
@@ -935,7 +1199,7 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
935
1199
  evidence.setSnapshotSummary(await snapshotSummary(page));
936
1200
  return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, approvalVerdict.reason);
937
1201
  }
938
- const changedAtApproval = reviewChangeReason(checkout.review, merchantHost, approvedFacts);
1202
+ const changedAtApproval = reviewChangeReason(checkout.review, merchantHost, approvedFacts, exactOrigin(page.url()) ?? undefined);
939
1203
  if (changedAtApproval) {
940
1204
  evidence.step('approval', {
941
1205
  approved: false,
@@ -945,7 +1209,10 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
945
1209
  evidence.setSnapshotSummary(await snapshotSummary(page));
946
1210
  return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, changedAtApproval);
947
1211
  }
948
- const approvalSubmit = await findSubmit(page);
1212
+ // Revalidate the same future target before credential minting. This may be
1213
+ // hidden on a multi-step checkout; the final lookup after reveal requires
1214
+ // the reviewed control to be visible and usable before any click.
1215
+ const approvalSubmit = await findSubmit(page, { allowDeferred: true });
949
1216
  const submitChangedAtApproval = submitTargetChangeReason(checkout.review, approvalSubmit);
950
1217
  if (submitChangedAtApproval) {
951
1218
  evidence.step('approval', {
@@ -967,6 +1234,34 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
967
1234
  return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, reason);
968
1235
  }
969
1236
  evidence.step('approval', { approved: true, reviewId: checkout.review.id });
1237
+ if (opts.mode === 'dry-run') {
1238
+ evidence.step('credential-skipped', {
1239
+ reason: 'dry-run stops before credential mint or merchant-page disclosure',
1240
+ });
1241
+ evidence.step('submit', { would: true, target: approvalSubmit?.desc ?? 'none found' });
1242
+ evidence.setSnapshotSummary(await snapshotSummary(page));
1243
+ return makeResult('reviewed-dry-run', state.fields, evidence, requiresAdapter, approvalSubmit
1244
+ ? `validated the reviewed checkout; would click ${approvalSubmit.desc}`
1245
+ : 'validated the reviewed checkout; no submit control detected');
1246
+ }
1247
+ if (options.checkoutRoute === 'guest-card' && (await isShopifyCheckoutPage(page))) {
1248
+ const guest = await assertShopifyGuestCheckout(page);
1249
+ evidence.step('note', {
1250
+ phase: 'pre-credential',
1251
+ checkoutRoute: options.checkoutRoute,
1252
+ shopifyGuestStatus: guest.status,
1253
+ signal: guest.signal,
1254
+ });
1255
+ if (guest.status === 'action-required') {
1256
+ evidence.step('outcome', {
1257
+ outcome: 'action-required',
1258
+ signal: guest.signal,
1259
+ phase: 'pre-credential',
1260
+ });
1261
+ evidence.setSnapshotSummary(await snapshotSummary(page));
1262
+ return makeResult('action-required', state.fields, evidence, requiresAdapter, guest.detail, undefined, 'human-action-required');
1263
+ }
1264
+ }
970
1265
  const credential = await opts.instrument.getCredential({
971
1266
  merchantHost,
972
1267
  amountMinor: approvedFacts.amountMinor,
@@ -976,17 +1271,23 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
976
1271
  ...(credential.credentialExpiresAt
977
1272
  ? { credentialExpiresAt: credential.credentialExpiresAt }
978
1273
  : {}),
1274
+ ...traceHandleFields(credential),
979
1275
  });
980
1276
  // Reveal + fill loop: fills whatever is present, then reveals the next
981
1277
  // surface (card radio / next step) until the card number is filled.
982
1278
  const clicked = new Set();
983
1279
  let adapterName = null;
1280
+ let adapterFillOk = true;
1281
+ let adapterFillDetail = null;
984
1282
  for (let attempt = 0; attempt < 4; attempt++) {
985
1283
  // A reveal/continue action can navigate between attempts. Never expose
986
1284
  // the credential to a host other than the one the human reviewed.
987
1285
  const fillHost = new URL(page.url()).hostname;
988
- if (fillHost !== checkout.review.merchantHost) {
989
- const reason = `merchant changed after review: ${checkout.review.merchantHost} -> ${fillHost}`;
1286
+ const fillOriginRefusal = trustedMerchantOriginRefusal(options, page.url(), checkout.review.merchantOrigin);
1287
+ if (fillOriginRefusal ||
1288
+ (!checkout.review.merchantOrigin && fillHost !== checkout.review.merchantHost)) {
1289
+ const reason = fillOriginRefusal ??
1290
+ `merchant changed after review: ${checkout.review.merchantHost} -> ${fillHost}`;
990
1291
  evidence.step('mandate-verdict', {
991
1292
  phase: 'approved-submit',
992
1293
  ok: false,
@@ -1009,12 +1310,16 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
1009
1310
  evidence.step('psp-detected', { psp: p.psp, requiresAdapter: p.requiresAdapter });
1010
1311
  }
1011
1312
  }
1012
- const adapter = selectAdapter(detected);
1313
+ const adapter = selectAdapter(detected, {
1314
+ shopify: await isShopifyCheckoutPage(page),
1315
+ });
1013
1316
  if (adapter.name !== adapterName) {
1014
1317
  adapterName = adapter.name;
1015
1318
  evidence.step('adapter-selected', { adapter: adapter.name });
1016
1319
  }
1017
1320
  const fill = await adapter.fill(page, detected.fields, credential, opts.contact);
1321
+ adapterFillOk = fill.ok;
1322
+ adapterFillDetail = fill.detail ?? null;
1018
1323
  for (const f of fill.filled) {
1019
1324
  evidence.step('field-fill', {
1020
1325
  role: f.role,
@@ -1024,6 +1329,7 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
1024
1329
  value: f.value,
1025
1330
  ok: f.ok,
1026
1331
  error: f.error,
1332
+ ...(f.relocated ? { relocated: true } : {}),
1027
1333
  });
1028
1334
  }
1029
1335
  const numberOk = fill.filled.some((f) => f.role === 'number' && f.ok);
@@ -1046,9 +1352,30 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
1046
1352
  ? []
1047
1353
  : ['expiry']),
1048
1354
  ];
1355
+ // Roles the adapter TRIED to fill and never landed, across every reveal
1356
+ // attempt. A job only exists when the field was detected, was visible, and
1357
+ // we held a value for it (see `add()` in adapters/generic.ts) — so a failure
1358
+ // here is never "the page didn't ask for it". It means the page asked, we
1359
+ // answered, and the element refused.
1360
+ //
1361
+ // Roles that failed on an early attempt and succeeded after a reveal are
1362
+ // excluded: `successfulRoles` spans all four attempts, same as above.
1363
+ const failedFillRoles = [
1364
+ ...new Set(evidence
1365
+ .getSteps()
1366
+ .filter((step) => step.type === 'field-fill' && step.data.ok === false)
1367
+ .map((step) => String(step.data.role))),
1368
+ ]
1369
+ .filter((role) => !successfulRoles.has(role))
1370
+ .sort();
1049
1371
  evidence.step('fill-complete', {
1050
- ok: missingCredentialRoles.length === 0,
1372
+ // "Ready to submit", not "the card fields landed". Before 2026-08-17 this
1373
+ // read only the credential roles, so a whop.com run whose city/state/
1374
+ // postalCode all timed out recorded `ok: true` and clicked Get access on
1375
+ // a form it knew was incomplete.
1376
+ ok: missingCredentialRoles.length === 0 && failedFillRoles.length === 0,
1051
1377
  missingCredentialRoles,
1378
+ failedFillRoles,
1052
1379
  });
1053
1380
  if (options.debugShotsDir) {
1054
1381
  await captureDebugShot(page, options.debugShotsDir, checkout.review.id, '2-filled', evidence, state.fields);
@@ -1056,7 +1383,7 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
1056
1383
  // Re-run the full gate after fill as well. Contact/shipping fields can
1057
1384
  // change the total; any drift from the approved review refuses before a
1058
1385
  // submit click and requires the caller to prepare a new review.
1059
- const submitFacts = await readTransactionFacts(page, options);
1386
+ const submitFacts = await readTransactionFacts(page, options, 'pre-submit');
1060
1387
  recordTransactionFacts(evidence, 'pre-submit', submitFacts);
1061
1388
  if (!submitFacts.ok) {
1062
1389
  evidence.step('mandate-verdict', {
@@ -1068,7 +1395,17 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
1068
1395
  return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, submitFacts.detail);
1069
1396
  }
1070
1397
  const submitMerchantHost = new URL(page.url()).hostname;
1071
- const verdict = checkMandate(options.mandate, {
1398
+ const submitOriginRefusal = trustedMerchantOriginRefusal(options, page.url(), checkout.review.merchantOrigin);
1399
+ if (submitOriginRefusal) {
1400
+ evidence.step('mandate-verdict', {
1401
+ phase: 'pre-submit-origin',
1402
+ ok: false,
1403
+ reason: submitOriginRefusal,
1404
+ });
1405
+ evidence.setSnapshotSummary(await snapshotSummary(page));
1406
+ return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, submitOriginRefusal);
1407
+ }
1408
+ const verdict = checkMandate(mandateForPage(options, page.url()), {
1072
1409
  merchantHost: submitMerchantHost,
1073
1410
  amountMinor: submitFacts.amountMinor,
1074
1411
  currency: submitFacts.currency,
@@ -1078,7 +1415,7 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
1078
1415
  evidence.setSnapshotSummary(await snapshotSummary(page));
1079
1416
  return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, verdict.reason);
1080
1417
  }
1081
- const changedBeforeSubmit = reviewChangeReason(checkout.review, submitMerchantHost, submitFacts);
1418
+ const changedBeforeSubmit = reviewChangeReason(checkout.review, submitMerchantHost, submitFacts, exactOrigin(page.url()) ?? undefined);
1082
1419
  if (changedBeforeSubmit) {
1083
1420
  evidence.step('mandate-verdict', {
1084
1421
  phase: 'pre-submit',
@@ -1099,21 +1436,44 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
1099
1436
  evidence.setSnapshotSummary(await snapshotSummary(page));
1100
1437
  return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, submitChangedBeforeClick);
1101
1438
  }
1102
- if (opts.mode === 'dry-run') {
1103
- evidence.step('submit', { would: true, target: submit?.desc ?? 'none found' });
1439
+ if (!adapterFillOk) {
1104
1440
  evidence.setSnapshotSummary(await snapshotSummary(page));
1105
- if (missingCredentialRoles.length > 0) {
1106
- const adapterRequired = requiresAdapter.size > 0;
1107
- return makeResult(adapterRequired ? 'adapter-required' : 'partial-fill', state.fields, evidence, requiresAdapter, adapterRequired
1108
- ? `credential fields require adapter: ${[...requiresAdapter].join(', ')}; missing ${missingCredentialRoles.join(', ')}`
1109
- : `credential fill incomplete: missing ${missingCredentialRoles.join(', ')}`);
1110
- }
1111
- return makeResult('filled-dry-run', state.fields, evidence, requiresAdapter, submit ? `would click ${submit.desc}` : 'no submit control detected');
1441
+ return makeResult('failed', state.fields, evidence, requiresAdapter, adapterFillDetail ?? `${adapterName ?? 'checkout'} adapter fill incomplete`);
1112
1442
  }
1113
1443
  if (missingCredentialRoles.length > 0) {
1114
1444
  evidence.setSnapshotSummary(await snapshotSummary(page));
1115
1445
  return makeResult('failed', state.fields, evidence, requiresAdapter, `credential fill incomplete: missing ${missingCredentialRoles.join(', ')}`);
1116
1446
  }
1447
+ // STOP BEFORE THE CLICK when any field we tried to fill refused. Submitting
1448
+ // a form we know is incomplete is how a PSP ends up holding a charge we
1449
+ // cannot then confirm or account for: the 2026-08-17 whop.com run filled the
1450
+ // card into Basis Theory iframes, watched city/state/postalCode time out at
1451
+ // 5s each, clicked Get access anyway, and could never observe an outcome.
1452
+ //
1453
+ // This refusal happens BEFORE the submit click, so nothing can be charged by
1454
+ // it — the safe direction, and the reason it is allowed to be strict. A
1455
+ // merchant whose address widget we cannot drive now fails cleanly and
1456
+ // retryably instead of dangerously.
1457
+ if (failedFillRoles.length > 0) {
1458
+ evidence.setSnapshotSummary(await snapshotSummary(page));
1459
+ // Name the CAUSE per field, not just the field. The refusal is the only
1460
+ // artifact that survives to the operator (a v2 receipt carries no evidence
1461
+ // log), and "city, postalCode refused" without a reason means the next
1462
+ // person has to reproduce a live merchant to learn anything. Each cause
1463
+ // points at a different fix — see summarizeFillFailure.
1464
+ //
1465
+ // Last attempt wins: a role that failed differently across reveal passes
1466
+ // is best described by how it failed when we finally gave up on it.
1467
+ const lastFillError = (role) => {
1468
+ const errors = evidence
1469
+ .getSteps()
1470
+ .filter((step) => step.type === 'field-fill' && step.data.ok === false && step.data.role === role)
1471
+ .map((step) => (typeof step.data.error === 'string' ? step.data.error : undefined));
1472
+ return errors[errors.length - 1];
1473
+ };
1474
+ const reasons = failedFillRoles.map((role) => `${role} (${summarizeFillFailure(lastFillError(role))})`);
1475
+ return makeResult('partial-fill', state.fields, evidence, requiresAdapter, `required field fill failed: ${reasons.join(', ')} — not submitting an incomplete form. Nothing was charged.`, undefined, 'required-field-unfillable');
1476
+ }
1117
1477
  if (!submit) {
1118
1478
  evidence.setSnapshotSummary(await snapshotSummary(page));
1119
1479
  return makeResult('failed', state.fields, evidence, requiresAdapter, 'no submit control detected');
@@ -1129,6 +1489,36 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
1129
1489
  // Stripe's submit chain mid-flight (#5879) — see waitForLinkLookupQuiet.
1130
1490
  const linkQuiet = await waitForLinkLookupQuiet(page);
1131
1491
  evidence.step('note', { linkQuiet });
1492
+ // This is deliberately the last await before the irreversible click.
1493
+ // Mint-time validation is not enough: reveal/fill and Link suppression can
1494
+ // consume a short-lived DAVV. Refuse malformed or <60s credentials so an
1495
+ // expiry decline cannot masquerade as a form-fill failure.
1496
+ const credentialExpiresAt = credential.credentialExpiresAt;
1497
+ const expiryMissing = opts.instrument.kind === 'agentic-token' && credentialExpiresAt === undefined;
1498
+ const expiresMs = credentialExpiresAt === undefined ? Number.NaN : Date.parse(credentialExpiresAt);
1499
+ if (expiryMissing ||
1500
+ (credentialExpiresAt !== undefined &&
1501
+ (!Number.isFinite(expiresMs) || expiresMs - Date.now() < 60_000))) {
1502
+ evidence.step('credential-expiry-check', {
1503
+ ok: false,
1504
+ reason: expiryMissing ? 'missing' : 'invalid-or-expiring',
1505
+ ...(credentialExpiresAt !== undefined ? { credentialExpiresAt } : {}),
1506
+ });
1507
+ evidence.setSnapshotSummary(await snapshotSummary(page));
1508
+ return makeResult('failed', state.fields, evidence, requiresAdapter, 'credential expired before submit; obtain a fresh intent and re-review');
1509
+ }
1510
+ if (credentialExpiresAt !== undefined) {
1511
+ evidence.step('credential-expiry-check', {
1512
+ ok: true,
1513
+ credentialExpiresAt,
1514
+ });
1515
+ }
1516
+ // ORDER IS LOAD-BEARING: record the click BEFORE performing it. The catch
1517
+ // block classifies a throw by whether this step exists — recorded means
1518
+ // "we may have charged" (`unverified`), absent means "retry is safe"
1519
+ // (`failed`). Recording after `submit.click()` would let a throw raised by
1520
+ // the click itself look retry-safe, which is the double-charge direction.
1521
+ // Pinned by "a throw AFTER the pay control was clicked reports unverified".
1132
1522
  evidence.step('submit', { clicked: true, target: submit.desc });
1133
1523
  await submit.click();
1134
1524
  await settle(page);
@@ -1250,19 +1640,35 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
1250
1640
  });
1251
1641
  return makeResult('confirmed', state.fields, evidence, requiresAdapter, undefined, confirmationRef);
1252
1642
  }
1643
+ // The pay control was clicked and the observer reached its deadline with no
1644
+ // definitive answer. This is NOT a failure — it is the absence of an answer,
1645
+ // and the charge may well have captured. Reporting it as `failed` is what
1646
+ // let a caller re-run the 2026-08-17 whop.com purchase and draw a second $5.
1253
1647
  evidence.step('outcome', {
1254
- outcome: 'failed',
1648
+ outcome: 'unverified',
1255
1649
  reason: 'no confirmation or decline signal',
1256
1650
  lastSeen: observed.lastSeen,
1257
1651
  attempts: observed.attempts,
1258
1652
  elapsedMs: observed.elapsedMs,
1259
1653
  });
1260
- return makeResult('failed', state.fields, evidence, requiresAdapter, observed.lastSeen === 'processing'
1261
- ? 'submitted but outcome unknown (page still processing at deadline)'
1262
- : 'submitted but outcome unknown');
1654
+ return makeResult('unverified', state.fields, evidence, requiresAdapter, observed.lastSeen === 'processing'
1655
+ ? 'submitted but outcome unknown (page still processing at deadline) — the charge may have gone through; verify with the merchant before any retry'
1656
+ : 'submitted but outcome unknown — the charge may have gone through; verify with the merchant before any retry');
1263
1657
  }
1264
1658
  catch (err) {
1265
1659
  const detail = err.message;
1660
+ // A throw AFTER the pay control was clicked (browser teardown, navigation
1661
+ // race, evidence I/O) leaves the same open question as the deadline path: we
1662
+ // clicked, and we do not know what happened. It must not report `failed`
1663
+ // either. A throw before the click never disclosed a payable form, so it
1664
+ // stays a clean, retry-safe failure.
1665
+ const submitted = evidence
1666
+ .getSteps()
1667
+ .some((step) => step.type === 'submit' && step.data.clicked === true);
1668
+ if (submitted) {
1669
+ evidence.step('outcome', { outcome: 'unverified', error: detail });
1670
+ return makeResult('unverified', state.fields, evidence, requiresAdapter, `${detail} — the pay control was already clicked; the charge may have gone through, so verify with the merchant before any retry`);
1671
+ }
1266
1672
  evidence.step('outcome', { outcome: 'failed', error: detail });
1267
1673
  return makeResult('failed', state.fields, evidence, requiresAdapter, detail);
1268
1674
  }
@@ -1292,7 +1698,7 @@ export async function cancelPreparedCheckout(reviewId, detail = 'checkout cancel
1292
1698
  // explicit prepareCheckout()/submitApprovedCheckout() pair instead.
1293
1699
  export async function runCheckout(opts, store = defaultPreparedCheckoutStore) {
1294
1700
  const { instrument, contact, mode, outcomeDeadlineMs, resolveEmailOtp, ...prepareOptions } = opts;
1295
- const preparation = await prepareCheckout(prepareOptions, store);
1701
+ const preparation = await prepareCheckout({ ...prepareOptions, contact }, store);
1296
1702
  if (preparation.status === 'finished')
1297
1703
  return preparation.result;
1298
1704
  return submitApprovedCheckout(preparation.checkout.review.id, {