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