@visa/cli 4.1.0-rc.9 → 4.1.0-rc.91

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +178 -231
  2. package/dist/checkout-engine/adapters/generic.d.ts +23 -0
  3. package/dist/checkout-engine/adapters/generic.js +216 -0
  4. package/dist/checkout-engine/adapters/index.d.ts +8 -0
  5. package/dist/checkout-engine/adapters/index.js +21 -0
  6. package/dist/checkout-engine/adapters/shopify.d.ts +31 -0
  7. package/dist/checkout-engine/adapters/shopify.js +423 -0
  8. package/dist/checkout-engine/adapters/stripe-like.d.ts +10 -0
  9. package/dist/checkout-engine/adapters/stripe-like.js +21 -0
  10. package/dist/checkout-engine/amount.d.ts +15 -0
  11. package/dist/checkout-engine/amount.js +72 -0
  12. package/dist/checkout-engine/browser-launch.d.ts +46 -0
  13. package/dist/checkout-engine/browser-launch.js +81 -0
  14. package/dist/checkout-engine/ceremony.d.ts +64 -0
  15. package/dist/checkout-engine/ceremony.js +261 -0
  16. package/dist/checkout-engine/cli-engine.d.ts +227 -0
  17. package/dist/checkout-engine/cli-engine.js +779 -0
  18. package/dist/checkout-engine/detect.d.ts +61 -0
  19. package/dist/checkout-engine/detect.js +398 -0
  20. package/dist/checkout-engine/evidence.d.ts +25 -0
  21. package/dist/checkout-engine/evidence.js +104 -0
  22. package/dist/checkout-engine/executor.d.ts +176 -0
  23. package/dist/checkout-engine/executor.js +1322 -0
  24. package/dist/checkout-engine/hosted-approval.d.ts +187 -0
  25. package/dist/checkout-engine/hosted-approval.js +478 -0
  26. package/dist/checkout-engine/index.d.ts +6 -0
  27. package/dist/checkout-engine/index.js +8 -0
  28. package/dist/checkout-engine/inline-target.d.ts +13 -0
  29. package/dist/checkout-engine/inline-target.js +37 -0
  30. package/dist/checkout-engine/instrument.d.ts +61 -0
  31. package/dist/checkout-engine/instrument.js +87 -0
  32. package/dist/checkout-engine/live-fill-approval.d.ts +43 -0
  33. package/dist/checkout-engine/live-fill-approval.js +90 -0
  34. package/dist/checkout-engine/mandate/card-mandate.d.ts +121 -0
  35. package/dist/checkout-engine/mandate/card-mandate.js +227 -0
  36. package/dist/checkout-engine/mandate/mandate-ledger.d.ts +142 -0
  37. package/dist/checkout-engine/mandate/mandate-ledger.js +338 -0
  38. package/dist/checkout-engine/mandate.d.ts +25 -0
  39. package/dist/checkout-engine/mandate.js +100 -0
  40. package/dist/checkout-engine/outcome.d.ts +30 -0
  41. package/dist/checkout-engine/outcome.js +225 -0
  42. package/dist/checkout-engine/owner-only-file.d.ts +19 -0
  43. package/dist/checkout-engine/owner-only-file.js +41 -0
  44. package/dist/checkout-engine/package.json +3 -0
  45. package/dist/checkout-engine/receipt.d.ts +81 -0
  46. package/dist/checkout-engine/receipt.js +109 -0
  47. package/dist/checkout-engine/repo-env.d.ts +11 -0
  48. package/dist/checkout-engine/repo-env.js +23 -0
  49. package/dist/checkout-engine/trace-handles.d.ts +8 -0
  50. package/dist/checkout-engine/trace-handles.js +12 -0
  51. package/dist/checkout-engine/types.d.ts +44 -0
  52. package/dist/checkout-engine/types.js +2 -0
  53. package/dist/checkout-engine/vgs-gateway/fetch-credential.d.mts +74 -0
  54. package/dist/checkout-engine/vgs-gateway/fetch-credential.mjs +248 -0
  55. package/dist/checkout-engine/vgs-gateway/server-mint-client.d.ts +82 -0
  56. package/dist/checkout-engine/vgs-gateway/server-mint-client.js +180 -0
  57. package/dist/checkout-engine/vgs-live-instrument.d.ts +179 -0
  58. package/dist/checkout-engine/vgs-live-instrument.js +296 -0
  59. package/dist/checkout-engine/vic-confirmation.d.ts +34 -0
  60. package/dist/checkout-engine/vic-confirmation.js +39 -0
  61. package/dist/cli.js +448 -433
  62. package/dist/mcp-server/index.js +366 -170
  63. package/dist/skills/pair-visa-agent/RUNTIMES.md +92 -0
  64. package/dist/skills/pair-visa-agent/SKILL.md +468 -0
  65. package/dist/skills/pair-visa-agent/scripts/setup.mjs +48 -0
  66. package/install.ps1 +3 -41
  67. package/install.sh +3 -35
  68. package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
  69. package/package.json +16 -12
  70. package/server.json +3 -3
@@ -0,0 +1,61 @@
1
+ import type { Page } from 'playwright-core';
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' | 'phone' | 'oneTimeCode' | 'addressLine1' | 'addressLine2' | 'city' | 'state' | 'postalCode' | 'country';
4
+ export declare const ALL_ROLES: FieldRole[];
5
+ export type SelectOption = {
6
+ value: string;
7
+ text: string;
8
+ };
9
+ export type CandidateMeta = {
10
+ idx: number;
11
+ tag: 'input' | 'select' | 'textarea';
12
+ type: string;
13
+ name: string;
14
+ id: string;
15
+ placeholder: string;
16
+ ariaLabel: string;
17
+ autocomplete: string;
18
+ maxlength: string;
19
+ inputmode: string;
20
+ pattern: string;
21
+ labelText: string;
22
+ options: SelectOption[];
23
+ visible: boolean;
24
+ };
25
+ export type RoleMatch = {
26
+ role: FieldRole;
27
+ confidence: number;
28
+ source: FieldSource;
29
+ };
30
+ export type FieldEntry = {
31
+ locator: string;
32
+ confidence: number;
33
+ source: FieldSource;
34
+ frame?: string;
35
+ tag: 'input' | 'select' | 'textarea';
36
+ inputType: string;
37
+ visible: boolean;
38
+ options?: SelectOption[];
39
+ maxlength?: number;
40
+ };
41
+ export type FieldMap = Partial<Record<FieldRole, FieldEntry>>;
42
+ export type FieldCandidate = {
43
+ role: FieldRole | null;
44
+ confidence: number;
45
+ source: FieldSource | null;
46
+ matches: RoleMatch[];
47
+ frame?: string;
48
+ meta: CandidateMeta;
49
+ };
50
+ export type PspHit = {
51
+ psp: string;
52
+ requiresAdapter: string | null;
53
+ frameSelector: string;
54
+ };
55
+ export type DetectResult = {
56
+ fields: FieldMap;
57
+ candidates: FieldCandidate[];
58
+ psps: PspHit[];
59
+ };
60
+ export declare function classifyCandidate(m: CandidateMeta): RoleMatch[];
61
+ export declare function detectFields(page: Page): Promise<DetectResult>;
@@ -0,0 +1,398 @@
1
+ // Layered field detection. This is the success-rate core of the spike: given a
2
+ // live page, work out which element is the card number, which is the expiry,
3
+ // the cvc, the cardholder name, and the contact/shipping fields, with a
4
+ // confidence and a provenance for each. Higher layers (explicit autocomplete
5
+ // attributes) beat lower ones (raw type inference). Every candidate is kept so
6
+ // the evidence log can show WHY a field was chosen.
7
+ //
8
+ // Layers, highest confidence first:
9
+ // 1.0 autocomplete attributes (cc-number, cc-exp, cc-csc, ...)
10
+ // 0.7 attribute regex bank over name/id/placeholder/aria-label + <select> shape
11
+ // 0.6 associated <label> text
12
+ // 0.5 PSP iframe fingerprints (Stripe/Braintree/Adyen) -> detect + report
13
+ // 0.4 type inference (numeric inputmode/pattern + maxlength)
14
+ export const ALL_ROLES = [
15
+ 'number',
16
+ 'expMonth',
17
+ 'expYear',
18
+ 'expCombined',
19
+ 'cvc',
20
+ 'name',
21
+ 'nameFirst',
22
+ 'nameLast',
23
+ 'email',
24
+ 'phone',
25
+ 'oneTimeCode',
26
+ 'addressLine1',
27
+ 'addressLine2',
28
+ 'city',
29
+ 'state',
30
+ 'postalCode',
31
+ 'country',
32
+ ];
33
+ // ---------------------------------------------------------------------------
34
+ // Regex bank (layer 2 / 3)
35
+ // ---------------------------------------------------------------------------
36
+ const numberRe = /(card.?number|cardnumber|cardnum|card_num|ccnum|cc.?number|(^|[^a-z])pan([^a-z]|$)|kartennummer|numero.?(de.?)?(carte|tarjeta))/;
37
+ const cvcRe = /(cvv|cvc|csc|cvn|security.?code|card.?code|card.?verif|pruef|prüf)/;
38
+ const expMonthRe = /(exp.?month|expmonth|exp_mm|(^|[^a-z])mm([^a-z]|$)|(^|[^a-z])month([^a-z]|$)|monat|mois|(^|[^a-z])mes([^a-z]|$))/;
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]|$))/;
40
+ const expCombinedRe = /(exp(iry|iration)?(.?date)?|mm.?\/.?yy|mm.?jj|valid.?thru|ablauf|caducidad)/;
41
+ const emailRe = /(e-?mail|correo|courriel)/;
42
+ const phoneRe = /(phone|telephone|mobile|(^|[^a-z])tel([^a-z]|$)|telefono|téléphone)/;
43
+ // One-time verification code (merchant email/SMS OTP). Ordered AFTER cvcRe in
44
+ // attrClassify so a card CVC ("security code"/"card code") still wins — this
45
+ // regex deliberately omits those card phrasings. It also OMITS a bare "code":
46
+ // `postal_code`/`zip_code`/`country_code`/`promo_code` all contain "code" and
47
+ // would otherwise misclassify as an OTP field and starve the address fill. A
48
+ // bare "code" is matched LAST (bareCodeRe), after every address role.
49
+ const oneTimeCodeRe = /(one[-_ ]?time|(^|[^a-z])otp([^a-z]|$)|passcode|(^|[^a-z])pin([^a-z]|$)|verif(y|ication)?.?code|auth.?code)/;
50
+ // Standalone verification "code" — a last-resort OTP match tried only after the
51
+ // specific address roles (postal/country/state/city/line) have had their say,
52
+ // so a compound `*_code` address/product field is never stolen by the OTP role.
53
+ const bareCodeRe = /(^|[^a-z])code([^a-z]|$)/;
54
+ const postalRe = /(zip|postal|postcode|(^|[^a-z])plz([^a-z]|$)|(^|[^a-z])cep([^a-z]|$)|codigo.?postal)/;
55
+ const countryRe = /(country|(^|[^a-z])land([^a-z]|$)|(^|[^a-z])pais)/;
56
+ const stateRe = /((^|[^a-z])state([^a-z]|$)|province|region|bundesland|provincia)/;
57
+ const cityRe = /((^|[^a-z])city([^a-z]|$)|(^|[^a-z])town([^a-z]|$)|(^|[^a-z])ort([^a-z]|$)|ciudad|ville)/;
58
+ const addr2Re = /(address.?2|addr.?2|line.?2|(^|[^a-z])apt|apartment|suite|(^|[^a-z])unit|zusatz)/;
59
+ const addr1Re = /(address|street|(^|[^a-z])addr|strasse|stra.?e|adresse|direccion|(^|[^a-z])rue)/;
60
+ const nameFirstRe = /(first.?name|(^|[^a-z])fname|given.?name|vorname|nombre|pr.?nom)/;
61
+ const nameLastRe = /(last.?name|(^|[^a-z])lname|surname|family.?name|nachname|apellido)/;
62
+ const nameRe = /(card.?holder|name.?on.?card|cardname|ccname|karteninhaber|titular|holder|(^|[^a-z])name([^a-z]|$)|(^|[^a-z])nom([^a-z]|$))/;
63
+ const AUTOCOMPLETE_MAP = {
64
+ 'cc-number': 'number',
65
+ 'cc-exp': 'expCombined',
66
+ 'cc-exp-month': 'expMonth',
67
+ 'cc-exp-year': 'expYear',
68
+ 'cc-csc': 'cvc',
69
+ 'cc-name': 'name',
70
+ name: 'name',
71
+ email: 'email',
72
+ tel: 'phone',
73
+ 'tel-national': 'phone',
74
+ 'one-time-code': 'oneTimeCode',
75
+ 'given-name': 'nameFirst',
76
+ 'family-name': 'nameLast',
77
+ 'address-line1': 'addressLine1',
78
+ 'address-line2': 'addressLine2',
79
+ 'address-level2': 'city',
80
+ 'address-level1': 'state',
81
+ 'postal-code': 'postalCode',
82
+ country: 'country',
83
+ 'country-name': 'country',
84
+ };
85
+ // Real PSP iframe fingerprints. A match on any of these hosts is a real,
86
+ // cross-origin PSP surface: for M0 we DETECT and report it (requiresAdapter)
87
+ // but do not attempt to fill it. Same-origin test iframes never match these.
88
+ const PSP_FINGERPRINTS = [
89
+ { name: 'stripe', re: /js\.stripe\.com|m\.stripe\.network/ },
90
+ { name: 'braintree', re: /braintree(gateway|-api)?\.com|assets\.braintreegateway\.com/ },
91
+ { name: 'adyen', re: /checkoutshopper|adyen\.com/ },
92
+ ];
93
+ function fingerprintPsp(src) {
94
+ for (const f of PSP_FINGERPRINTS)
95
+ if (f.re.test(src))
96
+ return f.name;
97
+ return null;
98
+ }
99
+ // ---------------------------------------------------------------------------
100
+ // Classifier (pure — unit-testable without a browser)
101
+ // ---------------------------------------------------------------------------
102
+ function isMonthOptions(options) {
103
+ const vals = options.map((o) => o.value).filter((v) => v !== '');
104
+ const texts = options.map((o) => o.text.toLowerCase()).join(' ');
105
+ if (/jan|feb|mar|month/.test(texts) && vals.length >= 11)
106
+ return true;
107
+ if (vals.length >= 12 && vals.length <= 13) {
108
+ const nums = vals.filter((v) => /^\d{1,2}$/.test(v)).map((v) => Number(v));
109
+ if (nums.length >= 12 && nums.every((n) => n >= 1 && n <= 12))
110
+ return true;
111
+ }
112
+ return false;
113
+ }
114
+ function isYearOptions(options) {
115
+ const vals = options.map((o) => o.value).filter((v) => v !== '');
116
+ if (vals.length < 4 || vals.length > 30)
117
+ return false;
118
+ const four = vals.filter((v) => /^\d{4}$/.test(v)).map(Number);
119
+ if (four.length >= 4 && four.some((y) => y >= 2020 && y <= 2100))
120
+ return true;
121
+ const two = vals.filter((v) => /^\d{2}$/.test(v)).map(Number);
122
+ if (two.length === vals.length && two.some((n) => n > 12))
123
+ return true;
124
+ return false;
125
+ }
126
+ function looksLikeCountry(options) {
127
+ const texts = options.map((o) => o.text.toLowerCase()).join(' ');
128
+ if (/united states|canada|united kingdom|australia|germany/.test(texts))
129
+ return true;
130
+ const codes = options.map((o) => o.value).filter((v) => /^[A-Za-z]{2}$/.test(v));
131
+ return codes.length >= 5;
132
+ }
133
+ function attrClassify(text, m) {
134
+ if (m.tag === 'select') {
135
+ if (expMonthRe.test(text))
136
+ return 'expMonth';
137
+ if (expYearRe.test(text))
138
+ return 'expYear';
139
+ if (countryRe.test(text))
140
+ return 'country';
141
+ if (stateRe.test(text))
142
+ return 'state';
143
+ if (isMonthOptions(m.options))
144
+ return 'expMonth';
145
+ if (isYearOptions(m.options))
146
+ return 'expYear';
147
+ if (looksLikeCountry(m.options))
148
+ return 'country';
149
+ return null;
150
+ }
151
+ if (numberRe.test(text))
152
+ return 'number';
153
+ if (cvcRe.test(text))
154
+ return 'cvc';
155
+ // Expiry: a combined MM/YY field must not be mistaken for a month-only one.
156
+ // Explicit combined markers, or the presence of BOTH month and year tokens,
157
+ // win over a single-part match.
158
+ const explicitCombined = /(mm\s*[/\-]?\s*yy|mm.?jj|valid.?thru|ablauf|caducidad)/.test(text);
159
+ const hasMonth = expMonthRe.test(text);
160
+ const hasYear = expYearRe.test(text);
161
+ if (explicitCombined || (hasMonth && hasYear))
162
+ return 'expCombined';
163
+ if (hasMonth && !hasYear)
164
+ return 'expMonth';
165
+ if (hasYear && !hasMonth)
166
+ return 'expYear';
167
+ if (expCombinedRe.test(text))
168
+ return 'expCombined';
169
+ if (emailRe.test(text))
170
+ return 'email';
171
+ // OTP comes after cvc (checked above) so card CVC still classifies as 'cvc'.
172
+ if (oneTimeCodeRe.test(text))
173
+ return 'oneTimeCode';
174
+ if (phoneRe.test(text))
175
+ return 'phone';
176
+ if (postalRe.test(text))
177
+ return 'postalCode';
178
+ if (countryRe.test(text))
179
+ return 'country';
180
+ if (stateRe.test(text))
181
+ return 'state';
182
+ if (cityRe.test(text))
183
+ return 'city';
184
+ if (addr2Re.test(text))
185
+ return 'addressLine2';
186
+ if (addr1Re.test(text))
187
+ return 'addressLine1';
188
+ // Last-resort bare "code" → OTP, only once every address role has been ruled
189
+ // out, so `postal_code`/`country_code`/etc. keep their own role above.
190
+ if (bareCodeRe.test(text))
191
+ return 'oneTimeCode';
192
+ if (nameFirstRe.test(text))
193
+ return 'nameFirst';
194
+ if (nameLastRe.test(text))
195
+ return 'nameLast';
196
+ if (nameRe.test(text))
197
+ return 'name';
198
+ return null;
199
+ }
200
+ function fromAutocomplete(ac) {
201
+ if (!ac)
202
+ return null;
203
+ for (const tok of ac.split(/\s+/)) {
204
+ const role = AUTOCOMPLETE_MAP[tok];
205
+ if (role)
206
+ return { role, confidence: 1.0, source: 'autocomplete' };
207
+ }
208
+ return null;
209
+ }
210
+ function typeInfer(m) {
211
+ if (m.tag !== 'input')
212
+ return null;
213
+ const ml = Number.parseInt(m.maxlength || '0', 10);
214
+ const numeric = m.inputmode === 'numeric' ||
215
+ /\\d|\[0-9\]/.test(m.pattern) ||
216
+ m.type === 'tel' ||
217
+ m.type === 'number';
218
+ if (!numeric)
219
+ return null;
220
+ if (ml >= 15 && ml <= 19)
221
+ return 'number';
222
+ if (ml >= 3 && ml <= 4)
223
+ return 'cvc';
224
+ return null;
225
+ }
226
+ // Returns every layer match for an element. Exported for unit tests.
227
+ export function classifyCandidate(m) {
228
+ const ac = fromAutocomplete(m.autocomplete);
229
+ if (ac)
230
+ return [ac];
231
+ const out = [];
232
+ const text = [m.name, m.id, m.placeholder, m.ariaLabel].join(' ').toLowerCase();
233
+ const attrRole = attrClassify(text, m);
234
+ if (attrRole)
235
+ out.push({ role: attrRole, confidence: 0.7, source: 'attr-heuristic' });
236
+ const labelRole = attrClassify(m.labelText, m);
237
+ if (labelRole)
238
+ out.push({ role: labelRole, confidence: 0.6, source: 'label-text' });
239
+ const inf = typeInfer(m);
240
+ if (inf)
241
+ out.push({ role: inf, confidence: 0.4, source: 'type-inference' });
242
+ return out;
243
+ }
244
+ function bestMatch(matches) {
245
+ let best = null;
246
+ for (const mm of matches)
247
+ if (!best || mm.confidence > best.confidence)
248
+ best = mm;
249
+ return best;
250
+ }
251
+ // ---------------------------------------------------------------------------
252
+ // DOM extraction (one evaluate pass per frame; stamps a stable data-ca-id)
253
+ // ---------------------------------------------------------------------------
254
+ async function extractCandidateMeta(root) {
255
+ return root.evaluate(() => {
256
+ const SKIP = new Set([
257
+ 'hidden',
258
+ 'submit',
259
+ 'button',
260
+ 'reset',
261
+ 'checkbox',
262
+ 'radio',
263
+ 'image',
264
+ 'file',
265
+ ]);
266
+ const els = Array.from(document.querySelectorAll('input, select, textarea'));
267
+ const out = [];
268
+ let i = 0;
269
+ for (const el of els) {
270
+ const tag = el.tagName.toLowerCase();
271
+ const type = (el.getAttribute('type') || '').toLowerCase();
272
+ if (tag === 'input' && SKIP.has(type))
273
+ continue;
274
+ const idx = i++;
275
+ el.setAttribute('data-ca-id', String(idx));
276
+ let labelText = '';
277
+ const anyEl = el;
278
+ if (anyEl.labels)
279
+ labelText += Array.from(anyEl.labels)
280
+ .map((l) => l.textContent || '')
281
+ .join(' ');
282
+ const alb = el.getAttribute('aria-labelledby');
283
+ if (alb) {
284
+ labelText +=
285
+ ' ' +
286
+ alb
287
+ .split(/\s+/)
288
+ .map((id) => {
289
+ const n = document.getElementById(id);
290
+ return n ? n.textContent || '' : '';
291
+ })
292
+ .join(' ');
293
+ }
294
+ const wrap = el.closest('label');
295
+ if (wrap)
296
+ labelText += ' ' + (wrap.textContent || '');
297
+ const style = getComputedStyle(el);
298
+ const visible = el.offsetParent !== null &&
299
+ style.visibility !== 'hidden' &&
300
+ style.display !== 'none';
301
+ const options = tag === 'select'
302
+ ? Array.from(el.options).map((o) => ({
303
+ value: o.value,
304
+ text: (o.textContent || '').trim(),
305
+ }))
306
+ : [];
307
+ out.push({
308
+ idx,
309
+ tag,
310
+ type,
311
+ name: el.getAttribute('name') || '',
312
+ id: el.id || '',
313
+ placeholder: el.getAttribute('placeholder') || '',
314
+ ariaLabel: el.getAttribute('aria-label') || '',
315
+ autocomplete: (el.getAttribute('autocomplete') || '').toLowerCase(),
316
+ maxlength: el.getAttribute('maxlength') || '',
317
+ inputmode: (el.getAttribute('inputmode') || '').toLowerCase(),
318
+ pattern: el.getAttribute('pattern') || '',
319
+ labelText: labelText.trim().toLowerCase(),
320
+ options,
321
+ visible,
322
+ });
323
+ }
324
+ return out;
325
+ });
326
+ }
327
+ function toCandidate(m, frame) {
328
+ const matches = classifyCandidate(m);
329
+ const best = bestMatch(matches);
330
+ return {
331
+ role: best?.role ?? null,
332
+ confidence: best?.confidence ?? 0,
333
+ source: best?.source ?? null,
334
+ matches,
335
+ frame,
336
+ meta: m,
337
+ };
338
+ }
339
+ function buildFieldMap(candidates) {
340
+ const map = {};
341
+ for (const c of candidates) {
342
+ if (!c.role || !c.source)
343
+ continue;
344
+ const existing = map[c.role];
345
+ const better = !existing ||
346
+ c.confidence > existing.confidence ||
347
+ (c.confidence === existing.confidence && c.meta.visible && !existing.visible);
348
+ if (!better)
349
+ continue;
350
+ map[c.role] = {
351
+ locator: `[data-ca-id="${c.meta.idx}"]`,
352
+ confidence: c.confidence,
353
+ // The true provenance is kept even for in-frame candidates — the frame
354
+ // is carried separately in `frame`. 'iframe-psp' means a PSP
355
+ // fingerprint match, never "this field happened to be in a frame".
356
+ source: c.source,
357
+ frame: c.frame,
358
+ tag: c.meta.tag,
359
+ inputType: c.meta.type,
360
+ visible: c.meta.visible,
361
+ options: c.meta.options.length ? c.meta.options : undefined,
362
+ maxlength: c.meta.maxlength ? Number.parseInt(c.meta.maxlength, 10) : undefined,
363
+ };
364
+ }
365
+ return map;
366
+ }
367
+ export async function detectFields(page) {
368
+ const candidates = [];
369
+ const psps = [];
370
+ const mainMeta = await extractCandidateMeta(page);
371
+ for (const m of mainMeta)
372
+ candidates.push(toCandidate(m, undefined));
373
+ const frameEls = await page.$$('iframe');
374
+ for (let k = 0; k < frameEls.length; k++) {
375
+ const el = frameEls[k];
376
+ const src = (await el.getAttribute('src')) || '';
377
+ const frameSelector = `iframe[data-ca-frame="${k}"]`;
378
+ await el.evaluate((node, idx) => node.setAttribute('data-ca-frame', String(idx)), k);
379
+ const psp = fingerprintPsp(src);
380
+ if (psp) {
381
+ // Real, cross-origin PSP surface: detect + report, do not fill in M0.
382
+ psps.push({ psp, requiresAdapter: psp, frameSelector });
383
+ continue;
384
+ }
385
+ const frame = await el.contentFrame();
386
+ if (!frame)
387
+ continue;
388
+ try {
389
+ const meta = await extractCandidateMeta(frame);
390
+ for (const m of meta)
391
+ candidates.push(toCandidate(m, frameSelector));
392
+ }
393
+ catch {
394
+ // Frame not accessible; skip.
395
+ }
396
+ }
397
+ return { fields: buildFieldMap(candidates), candidates, psps };
398
+ }
@@ -0,0 +1,25 @@
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
+ export type EvidenceStep = {
3
+ ts: string;
4
+ type: EvidenceStepType;
5
+ data: Record<string, unknown>;
6
+ };
7
+ export declare function maskPan(pan: string): string;
8
+ export declare function maskCvc(_cvc: string): string;
9
+ export declare function maskExpiry(): string;
10
+ export declare function maskOtp(): string;
11
+ export declare function hostOf(hostOrEmail: string): string;
12
+ export declare function verificationLinkAllowed(link: string, messageFrom: string): boolean;
13
+ export declare function redactContact(role: string, value: string): string;
14
+ export declare class EvidenceLog {
15
+ private steps;
16
+ private snapshotSummary;
17
+ step(type: EvidenceStepType, data?: Record<string, unknown>): void;
18
+ setSnapshotSummary(summary: string): void;
19
+ getSteps(): readonly EvidenceStep[];
20
+ toJSONL(): string;
21
+ toObject(): {
22
+ steps: EvidenceStep[];
23
+ snapshotSummary: string | null;
24
+ };
25
+ }
@@ -0,0 +1,104 @@
1
+ // Evidence log. A timestamped JSONL trail of everything the executor did:
2
+ // navigation, adapter choice, each field fill (with confidence + source),
3
+ // the mandate verdict, the submit, and the final outcome, plus a compact
4
+ // accessibility snapshot of the end state.
5
+ //
6
+ // Money-adjacent hygiene rule: the full PAN, the cvc, the card expiry, and any
7
+ // one-time verification code NEVER enter the log. PANs are masked to last-4;
8
+ // cvc, expiry, and OTP codes are redacted entirely. maskPan/maskCvc/maskExpiry/
9
+ // maskOtp are the only sanctioned way to put such a value anywhere near a log
10
+ // line. (receipt.ts redactPanLikeDigits only catches 12-19-digit PANs, so a
11
+ // 4-8-digit OTP would slip straight through — maskOtp at the fill site is the
12
+ // load-bearing guarantee it never reaches the evidence log.) Contact PII
13
+ // (cardholder name, email, address, ...) is redacted via redactContact: the
14
+ // log records THAT a field was filled, never the personal value.
15
+ // Mask a PAN to its last four digits. Non-digits are stripped for the count.
16
+ export function maskPan(pan) {
17
+ const digits = (pan || '').replace(/\D/g, '');
18
+ if (digits.length <= 4)
19
+ return '****';
20
+ return `**** **** **** ${digits.slice(-4)}`;
21
+ }
22
+ // The cvc is never displayed, even masked by length.
23
+ export function maskCvc(_cvc) {
24
+ return 'redacted';
25
+ }
26
+ // The expiry is credential material too (one of the three keyable components
27
+ // alongside the DPAN and the DAVV) — it never enters the log, in any format.
28
+ export function maskExpiry() {
29
+ return 'redacted';
30
+ }
31
+ // A merchant one-time verification code is single-use secret material — it
32
+ // never enters the log, in any format. Used at the oneTimeCode fill site so
33
+ // the code is masked exactly as cvc/PAN are (see the header note above).
34
+ export function maskOtp() {
35
+ return 'redacted';
36
+ }
37
+ // The registrable domain (approx eTLD+1) of an email address or hostname, for
38
+ // the caller-side from-domain guard on verification links: before navigating a
39
+ // link parsed from an untrusted OTP email, require the link host to match the
40
+ // message sender's domain. extract.ts guarantees mechanical extraction, NOT
41
+ // navigation safety — this guard is the anti-injection backstop.
42
+ export function hostOf(hostOrEmail) {
43
+ return (hostOrEmail.split('@').pop() ?? '').trim().toLowerCase().replace(/\.$/, '');
44
+ }
45
+ // Strict bidirectional host-suffix match (exact / child / parent). Deliberately
46
+ // NOT approximate eTLD+1 — that collapses shared-tenant hosts (`x.myshopify.com`,
47
+ // `x.co.uk`) to a common suffix and lets a sibling tenant match; siblings never
48
+ // match here. Fails SAFE: an unrelated-but-legitimate sender is skipped, not
49
+ // trusted.
50
+ function hostsRelated(a, b) {
51
+ const x = hostOf(a);
52
+ const y = hostOf(b);
53
+ if (!x || !y)
54
+ return false;
55
+ return x === y || x.endsWith('.' + y) || y.endsWith('.' + x);
56
+ }
57
+ // Whether a verification LINK is safe to navigate: the sender must NOT be the
58
+ // unauthenticated inbound variant (AgentMail marks spoofable inbound with an
59
+ // `.unauthenticated` sub-label), and the link host must belong to the sender's
60
+ // domain. Returns false to SKIP a link that fails either check.
61
+ export function verificationLinkAllowed(link, messageFrom) {
62
+ if (/\.unauthenticated\b/i.test(messageFrom))
63
+ return false;
64
+ let linkHost;
65
+ try {
66
+ linkHost = new URL(link).hostname;
67
+ }
68
+ catch {
69
+ return false;
70
+ }
71
+ return hostsRelated(linkHost, messageFrom);
72
+ }
73
+ // Contact values (cardholder name, email, address, ...) are PII and never
74
+ // enter the log raw either. Email keeps its domain for debuggability;
75
+ // everything else becomes a bare presence marker.
76
+ export function redactContact(role, value) {
77
+ if (!value)
78
+ return '';
79
+ if (role === 'email') {
80
+ const at = value.lastIndexOf('@');
81
+ return at > 0 ? `<redacted>@${value.slice(at + 1)}` : '<redacted>';
82
+ }
83
+ return '<redacted>';
84
+ }
85
+ export class EvidenceLog {
86
+ steps = [];
87
+ snapshotSummary = null;
88
+ step(type, data = {}) {
89
+ this.steps.push({ ts: new Date().toISOString(), type, data });
90
+ }
91
+ setSnapshotSummary(summary) {
92
+ this.snapshotSummary = summary;
93
+ }
94
+ getSteps() {
95
+ return this.steps;
96
+ }
97
+ // Newline-delimited JSON. One object per line.
98
+ toJSONL() {
99
+ return this.steps.map((s) => JSON.stringify(s)).join('\n');
100
+ }
101
+ toObject() {
102
+ return { steps: this.steps, snapshotSummary: this.snapshotSummary };
103
+ }
104
+ }