@visa/cli 4.1.0-rc.2 → 4.1.0-rc.200

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 (76) hide show
  1. package/README.md +200 -226
  2. package/dist/checkout-engine/adapters/generic.d.ts +88 -0
  3. package/dist/checkout-engine/adapters/generic.js +526 -0
  4. package/dist/checkout-engine/adapters/index.d.ts +10 -0
  5. package/dist/checkout-engine/adapters/index.js +24 -0
  6. package/dist/checkout-engine/adapters/shopify.d.ts +55 -0
  7. package/dist/checkout-engine/adapters/shopify.js +514 -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 +315 -0
  17. package/dist/checkout-engine/cli-engine.js +996 -0
  18. package/dist/checkout-engine/confirmed-merchants.d.ts +31 -0
  19. package/dist/checkout-engine/confirmed-merchants.js +165 -0
  20. package/dist/checkout-engine/detect.d.ts +61 -0
  21. package/dist/checkout-engine/detect.js +398 -0
  22. package/dist/checkout-engine/evidence.d.ts +25 -0
  23. package/dist/checkout-engine/evidence.js +104 -0
  24. package/dist/checkout-engine/executor.d.ts +215 -0
  25. package/dist/checkout-engine/executor.js +1520 -0
  26. package/dist/checkout-engine/hosted-approval.d.ts +195 -0
  27. package/dist/checkout-engine/hosted-approval.js +498 -0
  28. package/dist/checkout-engine/index.d.ts +9 -0
  29. package/dist/checkout-engine/index.js +11 -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/known-merchants.d.ts +10 -0
  33. package/dist/checkout-engine/known-merchants.js +38 -0
  34. package/dist/checkout-engine/live-fill-approval.d.ts +37 -0
  35. package/dist/checkout-engine/live-fill-approval.js +76 -0
  36. package/dist/checkout-engine/mandate/card-mandate.d.ts +121 -0
  37. package/dist/checkout-engine/mandate/card-mandate.js +227 -0
  38. package/dist/checkout-engine/mandate/mandate-ledger.d.ts +178 -0
  39. package/dist/checkout-engine/mandate/mandate-ledger.js +395 -0
  40. package/dist/checkout-engine/mandate.d.ts +25 -0
  41. package/dist/checkout-engine/mandate.js +100 -0
  42. package/dist/checkout-engine/outcome.d.ts +30 -0
  43. package/dist/checkout-engine/outcome.js +225 -0
  44. package/dist/checkout-engine/owner-only-file.d.ts +19 -0
  45. package/dist/checkout-engine/owner-only-file.js +41 -0
  46. package/dist/checkout-engine/package.json +3 -0
  47. package/dist/checkout-engine/receipt-dir.d.ts +6 -0
  48. package/dist/checkout-engine/receipt-dir.js +8 -0
  49. package/dist/checkout-engine/receipt.d.ts +121 -0
  50. package/dist/checkout-engine/receipt.js +138 -0
  51. package/dist/checkout-engine/trace-handles.d.ts +8 -0
  52. package/dist/checkout-engine/trace-handles.js +12 -0
  53. package/dist/checkout-engine/types.d.ts +52 -0
  54. package/dist/checkout-engine/types.js +2 -0
  55. package/dist/checkout-engine/unresolved-charges.d.ts +34 -0
  56. package/dist/checkout-engine/unresolved-charges.js +125 -0
  57. package/dist/checkout-engine/vgs-gateway/server-mint-client.d.ts +101 -0
  58. package/dist/checkout-engine/vgs-gateway/server-mint-client.js +218 -0
  59. package/dist/checkout-engine/vgs-live-instrument.d.ts +144 -0
  60. package/dist/checkout-engine/vgs-live-instrument.js +229 -0
  61. package/dist/checkout-engine/vic-confirmation.d.ts +52 -0
  62. package/dist/checkout-engine/vic-confirmation.js +45 -0
  63. package/dist/checkout-engine/web-bot-auth.d.ts +92 -0
  64. package/dist/checkout-engine/web-bot-auth.js +159 -0
  65. package/dist/cli.js +721 -445
  66. package/dist/mcp-apps/ucp-checkout.html +280 -0
  67. package/dist/mcp-server/index.js +573 -175
  68. package/dist/skills/pair-visa-agent/RUNTIMES.md +93 -0
  69. package/dist/skills/pair-visa-agent/SKILL.md +557 -0
  70. package/dist/skills/pair-visa-agent/scripts/setup.mjs +48 -0
  71. package/dist/subway-direct.mjs +1 -0
  72. package/install.ps1 +5 -43
  73. package/install.sh +5 -37
  74. package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
  75. package/package.json +33 -25
  76. package/server.json +4 -4
@@ -0,0 +1,31 @@
1
+ /** One confirmed charge, in the receipt's own terms. */
2
+ export type ConfirmedCharge = {
3
+ recordedAt: string;
4
+ amount: string;
5
+ currency: string;
6
+ /** Receipt file basename, so an operator can open the evidence log. */
7
+ receiptFile: string;
8
+ };
9
+ export type ConfirmedMerchant = {
10
+ /** The checkout page the card went through. */
11
+ url: string;
12
+ host: string;
13
+ /**
14
+ * Curated human identity (known-merchants.ts), present only when someone has
15
+ * identified who is behind this checkout URL. Hosted payment links carry an
16
+ * opaque path on the PSP's host, so without this a row names nobody.
17
+ */
18
+ name?: string;
19
+ category?: string;
20
+ website?: string;
21
+ confirmedCount: number;
22
+ lastConfirmedAt: string;
23
+ charges: ConfirmedCharge[];
24
+ };
25
+ /**
26
+ * Merchants this device has completed a real card checkout at, newest first.
27
+ *
28
+ * Never throws: a missing directory (nothing has ever been checked out here)
29
+ * and an unreadable one both read as an empty registry.
30
+ */
31
+ export declare function readConfirmedMerchants(receiptDir?: string): Promise<ConfirmedMerchant[]>;
@@ -0,0 +1,165 @@
1
+ // "Where can I use my card?", the merchant registry derived from this
2
+ // device's checkout receipts.
3
+ //
4
+ // Receipts (receipt.ts) are the only local record that a real merchant
5
+ // checkout completed on this box. This module reads them back and answers one
6
+ // question: which checkout pages has this card actually gone through?
7
+ //
8
+ // A merchant only counts when BOTH halves agree. The engine's own outcome must
9
+ // be 'confirmed' (the merchant showed a definitive success) AND the VIC
10
+ // confirmation must have posted APPROVED (the network side of the same
11
+ // purchase). Either half alone is a claim, not a completion: a 'confirmed' with
12
+ // no posted confirmation is exactly the reconciliation case receipt.ts flags,
13
+ // and a posted DECLINED is a completed report of a failure.
14
+ //
15
+ // Reading is best-effort by construction. The receipts directory is an operator
16
+ // artifact that anything on the box can touch, so an unreadable or malformed
17
+ // file is skipped rather than failing the whole read. One bad file must not
18
+ // hide every merchant behind it.
19
+ import { readdir, readFile } from 'node:fs/promises';
20
+ import { join } from 'node:path';
21
+ import { KNOWN_MERCHANT_IDENTITIES } from './known-merchants.js';
22
+ import { RECEIPT_DIR } from './receipt-dir.js';
23
+ /**
24
+ * The URL a receipt's charge happened at.
25
+ *
26
+ * A compact v2 receipt carries an exact checkout URL only when it is already in
27
+ * the curated identity map; otherwise it carries the host. Legacy v1 receipts
28
+ * keep the URL in the evidence log (the executor's first 'navigation' step is
29
+ * the page the run opened), so recover it there and fall back to the host when
30
+ * that evidence is absent or shaped differently.
31
+ */
32
+ function checkoutUrlOf(receipt) {
33
+ if (receipt.schema === 'checkout-agent-receipt/v2') {
34
+ const checkoutUrl = receipt.merchant.checkoutUrl;
35
+ return checkoutUrl && KNOWN_MERCHANT_IDENTITIES[checkoutUrl]
36
+ ? checkoutUrl
37
+ : `https://${receipt.merchant.host}/`;
38
+ }
39
+ const navigation = receipt.evidence.steps.find((step) => step?.type === 'navigation');
40
+ const data = navigation?.data;
41
+ const url = typeof data === 'object' && data !== null ? data.url : undefined;
42
+ return typeof url === 'string' && url.length > 0 ? url : `https://${receipt.merchant.host}/`;
43
+ }
44
+ function isConfirmedCompletion(receipt) {
45
+ if (receipt.schema === 'checkout-agent-receipt/v2') {
46
+ return receipt.outcome === 'confirmed' && receipt.network.confirmation === 'APPROVED';
47
+ }
48
+ return (receipt.outcome === 'confirmed' &&
49
+ receipt.vicConfirmation?.posted === true &&
50
+ receipt.vicConfirmation.transactionStatus === 'APPROVED');
51
+ }
52
+ /**
53
+ * Parse one supported receipt file, or null when its fields cannot safely feed
54
+ * the registry. Deliberately permissive about everything the grouping does not
55
+ * touch: older and newer engines only have to carry the fields read below.
56
+ */
57
+ function parseReceipt(json) {
58
+ let parsed;
59
+ try {
60
+ parsed = JSON.parse(json);
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ if (typeof parsed !== 'object' || parsed === null)
66
+ return null;
67
+ const receipt = parsed;
68
+ if (receipt.schema === 'checkout-agent-receipt/v1') {
69
+ const v1 = receipt;
70
+ if (typeof v1.recordedAt !== 'string')
71
+ return null;
72
+ if (typeof v1.merchant?.host !== 'string')
73
+ return null;
74
+ if (typeof v1.transaction?.amount !== 'string')
75
+ return null;
76
+ if (typeof v1.transaction?.currency !== 'string')
77
+ return null;
78
+ if (!Array.isArray(v1.evidence?.steps))
79
+ return null;
80
+ return v1;
81
+ }
82
+ if (receipt.schema === 'checkout-agent-receipt/v2') {
83
+ const v2 = receipt;
84
+ if (typeof v2.recordedAt !== 'string')
85
+ return null;
86
+ if (typeof v2.merchant?.host !== 'string')
87
+ return null;
88
+ if (v2.merchant.checkoutUrl !== null && typeof v2.merchant.checkoutUrl !== 'string')
89
+ return null;
90
+ if (typeof v2.transaction?.amount !== 'string')
91
+ return null;
92
+ if (typeof v2.transaction?.currency !== 'string')
93
+ return null;
94
+ if (v2.network?.confirmation !== null &&
95
+ v2.network?.confirmation !== 'APPROVED' &&
96
+ v2.network?.confirmation !== 'DECLINED') {
97
+ return null;
98
+ }
99
+ return v2;
100
+ }
101
+ return null;
102
+ }
103
+ /**
104
+ * Merchants this device has completed a real card checkout at, newest first.
105
+ *
106
+ * Never throws: a missing directory (nothing has ever been checked out here)
107
+ * and an unreadable one both read as an empty registry.
108
+ */
109
+ export async function readConfirmedMerchants(receiptDir = RECEIPT_DIR) {
110
+ let names;
111
+ try {
112
+ names = await readdir(receiptDir);
113
+ }
114
+ catch {
115
+ return [];
116
+ }
117
+ const byUrl = new Map();
118
+ for (const name of names) {
119
+ if (!name.endsWith('.json'))
120
+ continue;
121
+ let raw;
122
+ try {
123
+ raw = await readFile(join(receiptDir, name), 'utf8');
124
+ }
125
+ catch {
126
+ continue;
127
+ }
128
+ const receipt = parseReceipt(raw);
129
+ if (!receipt || !isConfirmedCompletion(receipt))
130
+ continue;
131
+ const url = checkoutUrlOf(receipt);
132
+ const charge = {
133
+ recordedAt: receipt.recordedAt,
134
+ amount: receipt.transaction.amount,
135
+ currency: receipt.transaction.currency,
136
+ receiptFile: name,
137
+ };
138
+ const existing = byUrl.get(url);
139
+ if (existing) {
140
+ existing.confirmedCount += 1;
141
+ existing.charges.push(charge);
142
+ if (charge.recordedAt > existing.lastConfirmedAt) {
143
+ existing.lastConfirmedAt = charge.recordedAt;
144
+ }
145
+ continue;
146
+ }
147
+ const identity = KNOWN_MERCHANT_IDENTITIES[url];
148
+ byUrl.set(url, {
149
+ url,
150
+ host: receipt.merchant.host,
151
+ // Conditional spread on purpose: an unidentified merchant carries no
152
+ // identity keys at all, rather than keys holding undefined.
153
+ ...(identity ?? {}),
154
+ confirmedCount: 1,
155
+ lastConfirmedAt: charge.recordedAt,
156
+ charges: [charge],
157
+ });
158
+ }
159
+ const merchants = [...byUrl.values()];
160
+ for (const merchant of merchants) {
161
+ merchant.charges.sort((left, right) => (left.recordedAt < right.recordedAt ? 1 : -1));
162
+ }
163
+ merchants.sort((left, right) => (left.lastConfirmedAt < right.lastConfirmedAt ? 1 : -1));
164
+ return merchants;
165
+ }
@@ -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
+ }