@visa/cli 4.1.0-rc.29 → 4.1.0-rc.291
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +310 -46
- package/dist/checkout-engine/adapters/generic.d.ts +69 -0
- package/dist/checkout-engine/adapters/generic.js +383 -58
- package/dist/checkout-engine/adapters/index.d.ts +4 -1
- package/dist/checkout-engine/adapters/index.js +10 -3
- package/dist/checkout-engine/adapters/shopify.d.ts +98 -0
- package/dist/checkout-engine/adapters/shopify.js +744 -0
- package/dist/checkout-engine/amount.d.ts +17 -0
- package/dist/checkout-engine/amount.js +72 -0
- package/dist/checkout-engine/browser-launch.d.ts +9 -4
- package/dist/checkout-engine/browser-launch.js +19 -4
- package/dist/checkout-engine/browserbase-browser.d.ts +24 -0
- package/dist/checkout-engine/browserbase-browser.js +186 -0
- package/dist/checkout-engine/cli-engine.d.ts +241 -32
- package/dist/checkout-engine/cli-engine.js +960 -222
- package/dist/checkout-engine/confirmed-merchants.d.ts +31 -0
- package/dist/checkout-engine/confirmed-merchants.js +165 -0
- package/dist/checkout-engine/detect.d.ts +1 -1
- package/dist/checkout-engine/detect.js +6 -0
- package/dist/checkout-engine/evidence.d.ts +1 -1
- package/dist/checkout-engine/executor.d.ts +92 -4
- package/dist/checkout-engine/executor.js +688 -157
- package/dist/checkout-engine/hosted-approval.d.ts +69 -9
- package/dist/checkout-engine/hosted-approval.js +211 -21
- package/dist/checkout-engine/index.d.ts +9 -3
- package/dist/checkout-engine/index.js +7 -2
- package/dist/checkout-engine/instrument.d.ts +6 -0
- package/dist/checkout-engine/known-merchants.d.ts +10 -0
- package/dist/checkout-engine/known-merchants.js +38 -0
- package/dist/checkout-engine/live-fill-approval.d.ts +5 -11
- package/dist/checkout-engine/live-fill-approval.js +20 -34
- package/dist/checkout-engine/mandate/card-mandate.d.ts +6 -2
- package/dist/checkout-engine/mandate/card-mandate.js +10 -5
- package/dist/checkout-engine/mandate/mandate-ledger.d.ts +63 -23
- package/dist/checkout-engine/mandate/mandate-ledger.js +124 -17
- package/dist/checkout-engine/mandate.d.ts +8 -0
- package/dist/checkout-engine/mandate.js +44 -9
- package/dist/checkout-engine/receipt-dir.d.ts +6 -0
- package/dist/checkout-engine/receipt-dir.js +8 -0
- package/dist/checkout-engine/receipt.d.ts +56 -2
- package/dist/checkout-engine/receipt.js +55 -16
- package/dist/checkout-engine/shopify-primary-domain.d.ts +25 -0
- package/dist/checkout-engine/shopify-primary-domain.js +96 -0
- package/dist/checkout-engine/trace-handles.d.ts +8 -0
- package/dist/checkout-engine/trace-handles.js +12 -0
- package/dist/checkout-engine/types.d.ts +15 -2
- package/dist/checkout-engine/unresolved-charges.d.ts +34 -0
- package/dist/checkout-engine/unresolved-charges.js +134 -0
- package/dist/checkout-engine/vgs-gateway/server-mint-client.d.ts +78 -7
- package/dist/checkout-engine/vgs-gateway/server-mint-client.js +342 -27
- package/dist/checkout-engine/vgs-live-instrument.d.ts +11 -35
- package/dist/checkout-engine/vgs-live-instrument.js +14 -74
- package/dist/checkout-engine/vic-confirmation.d.ts +18 -0
- package/dist/checkout-engine/vic-confirmation.js +9 -3
- package/dist/checkout-engine/web-bot-auth.d.ts +98 -0
- package/dist/checkout-engine/web-bot-auth.js +218 -0
- package/dist/cli.js +936 -389
- package/dist/managed-runtime/resolve-and-update.mjs +268 -0
- package/dist/managed-runtime/runtime-readiness.mjs +126 -0
- package/dist/managed-runtime/update-and-restart.mjs +1079 -0
- package/dist/mcp-apps/ucp-checkout.html +280 -0
- package/dist/mcp-server/index.js +763 -257
- package/dist/merchant-ucp-mcp/index.js +7 -0
- package/dist/skills/pair-visa-agent/RUNTIMES.md +122 -79
- package/dist/skills/pair-visa-agent/SKILL.md +434 -318
- package/dist/skills/pair-visa-agent/scripts/__tests__/setup.test.mjs +407 -0
- package/dist/skills/pair-visa-agent/scripts/setup.mjs +310 -30
- package/dist/skills/visa-shopify-checkout/SKILL.md +122 -0
- package/dist/skills/visa-shopify-checkout/references/evidence-and-states.md +37 -0
- package/dist/skills/visa-ucp-shopping/SKILL.md +86 -0
- package/dist/subway-direct.mjs +1 -0
- package/install.ps1 +7 -6
- package/install.sh +3 -3
- package/native/bin/darwin-arm64/visa-runtime-signer +0 -0
- package/native/bin/darwin-x64/visa-runtime-signer +0 -0
- package/native/bin/linux-arm64/visa-runtime-signer +0 -0
- package/native/bin/linux-x64/visa-runtime-signer +0 -0
- package/native/bin/win32-arm64/visa-runtime-signer.exe +0 -0
- package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
- package/native/bin/win32-x64/visa-runtime-signer.exe +0 -0
- package/package.json +33 -29
- package/server.json +4 -4
- package/dist/checkout-engine/inline-target.d.ts +0 -13
- package/dist/checkout-engine/inline-target.js +0 -37
- package/dist/checkout-engine/pay-args.d.ts +0 -14
- package/dist/checkout-engine/pay-args.js +0 -44
- package/dist/checkout-engine/pay.d.ts +0 -1
- package/dist/checkout-engine/pay.js +0 -13
- package/dist/checkout-engine/repo-env.d.ts +0 -11
- package/dist/checkout-engine/repo-env.js +0 -23
- package/dist/checkout-engine/run-live-fill.d.ts +0 -1
- package/dist/checkout-engine/run-live-fill.js +0 -493
- package/dist/checkout-engine/vgs-gateway/fetch-credential.d.mts +0 -74
- 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 ->
|
|
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,23 @@
|
|
|
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
|
|
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
|
-
import { checkMandate, checkMandatePreFill } from './mandate.js';
|
|
21
|
+
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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, refusalCode) {
|
|
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,34 @@ function makeResult(outcome, fields, evidence, requiresAdapter, detail, confirma
|
|
|
440
427
|
: {}),
|
|
441
428
|
...(completed ? { fillCompletedAt: completed.ts } : {}),
|
|
442
429
|
},
|
|
430
|
+
...(terminalFailureCode ? { failureCode: terminalFailureCode } : {}),
|
|
431
|
+
...(refusalCode ? { refusalCode } : {}),
|
|
443
432
|
...(detail ? { detail } : {}),
|
|
444
433
|
...(confirmationRef ? { confirmationRef } : {}),
|
|
445
434
|
};
|
|
446
435
|
}
|
|
447
|
-
|
|
448
|
-
|
|
436
|
+
// Shopify computes shipping and tax asynchronously after the delivery address
|
|
437
|
+
// lands; a settled, reconciled summary can take well over the default 6s on a
|
|
438
|
+
// cold checkout. Review is free and safe to wait on; approval and pre-submit
|
|
439
|
+
// keep the strict short window because they only confirm an already-settled page.
|
|
440
|
+
const SHOPIFY_REVIEW_SETTLE_MS = 20_000;
|
|
441
|
+
async function readTransactionFacts(page, opts, phase) {
|
|
442
|
+
const shopify = await isShopifyCheckoutPage(page);
|
|
443
|
+
const amountRead = shopify
|
|
444
|
+
? phase === 'review'
|
|
445
|
+
? await readStableShopifyAmount(page, SHOPIFY_REVIEW_SETTLE_MS, {
|
|
446
|
+
// A trusted UCP handoff carries the merchant-settled total; a page
|
|
447
|
+
// that omits a tax row must match it before the review trusts it.
|
|
448
|
+
expectedMinor: opts.trustedMerchantIdentity ? (opts.amountMinor ?? null) : null,
|
|
449
|
+
})
|
|
450
|
+
: await readShopifyAmount(page, true)
|
|
451
|
+
: await readGenericPageAmount(page);
|
|
452
|
+
const pageAmount = shopify && amountRead.kind === 'none'
|
|
453
|
+
? {
|
|
454
|
+
kind: 'unreadable',
|
|
455
|
+
reason: 'Shopify final tax and total summary is not available',
|
|
456
|
+
}
|
|
457
|
+
: amountRead;
|
|
449
458
|
const amountMinor = pageAmount.kind === 'ok'
|
|
450
459
|
? pageAmount.amountMinor
|
|
451
460
|
: pageAmount.kind === 'none'
|
|
@@ -473,11 +482,12 @@ async function readTransactionFacts(page, opts) {
|
|
|
473
482
|
amountMinor,
|
|
474
483
|
currency,
|
|
475
484
|
source,
|
|
485
|
+
code: 'amount_unreadable',
|
|
476
486
|
reason: pageAmount.kind === 'unreadable'
|
|
477
|
-
? 'page total is displayed but cannot be parsed unambiguously'
|
|
487
|
+
? (pageAmount.reason ?? 'page total is displayed but cannot be parsed unambiguously')
|
|
478
488
|
: 'transaction amount could not be determined',
|
|
479
489
|
detail: pageAmount.kind === 'unreadable'
|
|
480
|
-
?
|
|
490
|
+
? `transaction amount could not be determined (${pageAmount.reason ?? 'page total present but ambiguous'}); refusing fail-closed`
|
|
481
491
|
: 'transaction amount could not be determined (no readable page total, no amountMinor provided); refusing fail-closed',
|
|
482
492
|
};
|
|
483
493
|
}
|
|
@@ -487,6 +497,7 @@ async function readTransactionFacts(page, opts) {
|
|
|
487
497
|
amountMinor,
|
|
488
498
|
currency,
|
|
489
499
|
source,
|
|
500
|
+
code: 'currency_unreadable',
|
|
490
501
|
reason: 'transaction currency could not be determined',
|
|
491
502
|
detail: 'transaction currency could not be determined (page total does not state one unambiguously, no currency asserted by the caller); refusing fail-closed',
|
|
492
503
|
};
|
|
@@ -501,7 +512,10 @@ function recordTransactionFacts(evidence, phase, facts) {
|
|
|
501
512
|
currency: facts.currency,
|
|
502
513
|
});
|
|
503
514
|
}
|
|
504
|
-
function reviewChangeReason(review, merchantHost, facts) {
|
|
515
|
+
function reviewChangeReason(review, merchantHost, facts, merchantOrigin) {
|
|
516
|
+
if (review.merchantOrigin && merchantOrigin !== review.merchantOrigin) {
|
|
517
|
+
return `merchant origin changed after review: ${review.merchantOrigin} -> ${merchantOrigin ?? 'invalid'}`;
|
|
518
|
+
}
|
|
505
519
|
if (merchantHost !== review.merchantHost) {
|
|
506
520
|
return `merchant changed after review: ${review.merchantHost} -> ${merchantHost}`;
|
|
507
521
|
}
|
|
@@ -513,6 +527,113 @@ function reviewChangeReason(review, merchantHost, facts) {
|
|
|
513
527
|
}
|
|
514
528
|
return null;
|
|
515
529
|
}
|
|
530
|
+
function exactOrigin(value) {
|
|
531
|
+
try {
|
|
532
|
+
const url = new URL(value);
|
|
533
|
+
if (url.protocol !== 'https:' || url.username || url.password)
|
|
534
|
+
return null;
|
|
535
|
+
return url.origin.toLowerCase();
|
|
536
|
+
}
|
|
537
|
+
catch {
|
|
538
|
+
return null;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
export function trustedMerchantOriginVerdict(options, pageUrl, expectedOrigin) {
|
|
542
|
+
const identity = options.trustedMerchantIdentity;
|
|
543
|
+
if (!identity)
|
|
544
|
+
return null;
|
|
545
|
+
if (Date.parse(identity.expiresAt) <= Date.now()) {
|
|
546
|
+
return { code: 'trusted_handoff_expired', reason: 'trusted UCP checkout handoff expired' };
|
|
547
|
+
}
|
|
548
|
+
const origin = exactOrigin(pageUrl);
|
|
549
|
+
if (!origin) {
|
|
550
|
+
return {
|
|
551
|
+
code: 'trusted_origin_insecure',
|
|
552
|
+
reason: 'trusted UCP checkout reached a non-HTTPS or credentialed origin',
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
if (expectedOrigin) {
|
|
556
|
+
return origin === expectedOrigin
|
|
557
|
+
? null
|
|
558
|
+
: {
|
|
559
|
+
code: 'trusted_origin_changed',
|
|
560
|
+
reason: `merchant origin changed after review: ${expectedOrigin} -> ${origin}`,
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
return identity.allowedOrigins.includes(origin)
|
|
564
|
+
? null
|
|
565
|
+
: {
|
|
566
|
+
code: 'trusted_origin_undeclared',
|
|
567
|
+
reason: `trusted UCP checkout reached undeclared origin ${origin}`,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
export function trustedMerchantOriginRefusal(options, pageUrl, expectedOrigin) {
|
|
571
|
+
return trustedMerchantOriginVerdict(options, pageUrl, expectedOrigin)?.reason ?? null;
|
|
572
|
+
}
|
|
573
|
+
function wwwNormalizedHost(host) {
|
|
574
|
+
return host.toLowerCase().replace(/^www\./, '');
|
|
575
|
+
}
|
|
576
|
+
const MYSHOPIFY_SERVICE_HOST = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.myshopify\.com$/;
|
|
577
|
+
/**
|
|
578
|
+
* Bind the storefront a trusted UCP continuation actually lands on (#8669).
|
|
579
|
+
*
|
|
580
|
+
* The merchant published its UCP business profile at its own business origin
|
|
581
|
+
* and declared the permanent `*.myshopify.com` service that issued the
|
|
582
|
+
* continuation; the CLI verified both before minting the handoff. That is
|
|
583
|
+
* independently verified merchant provenance, so the review may bind the final
|
|
584
|
+
* page origin when, and only when: the navigation started on that declared
|
|
585
|
+
* myshopify origin, the final origin is plain HTTPS, and its host is the
|
|
586
|
+
* declared business host modulo a leading `www.` label. The redirect chain in
|
|
587
|
+
* between (Shopify's primary-domain hop, its shop.app bounce, #8496) carries no
|
|
588
|
+
* authority either way: a redirect cannot land on the merchant's own business
|
|
589
|
+
* domain unless the merchant controls it, and any other final host stays an
|
|
590
|
+
* undeclared origin. Shopify's `primary_domain_redirection` proof is recorded
|
|
591
|
+
* as evidence when present but is not required.
|
|
592
|
+
*/
|
|
593
|
+
export function trustedShopifyAliasOrigin(args) {
|
|
594
|
+
let initial;
|
|
595
|
+
let final;
|
|
596
|
+
try {
|
|
597
|
+
initial = new URL(args.initialUrl);
|
|
598
|
+
final = new URL(args.finalUrl);
|
|
599
|
+
}
|
|
600
|
+
catch {
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
if (initial.protocol !== 'https:' || final.protocol !== 'https:')
|
|
604
|
+
return null;
|
|
605
|
+
if (final.username || final.password || final.port)
|
|
606
|
+
return null;
|
|
607
|
+
const initialHost = initial.hostname.toLowerCase();
|
|
608
|
+
const finalHost = final.hostname.toLowerCase();
|
|
609
|
+
if (!MYSHOPIFY_SERVICE_HOST.test(initialHost) || initialHost === finalHost)
|
|
610
|
+
return null;
|
|
611
|
+
// A declared origin is held to the same standard as the final one: plain
|
|
612
|
+
// HTTPS, default port, no credentials. Anything else never contributes a
|
|
613
|
+
// host, so the identity stays an origin set rather than widening to a host.
|
|
614
|
+
const declared = args.allowedOrigins.flatMap((candidate) => {
|
|
615
|
+
try {
|
|
616
|
+
const url = new URL(candidate);
|
|
617
|
+
return url.protocol === 'https:' && !url.username && !url.password && !url.port
|
|
618
|
+
? [url.hostname.toLowerCase()]
|
|
619
|
+
: [];
|
|
620
|
+
}
|
|
621
|
+
catch {
|
|
622
|
+
return [];
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
if (!declared.includes(initialHost))
|
|
626
|
+
return null;
|
|
627
|
+
const declaredBusiness = declared.some((host) => host !== initialHost &&
|
|
628
|
+
!MYSHOPIFY_SERVICE_HOST.test(host) &&
|
|
629
|
+
wwwNormalizedHost(host) === wwwNormalizedHost(finalHost));
|
|
630
|
+
return declaredBusiness ? final.origin.toLowerCase() : null;
|
|
631
|
+
}
|
|
632
|
+
function mandateForPage(options, pageUrl) {
|
|
633
|
+
if (!options.trustedMerchantIdentity)
|
|
634
|
+
return options.mandate;
|
|
635
|
+
return { ...options.mandate, merchantHost: new URL(pageUrl).hostname };
|
|
636
|
+
}
|
|
516
637
|
function submitTargetChangeReason(review, current) {
|
|
517
638
|
const reviewed = review.submitTargetFingerprint;
|
|
518
639
|
if (!reviewed && !current)
|
|
@@ -729,9 +850,31 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
|
|
|
729
850
|
const options = {
|
|
730
851
|
...opts,
|
|
731
852
|
mandate: { ...opts.mandate },
|
|
853
|
+
...(opts.trustedMerchantIdentity
|
|
854
|
+
? {
|
|
855
|
+
trustedMerchantIdentity: Object.freeze({
|
|
856
|
+
...opts.trustedMerchantIdentity,
|
|
857
|
+
allowedOrigins: Object.freeze([...opts.trustedMerchantIdentity.allowedOrigins]),
|
|
858
|
+
}),
|
|
859
|
+
}
|
|
860
|
+
: {}),
|
|
732
861
|
};
|
|
733
862
|
const evidence = new EvidenceLog();
|
|
734
|
-
|
|
863
|
+
// Pin an English locale: amount reconciliation reads the order summary by
|
|
864
|
+
// its visible labels (Subtotal/Taxes/Total), and merchants localize by
|
|
865
|
+
// Accept-Language (observed live 2026-08-16: a Shopify checkout redirected
|
|
866
|
+
// to /es-us and rendered "Impuestos estimados", so the total never parsed
|
|
867
|
+
// and the review refused fail-closed on a perfectly good checkout).
|
|
868
|
+
// Present Web Bot Auth (RFC 9421) credentials when, and only when, an
|
|
869
|
+
// operator directory is configured to resolve them. Off by default: an
|
|
870
|
+
// unresolvable signature fails verification and is worse than none. The
|
|
871
|
+
// signature covers @authority, so it is bound to the checkout host — a
|
|
872
|
+
// cross-origin redirect simply arrives unverified, never wrongly verified.
|
|
873
|
+
const webBotAuthHeaders = webBotAuthHeadersOrNone(options.webBotAuth ?? null, options.url, Date.now() / 1000);
|
|
874
|
+
const context = await options.browser.newContext({
|
|
875
|
+
locale: 'en-US',
|
|
876
|
+
...(webBotAuthHeaders ? { extraHTTPHeaders: webBotAuthHeaders } : {}),
|
|
877
|
+
});
|
|
735
878
|
// Bound every action so a mis-detected or hidden element fails fast instead
|
|
736
879
|
// of stalling on Playwright's long default timeout.
|
|
737
880
|
context.setDefaultTimeout(6000);
|
|
@@ -745,12 +888,84 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
|
|
|
745
888
|
let fields = {};
|
|
746
889
|
let keepOpen = false;
|
|
747
890
|
try {
|
|
748
|
-
evidence.step('navigation', {
|
|
749
|
-
|
|
891
|
+
evidence.step('navigation', {
|
|
892
|
+
url: options.trustedMerchantIdentity ? exactOrigin(options.url) : options.url,
|
|
893
|
+
});
|
|
894
|
+
const navigationResponse = await page.goto(options.url, { waitUntil: 'domcontentloaded' });
|
|
750
895
|
await waitForStableDom(page);
|
|
751
|
-
evidence.step('dom-stable', {
|
|
752
|
-
|
|
753
|
-
|
|
896
|
+
evidence.step('dom-stable', {
|
|
897
|
+
url: options.trustedMerchantIdentity ? exactOrigin(page.url()) : page.url(),
|
|
898
|
+
});
|
|
899
|
+
let merchantHost = new URL(page.url()).hostname;
|
|
900
|
+
let initialOriginVerdict = trustedMerchantOriginVerdict(options, page.url());
|
|
901
|
+
if (initialOriginVerdict?.code === 'trusted_origin_undeclared' &&
|
|
902
|
+
options.trustedMerchantIdentity) {
|
|
903
|
+
// #8669: the trusted path never got the Shopify primary-domain proof the
|
|
904
|
+
// generic path has carried since #8469, so every declared-business
|
|
905
|
+
// storefront that Shopify serves from its primary domain refused here.
|
|
906
|
+
const aliasOrigin = trustedShopifyAliasOrigin({
|
|
907
|
+
allowedOrigins: options.trustedMerchantIdentity.allowedOrigins,
|
|
908
|
+
initialUrl: options.url,
|
|
909
|
+
finalUrl: page.url(),
|
|
910
|
+
redirects: await navigationRedirectEvidence(navigationResponse),
|
|
911
|
+
});
|
|
912
|
+
if (aliasOrigin) {
|
|
913
|
+
evidence.step('note', {
|
|
914
|
+
kind: 'shopify-primary-domain-alias',
|
|
915
|
+
trusted: true,
|
|
916
|
+
continuationHost: new URL(options.url).hostname,
|
|
917
|
+
checkoutHost: new URL(aliasOrigin).hostname,
|
|
918
|
+
shopifyProof: shopifyPrimaryDomainAlias({
|
|
919
|
+
mandateHost: new URL(options.url).hostname,
|
|
920
|
+
initialUrl: options.url,
|
|
921
|
+
finalUrl: page.url(),
|
|
922
|
+
redirects: await navigationRedirectEvidence(navigationResponse),
|
|
923
|
+
}) !== null,
|
|
924
|
+
});
|
|
925
|
+
options.trustedMerchantIdentity = Object.freeze({
|
|
926
|
+
...options.trustedMerchantIdentity,
|
|
927
|
+
allowedOrigins: Object.freeze([
|
|
928
|
+
...options.trustedMerchantIdentity.allowedOrigins,
|
|
929
|
+
aliasOrigin,
|
|
930
|
+
]),
|
|
931
|
+
});
|
|
932
|
+
initialOriginVerdict = trustedMerchantOriginVerdict(options, page.url());
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
if (initialOriginVerdict) {
|
|
936
|
+
evidence.step('mandate-verdict', {
|
|
937
|
+
phase: 'trusted-origin',
|
|
938
|
+
ok: false,
|
|
939
|
+
reason: initialOriginVerdict.reason,
|
|
940
|
+
code: initialOriginVerdict.code,
|
|
941
|
+
});
|
|
942
|
+
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
943
|
+
return {
|
|
944
|
+
status: 'finished',
|
|
945
|
+
result: makeResult('blocked-by-mandate', fields, evidence, requiresAdapter, initialOriginVerdict.reason, undefined, undefined, initialOriginVerdict.code),
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
if (!options.trustedMerchantIdentity) {
|
|
949
|
+
const shopifyAlias = shopifyPrimaryDomainAlias({
|
|
950
|
+
mandateHost: options.mandate.merchantHost,
|
|
951
|
+
initialUrl: options.url,
|
|
952
|
+
finalUrl: page.url(),
|
|
953
|
+
redirects: await navigationRedirectEvidence(navigationResponse),
|
|
954
|
+
});
|
|
955
|
+
if (shopifyAlias) {
|
|
956
|
+
evidence.step('note', {
|
|
957
|
+
kind: 'shopify-primary-domain-alias',
|
|
958
|
+
mandateHost: options.mandate.merchantHost,
|
|
959
|
+
checkoutHost: shopifyAlias,
|
|
960
|
+
});
|
|
961
|
+
// The proof is collected before any credential is minted. Bind this
|
|
962
|
+
// prepared session to Shopify's primary storefront host so every later
|
|
963
|
+
// approval and pre-submit revalidation stays strict on that host.
|
|
964
|
+
options.mandate = { ...options.mandate, merchantHost: shopifyAlias };
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
let reviewedOrigin;
|
|
968
|
+
const preFill = checkMandatePreFill(mandateForPage(options, page.url()), {
|
|
754
969
|
merchantHost,
|
|
755
970
|
currency: options.currency ?? null,
|
|
756
971
|
});
|
|
@@ -759,7 +974,7 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
|
|
|
759
974
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
760
975
|
return {
|
|
761
976
|
status: 'finished',
|
|
762
|
-
result: makeResult('blocked-by-mandate', fields, evidence, requiresAdapter, preFill.reason),
|
|
977
|
+
result: makeResult('blocked-by-mandate', fields, evidence, requiresAdapter, preFill.reason, undefined, undefined, preFill.code),
|
|
763
978
|
};
|
|
764
979
|
}
|
|
765
980
|
// Payer-chosen amount (Stripe payment links): an empty customUnitAmount
|
|
@@ -787,7 +1002,118 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
|
|
|
787
1002
|
}
|
|
788
1003
|
// Detection is read-only here. In particular, no adapter fill and no
|
|
789
1004
|
// Instrument.getCredential() call can occur before an explicit approval.
|
|
790
|
-
|
|
1005
|
+
let detected = await detectFields(page);
|
|
1006
|
+
const shopifyPage = await isShopifyCheckoutPage(page);
|
|
1007
|
+
if (shopifyPage) {
|
|
1008
|
+
// Same checkout session, English presentation — the amount reader needs
|
|
1009
|
+
// the English summary labels (see shopifyEnglishCheckoutUrl).
|
|
1010
|
+
const englishUrl = shopifyEnglishCheckoutUrl(page.url());
|
|
1011
|
+
if (englishUrl) {
|
|
1012
|
+
await page.goto(englishUrl, { waitUntil: 'domcontentloaded' }).catch(() => { });
|
|
1013
|
+
await waitForStableDom(page);
|
|
1014
|
+
if (await isShopifyCheckoutPage(page)) {
|
|
1015
|
+
evidence.step('navigation', {
|
|
1016
|
+
url: options.trustedMerchantIdentity ? exactOrigin(page.url()) : page.url(),
|
|
1017
|
+
reason: 'shopify-locale-normalized',
|
|
1018
|
+
});
|
|
1019
|
+
detected = await detectFields(page);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
let adapter = selectAdapter(detected, { shopify: shopifyPage });
|
|
1024
|
+
if (options.contact && adapter.prepareContact) {
|
|
1025
|
+
const recordPrefill = async (contact) => {
|
|
1026
|
+
const result = await adapter.prepareContact(page, contact);
|
|
1027
|
+
for (const field of result.filled) {
|
|
1028
|
+
evidence.step('contact-prefill', {
|
|
1029
|
+
role: field.role,
|
|
1030
|
+
confidence: field.confidence,
|
|
1031
|
+
source: field.source,
|
|
1032
|
+
frame: field.frame,
|
|
1033
|
+
value: field.value,
|
|
1034
|
+
ok: field.ok,
|
|
1035
|
+
error: field.error,
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1038
|
+
return result;
|
|
1039
|
+
};
|
|
1040
|
+
let preparedContact = await recordPrefill(options.contact);
|
|
1041
|
+
if (shopifyPage && options.checkoutRoute === 'guest-card') {
|
|
1042
|
+
const guest = await ensureShopifyGuestCheckout(page);
|
|
1043
|
+
evidence.step('note', {
|
|
1044
|
+
phase: 'contact-prefill',
|
|
1045
|
+
checkoutRoute: options.checkoutRoute,
|
|
1046
|
+
shopifyGuestStatus: guest.status,
|
|
1047
|
+
signal: guest.signal,
|
|
1048
|
+
});
|
|
1049
|
+
if (guest.status === 'action-required') {
|
|
1050
|
+
evidence.step('outcome', {
|
|
1051
|
+
outcome: 'action-required',
|
|
1052
|
+
signal: guest.signal,
|
|
1053
|
+
phase: 'contact-prefill',
|
|
1054
|
+
});
|
|
1055
|
+
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1056
|
+
return {
|
|
1057
|
+
status: 'finished',
|
|
1058
|
+
result: makeResult('action-required', fields, evidence, requiresAdapter, guest.detail, undefined, 'human-action-required'),
|
|
1059
|
+
};
|
|
1060
|
+
}
|
|
1061
|
+
if (guest.status === 'transitioned') {
|
|
1062
|
+
// Do not write the recognized email a second time: Shopify can open
|
|
1063
|
+
// the same modal on every email input event. Only retry contact fill
|
|
1064
|
+
// when the takeover interrupted it, and omit email on that retry.
|
|
1065
|
+
if (!preparedContact.ok) {
|
|
1066
|
+
await settle(page);
|
|
1067
|
+
detected = await detectFields(page);
|
|
1068
|
+
adapter = selectAdapter(detected, { shopify: true });
|
|
1069
|
+
preparedContact = await recordPrefill({ ...options.contact, email: undefined });
|
|
1070
|
+
}
|
|
1071
|
+
const verifiedGuest = await assertShopifyGuestCheckout(page);
|
|
1072
|
+
if (verifiedGuest.status === 'action-required') {
|
|
1073
|
+
evidence.step('outcome', {
|
|
1074
|
+
outcome: 'action-required',
|
|
1075
|
+
signal: verifiedGuest.signal,
|
|
1076
|
+
phase: 'contact-prefill-guest-verification',
|
|
1077
|
+
});
|
|
1078
|
+
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1079
|
+
return {
|
|
1080
|
+
status: 'finished',
|
|
1081
|
+
result: makeResult('action-required', fields, evidence, requiresAdapter, verifiedGuest.detail, undefined, 'human-action-required'),
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
if (!preparedContact.ok) {
|
|
1087
|
+
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1088
|
+
return {
|
|
1089
|
+
status: 'finished',
|
|
1090
|
+
result: makeResult('failed', fields, evidence, requiresAdapter, preparedContact.detail ??
|
|
1091
|
+
'Shopify contact prefill did not complete; no payment credential was requested'),
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
await settle(page);
|
|
1095
|
+
const prefillHost = new URL(page.url()).hostname;
|
|
1096
|
+
const trustedPrefillVerdict = trustedMerchantOriginVerdict(options, page.url());
|
|
1097
|
+
if (trustedPrefillVerdict || prefillHost !== merchantHost) {
|
|
1098
|
+
const reason = trustedPrefillVerdict?.reason ??
|
|
1099
|
+
`merchant changed during contact prefill: ${merchantHost} -> ${prefillHost}`;
|
|
1100
|
+
const code = trustedPrefillVerdict?.code ?? 'merchant_host_mismatch';
|
|
1101
|
+
evidence.step('mandate-verdict', {
|
|
1102
|
+
phase: 'contact-prefill',
|
|
1103
|
+
ok: false,
|
|
1104
|
+
reason,
|
|
1105
|
+
code,
|
|
1106
|
+
});
|
|
1107
|
+
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1108
|
+
return {
|
|
1109
|
+
status: 'finished',
|
|
1110
|
+
result: makeResult('blocked-by-mandate', fields, evidence, requiresAdapter, reason, undefined, undefined, code),
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
detected = await detectFields(page);
|
|
1114
|
+
adapter = selectAdapter(detected, { shopify: shopifyPage });
|
|
1115
|
+
evidence.step('adapter-selected', { adapter: adapter.name, phase: 'review' });
|
|
1116
|
+
}
|
|
791
1117
|
fields = detected.fields;
|
|
792
1118
|
evidence.step('detect', {
|
|
793
1119
|
phase: 'review',
|
|
@@ -801,17 +1127,58 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
|
|
|
801
1127
|
evidence.step('psp-detected', { psp: p.psp, requiresAdapter: p.requiresAdapter });
|
|
802
1128
|
}
|
|
803
1129
|
}
|
|
804
|
-
|
|
1130
|
+
// A checkout the runner cannot put a card INTO is not reviewable. Without a
|
|
1131
|
+
// detected card-number field a later pay would fill nothing and dispatch
|
|
1132
|
+
// whatever control the review happened to bind (observed live 2026-08-16:
|
|
1133
|
+
// a Payhip storefront SEARCH form, a FastSpring "PayPal Checkout" label,
|
|
1134
|
+
// and a Shopify discount-form "Submit" all reviewed clean this way — the
|
|
1135
|
+
// real card fields sat in unreachable PSP iframes or an unrendered payment
|
|
1136
|
+
// section). Every adapter fills from this same detection, so a missing
|
|
1137
|
+
// number field here means no pay can ever succeed: refuse while it is
|
|
1138
|
+
// still free.
|
|
1139
|
+
if (!fields.number) {
|
|
1140
|
+
const found = Object.keys(fields);
|
|
1141
|
+
const reason = `no card number field detected (roles found: ${found.length ? found.join(', ') : 'none'}) — ` +
|
|
1142
|
+
'the card form is likely inside a PSP iframe or behind a later step, so a credential cannot be entered on this page';
|
|
1143
|
+
evidence.step('detect', { phase: 'review', missingCardNumber: true, reason });
|
|
1144
|
+
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1145
|
+
return {
|
|
1146
|
+
status: 'finished',
|
|
1147
|
+
result: makeResult('failed', fields, evidence, requiresAdapter, reason, undefined, 'card-number-field-unavailable'),
|
|
1148
|
+
};
|
|
1149
|
+
}
|
|
1150
|
+
// Contact/shipping and Shopify locale normalization may legitimately move
|
|
1151
|
+
// between the exact service and business origins authorized by UCP. Freeze
|
|
1152
|
+
// whichever declared origin is actually on-screen only after those
|
|
1153
|
+
// credential-free steps, then require that exact origin for approval,
|
|
1154
|
+
// credential fill, and submit.
|
|
1155
|
+
const reviewOriginVerdict = trustedMerchantOriginVerdict(options, page.url());
|
|
1156
|
+
if (reviewOriginVerdict) {
|
|
1157
|
+
evidence.step('mandate-verdict', {
|
|
1158
|
+
phase: 'review-origin',
|
|
1159
|
+
ok: false,
|
|
1160
|
+
reason: reviewOriginVerdict.reason,
|
|
1161
|
+
code: reviewOriginVerdict.code,
|
|
1162
|
+
});
|
|
1163
|
+
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1164
|
+
return {
|
|
1165
|
+
status: 'finished',
|
|
1166
|
+
result: makeResult('blocked-by-mandate', fields, evidence, requiresAdapter, reviewOriginVerdict.reason, undefined, undefined, reviewOriginVerdict.code),
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
merchantHost = new URL(page.url()).hostname;
|
|
1170
|
+
reviewedOrigin = options.trustedMerchantIdentity ? exactOrigin(page.url()) : undefined;
|
|
1171
|
+
const facts = await readTransactionFacts(page, options, 'review');
|
|
805
1172
|
recordTransactionFacts(evidence, 'review', facts);
|
|
806
1173
|
if (!facts.ok) {
|
|
807
1174
|
evidence.step('mandate-verdict', { phase: 'review', ok: false, reason: facts.reason });
|
|
808
1175
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
809
1176
|
return {
|
|
810
1177
|
status: 'finished',
|
|
811
|
-
result: makeResult('blocked-by-mandate', fields, evidence, requiresAdapter, facts.detail),
|
|
1178
|
+
result: makeResult('blocked-by-mandate', fields, evidence, requiresAdapter, facts.detail, undefined, undefined, facts.code),
|
|
812
1179
|
};
|
|
813
1180
|
}
|
|
814
|
-
const verdict = checkMandate(options.
|
|
1181
|
+
const verdict = checkMandate(mandateForPage(options, page.url()), {
|
|
815
1182
|
merchantHost,
|
|
816
1183
|
amountMinor: facts.amountMinor,
|
|
817
1184
|
currency: facts.currency,
|
|
@@ -821,14 +1188,21 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
|
|
|
821
1188
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
822
1189
|
return {
|
|
823
1190
|
status: 'finished',
|
|
824
|
-
result: makeResult('blocked-by-mandate', fields, evidence, requiresAdapter, verdict.reason),
|
|
1191
|
+
result: makeResult('blocked-by-mandate', fields, evidence, requiresAdapter, verdict.reason, undefined, undefined, verdict.code),
|
|
825
1192
|
};
|
|
826
1193
|
}
|
|
827
|
-
|
|
1194
|
+
// Multi-step pages may expose the final submit control in a hidden section
|
|
1195
|
+
// before a safe Continue reveals it. Fingerprint that exact control for the
|
|
1196
|
+
// human review; the eventual click path still requires it to be usable.
|
|
1197
|
+
const submit = await findSubmit(page, { allowDeferred: true });
|
|
828
1198
|
const review = Object.freeze({
|
|
829
1199
|
id: randomUUID(),
|
|
830
|
-
|
|
1200
|
+
// A UCP continuation may contain a bearer-like path/query. The live page
|
|
1201
|
+
// stays in the process-bound store; serializable review facts expose only
|
|
1202
|
+
// the exact reviewed origin.
|
|
1203
|
+
url: reviewedOrigin ?? page.url(),
|
|
831
1204
|
merchantHost,
|
|
1205
|
+
...(reviewedOrigin ? { merchantOrigin: reviewedOrigin } : {}),
|
|
832
1206
|
amountMinor: facts.amountMinor,
|
|
833
1207
|
currency: facts.currency,
|
|
834
1208
|
mandateMaxAmountMinor: options.mandate.maxAmountMinor,
|
|
@@ -836,6 +1210,7 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
|
|
|
836
1210
|
submitTarget: submit?.desc ?? null,
|
|
837
1211
|
submitTargetFingerprint: submit ? Object.freeze({ ...submit.fingerprint }) : null,
|
|
838
1212
|
detectedRoles: Object.freeze(Object.keys(fields)),
|
|
1213
|
+
checkoutRoute: options.checkoutRoute,
|
|
839
1214
|
});
|
|
840
1215
|
evidence.step('review', {
|
|
841
1216
|
reviewId: review.id,
|
|
@@ -845,12 +1220,14 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
|
|
|
845
1220
|
submitTarget: review.submitTarget,
|
|
846
1221
|
submitTargetFingerprint: review.submitTargetFingerprint,
|
|
847
1222
|
detectedRoles: review.detectedRoles,
|
|
1223
|
+
checkoutRoute: review.checkoutRoute,
|
|
848
1224
|
});
|
|
849
1225
|
const checkout = Object.freeze({
|
|
850
1226
|
review,
|
|
851
1227
|
fields: Object.freeze({ ...fields }),
|
|
852
1228
|
evidence,
|
|
853
1229
|
requiresAdapter: Object.freeze([...requiresAdapter]),
|
|
1230
|
+
checkoutRoute: options.checkoutRoute,
|
|
854
1231
|
});
|
|
855
1232
|
store.put(review.id, {
|
|
856
1233
|
checkout,
|
|
@@ -902,7 +1279,18 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
902
1279
|
// the credential boundary. A changed checkout requires a fresh review.
|
|
903
1280
|
await waitForStableDom(page);
|
|
904
1281
|
const merchantHost = new URL(page.url()).hostname;
|
|
905
|
-
const
|
|
1282
|
+
const approvalOriginVerdict = trustedMerchantOriginVerdict(options, page.url(), checkout.review.merchantOrigin);
|
|
1283
|
+
if (approvalOriginVerdict) {
|
|
1284
|
+
evidence.step('mandate-verdict', {
|
|
1285
|
+
phase: 'approval-origin',
|
|
1286
|
+
ok: false,
|
|
1287
|
+
reason: approvalOriginVerdict.reason,
|
|
1288
|
+
code: approvalOriginVerdict.code,
|
|
1289
|
+
});
|
|
1290
|
+
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1291
|
+
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, approvalOriginVerdict.reason, undefined, undefined, approvalOriginVerdict.code);
|
|
1292
|
+
}
|
|
1293
|
+
const preFill = checkMandatePreFill(mandateForPage(options, page.url()), {
|
|
906
1294
|
merchantHost,
|
|
907
1295
|
currency: options.currency ?? null,
|
|
908
1296
|
});
|
|
@@ -910,9 +1298,9 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
910
1298
|
if (!preFill.ok) {
|
|
911
1299
|
evidence.step('approval', { approved: false, reviewId: checkout.review.id });
|
|
912
1300
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
913
|
-
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, preFill.reason);
|
|
1301
|
+
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, preFill.reason, undefined, undefined, preFill.code);
|
|
914
1302
|
}
|
|
915
|
-
const approvedFacts = await readTransactionFacts(page, options);
|
|
1303
|
+
const approvedFacts = await readTransactionFacts(page, options, 'approval');
|
|
916
1304
|
recordTransactionFacts(evidence, 'approval', approvedFacts);
|
|
917
1305
|
if (!approvedFacts.ok) {
|
|
918
1306
|
evidence.step('mandate-verdict', {
|
|
@@ -922,9 +1310,9 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
922
1310
|
});
|
|
923
1311
|
evidence.step('approval', { approved: false, reviewId: checkout.review.id });
|
|
924
1312
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
925
|
-
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, approvedFacts.detail);
|
|
1313
|
+
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, approvedFacts.detail, undefined, undefined, approvedFacts.code);
|
|
926
1314
|
}
|
|
927
|
-
const approvalVerdict = checkMandate(options.
|
|
1315
|
+
const approvalVerdict = checkMandate(mandateForPage(options, page.url()), {
|
|
928
1316
|
merchantHost,
|
|
929
1317
|
amountMinor: approvedFacts.amountMinor,
|
|
930
1318
|
currency: approvedFacts.currency,
|
|
@@ -933,9 +1321,9 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
933
1321
|
if (!approvalVerdict.ok) {
|
|
934
1322
|
evidence.step('approval', { approved: false, reviewId: checkout.review.id });
|
|
935
1323
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
936
|
-
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, approvalVerdict.reason);
|
|
1324
|
+
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, approvalVerdict.reason, undefined, undefined, approvalVerdict.code);
|
|
937
1325
|
}
|
|
938
|
-
const changedAtApproval = reviewChangeReason(checkout.review, merchantHost, approvedFacts);
|
|
1326
|
+
const changedAtApproval = reviewChangeReason(checkout.review, merchantHost, approvedFacts, exactOrigin(page.url()) ?? undefined);
|
|
939
1327
|
if (changedAtApproval) {
|
|
940
1328
|
evidence.step('approval', {
|
|
941
1329
|
approved: false,
|
|
@@ -943,9 +1331,12 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
943
1331
|
reason: changedAtApproval,
|
|
944
1332
|
});
|
|
945
1333
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
946
|
-
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, changedAtApproval);
|
|
1334
|
+
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, changedAtApproval, undefined, undefined, 'review_facts_changed');
|
|
947
1335
|
}
|
|
948
|
-
|
|
1336
|
+
// Revalidate the same future target before credential minting. This may be
|
|
1337
|
+
// hidden on a multi-step checkout; the final lookup after reveal requires
|
|
1338
|
+
// the reviewed control to be visible and usable before any click.
|
|
1339
|
+
const approvalSubmit = await findSubmit(page, { allowDeferred: true });
|
|
949
1340
|
const submitChangedAtApproval = submitTargetChangeReason(checkout.review, approvalSubmit);
|
|
950
1341
|
if (submitChangedAtApproval) {
|
|
951
1342
|
evidence.step('approval', {
|
|
@@ -954,7 +1345,7 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
954
1345
|
reason: submitChangedAtApproval,
|
|
955
1346
|
});
|
|
956
1347
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
957
|
-
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, submitChangedAtApproval);
|
|
1348
|
+
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, submitChangedAtApproval, undefined, undefined, 'review_facts_changed');
|
|
958
1349
|
}
|
|
959
1350
|
if (opts.mode === 'submit' && !approvalSubmit) {
|
|
960
1351
|
const reason = 'no submit target was available for human review';
|
|
@@ -967,6 +1358,34 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
967
1358
|
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, reason);
|
|
968
1359
|
}
|
|
969
1360
|
evidence.step('approval', { approved: true, reviewId: checkout.review.id });
|
|
1361
|
+
if (opts.mode === 'dry-run') {
|
|
1362
|
+
evidence.step('credential-skipped', {
|
|
1363
|
+
reason: 'dry-run stops before credential mint or merchant-page disclosure',
|
|
1364
|
+
});
|
|
1365
|
+
evidence.step('submit', { would: true, target: approvalSubmit?.desc ?? 'none found' });
|
|
1366
|
+
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1367
|
+
return makeResult('reviewed-dry-run', state.fields, evidence, requiresAdapter, approvalSubmit
|
|
1368
|
+
? `validated the reviewed checkout; would click ${approvalSubmit.desc}`
|
|
1369
|
+
: 'validated the reviewed checkout; no submit control detected');
|
|
1370
|
+
}
|
|
1371
|
+
if (options.checkoutRoute === 'guest-card' && (await isShopifyCheckoutPage(page))) {
|
|
1372
|
+
const guest = await assertShopifyGuestCheckout(page);
|
|
1373
|
+
evidence.step('note', {
|
|
1374
|
+
phase: 'pre-credential',
|
|
1375
|
+
checkoutRoute: options.checkoutRoute,
|
|
1376
|
+
shopifyGuestStatus: guest.status,
|
|
1377
|
+
signal: guest.signal,
|
|
1378
|
+
});
|
|
1379
|
+
if (guest.status === 'action-required') {
|
|
1380
|
+
evidence.step('outcome', {
|
|
1381
|
+
outcome: 'action-required',
|
|
1382
|
+
signal: guest.signal,
|
|
1383
|
+
phase: 'pre-credential',
|
|
1384
|
+
});
|
|
1385
|
+
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1386
|
+
return makeResult('action-required', state.fields, evidence, requiresAdapter, guest.detail, undefined, 'human-action-required');
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
970
1389
|
const credential = await opts.instrument.getCredential({
|
|
971
1390
|
merchantHost,
|
|
972
1391
|
amountMinor: approvedFacts.amountMinor,
|
|
@@ -976,17 +1395,23 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
976
1395
|
...(credential.credentialExpiresAt
|
|
977
1396
|
? { credentialExpiresAt: credential.credentialExpiresAt }
|
|
978
1397
|
: {}),
|
|
1398
|
+
...traceHandleFields(credential),
|
|
979
1399
|
});
|
|
980
1400
|
// Reveal + fill loop: fills whatever is present, then reveals the next
|
|
981
1401
|
// surface (card radio / next step) until the card number is filled.
|
|
982
1402
|
const clicked = new Set();
|
|
983
1403
|
let adapterName = null;
|
|
1404
|
+
let adapterFillOk = true;
|
|
1405
|
+
let adapterFillDetail = null;
|
|
984
1406
|
for (let attempt = 0; attempt < 4; attempt++) {
|
|
985
1407
|
// A reveal/continue action can navigate between attempts. Never expose
|
|
986
1408
|
// the credential to a host other than the one the human reviewed.
|
|
987
1409
|
const fillHost = new URL(page.url()).hostname;
|
|
988
|
-
|
|
989
|
-
|
|
1410
|
+
const fillOriginRefusal = trustedMerchantOriginRefusal(options, page.url(), checkout.review.merchantOrigin);
|
|
1411
|
+
if (fillOriginRefusal ||
|
|
1412
|
+
(!checkout.review.merchantOrigin && fillHost !== checkout.review.merchantHost)) {
|
|
1413
|
+
const reason = fillOriginRefusal ??
|
|
1414
|
+
`merchant changed after review: ${checkout.review.merchantHost} -> ${fillHost}`;
|
|
990
1415
|
evidence.step('mandate-verdict', {
|
|
991
1416
|
phase: 'approved-submit',
|
|
992
1417
|
ok: false,
|
|
@@ -1009,12 +1434,16 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
1009
1434
|
evidence.step('psp-detected', { psp: p.psp, requiresAdapter: p.requiresAdapter });
|
|
1010
1435
|
}
|
|
1011
1436
|
}
|
|
1012
|
-
const adapter = selectAdapter(detected
|
|
1437
|
+
const adapter = selectAdapter(detected, {
|
|
1438
|
+
shopify: await isShopifyCheckoutPage(page),
|
|
1439
|
+
});
|
|
1013
1440
|
if (adapter.name !== adapterName) {
|
|
1014
1441
|
adapterName = adapter.name;
|
|
1015
1442
|
evidence.step('adapter-selected', { adapter: adapter.name });
|
|
1016
1443
|
}
|
|
1017
1444
|
const fill = await adapter.fill(page, detected.fields, credential, opts.contact);
|
|
1445
|
+
adapterFillOk = fill.ok;
|
|
1446
|
+
adapterFillDetail = fill.detail ?? null;
|
|
1018
1447
|
for (const f of fill.filled) {
|
|
1019
1448
|
evidence.step('field-fill', {
|
|
1020
1449
|
role: f.role,
|
|
@@ -1024,6 +1453,7 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
1024
1453
|
value: f.value,
|
|
1025
1454
|
ok: f.ok,
|
|
1026
1455
|
error: f.error,
|
|
1456
|
+
...(f.relocated ? { relocated: true } : {}),
|
|
1027
1457
|
});
|
|
1028
1458
|
}
|
|
1029
1459
|
const numberOk = fill.filled.some((f) => f.role === 'number' && f.ok);
|
|
@@ -1046,9 +1476,30 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
1046
1476
|
? []
|
|
1047
1477
|
: ['expiry']),
|
|
1048
1478
|
];
|
|
1479
|
+
// Roles the adapter TRIED to fill and never landed, across every reveal
|
|
1480
|
+
// attempt. A job only exists when the field was detected, was visible, and
|
|
1481
|
+
// we held a value for it (see `add()` in adapters/generic.ts) — so a failure
|
|
1482
|
+
// here is never "the page didn't ask for it". It means the page asked, we
|
|
1483
|
+
// answered, and the element refused.
|
|
1484
|
+
//
|
|
1485
|
+
// Roles that failed on an early attempt and succeeded after a reveal are
|
|
1486
|
+
// excluded: `successfulRoles` spans all four attempts, same as above.
|
|
1487
|
+
const failedFillRoles = [
|
|
1488
|
+
...new Set(evidence
|
|
1489
|
+
.getSteps()
|
|
1490
|
+
.filter((step) => step.type === 'field-fill' && step.data.ok === false)
|
|
1491
|
+
.map((step) => String(step.data.role))),
|
|
1492
|
+
]
|
|
1493
|
+
.filter((role) => !successfulRoles.has(role))
|
|
1494
|
+
.sort();
|
|
1049
1495
|
evidence.step('fill-complete', {
|
|
1050
|
-
|
|
1496
|
+
// "Ready to submit", not "the card fields landed". Before 2026-08-17 this
|
|
1497
|
+
// read only the credential roles, so a whop.com run whose city/state/
|
|
1498
|
+
// postalCode all timed out recorded `ok: true` and clicked Get access on
|
|
1499
|
+
// a form it knew was incomplete.
|
|
1500
|
+
ok: missingCredentialRoles.length === 0 && failedFillRoles.length === 0,
|
|
1051
1501
|
missingCredentialRoles,
|
|
1502
|
+
failedFillRoles,
|
|
1052
1503
|
});
|
|
1053
1504
|
if (options.debugShotsDir) {
|
|
1054
1505
|
await captureDebugShot(page, options.debugShotsDir, checkout.review.id, '2-filled', evidence, state.fields);
|
|
@@ -1056,7 +1507,7 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
1056
1507
|
// Re-run the full gate after fill as well. Contact/shipping fields can
|
|
1057
1508
|
// change the total; any drift from the approved review refuses before a
|
|
1058
1509
|
// submit click and requires the caller to prepare a new review.
|
|
1059
|
-
const submitFacts = await readTransactionFacts(page, options);
|
|
1510
|
+
const submitFacts = await readTransactionFacts(page, options, 'pre-submit');
|
|
1060
1511
|
recordTransactionFacts(evidence, 'pre-submit', submitFacts);
|
|
1061
1512
|
if (!submitFacts.ok) {
|
|
1062
1513
|
evidence.step('mandate-verdict', {
|
|
@@ -1065,10 +1516,21 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
1065
1516
|
reason: submitFacts.reason,
|
|
1066
1517
|
});
|
|
1067
1518
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1068
|
-
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, submitFacts.detail);
|
|
1519
|
+
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, submitFacts.detail, undefined, undefined, submitFacts.code);
|
|
1069
1520
|
}
|
|
1070
1521
|
const submitMerchantHost = new URL(page.url()).hostname;
|
|
1071
|
-
const
|
|
1522
|
+
const submitOriginVerdict = trustedMerchantOriginVerdict(options, page.url(), checkout.review.merchantOrigin);
|
|
1523
|
+
if (submitOriginVerdict) {
|
|
1524
|
+
evidence.step('mandate-verdict', {
|
|
1525
|
+
phase: 'pre-submit-origin',
|
|
1526
|
+
ok: false,
|
|
1527
|
+
reason: submitOriginVerdict.reason,
|
|
1528
|
+
code: submitOriginVerdict.code,
|
|
1529
|
+
});
|
|
1530
|
+
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1531
|
+
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, submitOriginVerdict.reason, undefined, undefined, submitOriginVerdict.code);
|
|
1532
|
+
}
|
|
1533
|
+
const verdict = checkMandate(mandateForPage(options, page.url()), {
|
|
1072
1534
|
merchantHost: submitMerchantHost,
|
|
1073
1535
|
amountMinor: submitFacts.amountMinor,
|
|
1074
1536
|
currency: submitFacts.currency,
|
|
@@ -1076,9 +1538,9 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
1076
1538
|
evidence.step('mandate-verdict', { phase: 'pre-submit', ...verdict });
|
|
1077
1539
|
if (!verdict.ok) {
|
|
1078
1540
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1079
|
-
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, verdict.reason);
|
|
1541
|
+
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, verdict.reason, undefined, undefined, verdict.code);
|
|
1080
1542
|
}
|
|
1081
|
-
const changedBeforeSubmit = reviewChangeReason(checkout.review, submitMerchantHost, submitFacts);
|
|
1543
|
+
const changedBeforeSubmit = reviewChangeReason(checkout.review, submitMerchantHost, submitFacts, exactOrigin(page.url()) ?? undefined);
|
|
1082
1544
|
if (changedBeforeSubmit) {
|
|
1083
1545
|
evidence.step('mandate-verdict', {
|
|
1084
1546
|
phase: 'pre-submit',
|
|
@@ -1086,7 +1548,7 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
1086
1548
|
reason: changedBeforeSubmit,
|
|
1087
1549
|
});
|
|
1088
1550
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1089
|
-
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, changedBeforeSubmit);
|
|
1551
|
+
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, changedBeforeSubmit, undefined, undefined, 'review_facts_changed');
|
|
1090
1552
|
}
|
|
1091
1553
|
const submit = await findSubmit(page);
|
|
1092
1554
|
const submitChangedBeforeClick = submitTargetChangeReason(checkout.review, submit);
|
|
@@ -1097,23 +1559,46 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
1097
1559
|
reason: submitChangedBeforeClick,
|
|
1098
1560
|
});
|
|
1099
1561
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1100
|
-
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, submitChangedBeforeClick);
|
|
1562
|
+
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, submitChangedBeforeClick, undefined, undefined, 'review_facts_changed');
|
|
1101
1563
|
}
|
|
1102
|
-
if (
|
|
1103
|
-
evidence.step('submit', { would: true, target: submit?.desc ?? 'none found' });
|
|
1564
|
+
if (!adapterFillOk) {
|
|
1104
1565
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1105
|
-
|
|
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');
|
|
1566
|
+
return makeResult('failed', state.fields, evidence, requiresAdapter, adapterFillDetail ?? `${adapterName ?? 'checkout'} adapter fill incomplete`);
|
|
1112
1567
|
}
|
|
1113
1568
|
if (missingCredentialRoles.length > 0) {
|
|
1114
1569
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1115
1570
|
return makeResult('failed', state.fields, evidence, requiresAdapter, `credential fill incomplete: missing ${missingCredentialRoles.join(', ')}`);
|
|
1116
1571
|
}
|
|
1572
|
+
// STOP BEFORE THE CLICK when any field we tried to fill refused. Submitting
|
|
1573
|
+
// a form we know is incomplete is how a PSP ends up holding a charge we
|
|
1574
|
+
// cannot then confirm or account for: the 2026-08-17 whop.com run filled the
|
|
1575
|
+
// card into Basis Theory iframes, watched city/state/postalCode time out at
|
|
1576
|
+
// 5s each, clicked Get access anyway, and could never observe an outcome.
|
|
1577
|
+
//
|
|
1578
|
+
// This refusal happens BEFORE the submit click, so nothing can be charged by
|
|
1579
|
+
// it — the safe direction, and the reason it is allowed to be strict. A
|
|
1580
|
+
// merchant whose address widget we cannot drive now fails cleanly and
|
|
1581
|
+
// retryably instead of dangerously.
|
|
1582
|
+
if (failedFillRoles.length > 0) {
|
|
1583
|
+
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1584
|
+
// Name the CAUSE per field, not just the field. The refusal is the only
|
|
1585
|
+
// artifact that survives to the operator (a v2 receipt carries no evidence
|
|
1586
|
+
// log), and "city, postalCode refused" without a reason means the next
|
|
1587
|
+
// person has to reproduce a live merchant to learn anything. Each cause
|
|
1588
|
+
// points at a different fix — see summarizeFillFailure.
|
|
1589
|
+
//
|
|
1590
|
+
// Last attempt wins: a role that failed differently across reveal passes
|
|
1591
|
+
// is best described by how it failed when we finally gave up on it.
|
|
1592
|
+
const lastFillError = (role) => {
|
|
1593
|
+
const errors = evidence
|
|
1594
|
+
.getSteps()
|
|
1595
|
+
.filter((step) => step.type === 'field-fill' && step.data.ok === false && step.data.role === role)
|
|
1596
|
+
.map((step) => (typeof step.data.error === 'string' ? step.data.error : undefined));
|
|
1597
|
+
return errors[errors.length - 1];
|
|
1598
|
+
};
|
|
1599
|
+
const reasons = failedFillRoles.map((role) => `${role} (${summarizeFillFailure(lastFillError(role))})`);
|
|
1600
|
+
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');
|
|
1601
|
+
}
|
|
1117
1602
|
if (!submit) {
|
|
1118
1603
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1119
1604
|
return makeResult('failed', state.fields, evidence, requiresAdapter, 'no submit control detected');
|
|
@@ -1129,6 +1614,36 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
1129
1614
|
// Stripe's submit chain mid-flight (#5879) — see waitForLinkLookupQuiet.
|
|
1130
1615
|
const linkQuiet = await waitForLinkLookupQuiet(page);
|
|
1131
1616
|
evidence.step('note', { linkQuiet });
|
|
1617
|
+
// This is deliberately the last await before the irreversible click.
|
|
1618
|
+
// Mint-time validation is not enough: reveal/fill and Link suppression can
|
|
1619
|
+
// consume a short-lived DAVV. Refuse malformed or <60s credentials so an
|
|
1620
|
+
// expiry decline cannot masquerade as a form-fill failure.
|
|
1621
|
+
const credentialExpiresAt = credential.credentialExpiresAt;
|
|
1622
|
+
const expiryMissing = opts.instrument.kind === 'agentic-token' && credentialExpiresAt === undefined;
|
|
1623
|
+
const expiresMs = credentialExpiresAt === undefined ? Number.NaN : Date.parse(credentialExpiresAt);
|
|
1624
|
+
if (expiryMissing ||
|
|
1625
|
+
(credentialExpiresAt !== undefined &&
|
|
1626
|
+
(!Number.isFinite(expiresMs) || expiresMs - Date.now() < 60_000))) {
|
|
1627
|
+
evidence.step('credential-expiry-check', {
|
|
1628
|
+
ok: false,
|
|
1629
|
+
reason: expiryMissing ? 'missing' : 'invalid-or-expiring',
|
|
1630
|
+
...(credentialExpiresAt !== undefined ? { credentialExpiresAt } : {}),
|
|
1631
|
+
});
|
|
1632
|
+
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1633
|
+
return makeResult('failed', state.fields, evidence, requiresAdapter, 'credential expired before submit; obtain a fresh intent and re-review');
|
|
1634
|
+
}
|
|
1635
|
+
if (credentialExpiresAt !== undefined) {
|
|
1636
|
+
evidence.step('credential-expiry-check', {
|
|
1637
|
+
ok: true,
|
|
1638
|
+
credentialExpiresAt,
|
|
1639
|
+
});
|
|
1640
|
+
}
|
|
1641
|
+
// ORDER IS LOAD-BEARING: record the click BEFORE performing it. The catch
|
|
1642
|
+
// block classifies a throw by whether this step exists — recorded means
|
|
1643
|
+
// "we may have charged" (`unverified`), absent means "retry is safe"
|
|
1644
|
+
// (`failed`). Recording after `submit.click()` would let a throw raised by
|
|
1645
|
+
// the click itself look retry-safe, which is the double-charge direction.
|
|
1646
|
+
// Pinned by "a throw AFTER the pay control was clicked reports unverified".
|
|
1132
1647
|
evidence.step('submit', { clicked: true, target: submit.desc });
|
|
1133
1648
|
await submit.click();
|
|
1134
1649
|
await settle(page);
|
|
@@ -1250,19 +1765,35 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
1250
1765
|
});
|
|
1251
1766
|
return makeResult('confirmed', state.fields, evidence, requiresAdapter, undefined, confirmationRef);
|
|
1252
1767
|
}
|
|
1768
|
+
// The pay control was clicked and the observer reached its deadline with no
|
|
1769
|
+
// definitive answer. This is NOT a failure — it is the absence of an answer,
|
|
1770
|
+
// and the charge may well have captured. Reporting it as `failed` is what
|
|
1771
|
+
// let a caller re-run the 2026-08-17 whop.com purchase and draw a second $5.
|
|
1253
1772
|
evidence.step('outcome', {
|
|
1254
|
-
outcome: '
|
|
1773
|
+
outcome: 'unverified',
|
|
1255
1774
|
reason: 'no confirmation or decline signal',
|
|
1256
1775
|
lastSeen: observed.lastSeen,
|
|
1257
1776
|
attempts: observed.attempts,
|
|
1258
1777
|
elapsedMs: observed.elapsedMs,
|
|
1259
1778
|
});
|
|
1260
|
-
return makeResult('
|
|
1261
|
-
? 'submitted but outcome unknown (page still processing at deadline)'
|
|
1262
|
-
: 'submitted but outcome unknown');
|
|
1779
|
+
return makeResult('unverified', state.fields, evidence, requiresAdapter, observed.lastSeen === 'processing'
|
|
1780
|
+
? 'submitted but outcome unknown (page still processing at deadline) — the charge may have gone through; verify with the merchant before any retry'
|
|
1781
|
+
: 'submitted but outcome unknown — the charge may have gone through; verify with the merchant before any retry');
|
|
1263
1782
|
}
|
|
1264
1783
|
catch (err) {
|
|
1265
1784
|
const detail = err.message;
|
|
1785
|
+
// A throw AFTER the pay control was clicked (browser teardown, navigation
|
|
1786
|
+
// race, evidence I/O) leaves the same open question as the deadline path: we
|
|
1787
|
+
// clicked, and we do not know what happened. It must not report `failed`
|
|
1788
|
+
// either. A throw before the click never disclosed a payable form, so it
|
|
1789
|
+
// stays a clean, retry-safe failure.
|
|
1790
|
+
const submitted = evidence
|
|
1791
|
+
.getSteps()
|
|
1792
|
+
.some((step) => step.type === 'submit' && step.data.clicked === true);
|
|
1793
|
+
if (submitted) {
|
|
1794
|
+
evidence.step('outcome', { outcome: 'unverified', error: detail });
|
|
1795
|
+
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`);
|
|
1796
|
+
}
|
|
1266
1797
|
evidence.step('outcome', { outcome: 'failed', error: detail });
|
|
1267
1798
|
return makeResult('failed', state.fields, evidence, requiresAdapter, detail);
|
|
1268
1799
|
}
|
|
@@ -1292,7 +1823,7 @@ export async function cancelPreparedCheckout(reviewId, detail = 'checkout cancel
|
|
|
1292
1823
|
// explicit prepareCheckout()/submitApprovedCheckout() pair instead.
|
|
1293
1824
|
export async function runCheckout(opts, store = defaultPreparedCheckoutStore) {
|
|
1294
1825
|
const { instrument, contact, mode, outcomeDeadlineMs, resolveEmailOtp, ...prepareOptions } = opts;
|
|
1295
|
-
const preparation = await prepareCheckout(prepareOptions, store);
|
|
1826
|
+
const preparation = await prepareCheckout({ ...prepareOptions, contact }, store);
|
|
1296
1827
|
if (preparation.status === 'finished')
|
|
1297
1828
|
return preparation.result;
|
|
1298
1829
|
return submitApprovedCheckout(preparation.checkout.review.id, {
|