@visa/cli 4.1.0-rc.42 → 4.1.0-rc.44

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.
@@ -264,6 +264,7 @@ export function createCliCheckoutEngine(deps = {}) {
264
264
  amountMinor,
265
265
  currency: input.currency,
266
266
  browser,
267
+ contact: input.contact,
267
268
  }, store);
268
269
  if (prep.status !== 'ready') {
269
270
  await browser.close();
@@ -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 pageAmount = await readPageAmount(page);
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
- ? 'transaction amount could not be determined (page total present but ambiguous, e.g. separator layout); refusing fail-closed'
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
- const detected = await detectFields(page);
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;
@@ -341,6 +341,7 @@ try {
341
341
  amountMinor,
342
342
  currency,
343
343
  browser,
344
+ contact,
344
345
  debugShotsDir,
345
346
  });
346
347
  if (preparation.status === 'finished') {
@@ -0,0 +1,8 @@
1
+ export declare function safeTraceHandle(value: unknown): string | undefined;
2
+ export declare function traceHandleFields(value: {
3
+ vgsTraceId?: unknown;
4
+ networkCorrelationId?: unknown;
5
+ }): {
6
+ vgsTraceId?: string;
7
+ networkCorrelationId?: string;
8
+ };
@@ -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 Contact = {
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 !== undefined) {
166
- const expiresAtMs = Date.parse(value.cryptogramExpiresAt);
167
- if (!Number.isFinite(expiresAtMs))
168
- throw new Error('VGS credential expiry is invalid');
169
- if (expiresAtMs - now.getTime() < 60_000) {
170
- throw new Error('VGS credential has less than 60 seconds of validity remaining');
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
  }