@visa/cli 4.1.0-rc.42 → 4.1.0-rc.43
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/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 +1 -0
- package/dist/checkout-engine/adapters/index.js +6 -2
- 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.js +1 -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 +2 -2
- package/dist/checkout-engine/executor.js +111 -95
- package/dist/checkout-engine/instrument.d.ts +6 -0
- package/dist/checkout-engine/run-live-fill.js +1 -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 +7 -2
- package/dist/checkout-engine/vgs-gateway/server-mint-client.js +2 -0
- package/dist/checkout-engine/vgs-live-instrument.d.ts +2 -0
- package/dist/checkout-engine/vgs-live-instrument.js +11 -7
- package/dist/cli.js +272 -261
- package/dist/mcp-server/index.js +205 -194
- package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Page } from 'playwright-core';
|
|
2
2
|
export type FieldSource = 'autocomplete' | 'attr-heuristic' | 'label-text' | 'iframe-psp' | 'type-inference';
|
|
3
|
-
export type FieldRole = 'number' | 'expMonth' | 'expYear' | 'expCombined' | 'cvc' | 'name' | 'nameFirst' | 'nameLast' | 'email' | 'oneTimeCode' | 'addressLine1' | 'addressLine2' | 'city' | 'state' | 'postalCode' | 'country';
|
|
3
|
+
export type FieldRole = 'number' | 'expMonth' | 'expYear' | 'expCombined' | 'cvc' | 'name' | 'nameFirst' | 'nameLast' | 'email' | 'phone' | 'oneTimeCode' | 'addressLine1' | 'addressLine2' | 'city' | 'state' | 'postalCode' | 'country';
|
|
4
4
|
export declare const ALL_ROLES: FieldRole[];
|
|
5
5
|
export type SelectOption = {
|
|
6
6
|
value: string;
|
|
@@ -21,6 +21,7 @@ export const ALL_ROLES = [
|
|
|
21
21
|
'nameFirst',
|
|
22
22
|
'nameLast',
|
|
23
23
|
'email',
|
|
24
|
+
'phone',
|
|
24
25
|
'oneTimeCode',
|
|
25
26
|
'addressLine1',
|
|
26
27
|
'addressLine2',
|
|
@@ -38,6 +39,7 @@ const expMonthRe = /(exp.?month|expmonth|exp_mm|(^|[^a-z])mm([^a-z]|$)|(^|[^a-z]
|
|
|
38
39
|
const expYearRe = /(exp.?year|expyear|exp_yy|(^|[^a-z])yy(yy)?([^a-z]|$)|(^|[^a-z])year([^a-z]|$)|jahr|annee|(^|[^a-z])ano([^a-z]|$))/;
|
|
39
40
|
const expCombinedRe = /(exp(iry|iration)?(.?date)?|mm.?\/.?yy|mm.?jj|valid.?thru|ablauf|caducidad)/;
|
|
40
41
|
const emailRe = /(e-?mail|correo|courriel)/;
|
|
42
|
+
const phoneRe = /(phone|telephone|mobile|(^|[^a-z])tel([^a-z]|$)|telefono|téléphone)/;
|
|
41
43
|
// One-time verification code (merchant email/SMS OTP). Ordered AFTER cvcRe in
|
|
42
44
|
// attrClassify so a card CVC ("security code"/"card code") still wins — this
|
|
43
45
|
// regex deliberately omits those card phrasings. It also OMITS a bare "code":
|
|
@@ -67,6 +69,8 @@ const AUTOCOMPLETE_MAP = {
|
|
|
67
69
|
'cc-name': 'name',
|
|
68
70
|
name: 'name',
|
|
69
71
|
email: 'email',
|
|
72
|
+
tel: 'phone',
|
|
73
|
+
'tel-national': 'phone',
|
|
70
74
|
'one-time-code': 'oneTimeCode',
|
|
71
75
|
'given-name': 'nameFirst',
|
|
72
76
|
'family-name': 'nameLast',
|
|
@@ -167,6 +171,8 @@ function attrClassify(text, m) {
|
|
|
167
171
|
// OTP comes after cvc (checked above) so card CVC still classifies as 'cvc'.
|
|
168
172
|
if (oneTimeCodeRe.test(text))
|
|
169
173
|
return 'oneTimeCode';
|
|
174
|
+
if (phoneRe.test(text))
|
|
175
|
+
return 'phone';
|
|
170
176
|
if (postalRe.test(text))
|
|
171
177
|
return 'postalCode';
|
|
172
178
|
if (countryRe.test(text))
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type EvidenceStepType = 'navigation' | 'dom-stable' | 'review' | 'approval' | 'adapter-selected' | 'reveal' | 'detect' | 'field-fill' | 'amount-fill' | 'credential-minted' | 'credential-skipped' | 'fill-complete' | 'psp-detected' | 'mandate-verdict' | 'submit' | 'challenge-hold' | 'outcome' | 'note';
|
|
1
|
+
export type EvidenceStepType = 'navigation' | 'dom-stable' | 'review' | 'approval' | 'adapter-selected' | 'reveal' | 'detect' | 'field-fill' | 'amount-fill' | 'contact-prefill' | 'credential-minted' | 'credential-skipped' | 'credential-expiry-check' | 'fill-complete' | 'psp-detected' | 'mandate-verdict' | 'submit' | 'challenge-hold' | 'outcome' | 'note';
|
|
2
2
|
export type EvidenceStep = {
|
|
3
3
|
ts: string;
|
|
4
4
|
type: EvidenceStepType;
|
|
@@ -5,6 +5,7 @@ import type { Instrument } from './instrument.js';
|
|
|
5
5
|
import type { Contact, OtpResolver } from './types.js';
|
|
6
6
|
import { EvidenceLog } from './evidence.js';
|
|
7
7
|
import { type ObservedOutcome } from './outcome.js';
|
|
8
|
+
export { minorFromDecimal, pageCurrency } from './amount.js';
|
|
8
9
|
export type CheckoutMode = 'dry-run' | 'submit';
|
|
9
10
|
export type CheckoutOutcome = 'reviewed-dry-run'
|
|
10
11
|
/** Historical receipt value from the credential-disclosing dry-run. */
|
|
@@ -30,6 +31,7 @@ export type PrepareCheckoutOptions = {
|
|
|
30
31
|
url: string;
|
|
31
32
|
mandate: Mandate;
|
|
32
33
|
browser: Browser;
|
|
34
|
+
contact?: Contact;
|
|
33
35
|
amountMinor?: number;
|
|
34
36
|
currency?: string;
|
|
35
37
|
debugShotsDir?: string;
|
|
@@ -91,8 +93,6 @@ export type SubmitApprovedCheckoutOptions = {
|
|
|
91
93
|
onChallengeHold?: (signal: string | null) => void;
|
|
92
94
|
resolveEmailOtp?: OtpResolver;
|
|
93
95
|
};
|
|
94
|
-
export declare function minorFromDecimal(text: string): number | null;
|
|
95
|
-
export declare function pageCurrency(text: string): string | null;
|
|
96
96
|
export type PreparedCheckoutSession = {
|
|
97
97
|
checkout: PreparedCheckout;
|
|
98
98
|
context: BrowserContext;
|
|
@@ -22,6 +22,10 @@ import { checkMandate, checkMandatePreFill } from './mandate.js';
|
|
|
22
22
|
import { EvidenceLog, maskOtp } from './evidence.js';
|
|
23
23
|
import { observeOutcome } from './outcome.js';
|
|
24
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';
|
|
25
29
|
const SUBMIT_TEXT = /pay|place order|complete|buy|submit|checkout/i;
|
|
26
30
|
const REVEAL_TEXT = /continue|next|proceed|review|go to payment/i;
|
|
27
31
|
// Post-submit confirmed/declined/challenge signals live in outcome.ts
|
|
@@ -36,92 +40,6 @@ async function settle(page) {
|
|
|
36
40
|
await page.waitForTimeout(200);
|
|
37
41
|
await page.waitForLoadState('networkidle', { timeout: 1500 }).catch(() => { });
|
|
38
42
|
}
|
|
39
|
-
// Convert a human decimal like "$50.00" to integer minor units without
|
|
40
|
-
// floats. Fail-closed on separator ambiguity: only layouts with exactly one
|
|
41
|
-
// reading are parsed; anything else returns null and the caller refuses. The
|
|
42
|
-
// dangerous direction is UNDER-reading (an EU "1.234,56" read as 1.23 lets an
|
|
43
|
-
// over-cap total pass the gate), so no layout is ever guessed.
|
|
44
|
-
// Only 2-decimal currencies are supported (see pageCurrency's ISO allowlist).
|
|
45
|
-
export function minorFromDecimal(text) {
|
|
46
|
-
const m = text.match(/\d[\d.,]*/);
|
|
47
|
-
if (!m)
|
|
48
|
-
return null;
|
|
49
|
-
const token = m[0].replace(/[.,]+$/, '');
|
|
50
|
-
// "1234" — plain integer major units.
|
|
51
|
-
if (/^\d+$/.test(token))
|
|
52
|
-
return Number.parseInt(token, 10) * 100;
|
|
53
|
-
// "1,234.56" — thousands groups of exactly 3 plus a 2-digit decimal.
|
|
54
|
-
if (/^\d{1,3}(,\d{3})+\.\d{2}$/.test(token)) {
|
|
55
|
-
const [whole, frac] = token.replace(/,/g, '').split('.');
|
|
56
|
-
return Number.parseInt(whole, 10) * 100 + Number.parseInt(frac, 10);
|
|
57
|
-
}
|
|
58
|
-
// "1.234,56" — the EU mirror: dot thousands, comma decimal.
|
|
59
|
-
if (/^\d{1,3}(\.\d{3})+,\d{2}$/.test(token)) {
|
|
60
|
-
const [whole, frac] = token.replace(/\./g, '').split(',');
|
|
61
|
-
return Number.parseInt(whole, 10) * 100 + Number.parseInt(frac, 10);
|
|
62
|
-
}
|
|
63
|
-
// "49.99" / "49.9" — dot decimal. A 3-digit dot group ("1.234") is EU
|
|
64
|
-
// thousands, not a decimal, so only 1-2 fraction digits qualify.
|
|
65
|
-
if (/^\d+\.\d{1,2}$/.test(token)) {
|
|
66
|
-
const [whole, frac] = token.split('.');
|
|
67
|
-
return Number.parseInt(whole, 10) * 100 + Number.parseInt(frac.padEnd(2, '0'), 10);
|
|
68
|
-
}
|
|
69
|
-
// "49,99" / "49,9" — comma decimal. Unambiguous: a thousands group is
|
|
70
|
-
// always exactly 3 digits, so a 1-2 digit comma tail can only be a decimal.
|
|
71
|
-
if (/^\d+,\d{1,2}$/.test(token)) {
|
|
72
|
-
const [whole, frac] = token.split(',');
|
|
73
|
-
return Number.parseInt(whole, 10) * 100 + Number.parseInt(frac.padEnd(2, '0'), 10);
|
|
74
|
-
}
|
|
75
|
-
// Everything else ("1,234" thousands-or-3-decimals, "1.2.3", ...) is
|
|
76
|
-
// ambiguous: refuse rather than guess.
|
|
77
|
-
return null;
|
|
78
|
-
}
|
|
79
|
-
// Currency stated by the page's total text, when unambiguous. "$" is shared
|
|
80
|
-
// by USD/CAD/AUD/... and never qualifies. The ISO allowlist is 2-decimal
|
|
81
|
-
// currencies only, matching minorFromDecimal's scaling.
|
|
82
|
-
const ISO_CURRENCIES = ['USD', 'EUR', 'GBP', 'CAD', 'AUD', 'CHF', 'NZD'];
|
|
83
|
-
export function pageCurrency(text) {
|
|
84
|
-
const iso = text.match(/\b([A-Z]{3})\b/);
|
|
85
|
-
if (iso && ISO_CURRENCIES.includes(iso[1]))
|
|
86
|
-
return iso[1];
|
|
87
|
-
if (text.includes('€'))
|
|
88
|
-
return 'EUR';
|
|
89
|
-
if (text.includes('£'))
|
|
90
|
-
return 'GBP';
|
|
91
|
-
return null;
|
|
92
|
-
}
|
|
93
|
-
// Read the order total from the page. Prefers an explicit data-total-minor
|
|
94
|
-
// attribute (machine-readable), else parses a labelled total from the page.
|
|
95
|
-
// Either way the currency comes from the element's text, or stays null. A
|
|
96
|
-
// total that is present but ambiguous is 'unreadable' — the executor refuses
|
|
97
|
-
// rather than falling back to the caller amount, because the page is showing
|
|
98
|
-
// the user a number we cannot verify against the mandate.
|
|
99
|
-
async function readPageAmount(page) {
|
|
100
|
-
const explicitLoc = page.locator('[data-total-minor]').first();
|
|
101
|
-
if ((await explicitLoc.count().catch(() => 0)) > 0) {
|
|
102
|
-
const explicit = await explicitLoc.getAttribute('data-total-minor').catch(() => null);
|
|
103
|
-
if (explicit && /^\d+$/.test(explicit)) {
|
|
104
|
-
const text = (await explicitLoc.textContent().catch(() => null)) ?? '';
|
|
105
|
-
return {
|
|
106
|
-
kind: 'ok',
|
|
107
|
-
amountMinor: Number.parseInt(explicit, 10),
|
|
108
|
-
currency: pageCurrency(text),
|
|
109
|
-
source: 'page-attr',
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
const totalLoc = page.locator('#order-total, .order-total, [data-testid="order-total"]').first();
|
|
114
|
-
if ((await totalLoc.count().catch(() => 0)) > 0) {
|
|
115
|
-
const totalText = await totalLoc.textContent().catch(() => null);
|
|
116
|
-
if (totalText && /\d/.test(totalText)) {
|
|
117
|
-
const amountMinor = minorFromDecimal(totalText);
|
|
118
|
-
if (amountMinor == null)
|
|
119
|
-
return { kind: 'unreadable' };
|
|
120
|
-
return { kind: 'ok', amountMinor, currency: pageCurrency(totalText), source: 'page-text' };
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
return { kind: 'none' };
|
|
124
|
-
}
|
|
125
43
|
async function tryReveal(page, evidence, clicked) {
|
|
126
44
|
// 1) A payment-method radio for card/credit/debit (accordion layouts).
|
|
127
45
|
const radios = page.locator('input[type="radio"]');
|
|
@@ -445,8 +363,19 @@ function makeResult(outcome, fields, evidence, requiresAdapter, detail, confirma
|
|
|
445
363
|
...(confirmationRef ? { confirmationRef } : {}),
|
|
446
364
|
};
|
|
447
365
|
}
|
|
448
|
-
async function readTransactionFacts(page, opts) {
|
|
449
|
-
const
|
|
366
|
+
async function readTransactionFacts(page, opts, phase) {
|
|
367
|
+
const shopify = await isShopifyCheckoutPage(page);
|
|
368
|
+
const amountRead = shopify
|
|
369
|
+
? phase === 'review'
|
|
370
|
+
? await readStableShopifyAmount(page)
|
|
371
|
+
: await readShopifyAmount(page, true)
|
|
372
|
+
: await readGenericPageAmount(page);
|
|
373
|
+
const pageAmount = shopify && amountRead.kind === 'none'
|
|
374
|
+
? {
|
|
375
|
+
kind: 'unreadable',
|
|
376
|
+
reason: 'Shopify final tax and total summary is not available',
|
|
377
|
+
}
|
|
378
|
+
: amountRead;
|
|
450
379
|
const amountMinor = pageAmount.kind === 'ok'
|
|
451
380
|
? pageAmount.amountMinor
|
|
452
381
|
: pageAmount.kind === 'none'
|
|
@@ -475,10 +404,10 @@ async function readTransactionFacts(page, opts) {
|
|
|
475
404
|
currency,
|
|
476
405
|
source,
|
|
477
406
|
reason: pageAmount.kind === 'unreadable'
|
|
478
|
-
? 'page total is displayed but cannot be parsed unambiguously'
|
|
407
|
+
? (pageAmount.reason ?? 'page total is displayed but cannot be parsed unambiguously')
|
|
479
408
|
: 'transaction amount could not be determined',
|
|
480
409
|
detail: pageAmount.kind === 'unreadable'
|
|
481
|
-
?
|
|
410
|
+
? `transaction amount could not be determined (${pageAmount.reason ?? 'page total present but ambiguous'}); refusing fail-closed`
|
|
482
411
|
: 'transaction amount could not be determined (no readable page total, no amountMinor provided); refusing fail-closed',
|
|
483
412
|
};
|
|
484
413
|
}
|
|
@@ -788,7 +717,61 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
|
|
|
788
717
|
}
|
|
789
718
|
// Detection is read-only here. In particular, no adapter fill and no
|
|
790
719
|
// Instrument.getCredential() call can occur before an explicit approval.
|
|
791
|
-
|
|
720
|
+
let detected = await detectFields(page);
|
|
721
|
+
let adapter = selectAdapter(detected);
|
|
722
|
+
if (options.contact && adapter.prepareContact) {
|
|
723
|
+
const preparedContact = await adapter.prepareContact(page, options.contact);
|
|
724
|
+
for (const field of preparedContact.filled) {
|
|
725
|
+
evidence.step('contact-prefill', {
|
|
726
|
+
role: field.role,
|
|
727
|
+
confidence: field.confidence,
|
|
728
|
+
source: field.source,
|
|
729
|
+
frame: field.frame,
|
|
730
|
+
value: field.value,
|
|
731
|
+
ok: field.ok,
|
|
732
|
+
error: field.error,
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
const challenge = await detectShopifyChallenge(page);
|
|
736
|
+
if (challenge) {
|
|
737
|
+
evidence.step('outcome', {
|
|
738
|
+
outcome: 'action-required',
|
|
739
|
+
signal: challenge.signal,
|
|
740
|
+
phase: 'contact-prefill',
|
|
741
|
+
});
|
|
742
|
+
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
743
|
+
return {
|
|
744
|
+
status: 'finished',
|
|
745
|
+
result: makeResult('action-required', fields, evidence, requiresAdapter, `Shop Pay verification requires a human before review (${challenge.signal}); no payment credential was requested`),
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
if (!preparedContact.ok) {
|
|
749
|
+
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
750
|
+
return {
|
|
751
|
+
status: 'finished',
|
|
752
|
+
result: makeResult('failed', fields, evidence, requiresAdapter, preparedContact.detail ??
|
|
753
|
+
'Shopify contact prefill did not complete; no payment credential was requested'),
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
await settle(page);
|
|
757
|
+
const prefillHost = new URL(page.url()).hostname;
|
|
758
|
+
if (prefillHost !== merchantHost) {
|
|
759
|
+
const reason = `merchant changed during contact prefill: ${merchantHost} -> ${prefillHost}`;
|
|
760
|
+
evidence.step('mandate-verdict', {
|
|
761
|
+
phase: 'contact-prefill',
|
|
762
|
+
ok: false,
|
|
763
|
+
reason,
|
|
764
|
+
});
|
|
765
|
+
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
766
|
+
return {
|
|
767
|
+
status: 'finished',
|
|
768
|
+
result: makeResult('blocked-by-mandate', fields, evidence, requiresAdapter, reason),
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
detected = await detectFields(page);
|
|
772
|
+
adapter = selectAdapter(detected);
|
|
773
|
+
evidence.step('adapter-selected', { adapter: adapter.name, phase: 'review' });
|
|
774
|
+
}
|
|
792
775
|
fields = detected.fields;
|
|
793
776
|
evidence.step('detect', {
|
|
794
777
|
phase: 'review',
|
|
@@ -802,7 +785,7 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
|
|
|
802
785
|
evidence.step('psp-detected', { psp: p.psp, requiresAdapter: p.requiresAdapter });
|
|
803
786
|
}
|
|
804
787
|
}
|
|
805
|
-
const facts = await readTransactionFacts(page, options);
|
|
788
|
+
const facts = await readTransactionFacts(page, options, 'review');
|
|
806
789
|
recordTransactionFacts(evidence, 'review', facts);
|
|
807
790
|
if (!facts.ok) {
|
|
808
791
|
evidence.step('mandate-verdict', { phase: 'review', ok: false, reason: facts.reason });
|
|
@@ -913,7 +896,7 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
913
896
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
914
897
|
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, preFill.reason);
|
|
915
898
|
}
|
|
916
|
-
const approvedFacts = await readTransactionFacts(page, options);
|
|
899
|
+
const approvedFacts = await readTransactionFacts(page, options, 'approval');
|
|
917
900
|
recordTransactionFacts(evidence, 'approval', approvedFacts);
|
|
918
901
|
if (!approvedFacts.ok) {
|
|
919
902
|
evidence.step('mandate-verdict', {
|
|
@@ -987,11 +970,14 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
987
970
|
...(credential.credentialExpiresAt
|
|
988
971
|
? { credentialExpiresAt: credential.credentialExpiresAt }
|
|
989
972
|
: {}),
|
|
973
|
+
...traceHandleFields(credential),
|
|
990
974
|
});
|
|
991
975
|
// Reveal + fill loop: fills whatever is present, then reveals the next
|
|
992
976
|
// surface (card radio / next step) until the card number is filled.
|
|
993
977
|
const clicked = new Set();
|
|
994
978
|
let adapterName = null;
|
|
979
|
+
let adapterFillOk = true;
|
|
980
|
+
let adapterFillDetail = null;
|
|
995
981
|
for (let attempt = 0; attempt < 4; attempt++) {
|
|
996
982
|
// A reveal/continue action can navigate between attempts. Never expose
|
|
997
983
|
// the credential to a host other than the one the human reviewed.
|
|
@@ -1026,6 +1012,8 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
1026
1012
|
evidence.step('adapter-selected', { adapter: adapter.name });
|
|
1027
1013
|
}
|
|
1028
1014
|
const fill = await adapter.fill(page, detected.fields, credential, opts.contact);
|
|
1015
|
+
adapterFillOk = fill.ok;
|
|
1016
|
+
adapterFillDetail = fill.detail ?? null;
|
|
1029
1017
|
for (const f of fill.filled) {
|
|
1030
1018
|
evidence.step('field-fill', {
|
|
1031
1019
|
role: f.role,
|
|
@@ -1067,7 +1055,7 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
1067
1055
|
// Re-run the full gate after fill as well. Contact/shipping fields can
|
|
1068
1056
|
// change the total; any drift from the approved review refuses before a
|
|
1069
1057
|
// submit click and requires the caller to prepare a new review.
|
|
1070
|
-
const submitFacts = await readTransactionFacts(page, options);
|
|
1058
|
+
const submitFacts = await readTransactionFacts(page, options, 'pre-submit');
|
|
1071
1059
|
recordTransactionFacts(evidence, 'pre-submit', submitFacts);
|
|
1072
1060
|
if (!submitFacts.ok) {
|
|
1073
1061
|
evidence.step('mandate-verdict', {
|
|
@@ -1110,6 +1098,10 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
1110
1098
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1111
1099
|
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, submitChangedBeforeClick);
|
|
1112
1100
|
}
|
|
1101
|
+
if (!adapterFillOk) {
|
|
1102
|
+
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1103
|
+
return makeResult('failed', state.fields, evidence, requiresAdapter, adapterFillDetail ?? `${adapterName ?? 'checkout'} adapter fill incomplete`);
|
|
1104
|
+
}
|
|
1113
1105
|
if (missingCredentialRoles.length > 0) {
|
|
1114
1106
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1115
1107
|
return makeResult('failed', state.fields, evidence, requiresAdapter, `credential fill incomplete: missing ${missingCredentialRoles.join(', ')}`);
|
|
@@ -1129,6 +1121,30 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
1129
1121
|
// Stripe's submit chain mid-flight (#5879) — see waitForLinkLookupQuiet.
|
|
1130
1122
|
const linkQuiet = await waitForLinkLookupQuiet(page);
|
|
1131
1123
|
evidence.step('note', { linkQuiet });
|
|
1124
|
+
// This is deliberately the last await before the irreversible click.
|
|
1125
|
+
// Mint-time validation is not enough: reveal/fill and Link suppression can
|
|
1126
|
+
// consume a short-lived DAVV. Refuse malformed or <60s credentials so an
|
|
1127
|
+
// expiry decline cannot masquerade as a form-fill failure.
|
|
1128
|
+
const credentialExpiresAt = credential.credentialExpiresAt;
|
|
1129
|
+
const expiryMissing = opts.instrument.kind === 'agentic-token' && credentialExpiresAt === undefined;
|
|
1130
|
+
const expiresMs = credentialExpiresAt === undefined ? Number.NaN : Date.parse(credentialExpiresAt);
|
|
1131
|
+
if (expiryMissing ||
|
|
1132
|
+
(credentialExpiresAt !== undefined &&
|
|
1133
|
+
(!Number.isFinite(expiresMs) || expiresMs - Date.now() < 60_000))) {
|
|
1134
|
+
evidence.step('credential-expiry-check', {
|
|
1135
|
+
ok: false,
|
|
1136
|
+
reason: expiryMissing ? 'missing' : 'invalid-or-expiring',
|
|
1137
|
+
...(credentialExpiresAt !== undefined ? { credentialExpiresAt } : {}),
|
|
1138
|
+
});
|
|
1139
|
+
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1140
|
+
return makeResult('failed', state.fields, evidence, requiresAdapter, 'credential expired before submit; obtain a fresh intent and re-review');
|
|
1141
|
+
}
|
|
1142
|
+
if (credentialExpiresAt !== undefined) {
|
|
1143
|
+
evidence.step('credential-expiry-check', {
|
|
1144
|
+
ok: true,
|
|
1145
|
+
credentialExpiresAt,
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1132
1148
|
evidence.step('submit', { clicked: true, target: submit.desc });
|
|
1133
1149
|
await submit.click();
|
|
1134
1150
|
await settle(page);
|
|
@@ -1292,7 +1308,7 @@ export async function cancelPreparedCheckout(reviewId, detail = 'checkout cancel
|
|
|
1292
1308
|
// explicit prepareCheckout()/submitApprovedCheckout() pair instead.
|
|
1293
1309
|
export async function runCheckout(opts, store = defaultPreparedCheckoutStore) {
|
|
1294
1310
|
const { instrument, contact, mode, outcomeDeadlineMs, resolveEmailOtp, ...prepareOptions } = opts;
|
|
1295
|
-
const preparation = await prepareCheckout(prepareOptions, store);
|
|
1311
|
+
const preparation = await prepareCheckout({ ...prepareOptions, contact }, store);
|
|
1296
1312
|
if (preparation.status === 'finished')
|
|
1297
1313
|
return preparation.result;
|
|
1298
1314
|
return submitApprovedCheckout(preparation.checkout.review.id, {
|
|
@@ -6,6 +6,12 @@ export type CardCredential = {
|
|
|
6
6
|
cardholderName: string;
|
|
7
7
|
/** Dynamic payment credential expiry, distinct from the card expiration. */
|
|
8
8
|
credentialExpiresAt?: string;
|
|
9
|
+
/**
|
|
10
|
+
* Non-sensitive, bounded request-correlation handles returned by the mint
|
|
11
|
+
* route. These are evidence pointers, not card credential material.
|
|
12
|
+
*/
|
|
13
|
+
vgsTraceId?: string;
|
|
14
|
+
networkCorrelationId?: string;
|
|
9
15
|
};
|
|
10
16
|
export type InstrumentContext = {
|
|
11
17
|
merchantHost: string;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const SAFE_TRACE_HANDLE_RE = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
2
|
+
export function safeTraceHandle(value) {
|
|
3
|
+
return typeof value === 'string' && SAFE_TRACE_HANDLE_RE.test(value) ? value : undefined;
|
|
4
|
+
}
|
|
5
|
+
export function traceHandleFields(value) {
|
|
6
|
+
const vgsTraceId = safeTraceHandle(value.vgsTraceId);
|
|
7
|
+
const networkCorrelationId = safeTraceHandle(value.networkCorrelationId);
|
|
8
|
+
return {
|
|
9
|
+
...(vgsTraceId ? { vgsTraceId } : {}),
|
|
10
|
+
...(networkCorrelationId ? { networkCorrelationId } : {}),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
export type
|
|
2
|
-
email?: string;
|
|
1
|
+
export type PostalAddress = {
|
|
3
2
|
firstName?: string;
|
|
4
3
|
lastName?: string;
|
|
5
4
|
fullName?: string;
|
|
@@ -10,6 +9,11 @@ export type Contact = {
|
|
|
10
9
|
postalCode?: string;
|
|
11
10
|
country?: string;
|
|
12
11
|
};
|
|
12
|
+
export type Contact = PostalAddress & {
|
|
13
|
+
email?: string;
|
|
14
|
+
phone?: string;
|
|
15
|
+
billingAddress?: PostalAddress;
|
|
16
|
+
};
|
|
13
17
|
export type FilledField = {
|
|
14
18
|
role: string;
|
|
15
19
|
locator: string;
|
|
@@ -23,6 +27,7 @@ export type FilledField = {
|
|
|
23
27
|
export type FillResult = {
|
|
24
28
|
ok: boolean;
|
|
25
29
|
filled: FilledField[];
|
|
30
|
+
detail?: string;
|
|
26
31
|
};
|
|
27
32
|
export type OtpRequest = {
|
|
28
33
|
/** ISO watermark captured BEFORE the click that triggers the OTP email. */
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// Request derivation here mirrors ../fetch-credential.mjs EXACTLY (mandate cap,
|
|
12
12
|
// consumer prompt, PENDING retry cadence) — the only change is the transport:
|
|
13
13
|
// verify-web routes + Bearer mint token, never the VGS gateway + VGS secret.
|
|
14
|
+
import { traceHandleFields } from '../trace-handles.js';
|
|
14
15
|
const defaultSleep = (ms) => new Promise((r) => {
|
|
15
16
|
const t = setTimeout(r, ms);
|
|
16
17
|
t.unref?.();
|
|
@@ -144,6 +145,7 @@ export async function serverFetchCryptogram(base, mintToken, input, deps = {}) {
|
|
|
144
145
|
...(typeof c.cryptogramExpiresAt === 'string'
|
|
145
146
|
? { cryptogramExpiresAt: c.cryptogramExpiresAt }
|
|
146
147
|
: {}),
|
|
148
|
+
...traceHandleFields(c),
|
|
147
149
|
};
|
|
148
150
|
}
|
|
149
151
|
// A 4xx (bad request / binding refusal / auth) is terminal — never retry it.
|
|
@@ -57,6 +57,8 @@ export type VgsPaymentCredential = {
|
|
|
57
57
|
cryptogramType: string;
|
|
58
58
|
cryptogramValue: string;
|
|
59
59
|
cryptogramExpiresAt?: string;
|
|
60
|
+
vgsTraceId?: string;
|
|
61
|
+
networkCorrelationId?: string;
|
|
60
62
|
};
|
|
61
63
|
export type FetchVgsPaymentCredential = (input: {
|
|
62
64
|
tokenId: string;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { traceHandleFields } from './trace-handles.js';
|
|
1
2
|
/**
|
|
2
3
|
* Our fail-closed freshness bound, matching the runner's 15-minute checkout
|
|
3
4
|
* review window — NOT a claim about VGS's actual assurance TTL (unpublished).
|
|
@@ -162,13 +163,14 @@ export function validateCredential(value, now = new Date()) {
|
|
|
162
163
|
(value.expYear === now.getFullYear() && value.expMonth < now.getMonth() + 1)) {
|
|
163
164
|
throw new Error('VGS credential is expired');
|
|
164
165
|
}
|
|
165
|
-
if (value.cryptogramExpiresAt
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
166
|
+
if (value.cryptogramExpiresAt === undefined) {
|
|
167
|
+
throw new Error('VGS credential expiry is missing');
|
|
168
|
+
}
|
|
169
|
+
const expiresAtMs = Date.parse(value.cryptogramExpiresAt);
|
|
170
|
+
if (!Number.isFinite(expiresAtMs))
|
|
171
|
+
throw new Error('VGS credential expiry is invalid');
|
|
172
|
+
if (expiresAtMs - now.getTime() < 60_000) {
|
|
173
|
+
throw new Error('VGS credential has less than 60 seconds of validity remaining');
|
|
172
174
|
}
|
|
173
175
|
}
|
|
174
176
|
/**
|
|
@@ -225,6 +227,7 @@ export class VgsLiveInstrument {
|
|
|
225
227
|
cvc: value.cryptogramValue,
|
|
226
228
|
cardholderName: this.cardholderName.trim(),
|
|
227
229
|
...(value.cryptogramExpiresAt ? { credentialExpiresAt: value.cryptogramExpiresAt } : {}),
|
|
230
|
+
...traceHandleFields(value),
|
|
228
231
|
};
|
|
229
232
|
}
|
|
230
233
|
}
|
|
@@ -284,6 +287,7 @@ export class VgsAssuranceInstrument {
|
|
|
284
287
|
cvc: value.cryptogramValue,
|
|
285
288
|
cardholderName: this.cardholderName.trim(),
|
|
286
289
|
...(value.cryptogramExpiresAt ? { credentialExpiresAt: value.cryptogramExpiresAt } : {}),
|
|
290
|
+
...traceHandleFields(value),
|
|
287
291
|
};
|
|
288
292
|
}
|
|
289
293
|
}
|