@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.
- package/README.md +200 -226
- package/dist/checkout-engine/adapters/generic.d.ts +88 -0
- package/dist/checkout-engine/adapters/generic.js +526 -0
- package/dist/checkout-engine/adapters/index.d.ts +10 -0
- package/dist/checkout-engine/adapters/index.js +24 -0
- package/dist/checkout-engine/adapters/shopify.d.ts +55 -0
- package/dist/checkout-engine/adapters/shopify.js +514 -0
- package/dist/checkout-engine/adapters/stripe-like.d.ts +10 -0
- package/dist/checkout-engine/adapters/stripe-like.js +21 -0
- package/dist/checkout-engine/amount.d.ts +15 -0
- package/dist/checkout-engine/amount.js +72 -0
- package/dist/checkout-engine/browser-launch.d.ts +46 -0
- package/dist/checkout-engine/browser-launch.js +81 -0
- package/dist/checkout-engine/ceremony.d.ts +64 -0
- package/dist/checkout-engine/ceremony.js +261 -0
- package/dist/checkout-engine/cli-engine.d.ts +315 -0
- package/dist/checkout-engine/cli-engine.js +996 -0
- package/dist/checkout-engine/confirmed-merchants.d.ts +31 -0
- package/dist/checkout-engine/confirmed-merchants.js +165 -0
- package/dist/checkout-engine/detect.d.ts +61 -0
- package/dist/checkout-engine/detect.js +398 -0
- package/dist/checkout-engine/evidence.d.ts +25 -0
- package/dist/checkout-engine/evidence.js +104 -0
- package/dist/checkout-engine/executor.d.ts +215 -0
- package/dist/checkout-engine/executor.js +1520 -0
- package/dist/checkout-engine/hosted-approval.d.ts +195 -0
- package/dist/checkout-engine/hosted-approval.js +498 -0
- package/dist/checkout-engine/index.d.ts +9 -0
- package/dist/checkout-engine/index.js +11 -0
- package/dist/checkout-engine/instrument.d.ts +61 -0
- package/dist/checkout-engine/instrument.js +87 -0
- package/dist/checkout-engine/known-merchants.d.ts +10 -0
- package/dist/checkout-engine/known-merchants.js +38 -0
- package/dist/checkout-engine/live-fill-approval.d.ts +37 -0
- package/dist/checkout-engine/live-fill-approval.js +76 -0
- package/dist/checkout-engine/mandate/card-mandate.d.ts +121 -0
- package/dist/checkout-engine/mandate/card-mandate.js +227 -0
- package/dist/checkout-engine/mandate/mandate-ledger.d.ts +178 -0
- package/dist/checkout-engine/mandate/mandate-ledger.js +395 -0
- package/dist/checkout-engine/mandate.d.ts +25 -0
- package/dist/checkout-engine/mandate.js +100 -0
- package/dist/checkout-engine/outcome.d.ts +30 -0
- package/dist/checkout-engine/outcome.js +225 -0
- package/dist/checkout-engine/owner-only-file.d.ts +19 -0
- package/dist/checkout-engine/owner-only-file.js +41 -0
- package/dist/checkout-engine/package.json +3 -0
- package/dist/checkout-engine/receipt-dir.d.ts +6 -0
- package/dist/checkout-engine/receipt-dir.js +8 -0
- package/dist/checkout-engine/receipt.d.ts +121 -0
- package/dist/checkout-engine/receipt.js +138 -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 +52 -0
- package/dist/checkout-engine/types.js +2 -0
- package/dist/checkout-engine/unresolved-charges.d.ts +34 -0
- package/dist/checkout-engine/unresolved-charges.js +125 -0
- package/dist/checkout-engine/vgs-gateway/server-mint-client.d.ts +101 -0
- package/dist/checkout-engine/vgs-gateway/server-mint-client.js +218 -0
- package/dist/checkout-engine/vgs-live-instrument.d.ts +144 -0
- package/dist/checkout-engine/vgs-live-instrument.js +229 -0
- package/dist/checkout-engine/vic-confirmation.d.ts +52 -0
- package/dist/checkout-engine/vic-confirmation.js +45 -0
- package/dist/checkout-engine/web-bot-auth.d.ts +92 -0
- package/dist/checkout-engine/web-bot-auth.js +159 -0
- package/dist/cli.js +721 -445
- package/dist/mcp-apps/ucp-checkout.html +280 -0
- package/dist/mcp-server/index.js +573 -175
- package/dist/skills/pair-visa-agent/RUNTIMES.md +93 -0
- package/dist/skills/pair-visa-agent/SKILL.md +557 -0
- package/dist/skills/pair-visa-agent/scripts/setup.mjs +48 -0
- package/dist/subway-direct.mjs +1 -0
- package/install.ps1 +5 -43
- package/install.sh +5 -37
- package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
- package/package.json +33 -25
- 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
|
+
}
|